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