Purpose
The "is anyone willing to handle this URL?" event that fires
after PageResolving produced nothing. Listeners can still
claim the request by filling the same resolution slot
PageResolving exposes. If no listener does, Site::throw404()
runs and the 404 page renders.
This event exists so a plugin that handles deep dynamic URLs
(per-item detail pages, parametric report URLs, generated
gallery views) does not have to listen on PageResolving and
return early for every URL it does not care about. By
subscribing to RouteNotFound instead, the plugin's listener
runs only after every other resolver has passed.
FQCN + file path
- FQCN:
Scriptor\Boot\Events\Frontend\RouteNotFound - Source:
boot/Events/Frontend/RouteNotFound.php
When to use
Subscribe inside Plugin::register() when you want a hook that:
- Runs only when nothing else claimed the URL (cheap by design: the hot path skips you).
- Can claim the URL after all by filling
resolution. - Lets you opt into the existing
PageResolvedpipeline once you do (if you fill the slot,PageResolvedfires next as ifPageResolvinghad produced the page).
You do not use this event to render a custom 404 page; that
is a theme concern ($config['404page'] + the theme's
404.php). You also do not use it for blanket interception of
404s for logging; subscribe normally and pair with a
sentinel-page check.
Surface
Public properties
| Property | Type | Purpose |
|---|---|---|
urlSegments |
readonly UrlSegments |
Parsed segments of the unresolved URL |
resolution |
?Page (mutable) |
The slot listeners fill to claim the URL. Starts null. First non-null wins |
No methods.
Constructor
public function __construct(public readonly UrlSegments $urlSegments)
Site::execute() constructs the event with the request's
UrlSegments and dispatches it after PageResolving produced
nothing. You do not construct it yourself.
Lifecycle
Fires at most once per request, after PageResolving ran
and left resolution empty. Two outcomes:
- A listener writes
resolution:Siteaccepts the page,PageResolvedfires next, the request flows normally into rendering. - The slot stays empty:
Site::throw404()runs.PageResolveddoes not fire.
The cooperative convention from PageResolving carries over: the
canonical listener body starts with
if ($event->resolution !== null) return; to keep ordering
between multiple RouteNotFound subscribers sane.
Common patterns
Claiming a dynamic detail-page URL after a miss
$context->subscribe(
\Scriptor\Boot\Events\Frontend\RouteNotFound::class,
function (\Scriptor\Boot\Events\Frontend\RouteNotFound $event): void {
if ($event->resolution !== null) {
return;
}
$segments = $event->urlSegments;
if ($segments->first() !== 'product') {
return;
}
$sku = $segments->at(1);
if ($sku === null) {
return;
}
$product = $this->products->find($sku);
if ($product !== null) {
$event->resolution = $this->virtualPageFactory->fromProduct($product);
}
},
);
Redirecting away from a known old URL
$context->subscribe(
RouteNotFound::class,
function (RouteNotFound $event) use ($redirects, $site): void {
if ($event->resolution !== null) {
return;
}
$target = $redirects->lookup((string) $event->urlSegments->path());
if ($target !== null) {
$site->redirect($target, 301); // never returns
}
},
);
Note: redirect() exits, so this never falls through to a
resolution write. The pattern is "produce a side effect, do
not let the 404 path run".
Logging the miss before the 404 renders
$context->subscribe(
RouteNotFound::class,
function (RouteNotFound $event) use ($logger): void {
if ($event->resolution !== null) {
return;
}
$logger->info('404: {path}', ['path' => (string) $event->urlSegments->path()]);
},
);
Pure observation; the slot stays empty so the normal 404 still runs.
See also
PageResolving: the first event in the resolution pipeline; uses the sameresolution-slot shapePageResolved: fires next when this event produces a pagePage: the DTO listeners assign toresolutionSite: theexecute()method that dispatches this event afterPageResolving- Concept: Frontend Events: pipeline overview