anomaly/navigation-module
A Laravel navigation module for creating and managing site menus in Anomaly-based applications. Provides database-backed menu trees, configurable links, item ordering, and admin-friendly controls for rendering flexible, editable navigation across your Laravel views.
The Navigation module gives you admin-managed menus (called "navigations") composed of links to pages, module routes, or raw URLs, plus a rendering helper you drop into your layout. Treat the module as the source of menu structure, not as your front-end menu component.
Install and scaffold
composer require anomaly/navigation-module
php artisan addon:install anomaly.module.navigation
Then in the admin panel a Navigation section appears (typically under Content). Create at least one navigation with a slug like main, add a couple of links pointing at pages, and drag them into order.
Where to look first
vendor/anomaly/navigation-module/src/Navigation/ – the navigation model/repository (NavigationRepositoryInterface).src/Link/ – links, presenters, and the LinkType abstraction. This is where 90% of customization happens.src/Link/Type/ – the built-in link types (page, url, and whatever else the version ships). Copy one of these when writing your own.resources/views/ – the admin and rendering views; mirror paths under resources/{your_theme}/addons/ to override without touching vendor code.src/NavigationModuleServiceProvider.php – routes, bindings, view/asset registrations, and the admin menu registration.First use case: render main in your layout
The module's Twig/Blade bridge exposes a navigation() helper that resolves a navigation by slug and returns its links as presenters:
{# resources/views/layouts/app.twig #}
<ul class="menu">
{% for link in navigation('main').links %}
<li class="{{ link.active ? 'is-active' }}">
<a href="{{ link.url }}" target="{{ link.target }}">{{ link.title }}</a>
</li>
{% endfor %}
</ul>
{{-- Blade equivalent --}}
<ul class="menu">
@foreach(navigation('main')->links as $link)
<li class="{{ $link->active ? 'is-active' }}">
<a href="{{ $link->url }}" target="{{ $link->target }}">{{ $link->title }}</a>
</li>
@endforeach
</ul>
Start with a hardcoded main slug in one layout. Don't try to build a fully recursive, cache-aware menu renderer on day one — get links on screen, then layer on nesting.
Always render through a single partial. Create partials/navigation.twig (or a Blade component) that takes a slug and a CSS class prefix. Every layout includes that partial. When the client wants a footer menu, dropdowns, or a "utility" bar, they add a navigation in the admin and you include the same partial with a different slug — no code change, no deploy.
Handle nesting with recursion, once. Links carry a parent_id and a position; the presenter exposes children. Build one recursive macro and never think about it again:
{% macro tree(links, depth) %}
<ul class="menu menu--{{ depth }}">
{% for link in links %}
<li>
<a href="{{ link.url }}">{{ link.title }}</a>
{% if link.children.count %}
{{ _self.tree(link.children, depth + 1) }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
Programmatic creation via repositories. For seeds, installers, or tests, resolve the repositories and create links rather than inserting rows by hand, so slugs, positions, and type resolution stay consistent:
$navigations = app(NavigationRepositoryInterface::class);
$links = app(LinkRepositoryInterface::class);
$nav = $navigations->create(['slug' => 'footer', 'name' => 'Footer']);
$links->create([
'navigation_id' => $nav->getId(),
'type' => 'url',
'title' => 'Privacy',
'url' => '/privacy',
'position' => 1,
]);
Use this pattern for demo content in migrations — clients can then rearrange everything in the UI without you re-seeding.
Custom link types for domain objects. When a client needs a menu of "service categories" or "product ranges," don't make them paste URLs. Add a LinkType that stores an entry_id and resolves title/URL at render time:
class CategoryLinkType extends LinkType
{
protected $slug = 'category';
public function url()
{
return $this->link->category ? $this->link->category->route('view') : '#';
}
public function title()
{
return $this->link->category->name ?? $this->link->title;
}
}
Register it alongside the built-in types in the addon's service provider, mirror a built-in link type's admin form fields, and you get a menu type the client can manage fully themselves. This is the single highest-leverage extension point in the package.
Let pages attach themselves. Note how the Pages module listens for page lifecycle events and creates/updates its links. If you have another first-class content type (a "Team Member" module, say) that should always be reachable, mirror that listener pattern so new records get a nav link automatically instead of relying on editors.
Cache the rendered output, not the query. Navigation links are cheap but rendered on every request in every layout. Wrap the partial in your own cache keyed by slug and bust it on link/navigation save events:
$html = Cache::rememberForever("nav.main", fn () => view('partials.navigation', [
'links' => Navigation::links('main'),
])->render());
Register a listener on the link/navigation saved/deleted events to Cache::forget("nav.main"). This keeps menus instant while staying correct when an editor reorders things.
Reuse the data, not just the markup. The same link collection is a good source for a sitemap, breadcrumb trail, or mobile drawer. Pull Navigation::links('main') in a controller and transform it, rather than scraping your own HTML.
anomaly/navigation-module) and the URL in the metadata disagree, and docs are thin. Treat vendor/anomaly/navigation-module/src/ as the authoritative documentation — read the link types and views before assuming an API exists.navigation() returns presenters, not models. $link->url, $link->title, and $link->active are presenter accessors; some are computed per-request. Don't try to save() on them or serialize the collection to a queue — store IDs instead.# or throws. Guard url() with a null check in custom link types and add a cleanup listener on entry deletion. Periodically sanity-check the admin list for dead links.foreach over top-level links. Filter by parent_id === null at the root and recurse.position column. Drag-and-drop in the admin writes it. When inserting programmatically, always set position explicitly (or call the repository's sort method) or new links land unpredictably at the top.request()->url() and ignore the built-in flag.en-main, fr-main) rather than assuming isolation.php artisan view:clear after overriding views, php artisan cache:clear after reordering links in the admin if you have added your own render cache, and remember that addon view overrides are picked up by path — a typo in the override path silently falls back to the vendor view, which looks like "my changes did nothing."resources/views/ can be overridden from your theme; every behavior can be extended via a link type or a listener. Vendor edits vanish on composer update.php artisan tinker → Navigation::links('main') is the fastest way to distinguish "no links exist" from "template isn't rendering them."How can I help you explore Laravel packages today?