Navigation
Namespace Hilms\Navigation. Menus are what a visitor navigates by: the header, the mobile sheet and the footer draw nothing the panel did not put there. No address a visitor follows is written into a Blade file.
The module builds on theme and knows none of the modules above it. pages and catalog register their own models with it; it imports neither.
Where the pieces are
Section titled “Where the pieces are”| Piece | Where |
|---|---|
| The models | src/Models/{Menu,MenuItem,MenuLocation}.php |
| Places and audiences | src/Enums/{MenuArea,Audience}.php, src/Support/MenuLocations.php |
| What a menu may point at | src/Contracts/MenuLinkable.php, src/Concerns/LinkedFromMenus.php, src/Support/LinkSources.php |
| What the theme reads | src/Support/MenuTree.php, src/View/Components/Menu.php |
| The panel | src/Filament/Resources/Menus, src/Filament/{AuthorizingMenuItemService,BuilderFields}.php, src/Filament/Livewire/* |
| An address an editor may type | src/Rules/LinkTarget.php |
| The theme’s views | themes/hilms/views/navigation/components/{menu,link}.blade.php |
The package, and what is ours
Section titled “The package, and what is ours”datlechin/filament-menu-builder provides the builder. The models are ours (Menu, MenuItem, MenuLocation, bound through usingMenuModel and its siblings and in the morph map as menu, menu_item and menu_location), because a menu is read on every page a visitor opens, where no panel is current and the package’s own resolution does not reach.
The package ships its strings in English, French, Dutch, Russian and Vietnamese, so Polish is added beside them in lang/vendor/filament-menu-builder/pl/menu-builder.php. MenuBuilderTranslationsTest pins that file key for key against the package’s English one, so a package update that adds a string fails the suite instead of showing English inside a Polish panel.
Places, and why the key carries a language
Section titled “Places, and why the key carries a language”A place is one area in one language. MenuArea is Header or Footer, and the location key carries both:
MenuArea::Header->key('pl'); // 'header.pl'The package keys a location by one unique string, and this is how an installation that speaks two languages fits inside it; nothing about the package’s schema is changed to get it. Hilms\Navigation\Support\MenuLocations builds the list from the languages registry, which answers from a cached snapshot and so never needs a database while the panel boots.
A menu itself has no language: one menu may serve two places and two languages. A language with no menu of its own reads the default language’s, the way the footer always has — an empty header says less than one in another language — and the place remembers which language it was really read from, so the list carries lang when those words are not the page’s and nothing when they are (WCAG 3.1.2).
Audiences and depth
Section titled “Audiences and depth”MenuItem::audience (Audience: Everyone, SignedIn, Guests) says who an entry is drawn for — “My courses” belongs to somebody signed in, “Log in” to a guest. It is added through the package’s addMenuItemFields() hook, which with our own model is the whole extension mechanism: an icon later is one media collection and one field.
A menu is one level deep. A parent with children is a disclosure in the header and an indented list in the sheet and the footer, and a second level has nowhere to be drawn. The plugin’s maxDepth(1) is the rule, and the package’s own MenuHierarchy enforces it: a move may not put an item deeper than one level counting its own children, nor under itself or one of its descendants, nor into another menu. A drag that breaks it is refused with a notification naming the limit, and an indent button that would break it is not drawn.
What a menu may point at
Section titled “What a menu may point at”interface MenuLinkable extends MenuPanelable{ public function getMenuLabel(): string; public function isLinkableFromMenu(): bool; public function getMenuPanelQuery(): Builder;}Plus the package’s own getMenuPanelName(), getMenuPanelTitle() and getMenuPanelUrl(). The trait Hilms\Navigation\Concerns\LinkedFromMenus forgets the menu snapshot whenever such a record is saved or deleted.
A module registers its own model from its own provider, and navigation names none of them:
$this->app->make(LinkSources::class)->register(Page::class, sort: 10);Page, Course and Category are registered today, and the builder grows a picker for each.
Two distinctions matter:
- An item that names a record answers with that record’s own current address, so a renamed page never leaves a stale link.
MenuItem::creatingstores the item undergetMenuLabel()— the record’s plain name — while the picker showsgetMenuPanelTitle(), which carries the language tag. That tag belongs to the panel and never to a visitor. isLinkableFromMenu()is asked as the snapshot is built, so an item whose record is gone, unpublished or written in a language the installation no longer serves is dropped rather than drawn.
A custom link is an address an editor typed, and a custom text with no address is how an editor makes a parent.
An address is checked at three doors
Section titled “An address is checked at three doors”An editor can type a safe address and come back later to change it, so Hilms\Navigation\Rules\LinkTarget — a path, an anchor, https: or mailto:, never javascript: and never a protocol-relative //host — is applied three times:
Hilms\Navigation\Filament\BuilderFields::kept()puts the rule on the address field of the create form and the edit form alike.AuthorizingMenuItemService::update()checks aurlin the data it is handed, whoever the caller is.MenuTreedrops a custom item whose address does not pass as the snapshot is built, so a row written by a seeder, a console session or an import is never drawn either.
BuilderFields::kept() also takes the package’s icon, classes and rel fields and its two raw linkable columns out of both forms: the theme draws none of the three, rel follows from the target, and the columns are the picker’s own bookkeeping. It filters the parent’s components rather than writing the schema out again, so whatever a package update adds still reaches the panel.
The snapshot the theme reads
Section titled “The snapshot the theme reads”MenuTree::for(MenuArea::Header); // ['language' => 'pl'|null, 'items' => [...]]One place, as arrays and scalars only, every place under one cache key (navigation.menus), forever, with a per-request memo so several places cost one cache read. Like Branding::current() it keeps no models: a cache store unserialises only the classes cache.serializable_classes names, so a cached Eloquent graph comes back as an incomplete object and every request after the first one fails. Anything under that key that is not a place — a shape from another version, a key somebody else wrote — is read from the database again rather than drawn.
One key rather than one per location, because forgetting a set of keys means knowing what they are: a language deleted after its snapshot was written would leave an orphan behind that came back with the language.
Every write forgets it: a menu, an item, a place, a linked record, a language. And a reorder goes through the package’s query builder, where no model event fires, so AuthorizingMenuItemService flushes after every move. A warm page costs no menu query at all.
What is written down is what a visitor may be shown at all. Who is shown it — the audience — and which entry they are reading (aria-current="page") are decided as it is drawn, by Hilms\Navigation\View\Components\Menu:
<x-navigation::menu location="header" variant="bar" /><x-navigation::menu location="footer" variant="footer" />variant is bar, list or footer, and the component draws nothing at all when the menu is empty — no nav landmark around nothing. An entry with no address and no children is dropped on both sides, because a label with nothing behind it has nothing to say.
The panel
Section titled “The panel”The Menus resource is a table of name, visibility, the places it is assigned to and the number of items, and a two-column editor: the tree on the left, the pickers on the right (pages, courses, categories, a custom link and a custom text).
This is the one documented exception to the sidebar-actions convention (Conventions): the builder is the package’s own two-column page, so Delete and “Places” are header actions. Delete still never sits beside Save.
The package’s Livewire components authorise nothing of their own, so three thin subclasses and one service do it instead:
| Class | Does |
|---|---|
AuthorizingMenuItemService |
Every write the builder makes — editing, deleting, dragging, indenting — travels through it, so it checks update on the menu. Addressing the Livewire component directly gets a person no further than the panel does |
Filament\Livewire\MenuItems |
The tree; authorises before it opens, and puts ->authorize() on the edit, delete, indent and unindent actions |
Filament\Livewire\MenuPanel |
A record picker. It redeclares the package’s validated property with onUpdate: false, because the package validates its selection on every update through #[Validate], and answers an empty selection with a sentence of its own — the property the package binds is called data, and no editor should be shown that word |
Filament\Livewire\{CreateCustomLink,CreateCustomText} |
The two hand-written entries |
MenuPolicy declares all twelve methods and answers on the record set — ViewAny, View, Create, Update, Delete and DeleteAny:Menu — which NavigationPermissionSeeder gives to the editor role and to nobody else: an instructor has no business in the site’s navigation. Menu is audited on name and visibility, MenuItem on title, url, target and audience — never its order, since a drag would otherwise write a row per item — and MenuLocation on its location.
The module registers one stylesheet of its own through FilamentAsset, published by filament:assets.
What a new installation is handed
Section titled “What a new installation is handed”Hilms\Ops\Actions\SeedDefaultMenus gives a new installation something to navigate by, per enabled language and only when no place of that language has been assigned yet:
- a header menu with the page chosen as the course listing (for everyone) and the page chosen for students (for whoever is signed in);
- a footer menu with the starter legal pages that exist in that language, found by the slug
StarterPagesgives them there.
Seeding translates nothing: a language whose siblings nobody has written yet gets no items and therefore no menu, and falls back to the default language’s. The menus name their language when there is more than one. It lives in ops because it is the one thing that needs both the menus and the pages they point at — navigation sits below pages and knows nothing about them — runs inside Activity::withoutLogging(), creates nothing on a second run and never touches a language an editor has already assigned anything to. hilms:install and hilms:upgrade both run it, through the SeedMenus step.
Adding a link source
Section titled “Adding a link source”- Implement
MenuLinkableon the model and useLinkedFromMenus. - Answer
getMenuLabel()with the record’s plain name andgetMenuPanelTitle()with whatever tells two rows apart in the panel. - Answer
isLinkableFromMenu(): published, and written in a language the installation still serves. - Narrow
getMenuPanelQuery()to the columns a title and an address need, ordered the way an editor would look for a record. - Register it from your module’s provider:
LinkSources::register(YourModel::class, sort: 30).
navigation needs no change, and nothing new has to know what your model is.
app-modules/navigation/tests/Feature: the models and the audit options, the places per language and the fallback to the default one, the snapshot and every write that must flush it, the audiences, the one-level cap on every path into it, the authorisation of all four components, the two builder forms and the fields they drop, the picker’s empty selection, the link rule, the component’s variants and its silence on an empty menu, the layout drawing both places, and the package’s Polish strings against its English ones.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.