The final extension surface, and the one that closes the loop on plugin ownership of content trees. Earlier chapters showed plugins how to claim URLs (PageResolving), substitute rendered HTML (ContentRendering), and add editor surfaces (modules and menu items). This chapter is about how a plugin contributes to the site navigation itself: top-level nav, sidebar tree, anywhere a theme asks "what should appear in the nav for this page."

The two-sided contract

Frontend nav has two participants:

  • Plugins contribute nav-builder callables via PluginContext::contributeFrontendNav() (chapter 3).
  • Themes collect the merged tree by asking FrontendNavRegistry::collect() for the current request.

Between them sits the registry. It stores the builders at boot time and runs them at request time:

Plugin boot

The registry is the only thing both sides know about. The theme holds no reference to the plugin; the plugin holds no reference to the theme. Either side can change in isolation.

The NavItem DTO

What flows between them is a recursive value object:

final readonly class NavItem
{
    /** @param list<NavItem> $children */
    public function __construct(
        public string $url,
        public string $label,
        public int    $position = 0,
        public array  $children = [],
    ) {}
}

Four fields, all readonly. Notably absent: no isActive, no isCurrent. That state is request-relative, so the renderer figures it out by comparing the item's URL against the current request URL. Baking it into the DTO would lock the renderer's matching rules into the plugin contract.

