Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Menu Builder Laravel Package

biostate/filament-menu-builder

A drag-and-drop menu builder for Filament panels. Create and manage nested navigation menus visually, add custom links or model-based items, reorder entries easily, and integrate flexible, configurable menus into your Laravel app.

View on GitHub
Deep Wiki
Context7

Getting Started

biostate/filament-menu-builder is a Filament plugin that gives you a database-backed, nestable menu system (powered by kalnoy/nestedset) plus an admin UI for editing menu trees, and a rendering layer for your front end.

1. Install and publish

composer require biostate/filament-menu-builder
php artisan vendor:publish --tag="filament-menu-builder-config"
php artisan vendor:publish --tag="filament-menu-builder-migrations"
php artisan migrate

Check config/filament-menu-builder.php first — it controls the resource/model bindings, the Filament navigation group and label, and which "link types" are available in the item editor. Most "I want the menu admin somewhere else" requests are solved here before you touch code.

2. Register the plugin on your panel

use Biostate\FilamentMenuBuilder\FilamentMenuBuilderPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        ->plugin(FilamentMenuBuilderPlugin::make());
}

If you are on Filament v5 no extra service provider registration is needed; the v5.1.0 release dropped the last internal dependency on BladeCaptureDirectiveServiceProvider, so you should not be registering it manually in app or test code either.

3. First use case: a header nav

Create a menu in the admin (slug it header), add a few items, then render it in your layout:

{{-- resources/views/layouts/app.blade.php --}}
<header>
    <x-filament-menu-builder::menu slug="header" />
</header>

That is the whole loop: define the menu in the panel, render it by slug in Blade. Once that works, the next thing to look at is MenuItemTarget — the enum that decides whether a link opens in the same tab or a new one (MenuItemTarget::Self, MenuItemTarget::Blank). It is the most commonly referenced public symbol in the package and the one you will reach for when customising output.

Version requirements: this release adds Laravel 13 support by widening kalnoy/nestedset to ^6.0|^7.0. Composer will select nestedset v6 on Laravel 11/12 and v7 on Laravel 13. Laravel 13 requires PHP >= 8.3; Laravel 11/12 remain supported on PHP 8.2.

Implementation Patterns

Render through a single Blade partial. Rather than sprinkling <x-filament-menu-builder::menu> through every view, wrap it once in resources/views/partials/menu.blade.php and include that. When the plugin's markup changes (or you need to swap in your own recursive item view), you change one file. This also gives you one place to add caching.

Cache the rendered menu, keyed by slug. Menu trees change rarely and are read on every request.

Cache::rememberForever("menu:header", fn () => Blade::render(
    '<x-filament-menu-builder::menu slug="header" />'
));

Then bust it from a model observer on MenuItem/Menu saved/deleted. The nested set package already touches parent nodes on move, so saved alone catches most reorders, but hook deleted too — removing a node does not always fire saved on ancestors.

Use custom link types rather than hardcoding URLs. If your menu needs to point at a model (a page, a category, a product), register a custom link type instead of pasting URLs. That way a slug change does not silently break navigation, and the admin UI gives editors a picker. Keep the resolution logic in the link type, not in the Blade view — the view should only receive a URL and a label.

Treat menus as content, not config. Menus live in the DB, so they need to move between environments. Either seed them explicitly in a seeder, or export the tree in a deploy step. Do not let production become the only place the menu structure exists — it is the classic "we deployed and the nav is empty" incident.

Multi-panel / multi-tenant. Register the plugin only on the panels that should manage menus, and scope by slug naming convention (tenant-{id}-header) if you need per-tenant menus. The package's models are ordinary Eloquent models, so a global scope on Menu works fine — just remember that Menu::fixTree() and nested set operations run against whatever the current scope allows.

Eager load when you build custom output. If you drop the Blade component and walk the tree yourself, use ->with('children') (or defaultOrder()->linkNodes()) rather than lazy-loading children inside the loop. Nested set trees are exactly the shape that turns an innocent @foreach into a few hundred queries.

