Problem
A URL that no page in the tree matches should not always render the default 404. You want to bounce legacy slugs to their new homes (after a content migration), or pass the unresolved path to a search lookup, or serve a friendlier "did you mean ..." page.
Recipe
RouteNotFound fires after PageResolving has run and produced
no match, immediately before Site::throw404(). It carries the
same cooperative ?Page $resolution slot as PageResolving:
fill it and Scriptor treats the request as resolved after all;
leave it null and the 404 fires.
Subscribe inside the plugin's Plugin::register():
namespace Acme\LegacyRedirects;
use Scriptor\Boot\Events\Frontend\RouteNotFound;
use Scriptor\Boot\Plugin\Plugin as ScriptorPlugin;
use Scriptor\Boot\Plugin\PluginContext;
final class Plugin implements ScriptorPlugin
{
public function register(PluginContext $context): void
{
$context->subscribe(RouteNotFound::class, [$this, 'onUnresolved']);
}
public function version(): string { return '0.1.0'; }
public function onUnresolved(RouteNotFound $event): void
{
if ($event->resolution !== null) return; // someone got there first
$path = '/' . $event->urlSegments->path(false);
$redirects = [
'/old-blog/welcome' => '/blog/welcome/',
'/old-blog/about-us' => '/about/',
];
if (isset($redirects[$path])) {
header('Location: ' . $redirects[$path], true, 301);
exit;
}
}
}
The if ($event->resolution !== null) return; line is the
listener-cooperation convention. Multiple plugins can subscribe;
the first one that fills the slot wins, the rest skip.
For the redirect case the listener does not bother with the
resolution slot. header() + exit short-circuits Scriptor's
entire pipeline; the slot stays null and throw404() never runs
because the request is already over.
Variants
Serve a synthetic Page instead of redirecting
When the fallback is "render some content under this URL" rather
than "go somewhere else", build a synthetic
Page and fill the resolution slot:
use Imanager\Domain\Item;
use Scriptor\Boot\Frontend\Page;
public function onUnresolved(RouteNotFound $event): void
{
if ($event->resolution !== null) return;
$query = '/' . $event->urlSegments->path(false);
$hits = $this->search->find($query);
if ($hits === []) return; // let the 404 fire
$item = new Item(
id: null,
categoryId: 1,
name: 'No exact match for "' . $query . '"',
label: null,
position: 0,
active: true,
data: [
'slug' => 'search',
'template' => 'search-results',
'menu_title' => 'Search',
'content' => $this->renderHits($hits),
'parent' => 0,
'pagetype' => '1',
],
created: time(),
updated: time(),
);
$event->resolution = new Page($item);
}
The id: null marks the Item as synthetic (never persisted). The
theme's search-results.php template (or its basic.php
fallback) renders the content field exactly the way it would
for a real page. The bigins/scriptor-markdown-pages plugin uses
the same pattern; see its VirtualPageFactory for the canonical
shape.
Log + 404 (no behaviour change, just observability)
If the goal is "find out which URLs are 404ing" without changing what users see, leave the resolution slot alone and only emit a log line:
public function onUnresolved(RouteNotFound $event): void
{
$path = '/' . $event->urlSegments->path(false);
$this->logger->notice('404 path {path} from {ip}', [
'path' => $path,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '-',
]);
}
The 404 still fires; this listener just records the miss before
Scriptor's throw404() writes the response.
See also
- Serve dynamic pages from PageResolving: the upstream cousin, for URLs you claim authoritatively rather than catching what the page tree did not match
- Decorate page HTML in ContentRendering: for changes to a page that already resolved, not for catching unresolved ones
RouteNotFoundandPageResolving: the two resolution events and their?Page $resolutioncontractPage+PluginContext: the DTO the synthetic-page variant constructs and thesubscribe()surface the listener registers through- Concept: Frontend events: why the slot is cooperative and how the four events relate