Discovery produces a list of classes. The manager instantiates them.
But neither step gives a plugin anywhere to register against.
That's the job of PluginContext: it's the one argument every
plugin receives in register(), and it carries the entire
registration API.
This chapter walks each registration method on the context, the side-channel that records what was registered, and a small worked example tying all the surfaces together.
The contract
Every plugin implementation looks roughly like this:
final class Plugin implements \Scriptor\Boot\Plugin\Plugin
{
public function name(): string { return 'my-plugin'; }
public function version(): string { return '1.0.0'; }
public function register(PluginContext $context): void
{
// ... call methods on $context here ...
}
}
The framework does not pass any other handle. No global service locator, no static registry, no event-bus singleton. Anything a plugin wants to influence goes through the context, and the context has exactly five methods worth knowing about:
| Method | Adds | Covered in |
|---|---|---|
subscribe($eventClass, $handler) |
A PSR-14 listener. | Chapter 4: Frontend Events. |
registerEditorModule($slug, $factory) |
An /editor/<slug>/ route. |
Chapter 5: Editor Extensions. |
addEditorMenuItem($item) |
A sidebar or profile entry. | Chapter 5: Editor Extensions. |
contributeFrontendNav($builder) |
Items to the top-level site nav. | Chapter 6: Frontend Nav Registry. |
container() |
Escape hatch to the DI container. | This chapter. |
The first four are what plugin authors actually type. The fifth is for the rare case the typed methods don't cover.
Each call also gets recorded
Every registration method on the context does two things:
$context->subscribe(PageResolving::class, $handler);
│
├── records PageResolving::class on $this->events[]
│
└── resolves SubscriberListenerProvider from the container,
calls ->subscribe(PageResolving::class, $handler) on it
The actual registration goes to a shared registry (the event provider, the module registry, the menu registry, the nav registry). The recording stays on the context itself, scoped to this plugin only.
Why bother recording? Because the editor's Installed Plugins
surface (covered in chapter 2) asks registrationsFor($pluginName)
to render a per-plugin breakdown: this plugin subscribed to these
events, registered these modules, added these menu items,
contributed this many nav builders. Without a per-plugin context
object that breakdown would be impossible. The underlying registries
don't carry provenance.
The registration methods
subscribe($eventClass, $handler)
The handler fires for any dispatched event that is an instance of
$eventClass or any subclass. Handlers run in registration order.
$context->subscribe(
PageResolving::class,
function (PageResolving $event): void {
if ($event->urlSegments->first() === 'blog') {
$event->setPage($myBlogPage);
}
},
);
This is how the markdown-pages plugin intercepts URLs whose first
segment matches a configured track: it subscribes to PageResolving,
checks the segment, and (if it's its territory) calls setPage()
to short-circuit the database lookup. Chapter 4 covers the
dispatched-event shapes and the listener provider in detail.
registerEditorModule($slug, $factory)
Adds a top-level URL under /editor/<slug>/. The factory is a
closure that returns a Module instance:
$context->registerEditorModule(
'docs',
fn (Container $c, Editor $editor): Module => new DocsModule($editor),
);
Re-registering an existing slug replaces the previous factory. Combined with the core-plugins-boot-first rule from chapter 2, this gives third-party plugins a clean way to take over a route a core module owned.
addEditorMenuItem($item)
Hangs an entry off the editor chrome. A MenuItem carries a target
slug, a label, an optional icon, a position for sort order, a
displayType (sidebar or profile), and an optional explicit
href to override the slug-derived URL.
$context->addEditorMenuItem(new MenuItem(
slug: 'docs',
label: 'Documentation',
icon: 'gg-file-document',
position: 50,
));
Entries render in ascending position then by registration order.
Chapter 5 walks the displayType variants and the slot mechanics.
contributeFrontendNav($builder)
The newest method, added with the nested-nav refactor. The builder
is invoked once per request with the current UrlSegments, and
returns a list of top-level NavItem nodes (optionally nested
through NavItem::$children):
$context->contributeFrontendNav(
function (UrlSegments $segments): array {
return [
new NavItem(url: '/blog/', label: 'Blog', position: 50),
];
},
);
The registry merges every contributed builder when a theme calls
collect(), sorting by position then registration order. The
UrlSegments argument lets a builder decide whether the currently
active track is its territory and, if not, return an empty list to
avoid building a tree it won't be asked about. Chapter 6 covers
the merge semantics and the theme-side consumption.
container()
The escape hatch. Returns the same DI container the framework boots with. Use it when none of the four typed methods cover what you need, for example:
$context->container()->add(
MyHandRolledService::class,
fn () => new MyHandRolledService(/* ... */),
);
This is also how a plugin registers its own services into DI so
its event handlers and module factories can resolve them through
normal Container::get() calls. Treat container() as the back
door, not the front door: if you're calling it from a hot path or
on every register() invocation, the typed surface is probably a
better fit.
A complete tiny example
The scriptor-markdown-pages plugin uses four of the methods.
Stripped to essentials:
public function register(PluginContext $context): void
{
// 1. Hook the page-resolution pipeline.
$context->subscribe(PageResolving::class, $this->resolve(...));
$context->subscribe(ContentRendering::class, $this->render(...));
// 2. Add an editor surface for browsing the markdown tree.
$context->registerEditorModule(
'docs',
fn ($c, $editor) => new DocumentationModule($editor, /* ... */),
);
// 3. Add a sidebar link to that surface.
$context->addEditorMenuItem(new MenuItem(
slug: 'docs',
label: 'Documentation',
position: 60,
));
// 4. Contribute frontend nav for the active markdown track.
$context->contributeFrontendNav(new NavBuilder($contentRoot, $tracks));
}
Four calls on the context, four extension surfaces. Everything downstream (routing, dispatching, rendering, menu sorting, nav merging) is the framework's job.
What the editor sees
Once the plugin has booted, $context->registrations() returns a
snapshot of what got registered:
[
'events' => [
'Scriptor\Boot\Events\Frontend\PageResolving',
'Scriptor\Boot\Events\Frontend\ContentRendering',
],
'modules' => ['docs'],
'menuItems' => [/* MenuItem object */],
'navBuilders' => 1,
]
This is the shape the editor's Installed Plugins module renders.
menuItems is a list of the actual MenuItem objects so the view
can show label, URL, and position without re-resolving anything.
navBuilders is just a count: builders are anonymous callables, so
there's nothing useful to display about them beyond "this plugin
contributed N".
Gotcha: calling order is registration order, not boot order. Both
subscribe()andcontributeFrontendNav()run their handlers in the order they were registered, within a single dispatch. Plugin A subscribing to the same event before plugin B will run before plugin B. The boot order (core first, then discovered, then within each group by manifest order) decides who gets to subscribe first, which is usually what you want, but it's worth knowing about when two plugins both want the last word.
That's the surface every plugin uses. The next three chapters peel back what happens behind each method: how events get dispatched (chapter 4), how the editor router wires modules and menu items (chapter 5), and how themes consume the merged frontend nav (chapter 6).
Behind the scenes. The context source is
boot/Plugin/PluginContext.php.
The four registries it delegates to live next door:
SubscriberListenerProvider, ModuleRegistry, MenuRegistry,
FrontendNavRegistry.