artisanpack-ui/rbac
ArtisanPack UI RBAC brings role-based access control to Laravel apps with a ready-made UI for managing roles, permissions, and user assignments. Define access rules and administer authorization from your admin panel, simplifying RBAC setup and day-to-day permission management.
Install and boot
composer require artisanpack-ui/rbac
php artisan vendor:publish --tag=rbac-migrations
php artisan migrate
Since v1.1.0 artisanpack-ui/hooks: ^1.3 is pulled in automatically — there is nothing extra to install, but check composer.lock if you were already requiring the hooks package at a different constraint (see Gotchas).
Wire the trait onto your user model
use ArtisanPackUI\Rbac\Concerns\HasRoles;
use ArtisanPackUI\Rbac\Concerns\HasPermissions;
class User extends Authenticatable
{
use HasRoles, HasPermissions;
}
First use case: seed one role, gate one route. Don't try to model the whole permission matrix up front — the package's value shows up fastest when you seed a single admin role with two or three abilities and put @can in a Blade view:
$admin = Role::create(['name' => 'admin']);
$admin->givePermissionTo('users.manage');
$user->assignRole('admin');
@can('users.manage')
<a href="{{ route('users.index') }}">Manage users</a>
@endcan
Where to look first
HasPermissions::getAbilities() — the new method in v1.1.0. It is the canonical, post-filter list of everything the user can do. Read it once early to understand what the package thinks the user's ability surface is before you start debugging why @can doesn't render.Gate::before shim — understand that the package resolves abilities through a global gate hook before your policies run. That single fact explains most "why did this pass?" questions.app.rbac.*) — these are your extension points and where a lot of the v1.1.0 value sits. Register them in a service provider's boot().abilitiesForUser filterThis is the headline feature. Instead of subclassing the user model or writing a custom resolver, you attach abilities from an external source and every downstream consumer — $user->can(), Gate::allows(), @can — honors them for free:
use function ArtisanPackUI\Hooks\add_filter;
add_filter('ap.rbac.abilitiesForUser', function (array $abilities, $user) {
if ($user->tenant?->isEnterprise()) {
$abilities[] = 'reports.export';
}
return $abilities;
}, 10, 2);
Good fits for this seam:
if (Feature::active('beta.reports')) { $abilities[] = 'reports.beta'; }. Keeps rollout state out of your RBAC tables entirely.Keep the filter pure and fast: return a modified array, don't persist anything, and don't re-enter ability resolution from inside it (see Gotchas).
checkingAbilityadd_action('ap.rbac.checkingAbility', function ($user, string $ability, mixed $arguments) {
if (in_array($ability, config('rbac.audited_abilities'), true)) {
AuditLog::record($user, $ability, $arguments);
}
}, 10, 3);
Treat this as a logging seam, not a decision seam. It fires before resolution, so it cannot veto a check — return values are ignored. That makes it safe for compliance trails and unsafe for anything you want to enforce.
add_action('ap.rbac.role.assigned', function ($user, $role) {
Cache::forget("abilities:{$user->id}");
$user->notify(new RoleAssignedNotification($role));
}, 10, 2);
add_action('ap.rbac.role.revoked', function ($user, $role) {
Cache::forget("abilities:{$user->id}");
Session::forget("active_role:{$user->id}");
}, 10, 2);
Because assignRole()/removeRole() fire these after the pivot is written, the listener sees a consistent state — $user->hasRole($role) will already be true/false respectively. Pair every cache key derived from abilities with a matching invalidation here, or you'll ship a stale-permissions bug that only shows up in production.
getAbilities() for UI and serializationpublic function toArray(): array
{
return [
...parent::toArray(),
'abilities' => $this->getAbilities(),
];
}
One flat list beats N @can checks in a loop, and it's the only place the filter's output is visible without going through the gate. Good for:
$this->assertEqualsCanonicalizing([...], $user->getAbilities()).Note the list mixes names and slugs — expect both granularities and normalize in the consumer if you show them to users.
it('lets tenant admins export reports', function () {
$user = User::factory()->create();
$user->assignRole('admin');
add_filter('ap.rbac.abilitiesForUser', fn ($a) => [...$a, 'reports.export']);
expect($user->getAbilities())->toContain('reports.export')
->and($user->can('reports.export'))->toBeTrue();
});
Filter through getAbilities() rather than testing the filter callback directly — it proves the graft actually reaches the gate, which is the thing that broke in older hand-rolled implementations.
artisanpack-ui/hooks is now a hard dependency. This is the one change that can bite on upgrade: call sites are now unconditional, so the rbac package cannot degrade gracefully if hooks is missing or mis-versioned. If your app already requires hooks at ^1.0–^1.2, resolve that constraint before running composer update, or you'll get a conflict that looks like an rbac problem but isn't. Run composer why artisanpack-ui/hooks first when debugging a failed upgrade.
Never call hasPermissionTo() or can() from inside the abilitiesForUser filter. hasPermissionTo() now resolves through getAbilities(), so a check inside the filter re-enters the filter. You get infinite recursion, or — worse — silent truncation if something memoizes mid-flight. If you need conditional logic in the filter, read models and config directly.
Register hooks in a provider that boots early. AppServiceProvider::boot() is fine; a provider registered after the RBAC gate shim may be too late for anything that resolves abilities during boot. If a hook seems to never fire, the first thing to check is registration order, not the callback body.
checkingAbility fires on every check — including every policy call and every @can. A DB write per invocation will quietly tank a page with a permission-heavy nav. Filter aggressively by ability name, and prefer queueing over synchronous inserts. Also watch volume in tests: an audit listener that throws will fail unrelated feature tests.
The Gate::before shim runs before your policies. A true from the RBAC layer short-circuits your Policy::view() — including its false return. If a policy denies but @can passes, the shim found an ability string; the fix is usually to rename the ability or remove the policy's overlap, not to fiddle with gate ordering.
Cache the ability list, but invalidate on both role and filter inputs. Role events give you an obvious invalidation seam; LDAP claims, feature flags, and tenant plan changes do not. Key your cache by user and by whatever the filter reads (tenant_id, flag version) or you will serve stale permissions after a plan upgrade.
Distinguish role name vs. ability string. getAbilities() returns names and slugs flattened; assignRole('admin') and givePermissionTo('users.manage') are different namespaces that happen to share one string space. A typo'd ability silently becomes a no-op check rather than an error — use a backed enum or a constants class for both, and add a test asserting every seeded ability is getAbilities()-visible for at least one role.
role.revoked fires after detach, so guard against double-removal. Calling removeRole() twice usually throws or no-ops before reaching the listener; if your listener performs destructive work (revoking tokens, deleting tenant data), make it idempotent anyway — other code paths may revoke the role too.
Debugging order for "the user should have this ability but doesn't": (1) $user->getAbilities() — is it there at all? (2) If no, is the filter registered and does it receive this user? dd inside the filter, not after. (3) If yes but @can fails, look at the slug/name mismatch. (4) If @can passes but the policy denies, you're hitting the shim short-circuit described above.
Octane / long-running workers: any state your filters or listeners accumulate (memoized ability arrays, resolved tenant) persists across requests. Scope per-request caching to the request lifecycle or flush on RequestHandled.
Extension points to prefer: all four hooks are the intended customization surface. Reach for them before subclassing HasRoles/HasPermissions or swapping the resolver — subclassing puts you off the upgrade path for exactly the code the package is now actively growing (the getAbilities() refactor in v1.1.0 is a good example of internals moving under you).
How can I help you explore Laravel packages today?