TL;DR
- The sidebar, the new section switcher, and breadcrumbs all read the same
config/menu.phplist — so they can't drift. - Sections are resolved by longest URL-prefix match, so a detail page like
/admin/roles/42/editkeeps its parent section active even when no menu leaf matches it. - One
SectionResolveraction + Pest tests. Code is public in cleaniquecoders/kickoff.
The drift problem
Most sidebars rot the same way: you add a page, wire it into the menu, then forget the breadcrumb. Now the nav says one thing and the breadcrumb says another. The bug isn't the code — it's having two sources of truth for the same tree.
So in kickoff I collapsed everything to one list. config/menu.php names the builders, and everything else derives from it.
// config/menu.php
return [
'globals' => ['sidebar'], // pinned top (Dashboard, Notifications)
'sections' => ['administration'], // collapsible section groups
'footer' => ['sidebar-footer'], // pinned bottom
];
Add a section by creating its menu builder and appending its key here. That's the whole contract.
Resolving the active section
The section switcher needs to know which section owns the current page. Matching the exact URL against menu leaves fails immediately — detail pages (/admin/roles/42/edit) aren't menu items. The fix is longest-prefix wins:
foreach (self::leafUrls($items->all()) as $url) {
if ($currentUrl === $url || str_starts_with($currentUrl, rtrim($url, '/').'/')) {
$matchLen = max($matchLen, strlen($url));
}
}
// ...
$active = collect($sections)->where('matchLen', '>', 0)
->sortByDesc('matchLen')->first() ?? ($sections[0] ?? null);
Two design decisions worth calling out:
| Decision | Why |
|---|---|
| Longest-prefix match, not exact | Sub-pages keep their parent section selected |
| Label/icon/landing come from the builder heading | No duplicated metadata — builder is the source of truth |
Unauthorized or empty sections dropped in resolve()
|
The switcher never renders a section you can't enter |
| Fallback to first section when nothing matches | A hub page still gets a sane active state |
The resolver returns both the visible sections and the active one, so the Blade view is dumb — it just renders what it's handed.
Breadcrumbs from the same tree
Because breadcrumbs read the same config/menu.php, they can't disagree with the sidebar. Leaf pages are fully automatic; detail pages resolve their parent and push a leaf:
Breadcrumb::current(); // Dashboard > Administration > Settings
Breadcrumb::for('admin.roles.index')->push(__('Role Details'));
Testing the resolver
This is exactly the kind of pure logic that's cheap to lock down with Pest — no browser needed.
test('the active section owns the current URL by prefix', function () {
actingAs($this->admin);
// A sub-page that is NOT itself a menu leaf.
app()->instance('request', Request::create(route('admin.roles.index').'/42/edit'));
$active = SectionResolver::resolve()['active'];
expect($active['key'])->toBe('administration')
->and($active['owns'])->toBeTrue();
});
Other cases worth pinning: every section exposes a real (non-#) landing URL, and an unmatched path falls back to the first section.
Takeaway
If two UI surfaces describe the same hierarchy, don't let them each own a copy of it. Give them one registry and derive. The section switcher was almost free to add afterward — it's just another reader of a list that already existed.
Code's public: cleaniquecoders/kickoff.
Top comments (0)