From 0022f8c8e4ecde09fbaaab53719f2338663b595b Mon Sep 17 00:00:00 2001 From: Yurij Finiv Date: Wed, 23 Sep 2026 12:23:47 +0300 Subject: [PATCH] Support multiple and custom access rules for menu items --- README.md | 20 ++++++++++++++- src/Navigation/Menu/Menu.php | 4 ++- src/Navigation/Menu/MenuManager.php | 26 +++++++++++++++++--- tests/Feature/MenuTest.php | 38 +++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fe7144d..46219b8 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,25 @@ Menu::register(function (MenuManager $menu): void { }, id: 'pages', after: 'blog'); ``` -The `group` and item label are optional. The `permission` argument hides an item when the current user cannot pass Laravel's `can()` check; a guest also cannot see it. Badge callbacks for hidden items are not evaluated. Without a label, the default Blade template translates the route name with `__($route)`, so define keys such as `admin.blog.index` and `admin.pages.index` in your translation files. Without a translation, Laravel displays the route key. The group label is translated the same way. Existing explicit labels and manual `$menu->push(['group' => 'Blog'])` calls remain supported. +The `group` and item label are optional. `permission` accepts a single Laravel ability, an array of abilities (visible if **any** passes), or a callback receiving the authenticated user for custom logic. Items with a permission are hidden from guests; badge callbacks for hidden items are not evaluated. + +```php +// Any of these permissions is enough: +$menu->addItem('admin.blog.index', 'Articles', permission: ['blog.view', 'blog.manage']); + +// Require both permissions and an application-specific condition: +$menu->addItem( + 'admin.pages.index', + 'Pages', + permission: fn ($user): bool => $user->can('pages.view') + && $user->can('pages.publish') + && $user->active, +); + +// The same options work on nested items: +$menu->createMenu('admin.blog.create', 'Create') + ->permission(fn ($user): bool => $user->can('blog.create') && $user->is_editor); +``` Without a label, the default Blade template translates the route name with `__($route)`, so define keys such as `admin.blog.index` and `admin.pages.index` in your translation files. Without a translation, Laravel displays the route key. The group label is translated the same way. Existing explicit labels and manual `$menu->push(['group' => 'Blog'])` calls remain supported. Use `Menu::order('pages', before: 'blog')` in the host application's provider to change the order without editing installed modules. A lower priority appears first, and equal priorities preserve registration order. A missing anchor module is ignored: `Menu::order('pages', before: 'blog')` still shows Pages if Blog is absent. If Blog is registered later, the relative order applies automatically. Overrides for modules that never register are ignored. Cycles between installed modules throw a logic exception. Registration callbacks run once per request, when the menu renders. Cache database results inside callbacks where needed and filter by permissions with the `permission` argument. diff --git a/src/Navigation/Menu/Menu.php b/src/Navigation/Menu/Menu.php index 5b240ff..f2cd58f 100644 --- a/src/Navigation/Menu/Menu.php +++ b/src/Navigation/Menu/Menu.php @@ -2,6 +2,8 @@ namespace Step2dev\LazyMenu\Navigation\Menu; +use Closure; + /** @phpstan-consistent-constructor */ class Menu { @@ -25,7 +27,7 @@ public function children(callable $callback): static return $this; } - public function permission(string $permission): static + public function permission(string|array|Closure $permission): static { $this->items[count($this->items) - 1]['permission'] = $permission; diff --git a/src/Navigation/Menu/MenuManager.php b/src/Navigation/Menu/MenuManager.php index 290104a..ae0b808 100644 --- a/src/Navigation/Menu/MenuManager.php +++ b/src/Navigation/Menu/MenuManager.php @@ -5,6 +5,7 @@ use Closure; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; use LogicException; use Throwable; @@ -89,7 +90,7 @@ public function addItem( ?string $label = null, ?string $icon = null, ?array $children = null, - ?string $permission = null, + string|array|Closure|null $permission = null, ?string $iconView = null, ?array $parameters = null, mixed $badge = null, @@ -169,9 +170,7 @@ private function visible(array $items): array continue; } - $permission = $item['permission'] ?? null; - - if ($permission && ! auth()->user()?->can($permission)) { + if (! $this->permitted($item['permission'] ?? null)) { continue; } @@ -199,6 +198,25 @@ private function visible(array $items): array return $visible; } + private function permitted(string|array|Closure|null $permission): bool + { + if ($permission === null) { + return true; + } + + $user = auth()->user(); + + if ($user === null) { + return false; + } + + if ($permission instanceof Closure) { + return (bool) $permission($user); + } + + return Gate::forUser($user)->any((array) $permission); + } + /** * @throws Throwable */ diff --git a/tests/Feature/MenuTest.php b/tests/Feature/MenuTest.php index 1e26f32..46b6762 100644 --- a/tests/Feature/MenuTest.php +++ b/tests/Feature/MenuTest.php @@ -1,8 +1,10 @@ toContain('bg-slate-900', 'data-menu-group', 'rounded-full bg-cyan-500') ->not->toContain('bg-base-200', 'menu-title', 'class="badge"'); }); + +it('supports multiple abilities and custom access conditions without evaluating hidden badges', function (): void { + Gate::define('blog.view', fn (User $user): bool => true); + Gate::define('pages.view', fn (User $user): bool => false); + + $badgeCalls = 0; + $makeMenu = function () use (&$badgeCalls): MenuManager { + $menu = new MenuManager(new Menu); + $menu->addItem('/public', 'Public'); + $menu->addItem('/any', 'Any allowed', permission: ['blog.view', 'pages.view']); + $menu->addItem('/both', 'Both required', permission: fn (User $user): bool => $user->can('blog.view') && $user->can('pages.view'), badge: function () use (&$badgeCalls): int { + $badgeCalls++; + + return 5; + }); + $menu->addItem('/custom', 'Custom rule', permission: fn (User $user): bool => $user->getAuthIdentifier() === 7); + $menu->addItem('/parent', 'Parent', children: [ + Menu::make('/child', 'Child')->permission(['pages.view', 'blog.view']), + ]); + + return $menu; + }; + + expect(array_column($makeMenu()->visibleItems(), 'label'))->toBe(['Public', 'Parent']) + ->and($badgeCalls)->toBe(0); + + $user = new User; + $user->id = 7; + $this->be($user); + + $visible = $makeMenu()->visibleItems(); + + expect(array_column($visible, 'label'))->toBe(['Public', 'Any allowed', 'Custom rule', 'Parent']) + ->and($visible[3]['children'][0]['label'])->toBe('Child') + ->and($badgeCalls)->toBe(0); +});