spatie/laravel-menu
Build HTML menus in Laravel with a fluent API. Generate links from routes/actions/URLs, group items, add attributes/classes, and automatically set the active item from the current request. Extensible via macros; renders to HTML ready for Blade.
Installation:
composer require spatie/laravel-menu
Publish the config (optional):
php artisan vendor:publish --provider="Spatie\Menu\MenuServiceProvider"
First Menu Definition:
Define a macro in a service provider (e.g., AppServiceProvider):
use Spatie\Menu\Menu;
use Spatie\Menu\MenuItem;
Menu::macro('main', function () {
return Menu::new()
->add('Home', 'home')
->add('About', 'about')
->add('Contact', 'contact')
->setActiveFromRequest();
});
Blade Rendering:
<nav>
{!! Menu::main() !!}
</nav>
Create a simple top-level menu with active state detection:
Menu::macro('primary', function () {
return Menu::new()
->action('HomeController@index', 'Home')
->action('ProductsController@index', 'Products')
->action('BlogController@index', 'Blog')
->setActiveFromRequest();
});
MenuServiceProvider).Menu::macro('admin', function () {
return Menu::new()
->group('Users', function (Menu $menu) {
$menu->action('UsersController@index', 'List')
->action('UsersController@create', 'Create');
})
->group('Settings', function (Menu $menu) {
$menu->action('SettingsController@index', 'General')
->action('SettingsController@billing', 'Billing');
});
});
actionIf, actionIfCan, or viewIfCan for permission/state-based items:
Menu::macro('dashboard', function () {
return Menu::new()
->actionIfCan('reports.index', 'Reports', 'view-reports')
->actionIf(request()->user()->isAdmin(), 'AdminController@index', 'Admin Panel')
->viewIfCan('notifications.partial', 'Notifications', 'view-notifications');
});
Menu::macro('sidebar', function () {
return Menu::new()
->group('Content', function (Menu $menu) {
$menu->action('PostsController@index', 'Posts')
->action('PagesController@index', 'Pages');
})
->group('Settings', function (Menu $menu) {
$menu->action('SettingsController@general', 'General')
->action('SettingsController@security', 'Security');
});
});
Menu::macro('footer', function () {
return Menu::new()
->url('https://example.com/privacy', 'Privacy Policy')
->route('terms.show', 'Terms of Service')
->action('ContactController@show', 'Contact Us');
});
<nav class="navbar">
{!! Menu::main()->class('navbar-menu') !!}
</nav>
Menu::macro('userDropdown', function () {
return Menu::new()
->add('Profile', 'profile', ['data-testid' => 'user-profile'])
->add('Logout', 'logout', ['class' => 'text-red-500']);
});
trans() helper in menu definitions:
Menu::macro('langSelector', function () {
return Menu::new()
->add(trans('menu.english'), route('lang.switch', 'en'))
->add(trans('menu.spanish'), route('lang.switch', 'es'));
});
setActiveFromRequest() for custom logic:
Menu::macro('customActive', function () {
return Menu::new()
->action('HomeController@index', 'Home')
->action('DashboardController@index', 'Dashboard')
->setActiveFromRequest(function (string $routeName) {
return request()->routeIs($routeName) || request()->wantsJson();
});
});
public function test_main_menu()
{
$menu = Menu::main();
$this->assertEquals('Home', $menu->first()->title);
$this->assertTrue($menu->isActive('home'));
}
AppServiceProvider:
Menu::macro('cachedMenu', function () {
return Cache::remember('menu.sidebar', now()->addHours(1), function () {
return Menu::new()->action('...')->action('...');
});
});
Menu::macro('cmsMenu', function () {
return Menu::new()
->items(MenuItem::fromCollection(
MenuItem::fromCollection(Menu::where('is_active', 1)->get())
));
});
Active State Conflicts
setActiveFromRequest().setActiveFromRequest() with a closure or override the isActive() method:
Menu::macro('strictActive', function () {
return Menu::new()
->action('home', 'Home')
->action('dashboard', 'Dashboard')
->setActiveFromRequest(function ($routeName) {
return request()->routeIsExact($routeName);
});
});
Macro Overwriting
MenuServiceProvider) or check for macro existence:
if (!Menu::hasMacro('main')) {
Menu::macro('main', function () { ... });
}
URL Generation Edge Cases
javascript: links or mailto: links may not render as expected.Link::toUrl() explicitly:
Menu::macro('utils', function () {
return Menu::new()
->link('mailto:contact@example.com', 'Contact')
->link('javascript:alert("Hello")', 'Alert');
});
Blade Escaping
{!! !!} may cause XSS if menu items include user-generated content.@verbatim:
@verbatim
{!! Menu::dynamicMenu() !!}
@endverbatim
Laravel Version Mismatches
actionIfCan with older Laravel versions (pre-5.7).Menu::macro('legacyCan', function () {
return Menu::new()
->actionIfCan(['view-posts', 'user'], 'Posts', 'PostsController@index');
});
Inspect Menu Structure
dd(Menu::main()->toHtml());
->toArray() for a structured output:
dd(Menu::main()->toArray());
Check Active State Logic
isActive() temporarily:
Menu::macro('debugActive', function () {
return Menu::new()
->action('home', 'Home')
->setActiveFromRequest(function ($route) {
dd($route, request()->route()->getName());
return false;
});
});
Validate URL Generation
$this->assertEquals(
route('home'),
Menu::new()->action('home', 'Home')->first()->url
);
Spatie\Menu\MenuItem for domain-specific logic:
class PermissionAwareMenuItem extends MenuItem
{
public function addPermission(string $permission): self
{
$this->data['permission'] = $permission;
return $this;
}
How can I help you explore Laravel packages today?