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
returnfor already-filled slots is the cooperation convention. Multiple plugins can subscribe toPageResolving; the first one to fill the slot wins, the rest step aside. Your listener is one of many. id: nullmarks the Item as synthetic (never persisted to iManager). ThecategoryIdis 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 shipsthemes/<your-theme>/templates/team-member.php, Scriptor loads it; otherwise it falls through tobasic.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 defaultrender('content')helper writes into the template. If you want finer control over rendering (custom HTML structure, asset injection, partials) listen toContentRenderinginstead 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
- Replace 404 with a fallback handler: the downstream cousin, for URLs you want to catch only after the page tree did not match
- Decorate page HTML in ContentRendering: for transforming the HTML after the Page resolved (this recipe is about which Page resolves)
PageResolving: the event surface, including the cooperation contractPage: the synthetic DTO this recipe constructs; constructor takes anImanager\Domain\ItemPluginContext:subscribe()is the registration entry point- Build a Theme: Blog via Plugin:
the tutorial chapter that walks
bigins/scriptor-markdown-pages, the canonical PageResolving consumer