yuriitatur / permissions
simple permission manager
Requires
- php: ^8.4
- psr/log: ^3.0
- symfony/validator: ^8.0
- yuriitatur/nested-data: dev-master
- yuriitatur/repository-laravel: dev-master
- yuriitatur/search: dev-master
- zircote/swagger-php: ^6.0
Requires (Dev)
- dg/bypass-finals: ^v1.2.2
- filament/filament: ^5.0
- laravel/ai: ^1.3
- laravel/mcp: ^1.1
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^13.0
- symfony/cache: ^8.0
Suggests
- filament/filament: ^5.0 — required for the Filament admin panel integration
- laravel/ai: ^1.3 — to give the ACL tools to a Laravel AI agent
- laravel/mcp: ^1.1 — required for the MCP server and the ACL tools
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-30 13:03:55 UTC
README
A standalone PHP 8.4 RBAC/ACL library with a full Laravel adapter, Filament v5 admin UI, and optional REST API.
Features
- Role-based access control — assign roles to users, check roles with full hierarchy traversal
- Named and resource permissions — a plain
edit-postsname, or an action on a resource such asread:post:42andread:post:* - Any actor — users, teams or any model hold roles and grants, named by type and id
- Direct grants — assign a permission directly to an actor, bypassing the role system, optionally until a time
- Reverse queries —
Post::accessibleBy($user, 'update'), and who holds a key - Gate integration — every unanswered
can()/@cangoes through the package, no policy to register - Multiple-inheritance hierarchy — roles and permissions can extend multiple parents via a materialized-path tree
- Two storage backends —
local(PHP config arrays, read-only at runtime) andeloquent(database) - Command/Handler pattern — every mutation is a typed DTO dispatched to a dedicated handler with PSR-14 events
- Filament v5 plugin — full admin UI with visual hierarchy trees, and read-only pages for local catalogues
- REST API — optional
api-routesthat expose the full management surface over HTTP, andGET /mefor frontends - MCP server and AI tools — the management surface and the reverse queries as MCP tools, which a Laravel AI agent can use too
- Starter catalogue — a seeder with the actions, permissions and roles most applications start from
- PSR-3 logging — every change, refusal and check is logged, at a level that says how much it matters
Installation
composer require yuriitatur/permissions
Publish the config and migrations:
php artisan vendor:publish --tag=permission-config
php artisan vendor:publish --tag=permission-migrations
php artisan migrate
Seed the starter catalogue, a set most applications start from (it can run again, what exists is kept):
php artisan db:seed --class="YuriiTatur\Permissions\Laravel\Database\Seeders\PermissionsDatabaseSeeder"
Starter catalogue (StarterCatalogue::local()) | |
|---|---|
| Actions | viewAny, view, create, update, delete, restore, forceDelete: Laravel's policy abilities, so can('update', $post) checks update:post:{id} |
| Permissions | manage-acl > view-acl, manage-users > view-users, manage-settings, view-reports |
| Roles | super-admin > admin > member; admin holds the four administration permissions, member nothing yet (give it the keys of your resources) |
Set 'almighty-role' => 'super-admin'. The seeders skip a local catalogue; a local one can use the starter catalogue
as is: 'local' => StarterCatalogue::local() (merge your own entries into it).
Configuration
config/permissions.php controls every aspect of the package.
Drivers
'driver' => [
'action' => 'eloquent', // or 'local'
'permission' => 'eloquent',
'role' => 'eloquent',
'grant' => 'eloquent', // 'local': in memory for one request, tests only
],
'hierarchy' => [
'role' => true, // false = disabled
'permission' => true,
],
A local catalogue is defined in the config and never changes at runtime: every write to it (create, update,
rename, delete, configure, attach, detach) is refused with a 409 read_only error, and the Filament plugin shows it
read-only. It suits small projects whose roles and permissions live in code; an eloquent catalogue is managed in
the database.
The catalogues build on each other, action → permission → role, and what is local comes first, so the config never names a database record:
| action | permission | role | |
|---|---|---|---|
| local | local | local | everything in code |
| local | local | eloquent | fixed permissions, roles managed in the database |
| local | eloquent | eloquent | fixed verbs, the rest managed in the database |
| eloquent | eloquent | eloquent | everything in the database |
Any other combination (a local role over eloquent permissions, say) is refused with an InvalidArgumentException
when the repositories are built. A hierarchy is stored like its entity: in the hierarchy tables for an eloquent
catalogue, in local.*-hierarchy for a local one.
Whatever names a user always lives in the database: the roles of a user (roles_users) and the grants, for local
roles and keys too.
Migrations follow the drivers. A local catalogue gets no table, and nothing gets a foreign key to it. Decide the drivers before migrating; changing one later takes a migration of your own.
Keep the database in step with the config. A foreign key cannot guard a row naming a local entry, so removing a
local role, permission or action (or a morph map alias) that the database still names leaves such rows behind. They
give nothing at runtime; run php artisan acl:grant:prune # Delete the expired grants (--at: expired at another time)
php artisan acl:repository:driver:check on deploy or in CI to find them. It also fails
on a broken local config. With every catalogue eloquent there is nothing for it to find but resource types.
Local catalogues
'local' => [
'actions' => [
['name' => 'edit'],
['name' => 'archive', 'resource' => 'post', 'ui_name' => 'Archive'], // post:archive
],
'permissions' => [
['name' => 'manage-posts', 'ui_name' => 'Manage posts', 'resources' => ['edit:post:*', 'post:archive:*']],
['name' => 'review'],
],
'roles' => [
['name' => 'admin', 'ui_name' => 'Administrator', 'permissions' => ['manage-posts']],
['name' => 'editor', 'permissions' => ['review', 'edit:page:*'], 'active' => true],
],
'role-hierarchy' => [
'admin' => ['editor'], // admin holds everything editor has
],
'permission-hierarchy' => [
'manage-posts' => ['review'],
],
],
A local permission owns the resource keys in resources; a local role holds the keys, named and resource ones, in
permissions. A key naming a permission or action that is not configured, a hierarchy naming an entry that is not
configured or a cycle is a configuration error, raised when the catalogue is first read (and by the check command).
The local catalogues are built from the config once per request or job, hierarchies included: they are meant for a
small, rarely changed set of actions, permissions and roles.
Almighty role
'almighty-role' => 'admin', // a holder of this role passes every permission check
A holder of the role passes every permission check made through AccessManagerInterface: the any-permission
middleware, the AclBuilder, the policy and your own hasPermission() / requirePermission() calls. In
Gate::before it also passes every Gate check, your application's own gates and policies included.
Role checks are left alone: holding the almighty role does not make a user an editor for hasRole() or the
any-role middleware. The role is held like any other, through the role hierarchy and only while it is active, so
deactivating it switches the bypass off. Set the option to null to disable it.
Eloquent models
'eloquent' => [
'models' => [
'user' => App\Models\User::class,
'role' => \YuriiTatur\Permissions\Laravel\Models\Role::class,
'permission' => \YuriiTatur\Permissions\Laravel\Models\Permission::class,
'action' => \YuriiTatur\Permissions\Laravel\Models\Action::class,
'grant' => \YuriiTatur\Permissions\Laravel\Models\Grant::class,
],
],
User model
Make your User model an actor and add the helper traits:
use YuriiTatur\Permissions\Entities\ActorInterface;
use YuriiTatur\Permissions\Laravel\Models\Utils\IsActor;
use YuriiTatur\Permissions\Laravel\Models\Utils\HasRoles;
use YuriiTatur\Permissions\Laravel\Models\Utils\HasGrants;
class User extends Authenticatable implements ActorInterface
{
use IsActor; // getActorType(): the morph alias of the model
use HasRoles; // adds $user->roles() belongsToMany (eloquent roles only)
use HasGrants; // adds $user->receivedGrants() / grantedGrants() hasMany
}
An actor is stored as a type and an id: roles_users.actor_type/actor_id, grants.grantee_type/grantee_id and
granter_type/granter_id. The type is the morph alias of the model (its class name when the morph map has none), so
map your actor models too: 'resources-morph-map' => ['user' => User::class, 'team' => Team::class, ...]. Any model
can be an actor the same way: a Team with IsActor holds roles and grants of its own, and a user and a team sharing
an id never share them. Check access as the team ($acl->hasPermission($team, ...)); the package does not know who
belongs to a team. GenericActor($id, $type) names an actor without loading it (type defaults to user);
Laravel\Services\Actors maps types to models.
To make your own Eloquent models usable as permission targets, implement ResourceInterface via the IsResource trait
and register the model in the morph map:
use YuriiTatur\Permissions\Laravel\Models\Utils\IsResource;
class Post extends Model implements \YuriiTatur\Permissions\Entities\ResourceInterface
{
use IsResource;
}
// config/permissions.php
'eloquent' => [
'resources-morph-map' => ['post' => App\Models\Post::class],
],
The morph alias (post) is the resource type, the same key in the domain and in the database. A resource permission on
a type that is not in the morph map is rejected.
Superuser model (SystemUserInterface)
If your application has a separate admin model (e.g. a dedicated Admin Eloquent model), implement SystemUserInterface on it to bypass all permission and role checks unconditionally:
use YuriiTatur\Permissions\Entities\SystemUserInterface;
use YuriiTatur\Permissions\Entities\ActorInterface;
class Admin extends Authenticatable implements ActorInterface, SystemUserInterface
{
public function getId(): string|int
{
return $this->id;
}
}
Any actor that implements SystemUserInterface will always pass hasRole(), hasPermission(), and every gate check — no role or grant lookup is performed. This is the correct way to give an admin-level model blanket access without littering your code with superuser shortcuts.
The bypass is deliberate and unconditional:
- It is meant for dedicated admin models and for processes with no user behind them (console commands, jobs, seeders). Never put it on your regular user model: every instance would be all-powerful.
SystemUseris the package's own implementation. Theacl:*commands act as aSystemUserunless--actoris given.- A listener of
AccessCheckEventstill receives aSystemUserInterfaceactor; whether it may change the ACL is your listener's decision.
Permission model
- Named permissions (
manage-posts) are the only stored catalogue. They form a hierarchy: a named permission gives its named descendants. Names never contain:. - Resource permissions (
edit:post:42,edit:post:*,post:archive:7for an action bound to theposttype) are not a catalogue: they are built from an action of theactionstable and a resource type of the morph map. They are stored asaction_id+resource_type+resource_idwhere they are used,resource_id*standing for every resource of the type. - A named permission owns resource permissions (
permission_resources). It gives its own resource permissions and the ones of all its named descendants; they simply add up. - Roles (
roles_permissions) and grants (grants) hold named and resource permissions side by side. - A user holds the roles assigned to them and every descendant of those roles, so a parent role holds the permissions of its child roles. A role never holds its ancestors.
- An inactive role or permission gives nothing: neither itself nor the descendants reached only through it.
*is only ever explicit:new WholeResource('post')is every post. A resource without an id (a model not saved yet) is refused (422bad_resource) instead of standing for every resource, so a forgottensave()never grants or asks foredit:post:*.edit:post:*coversedit:post:42;edit:post:42covers only itself. Verbs are atomic:editnever impliesread, what a verb means (even ablockverb) is up to your application.- Writes are validated: a key must name an existing permission, or an existing action and a registered resource type.
- What is stored once is guarded by the database too: a role, permission or action name, a role assigned to a user, a key held by a role or granted to a user. Two concurrent writers both get past the handler's check; the second one gets the same 409 the check would have given.
One race is accepted: two creates of the same role, permission or action name. Storing is an upsert (look up, then update or insert), so when the first create commits between the second one's duplicate check and its lookup, the second one updates the new row and reports success: its label and description win, nothing is duplicated. If that matters to you, an atomic lock around the name (or around every create of the catalogue) is enough:
Cache::lock("acl:role:{$name}", 10)->block(5, fn () => app(CreateRoleHandler::class)( new CreateRoleCommand($actor, $name, /* ... */), ));- Roles and named permissions can be renamed (
RenameRoleCommand,RenamePermissionCommand): everything naming them follows, the hierarchy paths included, and a label that was the name follows it too. An action is never renamed: its id is the verb and the resource type it stands for. - The action catalogue is guarded: an action referenced by a permission, a role or a grant cannot be deleted (the handler refuses, the foreign keys back it up), and the verb and resource type of a stored action never change. A bound action needs a registered resource type.
- Roles and permissions in use are guarded the same way: a role a user holds or with child roles, and a permission a role or a grant holds or with child permissions, cannot be deleted. What belongs to the deleted entity (a role's keys, a permission's resource keys, their hierarchy positions) goes with it.
Access checks
Inject AccessManagerInterface from the container, or use the AclBuilder to evaluate several checks at once. A builder without checks always fails.
use YuriiTatur\Permissions\Services\AccessManagerInterface;
use YuriiTatur\Permissions\ValueObjects\PermissionKey;
class PostController
{
public function __construct(private AccessManagerInterface $acl) {}
public function update(Request $request, Post $post): Response
{
// Single check — throws AccessDeniedException on failure
$this->acl->requirePermission($request->user(), PermissionKey::forResource($editAction, $post));
// Named permission check
$this->acl->requirePermission($request->user(), PermissionKey::named('edit-posts'));
// Role check (includes hierarchy descendants)
$this->acl->requireRole($request->user(), 'editor');
// Boolean form
if ($this->acl->hasRole($request->user(), 'admin')) { ... }
// Several checks at once — every check must pass, throws on the first failure
$this->acl->createBuilder()
->requireRole($request->user(), 'editor')
->requirePermission($request->user(), PermissionKey::named('edit-posts'))
->check();
// One passing check is enough — throws when every check fails
$this->acl->createBuilder()
->any()
->requireRole($request->user(), 'admin')
->requirePermission($request->user(), PermissionKey::named('edit-posts'))
->check();
}
}
What a check costs
The interface is bound to the core AccessManager behind two decorators: SystemUserAccessManager (a
SystemUserInterface passes everything) and, when almighty-role is set, AlmightyRoleAccessManager. Inject the
interface; the concrete AccessManager knows no bypass. Outside Laravel, compose them yourself:
new SystemUserAccessManager(new AlmightyRoleAccessManager(new AccessManager(...), 'admin')).
It lives as long as the request (or queued job) it serves, Octane included: the repositories and the manager are scoped, the Gate and the listeners resolve them from the container of the current request, and the API controllers are rebuilt on every request. The first check of a user resolves everything they hold: roles, named permissions and resource keys, through both hierarchies. Every later check of that user in the same request runs in memory.
- A change made through a command handler is seen by the next check: the provider forgets what was resolved on every
ActionHappenedEvent. - After writing the tables directly (e.g.
$user->roles()->attach(...)), call$acl->forget($user)(orforget()for everyone). $acl->effectiveAccess($user)returns what the user holds (roles,permissions,resource_keys), e.g. for a frontend (GET /meserves it). It shows what is held, never a bypass: aSystemUserInterfaceor a holder of the almighty role passes checks for keys it does not list;$acl->passesEveryPermission($user)tells.AccessExplainer::explain($user)tells why: every role, named permission and resource key with the sources it comes from directly (assigned, arole, apermission, agrantwith its expiry).->leadingTo($key)keeps only what leads to one key (GET /me/explain?permission=edit:post:7). It reads the keys entry by entry: not for a hot path.
With the cache on, a new request reads only roles_users and grants: the roles, the permissions,
their hierarchies, the keys of the roles and the resource keys of the permissions come from the cache, and the
handlers invalidate what they change. A direct write to roles_permissions or permission_resources is not seen
until the cache expires, so change them through the handlers.
Reverse queries
Which records an actor may reach, as a query scope on any IsResource model, the ability read like the policy reads
it (the action bound to the type first, then the global one):
Post::accessibleBy($user, 'update')->paginate(); // only the posts the user may update
It adds no constraint for a bypass or a key on every post (update:post:*), and keeps no record for an ability that
names no action. Outside Eloquent, ResourceFilter::accessible($actor, $action, 'post') answers a ResourceIds
(all, or the ids).
Who holds a key, through roles (both hierarchies, active entries only) and unexpired grants:
$holders = app(HolderFinder::class)->holdersOf($key); // ->roles: the roles giving it; ->actors: {type, id}
app(HolderFinder::class)->holdersOfResource($post); // the holders of each action the post supports
GET /holders?permission=edit:post:7 serves it. It walks the role and permission catalogues, reading the keys of
each entry (from the cache when it is on): fine for an admin screen, not for every request. A bypass holds nothing and
is not listed: add the holders of your almighty role yourself.
Route middleware
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('any-role:editor,admin') // one of the roles, or a parent of one
->middleware('any-permission:edit-posts,edit:post:*'); // one of the named or resource keys
Gate policy
UniversalPermissionPolicy is the one policy of the package: it routes Gate checks through AccessManagerInterface.
It is registered for the package's own models, and a Gate::after fallback sends it every check your application
left unanswered (no ability, no policy, or a policy without the method), so your models need no registration:
$user->can('archive', $post); // the resource key post:archive:{id} (or archive:post:{id} for a global action)
$user->can('create', Post::class); // every post: create:post:*
$user->can('manage-acl'); // no subject: the named permission "manage-acl"
$user->can('edit:post:7'); // no subject: a resource key
@can('update', $post) ... @endcan
With a model implementing ResourceInterface (IsResource), the ability names an action: the one bound to the
resource type first, then the global one. Without such an action, the user needs the named permission called after
the ability. A model that is not saved yet is denied ($user->can('edit', new Post()) is false), and a subject that
is neither a resource nor a package model stays unanswered (denied). Your own Gate::define or policy always wins;
'gate-fallback' => false turns the fallback off. Registering the policy for a model yourself still works, but
Laravel does not pass a class name on to a registered policy: a class-level ability is then a named permission.
Mutations (command handlers)
All writes go through typed command DTOs dispatched to handlers. The handlers emit PSR-14 events (AccessCheckEvent, ActionHappenedEvent) before and after each operation.
Roles
use YuriiTatur\Permissions\CommandHandlers\Roles\CreateRoleHandler;
use YuriiTatur\Permissions\Commands\Roles\CreateRoleCommand;
use YuriiTatur\Permissions\Utilities\GenericActor;
app(CreateRoleHandler::class)(new CreateRoleCommand(
creator: new GenericActor($user->id),
roleName: 'editor',
description: 'Can edit posts',
uiLabel: 'Editor',
active: true,
));
// Assign role to user
app(AssignRoleHandler::class)(new AssignRoleCommand(new GenericActor($admin->id), $user, 'editor'));
// Sync all permissions on a role (replaces the full set): named and resource keys
app(ConfigureRoleHandler::class)(new ConfigureRoleCommand(
new GenericActor($admin->id),
'editor',
collect([PermissionKey::named('edit-posts'), PermissionKey::forResource($archiveAction, new WholeResource('post'))]),
));
// Keys from strings, e.g. user input: "edit-posts", "edit:post:42", "post:archive:*"
$key = app(PermissionKeyParser::class)->parse('edit:post:*');
// Change its label, description or state
app(UpdateRoleHandler::class)(new UpdateRoleCommand(new GenericActor($admin->id), 'editor', 'Editor', null, active: false));
// Rename it: its users, keys and hierarchy positions follow (409 when the name is taken)
app(RenameRoleHandler::class)(new RenameRoleCommand(new GenericActor($admin->id), 'editor', 'chief-editor'));
// Delete role (refused with a 409 RepositoryException while a user holds it or it has child roles)
app(DeleteRoleHandler::class)(new DeleteRoleCommand(new GenericActor($admin->id), 'editor'));
Who may change the ACL
The package does not decide who may manage roles, permissions and grants: every handler accepts any ActorInterface,
so do the REST API, the MCP tools, the acl:* commands and the Filament plugin. Your application carries this rule. Each handler
dispatches an AccessCheckEvent (eventName, target, actor) before it stores anything, and a listener that
throws refuses the command:
use Illuminate\Support\Facades\Event;
use YuriiTatur\Permissions\Events\AccessCheckEvent;
use YuriiTatur\Permissions\Exceptions\AccessDeniedException;
use YuriiTatur\Permissions\Services\AccessManagerInterface;
use YuriiTatur\Permissions\ValueObjects\PermissionKey;
Event::listen(AccessCheckEvent::class, function (AccessCheckEvent $event) {
// eventName: createNewRole, assignRole, configureRole, grantAccess, deleteAction, ...
if (!app(AccessManagerInterface::class)->hasPermission($event->actor, PermissionKey::named('manage-acl'))) {
throw new AccessDeniedException("{$event->eventName} is not allowed");
}
});
Restrict the Filament panel, the REST middleware and the MCP middleware the same way.
Delegation ("hand out only what you hold") is such a rule too. Delegation checks it through the access manager,
so a SystemUserInterface or a holder of the almighty role may hand out anything:
use YuriiTatur\Permissions\Services\Delegation;
use YuriiTatur\Permissions\CommandHandlers\Grants\GrantAccessHandler;
use YuriiTatur\Permissions\CommandHandlers\Roles\AssignRoleHandler;
use YuriiTatur\Permissions\CommandHandlers\Roles\ConfigureRoleHandler;
Event::listen(AccessCheckEvent::class, function (AccessCheckEvent $event) {
$delegation = app(Delegation::class);
match ($event->eventName) {
// the grant to store
GrantAccessHandler::SECURITY_EVENT => $delegation->assertHolds($event->actor, [$event->target->grantKey]),
// everything the role gives, its active descendants included
AssignRoleHandler::SECURITY_EVENT => $delegation->assertCanAssign($event->actor, $event->target->getId()),
// the role with the keys it is about to hold
ConfigureRoleHandler::SECURITY_EVENT => $delegation->assertHolds($event->actor, $event->target->getPermissions()),
default => null,
};
});
missingKeys() / missingForRole() list what is missing instead of throwing (AccessDeniedException, 403,
context.missing). Separation of duties and any other rule stay in your listener.
To set every role of an actor at once, SyncUserRolesHandler(new SyncUserRolesCommand($admin, $user, ['editor']))
assigns the missing ones and resigns the others in one transaction ([] clears them), and returns what it changed.
Hierarchy
use YuriiTatur\Nested\ValueObjects\ParentPath;
// Store 'admin' as a root, then make 'editor' its child
app(AttachRoleHandler::class)(new AttachRoleCommand(new GenericActor($admin->id), new ParentPath(), 'admin'));
app(AttachRoleHandler::class)(new AttachRoleCommand(
new GenericActor($admin->id),
new ParentPath('admin'), // ordered list of ancestors root → direct parent
'editor',
));
// Remove that relationship (cascades to the subtree below this path)
app(DetachRoleHandler::class)(new DetachRoleCommand(
new GenericActor($admin->id),
new ParentPath('admin'),
'editor',
));
Permissions
use YuriiTatur\Permissions\ValueObjects\PermissionKey;
app(CreatePermissionHandler::class)(new CreatePermissionCommand(
new GenericActor($admin->id),
'manage-posts',
description: 'Allows managing any post',
uiLabel: 'Manage Posts',
active: true,
));
// The resource permissions it owns (replaces the full set)
app(ConfigurePermissionHandler::class)(new ConfigurePermissionCommand(
new GenericActor($admin->id),
'manage-posts',
collect([
PermissionKey::forResource($editAction, new WholeResource('post')), // edit:post:*
PermissionKey::forResource($archiveAction, new GenericResource(7, 'post')), // post:archive:7
]),
));
Direct grants
// Grant access directly to an actor (bypasses role system): a user, or a team (new GenericActor($id, 'team'))
app(GrantAccessHandler::class)(new GrantAccessCommand(
granter: $admin,
grantee: $user,
key: PermissionKey::forResource($editAction, new GenericResource(42, 'post')),
grantedAt: now(),
expiresAt: now()->addWeek(), // optional: null never expires
));
// Revoke
app(RevokeGrantHandler::class)(new RevokeGrantCommand(
$admin,
$user,
PermissionKey::forResource($editAction, new GenericResource(42, 'post')),
));
An expired grant gives nothing, but it stays stored and still holds its key: granting the same key again is a conflict
(grant_exists, context.expired true) until the grant is revoked or pruned. Schedule the pruning:
Schedule::command('acl:grant:prune')->daily(); // PruneGrantsHandler: one grantPruned event per grant
Patterns left to the application
Some needs are met with the pieces above, on purpose, rather than built into the core.
Teams and tenants. A role or grant "within team X" is a catalogue per team: resolve the repositories for the
current tenant (e.g. a tenant database connection for the ACL tables, or your tenancy package's scoping), and every
role, permission, assignment and grant belongs to that tenant. When a team should hold access itself, make it an actor
(IsActor) and check as the team.
Ownership and conditions. Grant the owner what they may do when the record is created; a verb of your own
(owner) works as well as the standard ones, and stays fine-tunable per record:
Post::created(static fn(Post $post) => app(GrantAccessHandler::class)(new GrantAccessCommand(
new SystemUser(), $post->author, PermissionKey::forResource($update, $post), null,
)));
Deny and partial verbs. Verbs only add access. A verb that takes it away (block) or narrows it (partial: read
the metadata only) is your rule: check it first, or shape the response after the check
($acl->hasPermission($user, $blockPost)).
Logging
The handlers and services take a PSR-3 LoggerInterface (a NullLogger when none is given); Laravel injects the
application's logger, so the records go to the default channel. Messages name actors as type#id and keep the details
in the context.
| Level | What |
|---|---|
critical | acl:repository:driver:check finding a local catalogue that does not load |
error | Database rows naming what the configuration no longer has (the same check); an authenticated user that is no ActorInterface on the REST API |
warning | A command refused (an AccessCheckEvent listener, a conflict, a missing entity, an invalid key), with the command and the actor; a write that lost a race; stored keys skipped because their action no longer exists |
info | What a command changed (user#1 assigned role editor to user#7); a denied require*() or AclBuilder check; the consistency check passing |
debug | A command starting; every hasRole() / hasPermission() answer and bypass; resolving and forgetting an actor's access; Gate answers of the fallback policy; reverse queries, explanations and delegation refusals |
debug writes a record per access check: keep the channel above it in production. To send the package's records to a
channel of their own, bind the logger contextually, e.g.
$this->app->when(CreateRoleHandler::class)->needs(LoggerInterface::class)->give(fn() => Log::channel('acl'));.
An exception the package throws is not logged again unless the record adds to it (a refusal names the command and the
actor): an unexpected failure or a broken local catalogue reaches the application's error reporting as it is. Every
AppException carries its context to Laravel's reporting (context()); add the refusals to dontReport to keep
only the warnings.
Artisan commands
php artisan acl:role:create # Create a role
php artisan acl:role:delete # Delete a role
php artisan acl:role:update # Change the label, description or state of a role
php artisan acl:role:rename # Rename a role, keeping its users, keys and hierarchy positions
php artisan acl:role:assign # Assign a role to a user
php artisan acl:role:resign # Remove a role from a user
php artisan acl:role:configure # Sync all permissions on a role (names and resource keys, none clears it)
php artisan acl:role:attach # Attach a role under a parent in the hierarchy (no parent: as a root)
php artisan acl:role:detach # Detach a role from a parent in the hierarchy
php artisan acl:permission:create # Create a permission
php artisan acl:permission:delete # Delete a permission
php artisan acl:permission:update # Change the label, description or state of a permission
php artisan acl:permission:rename # Rename a permission, keeping everything that refers to it
php artisan acl:permission:configure # Sync the resource keys a permission owns (none clears them)
php artisan acl:permission:attach # Attach a permission under a parent in the hierarchy (no parent: as a root)
php artisan acl:permission:detach # Detach a permission from a parent in the hierarchy
php artisan acl:action:create # Create an action verb
php artisan acl:action:delete # Delete an action verb nothing refers to
php artisan acl:action:update # Change the label or description of an action verb
php artisan acl:repository:driver:check # Check the local catalogues and the database rows naming them
The update commands change only what is given (--description= clears the description); run interactively without
changes, they ask with the current values as defaults.
All commands accept an --actor=<user-id> option to record who performed the operation in the audit event. Without it
they act as a SystemUser.
Filament v5 plugin
Requires filament/filament: ^5.0 (dev dependency — add it yourself in your app).
Register the plugin on your Filament panel:
use YuriiTatur\Permissions\Laravel\Filament\PermissionsPlugin;
$panel->plugin(PermissionsPlugin::make());
What the plugin provides
Four resources appear in the "Permissions" navigation group:
| Resource | Driver mode | Capabilities |
|---|---|---|
| Roles | local | Read-only list + read-only hierarchy tree |
| Roles | eloquent | Full CRUD with the keys (named permissions and resource keys) + hierarchy tree |
| Permissions | local | Read-only list + read-only hierarchy tree |
| Permissions | eloquent | Full CRUD with the owned resource keys + hierarchy tree + roles relation manager |
| Actions | local | Read-only list |
| Actions | eloquent | Full CRUD |
| Grants | local | Read-only list |
| Grants | eloquent | List + Create, named or resource (grants are immutable — delete only) |
Hierarchy tree pages (/roles/hierarchy, /permissions/hierarchy):
- Renders the full hierarchy as a recursive indented tree
- Hover over any node to reveal Add child and Remove action buttons (open Filament modal forms)
- Add root header button; unattached entities are listed below the tree with a one-click Add as root shortcut
- Pages are automatically hidden when the hierarchy is disabled (
hierarchy.role/hierarchy.permissionfalse) - A local hierarchy is shown without its actions
The role and permission edit pages have a Rename action, which opens the record under its new name.
Relation managers:
- Permission edit page → Roles tab: see which roles include this permission, and detach them
- Role edit page → Users tab: the users holding the role directly; assign and resign them
User roles in your own resources. ManageRolesAction edits the roles of any actor record (a user, a team) from
your application's resource, whatever the driver of the roles. It syncs them through SyncUserRolesHandler, one
assignRole / resignRole check per change; a refused change refuses the whole sync and shows why:
use YuriiTatur\Permissions\Laravel\Filament\Support\ManageRolesAction;
public static function table(Table $table): Table
{
return $table->recordActions([ManageRolesAction::make()]); // or a header action of an edit page
}
The plugin shows and searches users by permissions.filament.user-title (email by default).
The role form edits the role's keys itself: its named permissions and its resource keys, offered from the configured
or stored catalogues (synced via ConfigureRoleHandler when they changed).
All writes go through the same command handlers as the Artisan commands and application code — no direct Eloquent writes.
REST API
Enable in config:
'api-routes' => [
'prefix' => '/api/acl',
'middleware' => ['api', 'auth'],
],
This mounts list, show, create and delete endpoints for roles, permissions, actions and grants, and:
| Endpoint | Does |
|---|---|
PUT /roles/{name}, PUT /permissions/{name} | Sync the keys of a role, the resource keys a permission owns |
PATCH /roles/{name}, /permissions/{name}, /actions/{id} | Change the label, description or state; a field left out is kept |
POST /roles/{name}/rename, /permissions/{name}/rename | Rename ({"name": "..."}) |
POST / DELETE /roles/{name}/parents, /permissions/{name}/parents | Attach / detach in the hierarchy ({"parents": ["admin"]}, [] for a root) |
POST /roles/{name}/users, DELETE /roles/{name}/users/{userId} | Assign a role to a user ({"user_id": "42"}), remove it |
POST /grants | Grant a key ({"grantee_id": "42", "permission": "edit:post:7"}); optional grantee_type (another actor type) and expires_at |
GET /me | What the caller holds: {roles, permissions, resource_keys, passes_every_permission}, for a frontend |
GET /me/explain | Why: each role, permission and resource key with its sources (via); ?permission= narrows it to one key |
GET /holders?permission=edit:post:7 | Who holds a key: {roles, actors} |
The operations are documented with OpenAPI attributes on the controllers. Roles and permissions answer with their
hierarchy positions in paths.
middlewaredefaults to['api', 'auth']. Whatever is configured, every call needs an authenticated user that implementsActorInterface(401 for a guest, 403 otherwise).- Lists take
search,active(roles and permissions),limit,pagination,sortandidsunlesssearch.{resource}.filtersis configured, and answer{data, meta}. - Errors answer
{type, message, context}with their status: 404 for a missing entity, 409 for a duplicate or an entity in use (context.used_bynames what holds it), 422 for an invalid request or key. - The API does not decide what the caller may change: see Who may change the ACL.
MCP server and AI tools
The management surface and the reverse queries are also MCP tools: an MCP client (Claude, an IDE, an agent) or a Laravel AI agent can manage the ACL in plain language. Install Laravel MCP, and Laravel AI for agents:
composer require laravel/mcp
composer require laravel/ai # for agents
Serve the tools in config:
'mcp' => [
'web' => [
'path' => '/mcp/acl',
'middleware' => ['api', 'auth:sanctum'],
],
'local' => 'acl',
],
webserves the tools over HTTP.middlewaredefaults to['api', 'auth']. Whatever is configured, every call acts as the authenticated user, who must implementActorInterface(401 for a guest, 403 otherwise).localserves them on stdio,php artisan mcp:start acl, for a client on the same machine:claude mcp add acl -- php artisan mcp:start acl. Whoever starts it runs the console, so it acts as theSystemUser, like theacl:*commands. Leave it out unless every user of the machine may manage the ACL.- Test the server with
php artisan mcp:inspector mcp/acl(oracl).
| Tools | Do |
|---|---|
list-roles, get-role, create-role, update-role, rename-role, delete-role, configure-role | Manage roles and the keys they hold |
attach-role, detach-role, attach-permission, detach-permission, show-hierarchy | Place roles and permissions in their hierarchies |
assign-role, resign-role, sync-actor-roles | Assign roles to actors |
list-permissions, get-permission, create-permission, update-permission, rename-permission, delete-permission, configure-permission | Manage named permissions and the resource keys they own |
list-actions, list-resource-types, create-action, update-action, delete-action | Manage the verbs of resource keys |
list-grants, grant-access, revoke-grant, prune-grants | Manage direct grants |
get-actor-access, check-access, explain-access, find-holders, accessible-resources | Answer what an actor holds and why, and who holds a key |
- Every write goes through the command handlers, so your
AccessCheckEventlisteners decide what the caller may change: see Who may change the ACL. The reads answer any actor the caller names: restrict who reaches the server inmiddleware. - A write to a local catalogue, or to a disabled hierarchy, is not offered.
- Each tool says whether it is read-only, destructive or idempotent, so a client can ask before it acts.
- A refused call answers an error with the body of the REST API and its status, e.g.
{"type": "role_assigned", "message": "...", "context": {...}, "code": 409}; invalid arguments are a 422invalid_arguments.
In a Laravel AI agent
An agent uses the same tools, as the authenticated user, without a server:
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
use YuriiTatur\Permissions\Laravel\Mcp\AclTools;
class AclAssistant implements Agent, HasTools
{
use Promptable;
public function instructions(): string
{
return 'You manage the access control list of the application. Read before you change anything.';
}
public function tools(): iterable
{
return AclTools::all(); // or AclTools::readOnly() for an agent that only answers
}
}
AclTools::all() leaves out what the drivers do not allow, as the server does. A refusal reaches the agent as the
error text, so it can explain it or try again. An agent run without an authenticated user (a queued job) is refused:
set one with Auth::setUser() first.
Testing
composer test
Coverage output: tests/html-coverage/ and tests/clover-coverage/clover.xml.
Every class in src/ must declare PHPUnit coverage attributes — requireCoverageMetadata=true is enforced in phpunit.xml.
License
MIT — see LICENSE.