Chapter 3 introduced subscribe() as one method on the context.
This chapter is what's on the other side of it: the four PSR-14
events Scriptor dispatches during a frontend request, and what
listeners are allowed to do with them.
The pipeline in one picture
A frontend request walks through four event slots between routing and rendering:
incoming request
│
▼
PageResolving (mutable: listener may set $event->resolution = Page)
│
├── slot filled → use that page
└── slot null → fall through to built-in lookup
│
▼
RouteNotFound (fires only if no resolver found a page)
│
├── slot filled → late save
└── slot null → 404 path
│
▼
PageResolved (read-only: a Page exists; logging, ACL, breadcrumbs)
│
▼
ContentRendering (mutable: listener may set $event->html = string)
│
▼
rendered HTML to the browser
Three of the four are mutable. The fourth, PageResolved, is
purely observational. Each section below walks one event.
PageResolving (mutable)
Dispatched at the start of Site::execute() so plugins can claim a
URL before the built-in lookup runs. The event carries the request's
UrlSegments and exposes a single mutable slot, $resolution:
final class PageResolving {
public ?Page $resolution = null;
public function __construct(public readonly UrlSegments $urlSegments) {}
}
A listener inspects the segments, decides whether to claim, and (if
yes) writes a Page to $event->resolution. After every listener
has run, Site reads the slot:
- slot filled → use that page, skip the built-in DB lookup
- slot null → fall through to the standard resolver chain
The cooperation convention is first writer wins: any listener that runs after a non-null slot is set should self-check and skip.
$context->subscribe(PageResolving::class, function (PageResolving $event): void {
if ($event->resolution !== null) {
return;
}
if ($event->urlSegments->first() !== 'blog') {
return;
}
$event->resolution = $this->blogPages->find($event->urlSegments);
});
Gotcha: subscriber order is registration order. Plugin A subscribing first runs first. Plugin B's "first writer wins" guard only helps if A actually wrote the slot. If two plugins claim the same URL prefix without coordinating, registration order decides. That order boils down to boot order: core plugins first, then discovered plugins in
installed.jsonorder.
RouteNotFound (mutable)
If nothing fills PageResolving and the built-in fall-through finds
no DB page either, Site dispatches RouteNotFound before rendering
the 404. Same shape as PageResolving: UrlSegments in, Page
slot to fill:
final class RouteNotFound {
public ?Page $resolution = null;
public function __construct(public readonly UrlSegments $urlSegments) {}
}
This is the late escape hatch for plugins that handle deep dynamic paths but don't want to claim resolution authority upfront. A search results page, a redirect-from-old-CMS handler, a paywall splash for missing content: all subscribe here so the standard resolver chain runs first, and they only step in when nothing else matched.
$context->subscribe(RouteNotFound::class, function (RouteNotFound $event): void {
if ($event->resolution !== null) {
return;
}
$redirectTo = $this->legacyRedirects->lookup($event->urlSegments);
if ($redirectTo === null) {
return;
}
$event->resolution = $this->buildRedirectPage($redirectTo);
});
Otherwise, identical mechanics: first writer wins, slot-null falls through to the 404 path.
PageResolved (read-only)
Once a page has been settled (by either a plugin or the built-in
resolver), Site dispatches PageResolved. The event is final readonly and exposes the resolved Page only:
final readonly class PageResolved {
public function __construct(public Page $page) {}
}
Use it for side effects that need a page but cannot influence it:
- Logging which path resolved to which page id.
- Breadcrumb building.
- ACL gates that abort the request (by throwing an exception).
- Per-page analytics or A/B-test bucketing.
$context->subscribe(PageResolved::class, function (PageResolved $event): void {
$this->logger->info('resolved', [
'page' => $event->page->id(),
'name' => $event->page->name,
]);
});
If a listener wants to swap the page, it has to subscribe to
PageResolving (or RouteNotFound) instead. PageResolved is the
framework saying "decision is made, react if you must but don't
redo it."
ContentRendering (mutable)
Last event, dispatched from Site::renderContent(). Lets a plugin
substitute the rendered HTML for the resolved page. The slot is
$html:
final class ContentRendering {
public ?string $html = null;
public function __construct(public readonly Page $page) {}
}
After dispatch:
$html !== null→Siteuses the string and skips the default Parsedown markdown rendering.$html === null→ standard$site->sanitizer->markdown($page->content)runs.
The markdown-pages plugin uses this end-to-end. It sets
$event->resolution in PageResolving to a synthesised Page whose
template is markdown-section, then in ContentRendering it returns
the CommonMark-rendered HTML it already computed during resolution.
The default Parsedown never sees the content.
$context->subscribe(ContentRendering::class, function (ContentRendering $event): void {
if ($event->html !== null) {
return;
}
if (! $this->ownsPage($event->page)) {
return;
}
$event->html = $this->renderer->render($event->page);
});
Same convention applies: first writer wins. A listener that
explicitly wants to wrap an earlier output (cache layer,
post-processor) may read the existing $event->html first.
How the listener provider stitches it together
Subscription goes through $context->subscribe() (covered in
chapter 3). Under the hood, that resolves iManager's
SubscriberListenerProvider from the container and registers the
callable against the event class.
When Site dispatches, it calls $dispatcher->dispatch($event).
The dispatcher iterates the provider's listeners for that event
class plus any parent class or interface. Order is registration
order. Each listener runs in turn; the dispatcher does not
short-circuit on the first writer (the cooperative-slot convention
handles that at listener level).
The provider lives in iManager (Imanager\Events\SubscriberListenerProvider)
rather than Scriptor because the iManager 2.0 stack already had a
PSR-14 implementation. Scriptor wires it into its own container at
boot, dispatches its own events through it, and exposes it to
plugins via PluginContext::subscribe().
A worked example end-to-end
To make the four-event flow concrete, here is the
scriptor-markdown-pages plugin's subscription block, abbreviated:
public function register(PluginContext $context): void
{
$context->subscribe(PageResolving::class, function (PageResolving $event): void {
if ($event->resolution !== null) return;
$page = $this->resolver->resolve($event->urlSegments);
if ($page !== null) {
$this->cachedPage = $page;
$event->resolution = $page;
}
});
$context->subscribe(ContentRendering::class, function (ContentRendering $event): void {
if ($event->html !== null) return;
if ($event->page !== $this->cachedPage) return;
$event->html = $this->renderer->render($event->page);
});
}
Two subscriptions, two slots. The plugin parks the resolved page on
itself between dispatches ($this->cachedPage) so ContentRendering
knows the resolution came from its own resolver. Anything not from
this plugin passes through untouched.
Behind the scenes. Event classes live in
boot/Events/Frontend/.
Dispatch happens from boot/Frontend/Site.php (execute(),
throw404(), renderContent()). The listener provider is
Imanager\Events\SubscriberListenerProvider, wired into the
container by ImanagerBootstrap.