everlutionsk/navigation
A PHP library for building navigation structures, with an optional Symfony bundle available for framework integration. Simple and lightweight, it lets you define and manage navigation menus in your applications.
First, verify you can even install it. Before writing a line of code, check the constraint against your runtime:
composer require everlutionsk/navigation --dry-run
If Composer refuses on a PHP 8.2/8.3 + Laravel 11/12 stack, stop here. Do not add --ignore-platform-reqs to force it through in a production app; that path leads to a fork anyway, and you should make that decision consciously rather than by accident.
Where to look first. There is no Laravel service provider, config stub, facade, or Blade view in this package — it is a framework-agnostic tree model with a Symfony bundle as its only first-party adapter. So read, in order:
src/Navigation.php — the container: how items are added, ordered, and filtered.src/NavigationItem.php — the node: children, parent, type, URI, roles, and the matching hooks.src/Factory/ — ItemFactory / PropertyFactory, which hydrate and flatten items from arrays. This is your bridge to config files and DB rows.src/Uri.php — the value object for targets. Confirm whether it handles query strings and fragments or only paths.Release notes tell you the API moved twice: v0.4.0 detached Match from the item and introduced Uri, and v0.6.0 added filter container and roles field. Assume v0.6.0 semantics; ignore blog posts or examples written against 0.2/0.3.
First use case — the footer. Do not start with the primary header nav. Build a static, three-item footer entirely in a config array, render it through a Blade partial, and confirm the tree, ordering, and active-state behavior match expectations. That is a half-day spike that answers the only question that matters: is this model better than the twenty lines of Blade I'd have written anyway?
A minimal Laravel foothold looks like this — note that everything framework-specific is yours, not the package's:
// app/Providers/NavigationServiceProvider.php
public function register(): void
{
$this->app->singleton(NavigationManager::class, fn ($app) => new NavigationManager(
$app['config']->get('navigation'),
$app['request'],
));
}
// config/navigation.php
return [
'menus' => [
'footer' => [
['label' => 'Privacy', 'uri' => '/privacy'],
['label' => 'Terms', 'uri' => '/terms'],
],
],
];
If the spike goes well, treat the whole thing as code you now own (see Gotchas).
Wrap it. Never let the third-party API reach application code. This is the single most important pattern with a library at this maturity level. Define your own contracts and make the package an implementation detail:
interface MenuRepository { public function for(string $menu, ?User $user = null): MenuTree; }
interface MenuRenderer { public function render(MenuTree $tree, string $view): Htmlable; }
Swapping the underlying builder later then costs an afternoon instead of a sprint.
Build the tree from a repository, not from views. Menus in real Laravel apps come from DB tables (a menu_items table with parent_id, order, label, route, permission) or from a CMS. Map those rows into the package's item model in one place:
final class DatabaseMenuRepository implements MenuRepository
{
public function for(string $menu, ?User $user = null): MenuTree
{
return Cache::remember(
"nav:{$menu}:".($user?->getKey() ?? 'guest'),
now()->addHour(),
fn () => $this->build($menu, $user),
);
}
}
Eager-load the item relations (MenuItem::with('children') or a single flat query assembled in PHP) before hydrating — the builder itself will happily recurse and trigger N+1 queries if you hand it lazy relations.
Render recursively with a Blade partial, not with HTML strings. The package models structure; it does not (and should not) emit markup for you. One recursive partial handles every menu on the site:
{{-- resources/views/components/nav/tree.blade.php --}}
<ul {{ $attributes->class(['nav', "nav--{$depth}"]) }}>
@foreach ($items as $item)
<li @class(['active' => $item->isActive(), 'has-children' => $item->hasChildren()])>
<a href="{{ $item->url() }}">{{ $item->label() }}</a>
@if ($item->hasChildren())
<x-nav.tree :items="$item->children()" :depth="$depth + 1" />
@endif
</li>
@endforeach
</ul>
Expose isActive(), url(), label() on your wrapper class so the partial never imports a package type.
Handle active state at the edge. The package ships a manual-match voter (ManualMatch, per v0.3.0), which means active-trail detection is something you configure rather than something you get. In practice, compute it yourself against the current request and pass the flag in:
$item->setActive(
$request->is(ltrim($item->uri, '/'))
|| $request->routeIs($item->routeName)
);
Then propagate activity upward for the "has-active-child" class — that is a five-line recursive walk and not worth hunting the library for.
Filter by role before you render, not during. v0.6.0 added a roles field and a filter container. Use it once, at tree-build time, keyed on the current user:
$tree = $this->navigation->filterByRoles($user?->roles ?? []);
Caveat: the roles field is a flat string list. It is not Gates, not Policies, not multi-tenant scoping. If your authorization story is anything richer than "user has role X," wrap the filter in your own predicate callback and use the package's filter container only as a pass-through.
Cache the compiled tree per role, locale, and tenant. Menus render on effectively every request. Key the cache on all three, and wire invalidation to the same events that already fire on content changes:
Event::listen([MenuItemSaved::class, RoleUpdated::class], fn () => Cache::tags('navigation')->flush());
If you avoid cache tags (file/database stores), key invalidation on explicit menu names instead.
Define menus twice: as config for static chrome, as DB rows for editable chrome. ItemFactory hydrating from arrays (per v0.3.0/v0.4.0) makes the config path trivial; the DB path just produces the same array shape before hydration. One hydration code path, two sources.
There is no NavigationServiceProvider to discover. Laravel package auto-discovery finds nothing. You write the provider, the config, the Blade components, and the tests. Budget accordingly: two days to spike, three to five to production-harden.
Pin the exact version and commit the lockfile. No caret range. Last release was 2021-05-14, there are zero dependents on Packagist, and there is no advisory channel for vulnerabilities. Register it in your dependency inventory with an explicit "unmaintained upstream" annotation and add it to SCA scanning, because nobody else is watching it.
Stale CI badges are not evidence. The Scrutinizer build/quality badges in the README are historical artifacts pointing at a branch that has not moved in years. Do not treat them as a current quality signal in a design review.
The package targets a DI container you don't have. Usage documentation lives in the Symfony bundle repo. Anything you read there assumes Symfony's service container and a Twig renderer. Translate the concepts (tree, item, voter, factory) and ignore the wiring.
Rendering is not pluggable out of the box — but the model is cleanly separable. Verify during the spike whether any HTML generation is baked in. If it is, don't fight it: render from your own wrapper types and never call the library's output methods.
Watch for presentation leaking into the model. With a tree library, it is tempting to hang CSS classes and title attributes off the items. Keep those on your wrapper or compute them in the Blade component. The moment NavigationItem starts carrying view concerns, the abstraction has failed and the swap-out cost doubles.
Localization needs your own layer. There is no i18n hook in the release history. Store a translation key (menu.footer.privacy) as the label and resolve it at render time with __(). Do not store translated strings in the tree if you cache per-role — you will need a locale dimension in the cache key too.
The single-maintainer risk is real and lands on you. Decide explicitly, and record it in an ADR: (a) adopt and vendor the fork under an internal namespace with a named owner, or (b) reimplement a ~200–300 line builder against the same concepts. Given that the surface area you actually use is small — items, children, ordering, URI, roles filter — option (b) is frequently cheaper than maintaining a fork. Either way, keep the wrapper interfaces above so the choice stays reversible.
Write the test fixture first. Before building real menus, write one test that asserts a nested, role-filtered, active-state menu renders correctly. That fixture becomes the contract for whichever implementation you end up with, and it is the only durable artifact from the spike.
Ramp-up is short but concentrated. A competent Laravel engineer is productive in a day or two, but all the knowledge starts with whoever ran the spike. Produce the ADR, a one-page integration guide, one example menu definition, and the test fixture above — otherwise you have created a bus-factor-of-one dependency on top of a bus-factor-of-one library.
How can I help you explore Laravel packages today?