gsebastiao / laravel-authz
Grupos e permissões (RBAC) para Laravel, simples de começar: cascata de prioridade, validade por data, multi-tenant, cache e auditoria opcionais.
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^2.0|^3.0|^4.0|^5.0
Suggests
- gsebastiao/laravel-auditable: Permite auditoria automática dos CRUDs deste pacote (config('authz.audit.enabled')).
Provides
None
Conflicts
None
Replaces
None
README
Controle quem pode fazer o quê no seu sistema Laravel usando grupos e permissões.
if ($user->hasPermission('financeiro.aprovar')) { // mostra o botão "Aprovar" }
- Coloque usuários em grupos ("Financeiro", "RH"...) e dê permissões aos grupos.
- Dê ou negue uma permissão a um usuário específico, quando precisar de uma exceção.
- Defina validade por data ("tem acesso só até 31/12").
- Funciona com
@can,Gate, Policies e middleware de rota do Laravel. - Multi-tenant, cache e auditoria são opcionais e começam desligados.
Índice
- Requisitos
- Instalação
- Primeiros passos (5 minutos)
- Onde checar permissões
- Como a decisão é tomada
- Referência rápida
- Recursos opcionais — catálogo no config, cache, multi-tenant, auditoria, nomes de tabela
- Problemas comuns
- Atualizando da versão 2.x
1. Requisitos
- PHP 8.2 ou superior
- Laravel 11, 12 ou 13
- Uma tabela de usuários (a
userspadrão do Laravel serve)
2. Instalação
Passo 1 — instale o pacote:
composer require gsebastiao/laravel-authz
Passo 2 — rode o instalador:
php artisan authz:install
Ele pergunta se você quer rodar o migrate. São criadas 5 tabelas:
auth_groups, auth_groups_users, auth_permissions,
auth_permissions_groups e auth_permissions_users.
O pacote traz as próprias migrations e o migrate as executa direto: nada é
copiado para o seu projeto, e a pasta database/migrations fica limpa. (Sem
o instalador, php artisan migrate faz o mesmo.)
Quer editar o config ou as migrations? Rode
php artisan authz:install --publish: ele copiaconfig/authz.phpe as migrations para o projeto, e você pode editá-los antes domigrate. Também dá para publicá-los separadamente, comphp artisan vendor:publish --tag=authz-configephp artisan vendor:publish --tag=authz-migrations. Se o projeto já tem uma cópia publicada, ela é a que vale.
Sua tabela de usuários não se chama
users? Responda não quando o instalador perguntar, coloqueAUTHZ_TABLE_USER=sua_tabelano.env(ou ajustetables.usersemconfig/authz.php) e depois rodephp artisan migrate. Veja todas as variáveis em Configurar pelo.env.
Passo 3 — adicione o trait no model de usuário:
// app/Models/User.php use Gsebastiao\LaravelAuthz\Traits\HasAuthz; class User extends Authenticatable { use HasAuthz; // ... }
Pronto. O pacote já está funcionando.
3. Primeiros passos (5 minutos)
Vamos montar um exemplo real: o grupo Financeiro pode ver e aprovar pagamentos.
3.1 Declare as permissões
Em config/authz.php:
'permissions' => [ 'financeiro.ver', 'financeiro.aprovar', ],
E rode:
php artisan authz:sync-permissions
Dica: use o formato
modulo.acao. O pacote separa o módulo (financeiro) e a ação (aprovar) sozinho. Rode este comando sempre que mudar a lista (ex: no deploy) — ele cria as novas, atualiza as alteradas e nunca apaga nada.
3.2 Crie o grupo e dê as permissões a ele
Num seeder, num php artisan tinker ou numa tela de administração:
use Gsebastiao\LaravelAuthz\Facades\Authz; $financeiro = Authz::createGroup('Financeiro', 'Equipe do financeiro'); Authz::grantPermissionToGroup($financeiro, Authz::resolvePermissionId('financeiro.ver')); Authz::grantPermissionToGroup($financeiro, Authz::resolvePermissionId('financeiro.aprovar'));
3.3 Coloque um usuário no grupo
$user = User::find(1); $user->assignRole('Financeiro');
3.4 Cheque
$user->hasRole('Financeiro'); // true $user->hasPermission('financeiro.aprovar'); // true $user->can('financeiro.aprovar'); // true (integração com o Gate do Laravel) $user->hasPermission('rh.ver'); // false
É isso. Todo o resto do pacote são variações desses quatro passos.
4. Onde checar permissões
| Onde | Como |
|---|---|
| Rota | Route::get(...)->middleware('authz.permission:financeiro.ver') |
| Controller | Gate::authorize('financeiro.aprovar'); (erro 403 se não tiver) |
| Blade | @can('financeiro.aprovar') ... @endcan ou @hasPermission('financeiro.aprovar') ... @endHasPermission |
| Model / Service | $user->hasPermission('financeiro.aprovar') |
| Policy | return $user->hasPermission('financeiro.editar') && $fatura->user_id === $user->id; |
| Qualquer lugar | Authz::hasPermission('financeiro.aprovar') (usuário logado) |
Middleware de rota
// Precisa desta permissão Route::get('/pagamentos', ...)->middleware('authz.permission:financeiro.ver'); // Precisa de UMA delas (separe com |) Route::get('/relatorios', ...)->middleware('authz.permission:financeiro.ver|rh.ver'); // Precisa de TODAS (separe com vírgula) Route::post('/pagamentos/aprovar', ...)->middleware('authz.permission:financeiro.ver,financeiro.aprovar'); // Por grupo, com a mesma sintaxe Route::get('/diretoria', ...)->middleware('authz.role:Diretoria');
Visitante não logado vai para a tela de login (401 em requisições JSON);
usuário logado sem permissão recebe erro 403. Ou seja: um 403 significa
sempre que o usuário está logado e a checagem deu false — nunca que ele
não está autenticado.
Nomes não diferenciam maiúsculas de minúsculas nem espaços em volta, tanto
para permissões como para grupos. Atenção à diferença entre os separadores:
| é "qualquer uma destas" e , é "todas estas" — trocar um pelo outro é a
causa mais comum de um 403 inesperado.
Blade
@hasPermission('financeiro.aprovar') <button>Aprovar</button> @endHasPermission @hasAnyPermission(['financeiro.ver', 'rh.ver']) ... @endHasAnyPermission @hasAllPermissions(['financeiro.ver', 'rh.ver']) ... @endHasAllPermissions @hasRole('Financeiro') ... @endHasRole @hasAnyRole(['Financeiro', 'Diretoria']) ... @endHasAnyRole
As diretivas nativas @can / @cannot também funcionam.
Gate e @can
O pacote se liga ao Gate do Laravel automaticamente. Suas Policies e
Gate::define() sempre têm prioridade — o pacote só responde quando nenhuma
regra do seu projeto respondeu. Isso também significa que um "super admin"
via Gate::before continua funcionando:
// AppServiceProvider::boot() Gate::before(fn ($user) => $user->hasRole('Admin') ? true : null);
5. Como a decisão é tomada
Um usuário recebe permissões de dois lugares: dos grupos dele e de exceções individuais (regras só para ele). Quando as regras se contradizem, vence a primeira linha desta tabela que se aplicar:
| # | Situação | Resultado |
|---|---|---|
| 1 | Exceção individual negando | ❌ Negado — nada reverte isso |
| 2 | Exceção individual concedendo | ✅ Concedido |
| 3 | Algum grupo nega de forma absoluta | ❌ Negado |
| 4 | Algum grupo concede | ✅ Concedido |
| 5 | Só há negações comuns de grupo | ❌ Negado |
| 6 | Nenhuma regra | ❌ Negado |
Na prática: se ninguém disse nada sobre o usuário, os grupos decidem. Se nenhum grupo disser nada, a resposta é "não".
Negação comum x absoluta. Uma negação comum de grupo perde para a concessão de outro grupo. Uma negação absoluta vence qualquer grupo — use para regras de compliance ("estagiário nunca aprova pagamento, mesmo que esteja em outro grupo que aprova"):
Authz::grantPermissionToGroup($estagiarios, $aprovarId, [ 'is_granted' => false, // nega 'is_absolute' => true, // e vence os outros grupos ]);
Só contam regras vigentes. Uma regra é ignorada se estiver apagada, se a
start_date ainda não chegou, se a end_date já passou, se o grupo ou a
membresia estiver com status = 0, ou se a permissão estiver desativada.
// Acesso temporário: só até o fim do ano $user->assignRole('Auditoria', ['end_date' => '2026-12-31']); // Acesso que começa no futuro $user->grantPermission('financeiro.aprovar', ['start_date' => '2026-10-01']);
6. Referência rápida
Permissões e grupos podem ser passados pelo nome ('financeiro.aprovar',
'Financeiro') ou pelo id (7). Nomes de permissão não diferenciam
maiúsculas de minúsculas.
No model User (trait HasAuthz)
| Método | O que faz |
|---|---|
hasPermission($p) |
Tem a permissão? |
hasAnyPermission([...]) / hasAllPermissions([...]) |
Tem alguma / todas? |
getPermissionNames() / getPermissionIds() |
Lista das permissões efetivas |
grantPermission($p, $opcoes = []) |
Dá a permissão só para este usuário |
denyPermission($p, $opcoes = []) |
Nega a permissão só para este usuário (vence os grupos) |
revokePermission($p) |
Retira o que foi dado com grantPermission |
clearPermissionOverride($p) |
Retira qualquer regra individual (concessão ou negação) |
syncPermissions([...]) |
Deixa as concessões individuais iguais à lista |
hasRole($g) / hasAnyRole([...]) / hasAllRoles([...]) |
Está no grupo? |
assignRole($g, $opcoes = []) / assignRoles([...]) |
Coloca no grupo (não duplica) |
removeRole($g) |
Tira do grupo |
syncRoles([...]) |
Deixa o usuário exatamente nos grupos da lista |
getRoleNames() / getRoleIds() / getGroups() |
Grupos atuais |
setPrimaryRole($g) / getPrimaryRole() |
Grupo principal |
User::getUsersWithRole($g) |
Todos os usuários de um grupo |
User::whereHasPermission($p) |
Query de usuários com a permissão (sem N+1) |
Prefere separar?
HasRoleseHasPermissionstambém existem e podem ser usados juntos.HasAuthzé só os dois combinados.
No Facade Authz (gerenciar dados)
| Assunto | Métodos |
|---|---|
| Grupos | createGroup($nome, $descricao = null), updateGroup($id, [...]), deleteGroup($id), restoreGroup($id) |
| Membros | addUserToGroup($userId, $groupId, [...]), updateGroupMembership($id, [...]), removeUserFromGroup($id) |
| Permissões | createPermission($nome, $modulo, $acao, $rotulo), updatePermission($id, [...]), deletePermission($id), restorePermission($id) |
| Regras de grupo | grantPermissionToGroup($groupId, $permId, [...]), updateGroupPermission($id, [...]), revokeGroupPermission($id) |
| Regras individuais | grantPermissionToUser($userId, $permId, [...]), updateUserPermission($id, [...]), revokeUserPermission($id) |
| Consultas | hasPermission, hasRole, getEffectivePermissions, getUserGroups, getAssignablePermissions |
| Nome → id | resolvePermissionId('financeiro.aprovar'), resolveGroupId('Financeiro') |
| Cache | forgetUserCache($userId), flushCache() |
Comportamentos importantes:
delete*fazem soft delete (dá para recuperar comrestore*). Passepurge: truepara apagar de vez:Authz::deleteGroup($id, purge: true).grant*eaddUserToGroupnão duplicam. Se a regra ou membresia já existir, ela é atualizada com as opções informadas.update*só aceitam campos conhecidos. Um campo inválido gera um erro que lista os campos aceitos. Retornamfalsequando nada mudou.- Erros de uso (id inexistente, nome duplicado, campo inválido) lançam
Gsebastiao\LaravelAuthz\Exceptions\AuthzExceptioncom uma mensagem que explica o que fazer. - Toda escrita roda dentro de uma transação e dispara um evento do Laravel
(
GroupCreated,UserAddedToGroup,PermissionGrantedToGroup... — vejasrc/Events).
Opções aceitas
| Onde | Campos |
|---|---|
| Grupo | name, description, status |
Membresia (assignRole, addUserToGroup) |
status, start_date, end_date, is_primary, observacao |
| Permissão | permission, module, action, label, description, order, status, tenant_id |
| Regra de grupo | is_granted, is_absolute, start_date, end_date |
| Regra individual | is_granted, start_date, end_date |
Funções globais
Também existem atalhos globais, úteis em qualquer lugar: authz(),
hasPermission(), hasAnyPermission(), hasAllPermissions(), hasRole(),
hasAnyRole(), hasAllRoles(), getUserGroups(), getUserPermissions().
Sem $userId, usam o usuário logado.
7. Recursos opcionais
Nada desta seção é necessário para usar o pacote.
Configurar pelo .env (sem editar o config)
O pacote carrega a sua configuração sozinho: não é preciso manter nem editar
config/authz.php para mudar as opções abaixo. Ponha no .env só o que
quiser mudar:
| Variável | Padrão | Para quê |
|---|---|---|
AUTHZ_TABLE_GROUP |
auth_groups |
Tabela dos grupos |
AUTHZ_TABLE_GROUP_USER |
auth_groups_users |
Tabela grupo ↔ usuário |
AUTHZ_TABLE_AUTH_PERMISSION |
auth_permissions |
Tabela das permissões |
AUTHZ_TABLE_AUTH_PERMISSION_USER |
auth_permissions_users |
Tabela permissão ↔ usuário |
AUTHZ_TABLE_AUTH_PERMISSION_GROUP |
auth_permissions_groups |
Tabela permissão ↔ grupo |
AUTHZ_TABLE_USER |
users |
Tabela de usuários do seu projeto |
AUTHZ_USER_MODEL |
(não definir) | Model de usuário; sem valor, o de config/auth.php |
AUTHZ_GATES |
true |
@can, Gate e can: passam a entender as permissões do pacote |
AUTHZ_CACHE_ENABLED |
false |
Liga o cache (recomendado em produção) |
AUTHZ_CACHE_STORE |
(não definir) | Store de cache; sem valor, o padrão do projeto |
AUTHZ_CACHE_TTL |
3600 |
Validade do cache, em segundos |
AUTHZ_CACHE_PREFIX |
authz |
Prefixo das chaves do cache |
AUTHZ_CACHE_INVALIDATE_ON_WRITE |
true |
Limpa o cache sozinho quando o pacote grava |
AUTHZ_TENANT_CONTEXT |
Gsebastiao\LaravelAuthz\Support\NullTenantContext |
Classe do multi-tenant |
AUTHZ_AUDIT_ENABLED |
false |
Liga a auditoria |
AUTHZ_AUDIT_COLUMN_PREFIX |
audit_ |
Prefixo das colunas de applyAuditJoins() |
AUTHZ_AUDIT_JOIN_EVENT |
created,updated,deleted |
Eventos usados por applyAuditJoins(), separados por vírgulas |
AUTHZ_AUDIT_LABEL_COLUMN_GROUP |
name |
Coluna com o nome legível do grupo na auditoria |
AUTHZ_AUDIT_LABEL_COLUMN_PERMISSION |
permission |
Coluna com o nome legível da permissão na auditoria |
AUTHZ_TABLE_USER=usuarios AUTHZ_CACHE_ENABLED=true AUTHZ_TENANT_CONTEXT=App\Support\TenantAtual
Não deixe uma variável em branco (
AUTHZ_CACHE_STORE=): o Laravel lê isso como texto vazio, não como "sem valor". Para voltar ao padrão, apague a linha. Comphp artisan config:cache, rode-o de novo depois de mudar o.env.
Só o catálogo de permissões (permissions, uma lista) precisa do arquivo
config/authz.php — veja abaixo. Se não usar o catálogo, o arquivo é opcional.
Catálogo completo no config
Além da forma curta, cada permissão aceita mais detalhes:
'permissions' => [ 'financeiro.ver', [ 'permission' => 'financeiro.aprovar', 'label' => 'Aprovar pagamentos', 'description' => 'Permite aprovar pagamentos acima de 10 mil', 'order' => 10, ], ],
Para montar uma tela de "gerenciar permissões", use
Authz::getAssignablePermissions().
Cache
Por padrão cada checagem consulta o banco. Em produção, ligue o cache no .env:
AUTHZ_CACHE_ENABLED=true
Tudo o que é feito pelos métodos do pacote atualiza o cache sozinho. Se você
alterar as tabelas direto no banco (SQL, seeder com DB::table), rode:
php artisan authz:cache-reset
Outras opções (AUTHZ_CACHE_STORE, AUTHZ_CACHE_TTL...) estão na
tabela do .env. Validades por data são reavaliadas quando o TTL expira
(padrão: 1 hora).
Multi-tenant (empresas, filiais, sedes...)
Use só se o sistema for dividido em partes que não podem ver os grupos umas das outras. Crie uma classe que diga qual é o tenant atual:
namespace App\Support; use Gsebastiao\LaravelAuthz\Contracts\TenantContext; class TenantAtual implements TenantContext { public function id(): int|string|null { return auth()->user()?->empresa_id; // ou sessão, subdomínio... } }
E aponte para ela no .env:
AUTHZ_TENANT_CONTEXT=App\Support\TenantAtual
(ou, se preferir, em config/authz.php:
'tenant_context' => \App\Support\TenantAtual::class.)
O que muda:
- Grupos passam a pertencer a um tenant. Pode existir um "Financeiro" em
cada empresa, cada um com suas permissões.
createGroup()usa o tenant atual. Um grupo comtenant_idNULL é global: vale em todos os tenants (é o caso dos grupos criados antes de você ligar o multi-tenant). Grupos de outro tenant continuam invisíveis. - Permissões continuam globais, a não ser que você crie uma exclusiva:
Authz::createPermission('exportar.massa', 'relatorios', 'exportar', 'Exportar', tenantId: 3). Permissões exclusivas ficam invisíveis e sem efeito nos outros tenants, e não podem ser dadas a grupos de outro tenant. - Nomes de grupo (
assignRole('Financeiro')) são procurados entre os grupos do tenant atual e os globais. Se existirem dois com o mesmo nome (um global e um do tenant), o pacote avisa e pede o id em vez de escolher por você.
Atenção: o id do tenant nunca pode ser
0nem string vazia.Os métodos de gerenciamento não verificam se o id que você passou pertence ao tenant atual. Numa tela de administração por tenant, valide isso no seu controller antes de chamar
updateGroup,deleteGroupetc.
Auditoria
Com o pacote gsebastiao/laravel-auditable instalado e
AUTHZ_AUDIT_ENABLED=true, toda alteração feita pelo pacote é auditada.
Authz::getAuditTrail('groups', $groupId); // histórico de um grupo // "Criado por / em" numa listagem, sem N+1: Authz::applyAuditJoins('groups', DB::table('auth_groups'))->get();
Sem o pacote de auditoria, tudo funciona normalmente, apenas sem registrar.
Nomes de tabela diferentes
Use as variáveis AUTHZ_TABLE_* no .env (ou edite tables em
config/authz.php) antes de rodar o migrate. Mudar depois não renomeia
tabelas já criadas.
8. Problemas comuns
Grupo 'X' não encontrado — confira o nome (maiúsculas e espaços em volta
não importam). Com multi-tenant, o grupo precisa ser do tenant atual ou global.
Permissão 'x' não encontrada — rode php artisan authz:sync-permissions
depois de declarar a permissão no config.
Dei a permissão mas hasPermission continua false (ou a rota dá 403).
Verifique, nesta ordem:
- O usuário tem uma negação individual? (
denyPermissionvence tudo) - Algum grupo dele tem negação absoluta para essa permissão?
- A regra, o grupo ou a membresia está com
status = 0,start_dateno futuro ouend_dateno passado? - A permissão está desativada ou é exclusiva de outro tenant?
- Cache ligado e você alterou o banco direto? Rode
php artisan authz:cache-reset. - Na rota, você usou
,(exige TODAS) onde queria|(basta UMA)?
Para ver o que o pacote enxerga do usuário:
Authz::getUserGroups($user->id); Authz::getEffectivePermissions($user->id, \Gsebastiao\LaravelAuthz\Enums\PermissionFormat::Permission);
Se a permissão aparece nessa segunda lista mas a rota continua a dar 403, o problema está na rota (item 6), não nos dados.
@can('financeiro.aprovar') retorna false mesmo com a permissão. Veja
se o seu projeto tem um Gate::define('financeiro.aprovar', ...) ou uma
Policy respondendo antes — eles têm prioridade. Confirme também que
gates.auto_register está true em config/authz.php.
Já existe um grupo apagado com o nome... — nomes são únicos mesmo depois
do soft delete. Use Authz::restoreGroup($id) (ou restorePermission) como a
mensagem indica.
Rodei vendor:publish duas vezes. Sem problema: migrations já publicadas
não são duplicadas.
migrate diz que a tabela já existe. Não deve acontecer: as migrations do
pacote ignoram tabelas que já existem. Se acontecer com um nome de tabela
diferente, confira as variáveis AUTHZ_TABLE_* do .env.
9. Atualizando da versão 2.x
A 2.x não conseguia ser instalada numa aplicação nova (o migrate falhava e as
migrations não eram publicadas corretamente). Se você contornou isso
manualmente e já tem as tabelas:
- Não publique as migrations de novo — as tabelas não mudaram. (Se rodar
php artisan migrate, as migrations do pacote ignoram as tabelas que já existem.) - Se usava
HasRoleseHasPermissionsjuntos, agora funciona; ou troque os dois porHasAuthz. - Revise o CHANGELOG — algumas regras passaram a ser
aplicadas como sempre foram documentadas (grupo inativo e
start_datefutura deixam de conceder, por exemplo). - Opcional: nas instalações novas, apagar um usuário apaga as membresias dele
em cascata. Se quiser o mesmo numa instalação antiga, troque a foreign key
auth_groups_users.user_idparaON DELETE CASCADEnuma migration sua.
Testes
composer test
A suíte (85 testes) roda com SQLite em memória e cobre a cascata de decisão, datas de validade, tenant, cache, auditoria, os traits do model, Gate, middlewares, Blade e os comandos artisan. Validada em Laravel 11, 12 e 13.