laravel-dynamic-menu maintained by gsebastiao
Laravel Dynamic Menu
Menus dinâmicos e hierárquicos (N níveis) para Laravel, com controlo de permissões flexível e cache para leitura rápida sem joins.
O pacote é 100% independente: não depende de Spatie nem de nenhum outro pacote de permissões. Podes usá-lo num projeto simples sem permissões, com permissões por string, ou apontando para qualquer tabela de permissões do teu projeto.
Índice
- Requisitos
- Instalação
- Conceito central: os três modos de permissão
- Configuração
- Estrutura de um item de menu
- Uso
- Modos de permissão em detalhe
- Middleware
- Cache
- Seeder
- Testes
- Licença
Requisitos
| Pacote | Versão |
|---|---|
| PHP | ^8.2 |
| Laravel | 11, 12 ou 13 |
Nota sobre Laravel 13 e PHP: o Laravel 13 exige PHP 8.3 no mínimo. Além disso, algumas versões de patch (13.3+) puxam componentes do Symfony 8 que exigem PHP 8.4. Se estiveres em PHP 8.3, considera fixar
laravel/framework:^13.0 <13.3até poderes atualizar o runtime. Em PHP 8.2 usa Laravel 11 ou 12.
Instalação
composer require gsebastiao/laravel-dynamic-menu
Publica a configuração (opcional, mas recomendado):
php artisan vendor:publish --tag=dynamic-menu-config
Corre as migrations (a tabela menu_items é registada automaticamente pelo pacote):
php artisan migrate
Se preferires versionar/editar a migration no teu projeto:
php artisan vendor:publish --tag=dynamic-menu-migrations
O Service Provider e a Facade Menu são registados automaticamente via package auto-discovery.
Conceito central: os três modos de permissão
Cada item de menu tem uma única coluna permission (nullable). Como ela é interpretada depende do permission_mode no config:
| Modo | O que guardas em permission |
Quando usar |
|---|---|---|
none |
(ignorado) | Projeto simples, sem permissões. É o default. |
string |
Texto, ex.: user.create |
Queres permissões mas sem depender de FK. |
id |
Um id, ex.: 42 |
Queres apontar para a tua tabela de permissões. |
A mesma coluna carrega ora uma string ora um id — o config é que decide a leitura. Simples e flexível.
Configuração
Ficheiro config/dynamic-menu.php (resumo dos campos principais):
return [
// 'none' | 'string' | 'id'
'permission_mode' => env('DYNAMIC_MENU_PERMISSION_MODE', 'none'),
// Usado apenas no modo 'id'. Aponta para QUALQUER tabela do teu projeto.
'resolver' => [
'table' => 'permissions',
'key' => 'id',
'column' => 'name',
],
// Callback opcional que devolve as permissões do utilizador.
// null => o pacote tenta $user->getAllPermissions() e faz fallback para [].
'user_permissions' => null,
'cache' => [
'enabled' => true,
'store' => null, // null = store default (podes pôr 'redis')
'key' => 'dynamic_menu',
'ttl' => null, // null = forever
],
'table' => 'menu_items',
'model' => \Gsebastiao\DynamicMenu\Models\MenuItem::class,
];
Estrutura de um item de menu
| Campo | Tipo | Descrição |
|---|---|---|
id |
bigint | PK |
parent_id |
bigint, null | Pai na hierarquia (N níveis) |
name |
string | Identificador interno/máquina (ex.: admin.users) |
label |
string | Texto exibido |
description |
string, null | Descrição opcional |
route |
string, null | Nome de rota, URL ou caminho |
icon |
string, null | Ícone |
permission |
string, null | Permissão (interpretação depende do modo) |
order |
int | Ordenação |
target |
string, null | _self, _blank, etc. |
is_active |
bool | Ativo/inativo |
is_separator |
bool | Item separador (linha) |
badge |
string, null | Badge (ex.: novo ou um contador) |
params |
json, null | Parâmetros de rota ou metadados |
timestamps |
— | created_at / updated_at |
deleted_at |
— | Soft delete |
Uso
Ler a árvore completa
Devolve todos os itens ativos, já aninhados, servidos da cache (sem joins):
use Gsebastiao\DynamicMenu\Facades\Menu;
$tree = Menu::tree();
// Collection de arrays; cada nó tem uma chave 'children' (array)
Filtrar pela permissão do utilizador
Devolve apenas os itens que o utilizador pode ver, de acordo com o modo configurado. Um pai é mantido se tiver filhos visíveis (evita "buracos" na navegação):
// Utilizador autenticado
$menu = Menu::forUser();
// Um utilizador específico
$menu = Menu::forUser($user);
// Ou passando as permissões diretamente
$menu = Menu::forUser(null, ['user.create', 'admin.access']);
No modo none, forUser() devolve simplesmente a árvore completa.
Renderizar em Blade
Cada nó é um array. Exemplo recursivo simples:
{{-- resources/views/partials/menu.blade.php --}}
<ul>
@foreach ($items as $item)
@if ($item['is_separator'])
<li class="separator"><hr></li>
@else
<li>
<a href="{{ $item['route'] ? (\Illuminate\Support\Str::startsWith($item['route'], ['http', '/']) ? $item['route'] : route($item['route'], $item['params'] ?? [])) : '#' }}"
target="{{ $item['target'] ?? '_self' }}">
@if ($item['icon']) <i class="icon-{{ $item['icon'] }}"></i> @endif
{{ $item['label'] }}
@if ($item['badge']) <span class="badge">{{ $item['badge'] }}</span> @endif
</a>
@if (!empty($item['children']))
@include('partials.menu', ['items' => $item['children']])
@endif
</li>
@endif
@endforeach
</ul>
{{-- No layout --}}
@include('partials.menu', ['items' => \Gsebastiao\DynamicMenu\Facades\Menu::forUser()])
Criar itens
use Gsebastiao\DynamicMenu\Models\MenuItem;
$admin = MenuItem::create([
'name' => 'admin',
'label' => 'Administração',
'icon' => 'shield',
'order' => 1,
'permission' => 'admin.access', // no modo 'id' seria o id, ex.: '42'
]);
MenuItem::create([
'parent_id' => $admin->id,
'name' => 'admin.users',
'label' => 'Utilizadores',
'route' => 'admin.users.index',
'order' => 1,
'permission' => 'users.view',
'badge' => 'novo',
'params' => ['tab' => 'active'],
]);
A cache é invalidada automaticamente sempre que gravas, apagas ou restauras um item.
Modos de permissão em detalhe
Modo none (default)
Nada a configurar. O campo permission é ignorado, Menu::tree() e Menu::forUser() devolvem tudo, e o middleware deixa passar sempre. Ideal para projetos simples.
// config/dynamic-menu.php
'permission_mode' => 'none',
Modo string
Guardas a permissão como texto. Por omissão, o pacote obtém as permissões do utilizador via $user->getAllPermissions() (compatível com vários setups, incluindo Spatie) e compara as strings. Podes personalizar com o callback user_permissions:
'permission_mode' => 'string',
'user_permissions' => function ($user) {
// devolve um array/Collection de strings
return $user?->permissions()->pluck('name')->all() ?? [];
},
$menu = Menu::forUser($user); // já filtrado pelas permissões do $user
Modo id
Guardas o id da permissão (como texto) e apontas o resolver para a tua tabela. O pacote resolve o id contra ela quando precisa do rótulo legível, e compara ids na filtragem:
'permission_mode' => 'id',
'resolver' => [
'table' => 'permissions', // a TUA tabela
'key' => 'id',
'column' => 'name',
],
'user_permissions' => function ($user) {
// devolve os IDS das permissões do utilizador
return $user?->permissions()->pluck('id')->all() ?? [];
},
Obter o rótulo legível de um item:
$item->resolvedPermissionLabel(); // ex.: 'gerir.tudo' (lido da tua tabela)
Middleware
O pacote regista o alias menu.permission. Protege rotas exigindo uma permissão:
Route::get('/admin', [AdminController::class, 'index'])
->middleware('menu.permission:admin.access');
- No modo
none, o middleware é um no-op (deixa passar sempre) — o mesmo código funciona em projetos com e sem permissões. - Nos modos
string/id, devolve 403 se o utilizador não tiver a permissão indicada.
Cache
A árvore é lida da cache, sem joins. Configura o store em config/dynamic-menu.php:
'cache' => [
'enabled' => true,
'store' => 'redis', // ou null para o default, 'file', etc.
'ttl' => 3600, // segundos; null = forever
],
A cache é invalidada automaticamente em saved / deleted / restored do model. Para gerir manualmente:
# Reconstruir a cache
php artisan dynamic-menu:cache
# Apenas limpar
php artisan dynamic-menu:cache --flush
Ou por código:
Menu::rebuildCache();
Menu::flushCache();
Seeder
O pacote inclui um seeder de exemplo com uma hierarquia de várias profundidades. Publica-o e adapta:
php artisan vendor:publish --tag=dynamic-menu-seeders
Depois corre:
// database/seeders/DatabaseSeeder.php
$this->call(\Database\Seeders\MenuItemsSeeder::class);
php artisan db:seed --class="Database\\Seeders\\MenuItemsSeeder"
Ou usa diretamente o seeder do pacote sem publicar:
php artisan db:seed --class="Gsebastiao\\DynamicMenu\\Database\\Seeders\\MenuItemsSeeder"
Testes
O pacote usa Pest com Orchestra Testbench.
composer install
vendor/bin/pest
A suite cobre: montagem da árvore em N níveis, ordenação, cache e sua invalidação, filtragem por permissão nos três modos, resolução via tabela no modo id, o middleware e o comando artisan.
Licença
MIT. Ver LICENSE.md.