URL convention:

  • Absolute (https://example.com/...): linked verbatim, never wins .active or .current.
  • Rooted (/developer-guide/welcome/): the theme prepends siteUrl. Plain leading-slash strings keep the matching arithmetic simple.

children is the same shape: a list<NavItem> for nesting. A content tree of any depth maps cleanly.

FrontendNavRegistry mechanic

The registry itself is small. Two public methods:

$registry->contribute(callable $builder): void;          // plugin side
$registry->collect(UrlSegments $url): list<NavItem>;     // theme side

contribute() appends the builder to an internal list. collect() walks the builders, calls each with the current UrlSegments, flattens their returns into a single sortable array, and emits a stably-ordered list:

foreach builders:
    items = builder($url)
    drop anything that isn't a NavItem
    record each survivor as (position, registrationIndex, item)

sort by position ascending, ties broken by registrationIndex
return [item, item, item, ...]

The registrationIndex tiebreaker is what makes the sort stable: two contributors with the same position keep their relative order deterministic across requests.

Why a builder callable instead of a static NavItem list

A plugin could in principle hand the registry a fixed list<NavItem> at boot time. Two reasons it gets a callable instead:

  • Plugin nav often depends on the request URL. The markdown-pages plugin, for example, expands the active track's filesystem children but leaves the other tracks collapsed. A builder that sees the URL can emit different shapes for different requests without the registry having to expose a "current request" hook.
  • The registry stays simple. No event system, no listener priorities beyond the explicit position field on each item.

Builders are pure functions of the request URL. Run them per request, get a fresh tree.

How a plugin contributes

The plugin side, recapped from chapter 3:

$context->contributeFrontendNav(function (UrlSegments $url): array {
    return [
        new NavItem(
            url: '/blog/',
            label: 'Blog',
            position: 50,
        ),
    ];
});

A builder is free to inspect $url and return different trees per request, return an empty list when its track isn't active, or walk a filesystem to enumerate content nodes. The registry doesn't care; it only drops non-NavItem entries from the return.

How a theme consumes

The theme side is a recursive walk over the collected list with URL matching for .current / .active state. The InfoTheme's implementation, abbreviated:

private function renderPluginContributedNav(): string
{
    $registry    = $this->container->get(FrontendNavRegistry::class);
    $items       = $registry->collect($this->urlSegments);
    $currentPath = $this->currentRequestPath();

    $out = '';
    foreach ($items as $item) {
        $out .= $this->renderNavItem($item, $currentPath);
    }
    return $out;
}

private function renderNavItem(NavItem $item, string $currentPath): string
{
    $href = $this->isAbsoluteUrl($item->url)
        ? $item->url
        : $this->siteUrl . $item->url;

    $cls = '';
    if (! $this->isAbsoluteUrl($item->url)) {
        if ($item->url === $currentPath) {
            $cls = ' class="current"';
        } elseif ($currentPath !== '/' && str_starts_with($currentPath, $item->url)) {
            $cls = ' class="active"';
        }
    }

    $children = '';
    foreach ($item->children as $child) {
        $children .= $this->renderNavItem($child, $currentPath);
    }
    return sprintf(
        '<li%s><a href="%s">%s</a>%s</li>',
        $cls,
        htmlspecialchars($href, \ENT_QUOTES),
        htmlspecialchars($item->label, \ENT_QUOTES),
        $children !== '' ? '<ul>' . $children . '</ul>' : '',
    );
}

The currentRequestPath() helper reconstructs a path with leading and trailing slashes (/developer-guide/welcome/) from UrlSegments, so the equality check against $item->url works without trailing-slash juggling.

Matching rules:

  • Exact match (item->url === currentPath) wins .current.
  • Request URL nested under the item's URL (str_starts_with) wins .active.
  • Root request (/) is excluded from the prefix check, otherwise it would mark every NavItem as .active.
  • Absolute URLs never get a class. An external link can't be "active" relative to the site's own URL space.

Gotcha: trailing slashes matter. Item URLs are convention-driven (/developer-guide/, not /developer-guide). If a plugin emits a URL without the trailing slash and the request path includes one (or vice versa), neither equality nor str_starts_with will give the expected match. Keep the shape symmetric on both sides.

The layering rule this enables

Before the nav registry, themes had to walk content trees that the plugin owned. The InfoTheme used to scan themes/info/content/<track>/ directly to build its sidebar, which meant the theme needed knowledge of the plugin's content layout, frontmatter shape, and sort conventions.

With the registry, that pattern goes away. The plugin walks its own tree (because it knows the layout), turns it into NavItems, and hands the result to the registry. The theme only knows how to render NavItems. A plugin's content is the plugin's concern; themes consume the contract, not the layout.

This is the rule called out elsewhere in the codebase: "plugin-owned data stays inside the plugin." The FrontendNavRegistry is what makes that rule enforceable for navigation.

A worked example: markdown-pages NavBuilder

The scriptor-markdown-pages plugin ships a NavBuilder invokable that walks content/<track>/ recursively for the active track only. Stripped to essentials:

final class NavBuilder
{
    public function __invoke(UrlSegments $url): array
    {
        $activeTrack = $url->first();
        if (! in_array($activeTrack, $this->tracks, true)) {
            return [];
        }
        $trackDir = $this->contentRoot . '/' . $activeTrack;
        return $this->walkDirectory($trackDir, '/' . $activeTrack . '/');
    }

    private function walkDirectory(string $dir, string $urlBase): array
    {
        // Read _index.md frontmatter for label + weight, recurse
        // into subdirectories, sort by weight then label, return
        // list<NavItem>.
    }
}

Two contracts, two layers:

  • The plugin owns content/<track>/ and the _index.md frontmatter shape (title, weight). It knows how to traverse.
  • The registry receives list<NavItem> and passes it on. It holds no opinion on where the NavItems came from.

The plugin wires the builder up in register():

$context->contributeFrontendNav(new NavBuilder($contentRoot, $tracks));

Anything callable works; an invokable class with constructor-injected dependencies tends to be the cleanest shape for non-trivial builders.

Closing the loop

This is the last extension surface. Recap of the chain across the six chapters:

  1. Discovery finds the plugin on disk (chapter 1).
  2. The PluginManager instantiates it and hands it a context (chapter 2).
  3. The PluginContext is the surface the plugin registers against (chapter 3).
  4. Events let the plugin intercept the page pipeline (chapter 4).
  5. Editor extensions let the plugin add admin surfaces (chapter 5).
  6. The FrontendNavRegistry lets the plugin contribute to the site's navigation without themes reaching into plugin-owned content (this chapter).

Six surfaces, one consistent shape: plugins register through the context, the framework calls them when relevant, the editor's Installed Plugins module reads it all back out for diagnostics. With these in hand, the Tutorial chapters next door walk through building a theme and a module from scratch.


Behind the scenes. Registry and DTO live in boot/Frontend/Nav/. The plugin-side registration method is on boot/Plugin/PluginContext.php. The InfoTheme's consumer code is in InfoTheme.php (renderPluginContributedNav(), renderNavItem(), currentRequestPath()). The scriptor-markdown-pages NavBuilder is at src/NavBuilder.php.