krystal-sf/ux-menus
A Laravel package for defining and rendering application menus. Build nested navigation structures, output them in Blade views, and keep menu definitions reusable and consistent across your app's layouts.
First, calibrate expectations: krystal-sf/ux-menus is a Symfony UX package, not a Laravel one. There is no config/app.php provider entry, no Blade directives, no artisan command, and no Illuminate\Contracts implementation. As a Laravel developer you have two realistic paths:
twig/twig (e.g. via rcsi/twigbridge), because rendering is Twig-component based.Minimal steps to evaluate:
composer require krystal-sf/ux-menus
Then read, in this order:
README.md — how a menu is declared and rendered (this package has effectively 0 stars and 0 dependents, so the README is your only real documentation).composer.json — look at type and extra.symfony.bundle / extra.symfony.flex. If it is a bundle, it relies on Flex auto-registration, which Laravel does not have.src/DependencyInjection/ — find the compiler pass. This is the heart of the package: it scans for a service tag (likely something like ux_menu.menu or krystal.ux_menu) and collects tagged services into a menu registry.src/Twig/ — find the extension or Twig component class that turns the registry into renderable output.First use case: an admin sidebar or top-nav where each menu item is a small service class. You tag the service, give it a menu and priority attribute, and the renderer sorts and renders them. In Laravel the equivalent first use case is a tagged collection resolved in a MenuServiceProvider and handed to a Blade component — see below.
The whole package is a compiler pass around Symfony's service tagging. In a Symfony app the shape is roughly:
# services.yaml
App\Menu\DashboardItem:
tags:
- { name: 'ux_menu.item', menu: 'admin', priority: 10 }
The compiler pass collects every tagged service, groups by the menu attribute, sorts by priority, and exposes a MenuInterface / collection to the renderer. Verify the exact tag name from the compiler pass source — it is the single most important string in the package and it is not guaranteed to be documented.
Laravel has the same primitive, just spelled differently. Register items with a container tag and resolve them in a builder:
// App\Providers\MenuServiceProvider.php
public function register(): void
{
$this->app->singleton(MenuRegistry::class, function ($app) {
return new MenuRegistry(
$app->tagged('menu.admin'),
$app->tagged('menu.account'),
);
});
}
public function boot(): void
{
$this->app->tag([DashboardMenuItem::class], 'menu.admin');
$this->app->tag([ProfileMenuItem::class], 'menu.account');
}
final class MenuRegistry
{
public function __construct(private iterable $admin, private iterable $account) {}
public function admin(): array
{
$items = iterator_to_array($this->admin);
usort($items, fn ($a, $b) => $b->priority() <=> $a->priority());
return $items;
}
}
This reproduces the package's behavior without the Symfony coupling, and is the pattern I would ship in a Laravel codebase nine times out of ten.
The cleanest part of the design to steal is the contract. Every item implements a small interface (getLabel(), getRoute()/getUrl(), isVisible(), getPriority()). isVisible() matters more than it looks — it lets you keep authorization logic next to the item instead of scattering @can checks in the template.
In Symfony the renderer is a Twig component or a {{ ux_menu('admin') }}-style function. In Laravel, wrap the registry in an anonymous Blade component:
{{-- resources/views/components/menu.blade.php --}}
<ul {{ $attributes->merge(['class' => 'menu']) }}>
@foreach ($items as $item)
@if ($item->isVisible())
<li><a href="{{ $item->getUrl() }}">{{ $item->getLabel() }}</a></li>
@endif
@endforeach
</ul>
Menus are static per deploy. Cache the resolved array in a Cache::rememberForever() keyed on the menu name and config('app.version'), and flush on deploy. Without this you rebuild the item graph on every request.
composer.json, vendor the compiler pass logic if you must use it, and do not build a critical navigation layer on top of it. Treat it as a reference implementation.name: attribute fails silently — the item simply never appears, with no exception. Debug in Symfony with bin/console debug:container --tag=ux_menu.item. In Laravel the equivalent is php artisan tinker plus app()->tagged('menu.admin') to confirm the collection is non-empty.priority convention is higher first, but plenty of packages sort ascending. Read the usort/uasort line in the compiler pass rather than assuming — wrong direction produces a menu that looks almost right, which is the worst kind of bug.extra.symfony.flex; if you copy the package into a non-Flex skeleton, nothing works and no error tells you why. In Laravel, TwigBridge will not auto-register Twig namespaces either — you must add the bundle's view path to config/view.php or the bridge config.isVisible() is the extension point. If you need per-user menus, override the visibility policy rather than subclassing the registry — subclassing breaks the compiler-pass ordering and is the usual source of "menu items duplicate" bugs.Request or user, you will leak state between queued jobs and long-running workers (Octane especially). Keep items stateless and inject Request at render time.menu attribute. Write a feature test that asserts the rendered menu order on day one — it is cheaper than debugging a silently empty nav in staging.MenuType-style builder class for grouping, and the visibility callback. Anything beyond those three is likely a fork.How can I help you explore Laravel packages today?