Test with the plugin registered on a test panel. livewire(MenuResource\Pages\ListMenus::class)->assertCanSeeTableRecords(...) against a panel that calls ->plugin(FilamentMenuBuilderPlugin::make()) is the fastest way to guard your custom link types. Note the v5.1.0 CI change: on Filament v5 you no longer need BladeCaptureDirectiveServiceProvider in TestCase — remove it if you copied it from an older example, it is dead weight and will confuse the next person.

CI matrix, if you maintain a downstream package. The v5.1.0 release widened dev constraints (Pest 2/3/4, larastan 2/3, testbench 9/10/11, PHPStan rules 1/2) and tests Laravel 11/12/13. If you build on top of this plugin, mirror that matrix — the failure modes on larastan 3 / PHPStan 2 in particular are baseline changes, not real bugs.

Gotchas and Tips

The kalnoy/nestedset constraint is the thing to watch. The package now allows ^6.0|^7.0, and nestedset v7 requires illuminate/* >=13.0. That resolves the install conflict from #31, but the reverse case still bites: if your app pins kalnoy/nestedset:^7.0 and you are on Laravel 12, Composer cannot satisfy both and you will get an unresolvable conflict. Pin nothing, or pin ^6.0|^7.0 yourself, and let Composer pick.

PHP 8.2 + Laravel 13 will not install. Laravel 13 requires PHP >= 8.3. If your CI runs a PHP 8.2 / Laravel 13 combination you will get a confusing dependency failure rather than a clean "unsupported" message — the plugin's own CI explicitly excludes that pairing.

Do not let Pint rewrite enum cases. v5.1.0 disables Pint's lowercase_static_reference rule because it rewrote MenuItemTarget::Self to MenuItemTarget::self. If you run Pint with a strict preset that includes that rule, your code referencing the enum case will be "fixed" into something that does not exist and breaks at runtime. Add the same exclusion to your pint.json:

{
    "rules": {
        "lowercase_static_reference": false
    }
}

Nested set trees drift. Reordering through raw model calls, bulk updates, or direct SQL will desynchronise _lft/_rgt/parent_id. Symptoms: items vanish from the tree, or render in the wrong order with no error at all. Menu::fixTree() (or MenuItem::fixTree()) repairs it. If the admin UI looks correct but the front end does not, check the tree before you check your Blade.

Empty menu renders empty, silently. A misspelled slug in <x-filament-menu-builder::menu slug="heder" /> produces no output and no exception. Wrap it during development with a check that the menu exists, or at minimum @if on a Menu::whereSlug(...)->exists() so a typo fails loudly in staging.

Filament version skew is invisible until it is not. v5 of this package targets Filament v5. If you are on a mixed panel suite (some Filament v4, some v5), the plugin registration will succeed but the resource pages may 500 on view resolution. Confirm one Filament major version across the whole app before upgrading the plugin.

Cache invalidation is the number one production issue. A menu edited in the admin that does not change on the front end is almost always a stale Cache::rememberForever key. Key by slug and by Menu::max('updated_at') if you want a cheap, correct fallback without observers:

$version = Menu::whereSlug('header')->max('updated_at');
Cache::rememberForever("menu:header:{$version}", fn () => /* render */);

Old keys linger, so pair it with a short TTL or a scheduled prune.

Extension points worth knowing. Custom link types (registers a new entry in the item editor), the published Blade views (override the item markup without forking the package), the config file (resource, navigation, model bindings), and model swapping if you need extra columns on Menu/MenuItem. Model swapping is the most invasive — do it before you have data, not after.

Debugging tip: because rendering goes through a Blade component, Blade::render() inside a dd() is not always helpful. Test with view('partials.menu', ['slug' => 'header'])->render() in php artisan tinker, and inspect the tree with MenuItem::defaultOrder()->get()->toTree() to confirm the shape before touching the template.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin