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.

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 passes slug: 'docs'.
  • label: what the user sees.
  • icon: optional CSS class for the leading icon (typically a gg-* class from the css.gg icon set the editor uses).
  • displayType: which layout slot renders this entry. Two built-ins ship: sidebar and profile (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::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.