Panels
Navigation
How a resource reaches a menu — the entry it declares, the group it sits in, and the workspace that orders both.
On this page
A registry answers what exists. A menu is a different question — what is shown, under which heading, in what order, and where each entry links. That question is answered by three small objects: an entry a resource declares, a group the application declares, and a workspace that arranges them.
Declaring An Entry
A resource that should appear in a menu implements ProvidesNavigation:
use NyonCode\WireCore\Core\Resources\Contracts\ProvidesNavigation;use NyonCode\WireCore\Core\Resources\Navigation\NavigationItem; public static function navigation(): NavigationItem { return NavigationItem::make('Orders') ->icon('outline:shopping-cart') ->group('sales') ->sort(10) ->badge(fn () => Order::whereNull('shipped_at')->count(), 'danger');}
Static, like identity, and for the same reason: a menu is built from every registered resource at once, and instantiating each to ask what it is called would compose a table and a form per entry. A resource that does not implement it is still registered and routable — it just does not appear, which is what an internal or nested resource wants.
NavigationItem is built on the canonical HasLabel / HasIcon /
HasVisibility concerns rather than on properties of its own, so it speaks the
same vocabulary as every other component. What it adds is only what a menu
needs: group(), sort() and badge(). A badge closure is resolved on every
read, never cached — a count of unshipped orders is wrong the moment it is.
An entry that names no label of its own is named by its resource:
NavigationItem::make() beside ->icon() and ->group() is the ordinary shape,
and the menu shows pluralLabel() — "Orders". A resource that wants a menu
label different from its plural passes one, and that one wins.
Entries Under An Entry
An entry may carry entries of its own, which is the second heading a menu has — one that is not a group, because it is a destination as well as a parent:
use NyonCode\WireCore\Core\Resources\Navigation\NavigationItem; NavigationItem::make('Catalogue') ->icon('outline:squares-2x2') ->children([ NavigationItem::make('Products')->url(route('products.index')), NavigationItem::make('Categories')->url(route('categories.index'))->sort(10), NavigationItem::make('Archive') ->url(route('products.archive')) ->visible(fn (): bool => auth()->user()?->can('viewArchive') ?? false), ]);
One level, and that is on purpose. A child's own children are not read by anything that draws a menu: a sidebar nesting three deep is a sidebar nobody can hit with a mouse, and the third level belongs on the page, as tabs or a secondary nav. Nesting further is not rejected, it is simply not drawn — a caller who does it sees the result immediately.
getChildren() does the filtering and the ordering, not the view: hidden
children are dropped and the rest come back in sort() order, so every surface
that draws a submenu agrees about what is in it. That is the same reason
Workspace filters entries rather than leaving it to the sidebar. A closure is
resolved per read for the reason a badge is — children that depend on what the
current user may see must not be decided once, at registration, for everybody.
hasChildren() answers whether anything would be drawn, which is what decides
whether a row is a link or a disclosure. Ask it rather than counting, because the
children may still be a closure at that point. What a collapsed
rail does with the difference — a
tooltip for a leaf, a popover for a parent — is the shell's business, not this
layer's.
Groups
group() takes a key, not a heading. No resource owns the group it sits in —
several share it — so what the heading says, which icon it carries, where it sits
among the other groups and whether it is shown at all belong to a
NavigationGroup, declared where the application composes that part of itself:
use NyonCode\WireCore\Core\Resources\Navigation\NavigationGroup;use NyonCode\WireCore\Core\Resources\Navigation\NavigationGroups; public function boot(): void{ $this->app->make(NavigationGroups::class)->registerMany([ NavigationGroup::make('sales') ->label(__('nav.sales')) ->icon('outline:banknotes') ->sort(10), NavigationGroup::make('admin') ->sort(90) ->collapsed() ->visible(fn (): bool => auth()->user()?->isAdmin() ?? false), ]); }
A group nothing declares still works. Workspace makes an implicit one from
the key, so ->group('sales') needs no registration; the heading falls back to
Str::headline() of the key. Registering says the five things a bare key cannot
— a heading separate from the key, an icon, an order among the other groups, one
visibility condition covering everything in the group, and whether it folds. The
typed list is the NavigationGroup API below.
The heading and the key are separate on purpose. ->group(__('nav.billing'))
made the translation the array key, so the same menu was keyed differently per
locale; the key is now a slug and HasLabel owns the text. hiddenLabel() keeps
the group without drawing its heading, which is what a menu that separates with
rules rather than words wants.
Folding comes from CanBeCollapsed — ->collapsible() gives the heading a
disclosure, ->collapsed() starts it shut — the same concern Section and
Repeater use, so the pair means one thing across the framework rather than
three. A fold hides, it does not remove: a folded group still shows its entries
in the collapsed rail, where the
heading that would unfold it is not drawn at all.
A provider is not the only place. NavigationGroups is a container
singleton, so anything holding the container may register into it before the menu
is built — a provider's boot() is simply the ordinary moment. A
module does not touch it at all: it returns its group from
Module::navigation() and wire-core registers it while booting the modules, so
a module ships its heading, icon and order beside the resources it groups rather
than leaving them to whoever installed it. Modules are booted in registration
order, so one listed after a module it depends on sees that module's groups
already declared.
Registering the same key twice replaces, which is how an application adjusts a
group that a package shipped without editing the package. NavigationGroups is
a container singleton and is otherwise a plain registry —
its API is five methods.
The Workspace
Workspace arranges the result:
use NyonCode\WireCore\Core\Resources\Workspace; $nav = app(Workspace::class)->navigation();// ['sales' => NavigationGroup, '' => NavigationGroup] ungrouped is the '' key
Groups come back in sort() order, and groups that tie keep the order their
first entry was registered in; within a group, entries follow the same rule.
Hidden entries are dropped, and a hidden group takes its entries with it.
Entries stay keyed by registered key, through the grouping and through the
sort, and each one carries the URL of that key's page. Nothing declares it: the
registry still holds no URL — one that did would be a panel — but the menu asks
where the key is routed and fills in the answer, which is null for a resource
that declares no pages and for an application that routes nothing.
An entry may name its own destination with ->url('https://status.example.com'),
and what it names always wins — an external link, or an application whose shell
has a URL scheme of its own.
Workspace::items() answers the same question without the headings: every
visible entry, flat, in sort() order, keyed by registered key — what a menu
that draws no groups shows. Entries whose group is hidden are not in it either.
Both take a zone, and linkedOnly: true when the menu should hold only
what that zone can reach:
app(Workspace::class)->navigation(); // every entry, unzonedapp(Workspace::class)->navigation(zone: 'business'); // linked into businessapp(Workspace::class)->navigation(zone: 'business', linkedOnly: true);
Workspace does not know what a resource is, and that is deliberate. Its
entries come from the Catalog, which reads any number of RegistrySources —
ResourceRegistry is one, Widgets\DashboardRegistry is another — so a menu
mixes resources, dashboards and anything an application registers later without
Workspace learning about any of them. The router and the global search palette
read the same catalogue, so registering something once reaches all three. Two sources claiming one key is refused rather than resolved: one
entry would otherwise take the other's place, and a menu that quietly lost a row
is noticed on the day that row mattered.
The label fallback above is a resource's, because pluralLabel() is a resource's
word. Anything else in a menu names its own entry.
Like the registry, Workspace owns no routing and no layout — what renders the
menu is the application's. It asks where a key is routed; it does not decide.
Rendering The Menu
@foreach($nav as $group) @if($group->hasVisibleLabel()) <p>{!! icon($group->getIcon()) !!} {{ $group->getLabel() }}</p> @endif @foreach($group->getItems() as $key => $item) {{-- A registered entry with no page of its own still belongs in the menu; it simply is not a link. --}} <a @if($item->getUrl()) href="{{ $item->getUrl() }}" wire:navigate @endif> {!! icon($item->getIcon()) !!} {{ $item->getLabel() }} <x-wire::badge :color="$item->getBadgeColor() ?? 'gray'">{{ $item->getBadge() }}</x-wire::badge> </a> @if($item->hasChildren()) <ul> @foreach($item->getChildren() as $child) <li><a href="{{ $child->getUrl() }}" wire:navigate>{{ $child->getLabel() }}</a></li> @endforeach </ul> @endif @endforeach@endforeach
wire-admin ships this menu already written — see the
sidebar, which draws the same three objects, marks the
active entry from the route being rendered, and holds no state of its own. What
is above is for an application rendering its own frame.
Which Entry Is Active
One reading of the request, built once while the page renders and handed to every
row: ActiveNavigation. Three rules decide, first answer wins.
- What the entry declared.
activeWhen(), below. Nothing else is consulted. - Its registered key, against the key of the route being rendered. This is
what keeps the Orders row lit on
wire.orders.edit: the menu knows the resource, not which of its pages you are on. - Its URL, matched exactly — a trailing slash and a relative URL are normalised away, a query string is not. Two entries over one list, an All and an Archived, are two entries.
aria-current follows the same reading and splits it in two: page for the entry
whose URL is the URL being rendered, and true for the branch you are inside.
That is not pedantry — a resource row stays active on that resource's edit screen,
and the record's tabs above the form are
already saying "this is the page you are on".
When the convention is wrong
Rule 3 is deliberately not a prefix match. A prefix rule would light
/settings on /settings/general/edit — which anyone would want — and would
equally light a Home entry pointing at the shell's own mount path on every page
under it, for ever, because nothing here can tell a section from a root. A row
that is always highlighted is a louder defect than a row that is not highlighted
when it could be.
So the entry that knows says so, and gets exactly what it meant:
NavigationItem::make('Settings') ->url(route('settings.general')) ->activeWhen('settings/*'); // and every page under it
A pattern is matched against the current path and the current route name, so both readings of the same intent work:
->activeWhen('admin.settings.*'); // a route-name pattern->activeWhen(['orders/*', 'invoices/*']); // several->activeWhen(fn (ActiveNavigation $active): bool => $active->page === 'edit');
Declaring it replaces the convention rather than adding to it, which is what makes "never active here" possible to write at all.
Changing A Menu You Did Not Register
Installing a module puts its entries in the menu, and an application
adjusts them through the navigation.building hook
rather than by not installing the module:
$manager->hook(Hook::NavigationBuilding, function (NavigationBuildingPayload $payload) { unset($payload->items['media']); $payload->items['docs'] = NavigationItem::make('Docs')->url('/docs')->sort(90); return $payload;}, for: 'admin');
It runs on the flat, keyed list, before grouping and sorting, so it feeds
navigation() and items() alike — a hook in only one of them would let a sidebar
and a command palette disagree about what is in the menu. Preserve the keys: they
are what a consumer turns into a link. Sorting here is wasted work, because the
item's own sort() orders the menu afterwards.
for: names the zone, which is the only identity a menu has — it belongs to no
component and shows no single registered class. A menu built for no zone carries no
scope, so a scoped callback sits it out.
NavigationItem API
| Method | Returns | Purpose |
|---|---|---|
NavigationItem::make(string|Closure|null $label = null) |
self |
A new entry. No label means the resource's pluralLabel() names it |
label(string|Closure|null $label) |
self |
The entry's own text, overriding that fallback |
hiddenLabel(bool $condition = true) |
self |
Keeps the entry, draws no text — an icon-only row |
icon(string|Icon|Closure|null $icon, string|IconPosition|null $position = null) |
self |
The icon beside the label |
group(string|Closure|null $group) |
self |
The group key this entry sits under; null is the top level |
sort(int $sort) |
self |
Order within its group; ties keep first-appearance order |
badge(mixed $badge, string|Closure|null $color = null) |
self |
A count or short string beside the label, with an optional colour |
url(string|Closure|null $url) |
self |
An explicit destination, which always beats the routed one |
children(array|Closure $children) |
self |
Entries under this one — one level, filtered and sorted on read |
activeWhen(Closure|array|string|null $activeWhen) |
self |
When this entry counts as the page you are on, when the convention is wrong: path or route-name patterns, or a Closure taking ActiveNavigation. Replaces the convention |
visible(bool|Closure $condition = true) / hidden(bool|Closure $condition = true) |
self |
Whether the entry is in the menu at all |
getLabel(): ?string |
string|null |
The resolved text, or null when nothing named it |
hasVisibleLabel(): bool / isLabelHidden(): bool |
bool |
Whether to draw the text |
getIcon(): ?string |
string|null |
The resolved icon name |
getGroup(): ?string |
string|null |
The resolved group key |
getSort(): int |
int |
The sort weight |
getBadge(): ?string |
string|null |
The badge, resolved on every read, never cached |
getBadgeColor(): ?string |
string|null |
Its colour, or null for the consumer's default |
getUrl(): ?string |
string|null |
Explicit URL, else the routed one, else null |
getChildren(): array |
array<int, NavigationItem> |
The visible children, in sort() order |
hasChildren(): bool |
bool |
Whether a row is a disclosure rather than a plain link |
isActiveWhen(ActiveNavigation $active): ?bool |
bool|null |
The entry's own answer, or null when it declared none — three-valued, so a declared “no” cannot fall through to the conventions |
isVisible(): bool / isHidden(): bool |
bool |
The resolved visibility |
ActiveNavigation API
| Method | Returns | Purpose |
|---|---|---|
ActiveNavigation::current() |
self |
The reading for the request being rendered — key, page kind, URL, path and route name. A full page render only: inside a Livewire update the route name is livewire.update |
withKey(?string $key) |
self |
The same reading with the key a host resolved for itself, which is what <x-wire-admin::sidebar :active-key="…"> passes |
isActive(NavigationItem $item, ?string $key = null): bool |
bool |
Whether this entry is where you are, or where you are inside of |
isExactly(NavigationItem $item): bool |
bool |
Whether this entry is the page being rendered |
hasActiveChild(NavigationItem $item): bool |
bool |
Whether anything under it is |
ariaCurrent(NavigationItem $item, ?string $key = null): ?string |
string|null |
'page', 'true', or null |
matchesPatterns(array $patterns): bool |
bool |
Whether the request matches any pattern, by path or by route name |
public ?string $key / $page / $url / $path / $routeName |
string|null |
The reading itself |
NavigationGroup API
| Method | Returns | Purpose |
|---|---|---|
NavigationGroup::make(string $key) |
self |
The key entries point at with group() |
label(string|Closure|null $label) |
self |
The heading. Separate from the key on purpose: a translated heading must not become the array key |
hiddenLabel(bool $condition = true) |
self |
Group the entries, draw no heading |
icon(string|Icon|Closure|null $icon, string|IconPosition|null $position = null) |
self |
Icon beside the heading |
sort(int $sort) |
self |
Order among the other groups; ties keep first-appearance order |
visible(bool|Closure $condition = true) / hidden(bool|Closure $condition = true) |
self |
Shows or hides the whole group — one condition instead of the same one on every resource in it |
collapsible(bool|Closure $condition = true) |
self |
The heading gets a disclosure |
collapsed(bool|Closure $condition = true) |
self |
It starts folded. A fold hides; it never removes entries from the collapsed rail |
withItems(array $items): self |
self |
A copy carrying the entries a menu shows under it — Workspace calls it; a declared group is a singleton, so filling it in place would make the second navigation() call answer differently from the first |
getKey(): string |
string |
The slug, which is also getName() |
getLabel(): ?string |
string|null |
The heading, falling back to Str::headline() of the key |
hasVisibleLabel(): bool |
bool |
Whether to draw the heading |
getItems(): array |
array<string, NavigationItem> |
The entries under it, keyed by registered key |
hasItems(): bool |
bool |
Whether the group would draw anything |
isCollapsible(): bool / isCollapsed(): bool |
bool |
The resolved fold state |
NavigationGroups API
The container singleton the declared groups live in. Workspace reads it; an
application and a package both write to it.
| Method | Returns | Purpose |
|---|---|---|
register(NavigationGroup $group): void |
void |
Declare one group; the same key registered again replaces, which is how an application adjusts a group a package shipped |
registerMany(iterable $groups): void |
void |
The same for several at once |
find(string $key): ?NavigationGroup |
NavigationGroup|null |
The declared group with this key, or null when only entries name it |
has(string $key): bool |
bool |
Whether a key was declared |
all(): array |
array<string, NavigationGroup> |
Every declared group, keyed by key, in registration order |
Workspace API
| Method | Returns | Purpose |
|---|---|---|
navigation(?string $zone = null, bool $linkedOnly = false) |
array<string, NavigationGroup> |
The menu: groups in order, each carrying its entries |
items(?string $zone = null, bool $linkedOnly = false) |
array<string, NavigationItem> |
The same menu flat, without headings |
registered() |
array<string, class-string> |
Every class behind the menu, entry or not |
Catalog API
Everything an application registered, whatever kind it is — the one list the menu, the router and the search palette read.
| Method | Returns | Purpose |
|---|---|---|
all(): array |
array<string, class-string> |
Every registered class, keyed, in registration order; refuses two sources claiming one key |
implementing(string $capability): array |
array<string, class-string> |
Only those implementing one contract — ProvidesNavigation, ProvidesPages, GloballySearchable |
find(string $key): ?string |
class-string|null |
The class with this key |
has(string $key): bool |
bool |
Whether a key is registered |
A registry becomes one of its sources by implementing RegistrySource
(registeredClasses(): array), which is how a dashboard registry reaches all
three surfaces without any of them importing it. Anything the router may address
also implements HasRegistryKey (static key(): string) — ProvidesPages
extends it, because a page that cannot be addressed cannot be given a URL.
Related
- Resources — the owner an entry names
- Routing — where an entry's URL comes from, and what a zone changes
- Modules — a whole area's entries declared in one manifest
- The Sidebar — the menu
wire-admindraws from all of this - Global Search — the palette reading the same catalogue
- Widgets — dashboards, the other kind of thing a menu holds