Chapter 3 introduced two registration methods for the editor side:
registerEditorModule() to add a route, and addEditorMenuItem()
to hang an entry off the chrome. This chapter is the deeper look at
how the router dispatches modules, how the menu registry filters by
slot, and how a plugin can introduce its own slot if the two
built-in ones aren't enough.
Two distinct surfaces
The editor has two extension surfaces that go together but are managed separately:
| Surface | What it adds | Read by | Lives at |
|---|---|---|---|
| Module | A top-level URL under /editor/<slug>/.... |
EditorRouter |
ModuleRegistry |
| Menu item | A clickable entry in a layout slot (sidebar or profile). | summary.php / header.php template fragments |
MenuRegistry |
A typical plugin adds both: register a module so the URL works, add a menu item so users can find it. They're decoupled on purpose though. A plugin can register a module without any sidebar entry (deep-link only, accessible via direct URL), or it can add a menu item that points somewhere outside the editor (a help link, an external dashboard).
The Module interface
Every module satisfies one contract:
interface Module {
public function execute(): void;
}
That's it. execute() is called once per request after the auth
gate has passed. Render output lands on $editor->pageContent; the
layout template (editor/theme/template.php) wraps it with head,
header, sidebar, footer, and flash messages. A module never echoes
directly, so the chrome stays in charge of presentation.
ModuleRegistry: slug to factory
ModuleRegistry is the slug-to-factory lookup that EditorRouter
consults:
/editor/<slug>/...
│
▼
ModuleRegistry::create(slug, container, editor)
│
├── looks up the factory closure registered for $slug
│
└── invokes (Container $c, Editor $editor): Module
│
▼
Module::execute()
Factories receive both the DI container and the per-request Editor
instance. The split exists because Editor is constructed in
editor/index.php after the session has booted, which is later
than plugin register() runs. The container can't hand back a
fully-wired Editor at plugin-boot time, so it travels as a
positional argument to the factory instead.
$context->registerEditorModule(
'docs',
fn (Container $c, Editor $editor): Module => new DocumentationModule(
$editor,
$c->get(FileRepository::class),
),
);
The factory runs lazily, once per request, only when the URL actually hits the module. A plugin that ships ten modules pays the instantiation cost only for the one the user navigated to.
Override semantics
Re-registering an existing slug replaces the previous factory.
Combined with the core-plugins-boot-first rule (chapter 2), this
makes targeted core overrides clean: a third-party plugin can take
over /editor/pages/ by registering its own factory under the
pages slug. The route still works; the implementation changed.
The EditorRouter
EditorRouter::execute() runs four checks before delegating to a
module:
incoming /editor/<...>/...
│
▼
1. first segment === 'auth'? ──→ dispatch auth (always reachable)
│
▼
2. user logged in? ──→ if no:
│ · /api → 401 JSON
│ · else → 302 to /auth/
▼
3. first segment === 'api'? ──→ dispatch /editor/api/* JSON
│
▼
4. first segment null? ──→ render dashboard
│
▼
5. registry knows this slug? ──→ yes: instantiate + execute
no: 404 "module not available"
The router itself stays small. The hard-coded slots are auth (always reachable, because the login form has to be) and api (a separate JSON path). Everything else is just a registry lookup, which is what makes plugin modules indistinguishable from core ones at dispatch time.
MenuItem shape
The menu entry value object:
final readonly class MenuItem
{
public function __construct(
public string $slug,
public string $label,
public string $icon = '',
public string $displayType = 'sidebar',
public int $position = 0,
public ?string $href = null,
) {}
}
Field-by-field:
slug: the URL slug the item links to. By default the layout builds the href as<siteUrl>/<slug>/, so an entry for the docs module just passesslug: 'docs'.label: what the user sees.icon: optional CSS class for the leading icon (typically agg-*class from the css.gg icon set the editor uses).displayType: which layout slot renders this entry. Two built-ins ship:sidebarandprofile(covered below). Plugins can introduce more.position: sort key inside the slot. Lower numbers render first; ties break on registration order.href: explicit override for the link target. Use this when the slug-derived URL is wrong, for example a logout link that needs a CSRF query string, or an external dashboard URL.
$context->addEditorMenuItem(new MenuItem(
slug: 'docs',
label: 'Documentation',
icon: 'gg-file-document',
position: 60,
));
MenuRegistry and the display slots
MenuRegistry::forDisplay($slot) returns the items registered for
that slot, sorted by ascending position and then by registration
order. Two slots ship out of the box:
| Slot | Template that reads it | What it renders |
|---|---|---|
sidebar |
editor/theme/summary.php |
The left-rail navigation column. |
profile |
editor/theme/header.php |
The top-right cluster (profile menu, logout). |
The core editor modules (Pages, Profile, Settings, Plugins) seed
sidebar entries at boot through the same registry. A plugin's
addEditorMenuItem() calls slot into the same list, so plugin
entries and core entries sort together by position.
Adding a new slot
If a plugin wants its own slot, the work splits between the
contributor and the layout. The contributor side is just passing a
new displayType string:
$context->addEditorMenuItem(new MenuItem(
slug: 'help',
label: 'Help',
displayType: 'topbar',
));
The layout side is a template fragment that asks the registry for that slot:
/** @var MenuRegistry $menus */
$menus = $container->get(MenuRegistry::class);
foreach ($menus->forDisplay('topbar') as $entry) {
// render entry
}
This pattern is how the chrome itself stays open to plugin contributions: any new layout zone is just a new slot name.
A worked example
Suppose a plugin wants to add a read-only Reports view at
/editor/reports/, with a sidebar entry and a profile-cluster
shortcut. The register() body:
public function register(PluginContext $context): void
{
// 1. Mount the route.
$context->registerEditorModule(
'reports',
fn ($c, $editor) => new ReportsModule(
$editor,
$c->get(ReportRepository::class),
),
);
// 2. Sidebar entry, between Pages (position 10) and Settings (position 90).
$context->addEditorMenuItem(new MenuItem(
slug: 'reports',
label: 'Reports',
icon: 'gg-chart',
position: 50,
));
// 3. Profile-cluster shortcut to the same module.
$context->addEditorMenuItem(new MenuItem(
slug: 'reports',
label: 'My reports',
displayType: 'profile',
position: 5,
));
}
One module, two menu items, three calls on the context. The router
makes /editor/reports/ work; summary.php shows "Reports" in the
sidebar at position 50; header.php shows "My reports" in the
profile cluster.
What the InstalledPlugins surface sees
This whole chapter is about what a plugin contributes. The editor's
own Installed Plugins module (at /editor/plugins/) reads it
back out for diagnostics. For each booted plugin it shows:
- The Composer package name and version (from
manifestFor()in chapter 2). - Which events the plugin subscribed to.
- Which module slugs the plugin registered.
- Which menu items the plugin added (label, slot, position).
- How many frontend nav builders the plugin contributed (the topic of chapter 6).
The data comes straight from PluginContext::registrations(),
which is recorded as the plugin runs its register() calls. No
double-bookkeeping.
Behind the scenes. The module interface is
boot/Editor/Module.php,
the registry next to it. Menu types live in
boot/Editor/Menu/.
The router is
boot/Editor/EditorRouter.php.
Seeding of the core menu items happens in
boot/Plugin/CorePlugins/CoreEditorPlugin.php.