Problem

You want certain URLs to resolve to pages that the editor never created: a /team/<slug>/ page generated from a team_members database row, a /sku/<code>/ page pulled from a product API, a /post/<year>/<month>/<slug>/ route backed by a flat-file blog. The data does not belong in the page tree, but the responses should still flow through Scriptor's template, layout, and nav machinery.

Recipe

PageResolving fires first in the resolution pipeline. Listeners that match the URL fill its cooperative ?Page $resolution slot with a synthetic Page; Scriptor picks that Page up and treats it as the resolved one. The route never reaches the database-backed page lookup that the DbPagesResolverPlugin ships, so your dynamic URLs win over a page with the same slug.

Subscribe inside the plugin's Plugin::register():

namespace Acme\TeamPages;

use Imanager\Domain\Item;
use Scriptor\Boot\Events\Frontend\PageResolving;
use Scriptor\Boot\Frontend\Page;
use Scriptor\Boot\Plugin\Plugin as ScriptorPlugin;
use Scriptor\Boot\Plugin\PluginContext;

final class Plugin implements ScriptorPlugin
{
    public function __construct(private TeamRepository $team) {}

    public function register(PluginContext $context): void
    {
        $context->subscribe(PageResolving::class, [$this, 'onResolving']);
    }

    public function version(): string { return '0.1.0'; }

    public function onResolving(PageResolving $event): void
    {
        if ($event->resolution !== null) return;

        $segments = $event->urlSegments->segments;
        if (count($segments) !== 2 || $segments[0] !== 'team') return;

        $member = $this->team->findBySlug($segments[1]);
        if ($member === null) return;

        $event->resolution = $this->buildPage($member);
    }

    private function buildPage(TeamMember $m): Page
    {
        $item = new Item(
            id:         null,
            categoryId: 1,
            name:       $m->fullName,
            label:      null,
            position:   0,
            active:     true,
            data: [
                'slug'       => $m->slug,
                'template'   => 'team-member',
                'menu_title' => $m->fullName,
                'content'    => $this->renderBio($m),
                'parent'     => 0,
                'pagetype'   => '1',
            ],
            created: $m->createdAt,
            updated: $m->updatedAt,
        );

        return new Page($item);
    }
}

Four pieces a reader cannot infer from the snippet alone:

  • The early return for already-filled slots is the cooperation convention. Multiple plugins can subscribe to PageResolving; the first one to fill the slot wins, the rest step aside. Your listener is one of many.
  • id: null marks the Item as synthetic (never persisted to iManager). The categoryId is required but does not have to match a real category since nothing reads it for synthetic items.
  • data['template'] picks the theme template. If your theme ships themes/<your-theme>/templates/team-member.php, Scriptor loads it; otherwise it falls through to basic.php. The slug shape is enforced upstream (^[A-Za-z0-9_-]+$) so an attacker-controlled value cannot steer the template loader.
  • data['content'] is the HTML body that the default render('content') helper writes into the template. If you want finer control over rendering (custom HTML structure, asset injection, partials) listen to ContentRendering instead and return a different string from the slot.

The plugin in this snippet receives its TeamRepository dependency through the constructor; the container resolves it by reflection because the plugin class is itself container-instantiated during boot. Anything bound in boot.php (or extend()-ed by another plugin) is injectable.

Variants

Match a path prefix without a fixed segment count

When the URL shape is /post/<year>/<month>/<slug>/, the segment count check is too rigid:

$segments = $event->urlSegments->segments;
if (($segments[0] ?? '') !== 'post' || count($segments) !== 4) return;

[$_, $year, $month, $slug] = $segments;
if (! ctype_digit($year) || ! ctype_digit($month)) return;

$post = $this->blog->find((int) $year, (int) $month, $slug);
// ... build Page if $post !== null

For prefix-only listeners that handle many paths under one root, delegating to a small router inside the listener is fine. That is the dynamic counterpart to the Wrap FastRoute recipe; it lives at a different hook because page-shaped responses go through PageResolving, JSON responses go through _ext.php.

Set non-trivial response headers (Cache-Control, ETag)

Filling the resolution slot only controls the Page. To set headers (caching, ETag, Vary), do it in the listener body before returning:

header('Cache-Control: public, max-age=300');
header('ETag: "' . sha1($m->updatedAt . $m->slug) . '"');
$event->resolution = $this->buildPage($m);

The Page still renders through the normal template path; only the HTTP response gets the extra headers.

See also