Search by

yuriitatur / permissions

yuriitatur

simple permission manager

Package info

bitbucket.org/yurii_tatur/permissions

pkg:composer/yuriitatur/permissions

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

v0.1.0 2026-10-30 13:03 UTC

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-posts name, or an action on a resource such as read:post:42 and read: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() / @can goes 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) and eloquent (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-routes that expose the full management surface over HTTP, and GET /me for 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())
ActionsviewAny, view, create, update, delete, restore, forceDelete: Laravel's policy abilities, so can('update', $post) checks update:post:{id}
Permissionsmanage-acl > view-acl, manage-users > view-users, manage-settings, view-reports
Rolessuper-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:

actionpermissionrole
locallocallocaleverything in code
locallocaleloquentfixed permissions, roles managed in the database
localeloquenteloquentfixed verbs, the rest managed in the database
eloquenteloquenteloquenteverything 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.
  • SystemUser is the package's own implementation. The acl:* commands act as a SystemUser unless --actor is given.
  • A listener of AccessCheckEvent still receives a SystemUserInterface actor; 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:7 for an action bound to the post type) are not a catalogue: they are built from an action of the actions table and a resource type of the morph map. They are stored as action_id + resource_type + resource_id where 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 (422 bad_resource) instead of standing for every resource, so a forgotten save() never grants or asks for edit:post:*.
  • edit:post:* covers edit:post:42; edit:post:42 covers only itself. Verbs are atomic: edit never implies read, what a verb means (even a block verb) 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) (or forget() for everyone).
  • $acl->effectiveAccess($user) returns what the user holds (roles, permissions, resource_keys), e.g. for a frontend (GET /me serves it). It shows what is held, never a bypass: a SystemUserInterface or 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, a role, a permission, a grant with 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.

LevelWhat
criticalacl:repository:driver:check finding a local catalogue that does not load
errorDatabase rows naming what the configuration no longer has (the same check); an authenticated user that is no ActorInterface on the REST API
warningA 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
infoWhat a command changed (user#1 assigned role editor to user#7); a denied require*() or AclBuilder check; the consistency check passing
debugA 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:

ResourceDriver modeCapabilities
RoleslocalRead-only list + read-only hierarchy tree
RoleseloquentFull CRUD with the keys (named permissions and resource keys) + hierarchy tree
PermissionslocalRead-only list + read-only hierarchy tree
PermissionseloquentFull CRUD with the owned resource keys + hierarchy tree + roles relation manager
ActionslocalRead-only list
ActionseloquentFull CRUD
GrantslocalRead-only list
GrantseloquentList + 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.permission false)
  • 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:

EndpointDoes
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}/renameRename ({"name": "..."})
POST / DELETE /roles/{name}/parents, /permissions/{name}/parentsAttach / 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 /grantsGrant a key ({"grantee_id": "42", "permission": "edit:post:7"}); optional grantee_type (another actor type) and expires_at
GET /meWhat the caller holds: {roles, permissions, resource_keys, passes_every_permission}, for a frontend
GET /me/explainWhy: each role, permission and resource key with its sources (via); ?permission= narrows it to one key
GET /holders?permission=edit:post:7Who 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.

  • middleware defaults to ['api', 'auth']. Whatever is configured, every call needs an authenticated user that implements ActorInterface (401 for a guest, 403 otherwise).
  • Lists take search, active (roles and permissions), limit, pagination, sort and ids unless search.{resource}.filters is 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_by names 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',
],
  • web serves the tools over HTTP. middleware defaults to ['api', 'auth']. Whatever is configured, every call acts as the authenticated user, who must implement ActorInterface (401 for a guest, 403 otherwise).
  • local serves 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 the SystemUser, like the acl:* commands. Leave it out unless every user of the machine may manage the ACL.
  • Test the server with php artisan mcp:inspector mcp/acl (or acl).
ToolsDo
list-roles, get-role, create-role, update-role, rename-role, delete-role, configure-roleManage roles and the keys they hold
attach-role, detach-role, attach-permission, detach-permission, show-hierarchyPlace roles and permissions in their hierarchies
assign-role, resign-role, sync-actor-rolesAssign roles to actors
list-permissions, get-permission, create-permission, update-permission, rename-permission, delete-permission, configure-permissionManage named permissions and the resource keys they own
list-actions, list-resource-types, create-action, update-action, delete-actionManage the verbs of resource keys
list-grants, grant-access, revoke-grant, prune-grantsManage direct grants
get-actor-access, check-access, explain-access, find-holders, accessible-resourcesAnswer what an actor holds and why, and who holds a key
  • Every write goes through the command handlers, so your AccessCheckEvent listeners 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 in middleware.
  • 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 422 invalid_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.