datlechin/filament-menu-builder
A flexible menu builder for Filament that lets you create and manage nested navigation menus with drag-and-drop sorting, custom links, and model-linked items. Easily build multi-level menus and render them anywhere in your Laravel app.
Prerequisites — Filament v3 (v4 support landed in later releases; check the filament/filament constraint in composer.json before upgrading panels), PHP 8.1+, and a database. The package ships its own menus / menu_items tables, so treat it as persistent app data, not a dev-only tool.
Install
composer require datlechin/filament-menu-builder
php artisan vendor:publish --tag="filament-menu-builder-migrations"
php artisan migrate
php artisan vendor:publish --tag="filament-menu-builder-config" # optional but recommended
Register the plugin in the panel provider (app/Providers/Filament/AdminPanelProvider.php):
use Datlechin\FilamentMenuBuilder\FilamentMenuBuilderPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugin(FilamentMenuBuilderPlugin::make());
}
Reload the admin, and a Menus entry appears in the navigation. Create a menu (e.g. "Main"), give it a slug (main), then add items: custom links, routes, or whatever link types are registered. Items are reorderable by drag-and-drop and nestable via the parent selector.
Where to look first
config/filament-menu-builder.php — model bindings and any link-type registration. Read this before writing models.src/Models/Menu.php and src/Models/MenuItem.php — the link/type/target/order/parent_id columns and the URL accessor are what you render against.src/FilamentMenuBuilderPlugin.php — the fluent ->make() chain is the extension surface (models, resources, link types).menu_items migration — confirm FK/cascade behaviour before you wire up deletion flows.First use case: build a main menu in the admin, then render it in resources/views/layouts/app.blade.php with a recursive Blade partial, and cache the resulting tree. Don't hard-code navigation arrays anymore — the whole point is that a non-developer can edit the nav.
Look up menus by slug, never by ID. IDs differ between local, staging and production, so seed a main and footer menu in a seeder and reference them by slug everywhere in code.
// database/seeders/MenuSeeder.php
Menu::firstOrCreate(['slug' => 'main'], ['name' => 'Main navigation']);
Menu::firstOrCreate(['slug' => 'footer'], ['name' => 'Footer links']);
Render with a recursive partial, not a package-specific Blade tag — you keep full control over markup and Tailwind classes:
{{-- resources/views/partials/menu.blade.php --}}
@props(['items'])
<ul {{ $attributes->merge(['class' => 'flex gap-6']) }}>
@foreach ($items->sortBy('order') as $item)
<li @class(['font-semibold' => $item->children->isNotEmpty()])>
<a href="{{ $item->url }}" target="{{ $item->target ?? '_self' }}"
@if($item->target === '_blank') rel="noopener" @endif>
{{ $item->name }}
</a>
@if($item->children->isNotEmpty())
@include('partials.menu', ['items' => $item->children])
@endif
</li>
@endforeach
</ul>
Cache the tree and invalidate on write. Menus are read on every page and written a few times a year — the ideal cache candidate. An observer on both models is the cleanest invalidation point:
// app/Observers/MenuCacheObserver.php
public function saved(Model $model): void
{
Cache::forget('menu.main');
Cache::forget('menu.footer');
}
public function deleted(Model $model): void
{
$this->saved($model);
}
Resolve menus through a service, not the view. A small MenuRepository (with Cache::rememberForever) injected into a view composer keeps Blade clean and gives you one place to change the query:
View::composer(['layouts.app', 'layouts.marketing'], function ($view) {
$view->with([
'mainMenu' => $this->menus->get('main'),
'footerMenu' => $this->menus->get('footer'),
]);
});
Multi-location / multi-tenant sites. If each tenant needs its own nav, add a tenant_id (or morph) column to menus via your own migration, swap the model in config, and add a global scope. Slugs alone won't be unique across tenants — make the unique index (tenant_id, slug).
Custom link types are the real integration point. The plugin's value is that "link to a Route" or "link to a Page model" can be a first-class admin UI option. Register project-specific types through the plugin's fluent API so editors can pick a Page record and have the URL resolved at render time — that keeps URLs correct when slugs change, which is the main failure mode of hand-typed custom links.
Wire it into Filament navigation tests. Use Livewire/Filament test helpers to assert the resource pages boot without a 500 — plugin registration errors surface here first:
it('renders the menu resource', function () {
$this->actingAs(User::factory()->create())
->get(MenuResource::getUrl())
->assertOk();
});
php artisan migrate before the vendor publish produces a missing-table error on the first admin visit, which looks like a plugin bug but is just an un-run install step.php artisan config:clear (and optimize:clear) — stale cached config is the #1 source of "my custom model is ignored."main. Consider making the slug read-only in the resource form, or add a key column that your code uses instead.with('items') only gets depth 1; children load lazily and you'll get N+1 queries on a nested nav. Use with('items.children.children') to a known depth and cache the result — do not load the tree uncached on every request.orderBy('order'). The drag-and-drop UI writes an order column; relying on the default id ordering makes the rendered menu look "random" after a reorder.menu_items FK: deleting a menu should delete its items. If the migration doesn't cascade (or your DB driver ignores FKs), you'll leave orphaned items that reappear if a menu with the same ID range is created.{!! !!} in a nav is an XSS vector. Prefer {{ $item->name }} and store plain text; use a separate description field if you need markup.target="_blank" without rel="noopener" is a live security finding in audits. Add it in the partial, not the admin — editors won't reliably set rel.MenuPolicy early. Otherwise every logged-in user who can reach the panel can rewrite the site navigation.Menu/MenuItem models, registering custom link types, and toggling the resource registration (useful when you want menus managed but rendered by your own UI only).filament/filament in composer.json and upgrade the plugin in the same PR — a Filament v4 panel with a v3-only plugin version typically fails at plugin boot with a class/method signature error, not a helpful message.How can I help you explore Laravel packages today?