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:

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.activeor.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
positionfield 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 norstr_starts_withwill 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.mdfrontmatter 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:
- Discovery finds the plugin on disk (chapter 1).
- The PluginManager instantiates it and hands it a context (chapter 2).
- The PluginContext is the surface the plugin registers against (chapter 3).
- Events let the plugin intercept the page pipeline (chapter 4).
- Editor extensions let the plugin add admin surfaces (chapter 5).
- 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.