elpandape / warden
Roles & permissions for Laravel โ instance-level grants, explicit forbids, ownership, multi-tenancy, ABAC. A modernized evolution of Joseph Silber's Bouncer.
Requires
- php: ^8.4
- illuminate/auth: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- symfony/uid: ^7.4 || ^8.0
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pint: ^1.14
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-phpstan: ^5.0
- pestphp/pest-plugin-rector: ^5.0
- pestphp/pest-plugin-type-coverage: ^5.0
- phpstan/extension-installer: ^1.4
- rector/rector: ^2.0
Suggests
None
Provides
None
Conflicts
Replaces
None
README
Warden
Roles & permissions for Laravel
Instance-level grants, explicit forbids, ownership, multi-tenancy, and ABAC.
Authorization that explains itself.
๐ Table of Contents
- โจ Features
- ๐ Requirements
- ๐ Installation
- โก Quick Start
- ๐ Checking Permissions
- ๐ Granting & Forbidding
- โณ Temporary Access
- ๐ Ownership
- ๐ฏ Scoped Roles
- ๐ข Multi-tenancy
- ๐ง Conditional Permissions (ABAC)
- ๐ Querying by Permission
- ๐ Debugging with
explain() - ๐ก Events
- โ ๏ธ Exceptions
- ๐ข Enums
- ๐พ Caching
- ๐งช Testing
- ๐ก๏ธ Middleware & Blade
- ๐๏ธ Schema & Models
- โ๏ธ Configuration
- ๐ Recipes
- ๐ Migrating from silber/bouncer
- ๐งช Development
- ๐ค Credits & License
โจ Features
| Feature | Description |
|---|---|
| ๐ฏ Laravel's Gate, zero learning curve | can(), @can, authorize() โ works out of the box. |
| ๐ Explicit forbids | A forbid() beats every grant. Distinguishes "denied" from "not granted." |
๐ whereCan() query scope |
The only package that can answer "over which rows?" as an Eloquent scope. |
๐ explain() debugging |
Know why a check resolved the way it did โ including "explicitly forbidden." |
| ๐๏ธ ABAC constraints | where('status', 'published') on grants โ evaluated on every check. |
| ๐ Ownership | toOwn(Post::class) โ grant only what the user owns, resolved by attribute or closure. |
| ๐ฏ Scoped roles | assign('editor')->on($org) โ same role, different contexts. |
| โณ Temporary access | until($moment) on a grant or an assignment โ it stops authorizing on its own. |
| ๐ช Nested roles | A role inside a role lends its grants, off by default and switchable live. |
| ๐ข Multi-tenancy | Tenant-scoped rows with global fallback, injectable resolver, exception-safe onceTo(). |
| ๐พ Smart caching | O(1) invalidation, versioned payloads, anti-stampede locking, Octane-safe. |
| ๐ก Typed events | Warden's verbs and catalog models announce exactly the rows they changed โ hydrated models (snapshots for what a deleted role held), end dates and contexts โ each stamped with the operation that wrote it. |
| ๐ข Enum support | BackedEnum accepted everywhere a name string is. |
| ๐งช Testing helpers | Warden::fake(), WithPermissions trait, artisan commands. |
| ๐ Migration path | warden:upgrade + Rector set for silber/bouncer users. |
๐ Requirements
| Requirement | Version |
|---|---|
| PHP | ^8.4 |
| Laravel | ^13.0 |
๐ Installation
composer require elpandape/warden php artisan warden:install --migrate
warden:install publishes the config, the migration, and runs it. You can also publish individually:
php artisan vendor:publish --tag=warden-config php artisan vendor:publish --tag=warden-migrations
Then add the concern to your authority model(s):
use ElPandaPe\Warden\Concerns\HasRolesAndPermissions; class User extends Authenticatable { use HasRolesAndPermissions; }
๐ Coming from silber/bouncer? This package conflicts with it by design (same default tables). Run
php artisan warden:upgradeto migrate the schema in place. See MIGRATING-FROM-BOUNCER.md.
โฌ๏ธ Already on warden 1.x? 2.0 adds a column and a unique index to
permissions. Publish and run the upgrade migration โvendor:publish --tag=warden-migrations-v2thenmigrateโ before the first write. See UPGRADE.md.
โก Quick Start
use ElPandaPe\Warden\Facades\Warden; // Grant Warden::allow($user)->to('edit', Post::class); // Forbid (always wins) Warden::forbid($user)->to('edit', $secretPost); // Scoped role Warden::assign('editor')->on($org)->to($user); // Check $user->can('edit', $post); // Laravel's Gate Post::whereCan($user, 'edit')->paginate(); // Which rows? Warden::explain($user, 'edit', $post); // Why?
๐ Checking Permissions
Nothing to learn โ it's Laravel's Gate.
$user->can('edit-site'); // simple permission $user->can('edit', $post); // one instance $user->can('edit', Post::class); // the whole class Gate::authorize('edit', $post); // throws on deny @can('edit', $post) ... @endcan // Blade, as always
Grant vs Check Matrix
| Grant โ / Check โ | can('edit') |
can('edit', Post::class) |
can('edit', $post) |
|---|---|---|---|
to('edit') |
โ | โ | โ |
to('edit', Post::class) |
โ | โ | โ |
to('edit', $post) |
โ | โ | โ that one |
to('edit', '*') |
โ | โ | โ |
to('*') |
โ | โ | โ |
toManage(Post::class) |
โ | โ | โ |
everything() |
โ | โ | โ |
๐ Rules:
forbid()beats every Warden grant.- By default, Warden answers after your policies โ policies always win.
- Set
warden.gate.run_before_policiesto make forbids veto everything.- Checks with more than one argument are left to your policies.
- Guests and non-model arguments are never answered by Warden.
Warden::can()inside a policy recurses through the Gate. Ask the resolver directly โapp(Contracts\Resolver::class)โ when a policy needs Warden's own answer.- With
warden.gate.registeroff, Warden abstains from every Gate answer, so a loose permission with no policy behind it reads as denied by every route through the Gate โ$user->can(),Warden::can(),cannot(),canAny(),authorize()and thewarden.permissionmiddleware all go through the same Gate. What keeps answering is the resolver,app(Contracts\Resolver::class)โ and your policies, wherever you have one.
๐ Granting & Forbidding
use ElPandaPe\Warden\Facades\Warden; // Simple permission Warden::allow($user)->to('ban-users'); // Class-level Warden::allow($user)->to('edit', Post::class); // Instance-level Warden::allow($user)->to('edit', $post); // Wildcard Warden::allow($user)->everything(); // Everyone Warden::allowEveryone()->to('browse'); // Roles Warden::assign('admin')->to($user); Warden::allow('admin')->to('audit'); // Declarative sync Warden::sync($user)->roles(['editor', 'writer']);
๐ An authority is a saved model with a usable key.
allow(),forbid(),assign()->to()andsync()refuse one that is not saved โ a deleted one included โ or whose key is not an int or a non-empty string, with aConfigurationExceptionthrown before any row is written: the row would name nobody while the event named the model.disallow(),unforbid()andretract()->from()only need the key, so they keep working from a model's owndeletedhook. When everyone is what you mean, say so withallowEveryone().
๐ Only
allowEveryone()grants to everyone. Its rows carry neither an authority type nor a key, and those are the only rowscan(),whereCan()andgetPermissions()read as everyone's. A row with a type and no key โ 3.0.0 wrote one for an unsaved authority โ grants no saved model, andphp artisan warden:clean --strandeddeletes it.
๐ Assignments are one hop unless you turn nesting on.
assign('auditor')->to($role)writes an edge between roles. By default holders of the outer role gain nothing from it โ setwarden.roles.nestedtotrueand they inherit the inner role's grants, towarden.roles.max_depthlevels deep.// config/warden.php 'roles' => ['nested' => true, 'max_depth' => 10],Off by default on purpose, because turning it on widens what every existing assignment reaches. The switch is read on every check rather than baked into a cached payload, so turning it back off takes effect immediately โ it is meant to work as an emergency lever. A cycle stops expanding at the depth ceiling instead of throwing.
can(),getPermissions(),getForbiddenPermissions()and every role check nest together โisA()and its variants,isAll(),Warden::is(),whereIs(),whereIsAll(),whereIsNot()and thewarden.rolemiddleware: a split would letcan('publish')say yes whileWarden::is($user)->an('editor')says no, painting a menu wrong for precisely the users with the most access. The two listings count unrestricted assignments only, as they always have.
$role->nestedRoles()only reads the edges;Warden::assign($inner)->to($outer)andWarden::retract($inner)->from($outer)write them, scoped to the tenant, with the cache invalidated and the event fired. Every writer the relation declares โattach(),detach(),sync(),toggle(),updateExistingPivot(),save(),create(),firstOrCreate()and the rest,OrFailandQuietlyvariants included โ throws aConfigurationExceptionpointing to those two, because a write through the relation would reach every tenant and context at once, with no invalidation and no event. So does the pivot on a loaded edge:$role->nestedRoles->first()->pivotrefusesdelete(), asave()that would change it, and theincrement/decrementfamily.
$role->nestedRoles()->delete()deletes the inner roles themselves, as on any Eloquent relation โ it is not an unnest. It runs through the query builder, so no model event fires and the cache is not invalidated, and the foreign key takes every assignment of those roles with them.
Best Practices
โ
Do โ use forbid() for exceptions:
Warden::allow($user)->to('view', Document::class); Warden::forbid($user)->to('view', $classifiedDocument);
โ Don't โ model exceptions with scattered conditionals; a forbid() row is queryable, auditable, and revocable:
Warden::unforbid($user)->to('view', $classifiedDocument);
โณ Temporary Access
Grants and role assignments can carry an end date. Past it they stop authorizing, stop appearing in whereCan(), and stop being listed by getPermissions() โ no command has to run for that to happen.
use ElPandaPe\Warden\Facades\Warden; Warden::allow($user)->until(now()->addDays(7))->to('publish', Post::class); Warden::assign('auditor')->until($audit->ends_at)->to($user); // Lift an end date a previous write left; saying nothing leaves it alone. Warden::allow($user)->until(null)->to('publish', Post::class);
๐ The date lives on the assignment, not on the role. A role is a shared definition, so an end date there would end it for everyone. On the assignment, the same role can end on different days for different holders โ and when it does, the permissions that role lent go with it.
๐ An expired assignment stops counting as held, not only for
can().isA(),isAll(),Warden::is(),whereIs(),whereIsAll()andwhereIsNot()read the date too, with nesting on or off, and so does thewarden.rolemiddleware, which answers an expired role with a 403. The relation is left alone:$user->rolesstill lists the row until something deletes it, with its date on the pivot'sexpires_at.
๐
until()goes beforeto(), likeon(): writes execute immediately, so calling it afterwards throws rather than quietly doing nothing. Moving a date counts as a write โ it invalidates the cache and fires the same event as any other, whose entry carries the date before and after.
๐ A condition keeps the date.
where()re-points a grant at its constrained twin (see Conditional Permissions), and the twin takes the date the same chain declared โuntil(null)included, which lifts it. Withoutuntil()it keeps the date the grant already had; if the authority held that permission both plain and under a condition, with different dates, the later one wins and no end date beats any.
๐ Re-assigning does not revive an expired row. Saying nothing about time leaves the date alone, and that date has passed:
assign('auditor')->to($user)orallow($user)->to('publish')over an expired row writes nothing, and the access stays ended. Give it a new date, oruntil(null)to make it permanent.
โ ๏ธ A
forbid()cannot expire, anduntil()on one throws. A prohibition that lapsed by clock would turn a forbid beats every grant into until Tuesday, with the grant beneath it still live. Lift it deliberately withunforbid().
๐ A grant reached through a role outlives neither: the earlier of the two dates ends it.
๐ The boundary is exclusive. A row stops counting at the instant it names, not a tick later.
php artisan warden:clean --expired deletes rows past their date. It is hygiene, not part of the mechanism: an expired grant stops authorizing whether or not anyone runs it.
๐ Ownership
// All actions on owned posts Warden::allow($user)->toOwn(Post::class); // Only specific actions Warden::allow($user)->toOwn(Post::class, ['edit']); // Everything owned Warden::allow($user)->toOwnEverything();
Configure ownership resolution
// Global attribute Warden::ownedVia('author_id'); // Per class Warden::ownedVia(Post::class, 'writer_id'); // Closure (evaluated live, never cached) Warden::ownedVia(fn ($post, $user) => $post->team_id === $user->team_id); // This class has no owner at all โ overrides the global fallback Warden::notOwned(Setting::class);
๐
ownedVia()only registers; it never removes.ownedVia(Post::class, null)sets the global attribute to"App\Models\Post", which is never what you meant. UsenotOwned()to take one class out, or'default_attribute' => nullin the config to turn the fallback off everywhere.
๐ A
toOwn()grant against a class that resolves no ownership can never grant. The row is written and looks healthy; warden logs a warning so it is greppable.
Best Practices
โ Do โ let ownership carry the common case, forbid the exceptions:
Warden::allow($user)->toOwn(Post::class); Warden::forbid($user)->toOwn(Post::class, 'delete'); // owners still can't delete
โ Don't โ reimplement ownership inside policies you'll have to keep in sync.
๐ฏ Scoped Roles
Restrict a role to any model โ no global team_id required.
Warden::assign('editor')->on($orgOne)->to($user); // editor only inside orgOne Warden::assign('editor')->on($orgTwo)->to($user); // same role, second context Warden::retract('editor')->on($orgOne)->from($user); // leave one; without on(), all
Configure membership resolution
Warden::restrictedVia(Post::class, 'organization_id'); // membership by FK Warden::restrictedVia(fn ($entity, $context) => ...); // or a closure
๐ A restricted role's grants apply when the checked entity belongs to the context. Checks without an instance fail closed. Role membership checks (
isAn('editor')) ignore restrictions by design โ but not end dates: an assignment past itsuntil()stops counting, restricted or not.
Best Practices
โ Do โ model teams with the models you already have:
Warden::assign('admin')->on($project)->to($user); $user->can('manage', $project); // true: the entity IS the context $user->can('edit', $taskInProject); // true: task->project_id points at it
โ Don't โ fall back to one global role plus scattered if ($user->org_id === โฆ) checks.
๐ข Multi-tenancy
Warden::tenant()->to($tenantId); // scope everything to this tenant Warden::tenant()->onceTo(9, fn () => ...); // temporary, exception-safe Warden::tenant()->onlyRelations(); // keep permission catalog global Warden::tenant()->dontScopeRoleGrants();
Behavior with no active tenant
Configure warden.scope.null_behavior:
'all'โ sees everything (global + all tenants)'strict'โ sees only global rows
๐ Writes always target one exact scope. A write under tenant 5 only affects tenant-5 rows. Global rules are only writable globally.
Reads and deletes are therefore asymmetric: a check under tenant 5 answers global or tenant 5, while retract() and disallow() delete tenant-5 rows only. So a retract under a tenant can succeed and leave the authority still holding the role globally. retract()->from() exposes retractedCount() for callers that need to tell the cases apart:
$removed = Warden::retract('editor')->from($user)->retractedCount(); // rows deleted at this scope
โ ๏ธ The scope rule is warden's, not Eloquent's.
TenantScopefilters reads and stamps creates; it does not isolate writes.$permission->delete()and$role->delete()reach rows in every tenant, and the foreign keys cascade below Eloquent entirely. Remove rows through warden's own verbs, or throughwarden:clean.
โ ๏ธ Pivot tenancy is a plain predicate, not a registered scope, so
withoutGlobalScopes()does not lift it. Widen deliberately withWarden::tenant()->removeOnce(...), which is the supported escape hatch.Under
null_behavior => 'strict'it narrows instead. With no active tenant, strict reads only global rows, soremoveOnce()turns(scope is null or scope = $tenant)intoscope is nullโ strictly fewer rows than the read it was meant to widen. Under the default'all'it widens as described.
๐ Relation writes obey the rule too.
detach(),sync(),toggle(),syncWithoutDetaching()andupdateExistingPivot()onroles()andpermissions()touch only rows at the active write scope, andattach()stamps it. A global row the tenant inherits stays out of reach in both directions: under tenant 5,sync([$role])adds the tenant-5 row beside the global one instead of adopting it, andsync([])leaves the global one standing. Relation writes invalidate the cache but announce nothing: see Events for auditing.
โ ๏ธ Scope, yes; restriction, no. A relation write narrows to one scope and stops there: it does not filter
restricted_to_*, so$user->roles()->detach($editor)removes the scoped-role assignments along with the plain one. That mirrorsWarden::retract('editor')->from($user)without->on(), which deletes them all the same way โ the relation is not narrower than the verb it reflects. To remove one context and leave the others, name it:Warden::retract('editor')->on($org)->from($user).
โ ๏ธ A relation captures its write scope when it is built, not when it writes.
$user->roles()resolves the active tenant at construction time, so a relation held in a property across a tenant change still writes to the scope it was born in. Ask for it again after switching tenants, or write throughWarden::assign()/retract(), which resolve the scope per call โ and which also dispatch the typed events and reportretractedCount().
๐ A role is global unless the write mints a tenant one. Under an active tenant,
allow('editor')attaches to a globaleditorif one exists, rather than creating a tenant-scoped twin. Roles are looked up by name and scope; a tenant twin only exists once something writes it.
๐ The permission catalog behaves the same way, and in both halves a row the tenant minted for itself wins the global one it shadows. Under a tenant,
allow($user)->to('publish')reuses a globalpublishrow when that is all there is, and picks the tenant's own the moment one exists. SetWarden::tenant()->onlyRelations()to keep the catalog global on purpose.
๐
Tenancy::writeScope()takesforRoleGrant, and it defaults tofalse. A bare call therefore reports the scope of an authority grant; ask withforRoleGrant: truewhen the holder is a role, or the answer describes a different write than the one you meant.
Best Practices
โ Do โ remove a global forbid where it lives: outside any tenant:
Warden::tenant()->removeOnce(fn () => Warden::unforbid($user)->to('publish'));
โ Don't โ expect a tenant-scoped unforbid() to lift a global forbid.
๐ง Conditional Permissions (ABAC)
Grants can carry conditions, written in the grammar your queries already use:
Warden::allow($user)->to('view', Document::class) ->where('status', 'published') ->orWhere(fn ($group) => $group ->where('tier', '>=', 2) ->whereColumn('owner_id', 'id') );
Available operators
| Method | Description |
|---|---|
where('col', 'value') |
Entity attribute equals value |
where('col', '>=', 5) |
With explicit operator |
whereColumn('owner_id', 'id') |
Compare against authority's attribute |
orWhere(...) |
OR grouping |
orWhere(fn) |
Nested closure grouping |
๐ Important:
- Precedence is SQL's:
ANDbinds tighter thanOR.- Comparisons are strict โ no PHP type juggling.
- A null attribute satisfies no operator at all,
!=included, and values whose types are not decidably comparable fail closed the same way. A missing attribute is different: underModel::preventAccessingMissingAttributes()it throws rather than failing closed.- Constrained grants share one catalog row per distinct rule, so editing a permission's options changes the rule for every holder of that shape. Write a new condition instead of editing a shared row.
- A boolean value matches only a column the model casts to
bool, and such a column matches only a boolean, so writing either mismatch is refused:where('classified', true)needs'classified' => 'bool'in the model's$casts. A row stored before 3.0 keeps failing closed in checks and in queries alike โphp artisan warden:doctorlists those rows.- The refusal exists because of what the mismatch does to a
forbid(): a condition that can never be true makes the prohibition inert, the grant underneath it stays live, andexplain()reports that grant without ever mentioning the forbid โ a missing cast read as "allowed".- A permission with no entity is only ever checked without an instance, so constraining one is refused: the shape that would make it match is the shape that rejects it.
- A constrained grant never matches instance-less checks (
can('view'),can('view', Document::class)) โ they fail closed.to()->where()is two writes, not one.to()lands an unconstrained grant that authorises every instance, andwhere()re-points it at the constrained twin. Only the second step runs in a transaction; between the two the live row is unconditional, and a throw inwhere()โ an unknown operator, a permission with no entity, a twin in the trash (TrashedCatalogRow) โ leaves it that way. Wrap the whole chain in your own transaction when that window matters.
Best Practices
โ Do โ grant broadly, constrain the sensitive part:
Warden::allow('viewer')->to('view', Document::class)->where('status', 'published'); Warden::forbid($user)->to('view', Document::class)->where('classified', true);
โ Don't โ encode workflow logic as constraints (e.g., "drafts visible on Tuesdays"). Complex rules belong in policies.
๐ Querying by Permission
Checks answer "can X do Y?"; Warden can also answer "over which rows?"
use ElPandaPe\Warden\Concerns\QueriesByPermission; class Post extends Model { use QueriesByPermission; } // Usage Post::whereCan($user, 'view')->latest()->paginate();
Instance grants, class grants, wildcards, everyone-grants, role grants, forbids, tenancy, ownership, and ABAC constraints all compile into the query.
โ ๏ธ What cannot become SQL fails closed: closure-resolved ownership and restricted-role grants contribute no rows.
โ ๏ธ No Gate, no policies.
whereCan()answers from warden's own rows only. A policy that would have granted or denied a row is not consulted, so a query and a check can disagree wherever a policy has the last word.
โ ๏ธ The trait is required. Without it,
Post::whereCan($user, 'view')never reaches Warden: Laravel reads it as a dynamicwhereagainst a column namedcan, and you get zero rows or a driver error instead of an answer.
Best Practices
โ Do โ drive index pages straight from authorization:
Post::whereCan($user, 'view')->latest()->paginate();
โ Don't โ post-filter with ->get()->filter(fn ($p) => $user->can('view', $p)) โ that's the N+1 this scope exists to delete.
๐ Debugging with explain()
$why = Warden::explain($user, 'edit', $post); $why->allowed(); // bool $why->cause; // Cause::ForbiddenViaRole, Cause::GrantedDirectly, โฆ $why->permission; // the decisive catalog row, when one decided $why->role; // the role that carried it, when one did (string) $why; // "Explicitly forbidden by permission [edit] via role [banned]."
Which of permission and role are populated depends on the cause:
| Cause | allowed() |
permission |
role |
|---|---|---|---|
GrantedDirectly |
true |
the row | โ |
GrantedViaRole |
true |
the row | the role |
GrantedToEveryone |
true |
the row | โ |
ForbiddenDirectly |
false |
the row | โ |
ForbiddenViaRole |
false |
the row | the role |
ForbiddenToEveryone |
false |
the row | โ |
ConditionsNotMet |
false |
the row whose conditions were not satisfied | โ |
NoMatchingGrant |
false |
โ | โ |
NotApplicable |
false |
โ | โ |
๐
ConditionsNotMetandNoMatchingGrantare different answers: the first names a row that matched the shape but whose conditions were not satisfied โ they failed against the instance, or the check named a class and there was no instance to satisfy them with โ while the second means nothing matched at all. Both leave Warden abstaining so your policies decide.
๐ Always answered by the database engine โ never from cache โ so it diagnoses stale-cache issues too.
๐ It blames the row that decided, and the role that holds it. A row past its end date is never blamed, and neither is a role in the trash:
explain()looks past both to the next source. Withwarden.roles.nestedon, a row held by a role nested inside one the authority holds isGrantedViaRoleorForbiddenViaRole, androlenames the nested role โ or the authority's own role, when that one holds the row too. When several roles hold the row,rolenames the first one the check counted, the roles the authority holds before those nested inside them โ so a role it holds under a restriction that does not apply, and also reaches through another role it holds, can be named ahead of that other role.
๐ก Events
Warden announces its own writes. Every verb โ allow(), forbid(), disallow(), unforbid(), assign(), retract(), sync(), and the where() that narrows a grant โ dispatches a typed, readonly event, and so does creating, editing, deleting or restoring a role or permission through its model. Each event names what it touched with hydrated models (never raw IDs; what a deleted role held arrives as snapshots), lists exactly the rows that changed, and says which operation dispatched it. Writes made around warden are not announced: Events for auditing says which, and when each event is dispatched. Disable globally with warden.events_enabled.
| Event | Fired By | Payload |
|---|---|---|
PermissionGranted / PermissionForbidden |
allow(), forbid(), and the where() that narrows them |
?Model $authority, Collection $permissions, $scope, ?Model $actor, list<GrantChange> $grants |
PermissionRevoked / PermissionUnforbidden |
disallow(), unforbid(), a narrowing where(), deleting a permission |
?Model $authority, Collection $permissions, $scope, ?Model $actor, list<GrantRemoval> $grants |
RoleAssigned |
assign() |
Model $authority, Collection $roles, $scope, ?Model $restrictedTo, ?Model $actor, list<AssignmentChange> $assignments |
RoleRetracted |
retract(), deleting a role |
Model $authority, Collection $roles, $scope, ?Model $restrictedTo, ?Model $actor, list<AssignmentRemoval> $assignments |
RolesSynced / PermissionsSynced |
sync() |
Model $authority, SyncResult $changes (attached / detached / kept), $scope, ?Model $actor; PermissionsSynced adds bool $forbidden |
RoleCreated / PermissionCreated |
Creating the row โ also when a verb names one that does not exist yet | The model, ?Model $actor |
RoleUpdated / PermissionUpdated |
A model save that changes the row's snapshot | The model, array $before, array $after, list<string> $changed, ?Model $actor |
RoleDeleted |
Deleting the role through its model โ to the trash too | Model $role, ?Model $actor, array $heldGrants, array $heldRoles, bool $softDeleted |
PermissionDeleted |
Deleting the permission through its model โ to the trash too | Model $permission, ?Model $actor, bool $softDeleted |
RoleRestored / PermissionRestored |
restore() on a row in the trash, through its model |
The model, ?Model $actor |
๐ Every event ends with
?string $operationโ these sixteen and the six pre-action events alike: the id of the operation that dispatched it. Warden stamps it on every event it builds; one you build yourself carriesnullunless you pass it. A job 3.1 queued restores without it, so read it as$event->operation ?? nulluntil those queues drain.bool $softDeletedcomes just before it on the two deletion events:truewhen the row went to the trash,falsewhen it was destroyed โ aforceDelete()from the trash included. InsideEvent::defer()aforceDelete()saystruetoo: trust the flag only for deletes made outside it.
The six write events carry one value per pivot row they wrote or deleted โ $grants on the permission events, $assignments on the role events:
| Value | On | Properties |
|---|---|---|
GrantChange |
PermissionGranted, PermissionForbidden |
Model $permission, bool $created, ?CarbonImmutable $expiresAt, ?CarbonImmutable $previousExpiresAt |
AssignmentChange |
RoleAssigned |
Model $role, bool $created, ?CarbonImmutable $expiresAt, ?CarbonImmutable $previousExpiresAt |
GrantRemoval |
PermissionRevoked, PermissionUnforbidden |
Model $permission, ?CarbonImmutable $expiresAt โ the date the row had when it went |
AssignmentRemoval |
RoleRetracted |
Model $role, ?Model $restrictedTo โ the row's own context โ and ?CarbonImmutable $expiresAt |
A write reads like this:
| The row | created |
expiresAt |
previousExpiresAt |
|---|---|---|---|
| Was just created | true |
the date it was created with, or null |
null |
| Had its date moved | false |
the new date | the old date |
| Got its first date | false |
the new date | null |
Had its date lifted with until(null) |
false |
null |
the old date |
๐ With
created: falsethe two dates always differ โ a write that moves nothing is not announced.expiresAtis read back from the row, so it is the wall time the column stores, in your application's timezone, rather than the object you passed tountil().
๐
PermissionForbiddencarries$grantstoo. A forbid cannot takeuntil(), so in practice its entries are new rows with no end date.
use ElPandaPe\Warden\Events\PermissionGranted; Event::listen(PermissionGranted::class, function (PermissionGranted $event) { foreach ($event->grants as $grant) { // $authority received it; $actor granted it. audit( $grant->created ? 'granted' : 'date changed', $event->actor, $event->authority, $grant->permission->getAttribute('name'), $grant->expiresAt, ); } });
๐ Building an event yourself โ in a test, say? Pass its arguments by name from
actoron. Warden only ever appends optional parameters, so a name stays valid where a position would not.
$actor defaults to the authenticated user, on every post-write event โ the catalog's included โ and warden resolves it only for an event that goes out: never with events off, nor for the writes a sync() silences. Queues, console commands and impersonation are cases only your application can answer, so point warden.actor_resolver at a class implementing Contracts\ActorResolver:
final class CurrentActor implements ActorResolver { public function resolve(): ?Model { return Context::actingUser() ?? Auth::user(); } }
Pre-action events (opt-in)
Enable with warden.cancellable_events. A listener returning false aborts the write:
// GrantingPermission, ForbiddingPermission, AssigningRole // RevokingPermission, UnforbiddingPermission, RetractingRole
๐ A pre-action event covers the whole call, not one item of it.
allow($user)->to(['a', 'b'])announces both names in one event, and a listener returningfalseaborts both: there is no way to veto one and keep the other. Split the call if you need per-item decisions.
๐
sync()never fires nor honors pre-action events โ its declarative diff events tell the whole story.
๐
where()fires none. It refines the grantto()has just made, andto()fired its own.
๐ A cascade fires none either. Deleting a role never fires
RetractingRole, nor deleting a permissionRevokingPermission: the foreign key deletes inside the engine, and the only veto over the delete is Eloquent's own โ returnfalsefrom adeletinglistener on the model.
๐
Event::defer()defeats the veto. A pre-action event dispatched inside it reaches its listeners only after the write it announces, so afalsestops nothing: see When an event is dispatched.
Events for auditing
What an audit log built on these events can rely on, and what it cannot.
What an event describes
An event describes rows warden wrote or deleted at $scope โ not the access that results. Ask Warden::explain() about access. So:
- a
PermissionGrantedunder a tenant can leave access unchanged, when a global grant already gave it; - deleting a row whose end date had passed is announced, with that date: the entry tells a revocation from the sweep of something already dead;
RoleCreatedandPermissionCreatednever change access โ a catalog row nobody holds authorizes nothing โ so an access log can ignore them;- a trip to the trash changes access though no holder's row moves:
RoleDeletedorPermissionDeletedwithsoftDeleted: trueends what the row granted and forbade, andRoleRestoredorPermissionRestoredgives it back โ Deleting a role or a permission shows how that reads in a log.
What a write announces
- Only what changed, once per authority.
assign('editor')->to([$ana, $luis])with Ana already an editor dispatches oneRoleAssigned, for Luis. An authority the call changed nothing for receives nothing, and a call that changes nothing dispatches nothing โsync()excepted, below. $rolesand$permissionsare the models of those rows, each once, in the order you named them. The entries say the rest: which row was created, which had its date moved, which context a removal took.- Moving an end date is a write.
until()over an existing row dispatches the same event as a new row, told apart by its entry:created: false, with the dates after and before. Reaching the date dispatches nothing โ expiry by clock is silent by design, and the date was announced when it was written. - Re-assigning an expired row without
until()announces nothing, because it writes nothing: Temporary Access explains why the row stays as it was.sync()lists that row underkept. Give it a new date, oruntil(null), and the write is announced as a moved date. retract()withouton()removes every context of the role.RoleRetracted::$restrictedTois the context the call named โnullwhen it named none โ and eachAssignmentRemovalcarries the context its own row had. Losingeditorwith no context and in two organizations is one event with three entries, andeditoronce in$roles. A row whose context can no longer be named gets no entry, thoughretractedCount()still counts it.- A call writes its rows before it announces them. Every grant and assignment row the call touches, for every authority it names, is written or deleted before its first write event goes out: a listener that throws on one cannot interrupt those writes, only the announcements still to come โ and the exception leaves the call. Two kinds of write fall outside that. A role or permission the call creates by name dispatches its
RoleCreatedorPermissionCreatedas it is created, before the rows that use it are written, so a listener that throws there stops the call before them โ see Implicit creation. And the unused plain row awhere()deletes goes after the announcements, below.
One call, one operation
Every event carries $operation, the id of the operation that dispatched it, so a log can show what one act wrote as one entry. It is a ULID โ 26 upper-case characters, ordered by the time the operation opened โ and it only says which events belong together.
- A call is an operation. Every call that writes โ
allow()orforbid()withto(),toOwn()and what builds on them,disallow()andunforbid()the same way,assign()->to(),retract()->from(), andsync()withroles(),permissions()orforbiddenPermissions()โ opens one before anything else, so its pre-action event, the roles and permissions it creates by name, and its write events share one id. - A narrowing chain is one operation.
where(),orWhere(),whereColumn()andorWhereColumn()resume the operation of theto()they refine, so every event of a chain shares one id โ thePermissionDeletedof the plain row it retires included. - A catalog write through the model is an operation of its own when no call is open around it โ
Role::create(), an admin form's$permission->update(), arestore()โ and a delete shares one with its cascade:RoleDeletedand everyRoleRetractedafter it carry the same id. Arestore()that also saves another changed column is two writes, though: itsRoleUpdatedorPermissionUpdatedand itsRoleRestoredorPermissionRestoredcarry different ids, unlessWarden::operation(), below, wraps the call. warden:cleanis one operation per run.- Separate calls are separate operations, chained on one builder too:
sync($user)->roles([...])->permissions([...]),allow($user)->to('a')->to('b')andretract('editor')->from($ana)->from($luis)make one per call. Wrap them to make them one:
use ElPandaPe\Warden\Facades\Warden; // Saving a permission grid: 'delete' flips from granted to forbidden; 'view' and 'edit' are granted. $operation = Warden::operation(function (string $operation) use ($role) { Warden::disallow($role)->to('delete', Document::class); Warden::forbid($role)->to('delete', Document::class); Warden::allow($role)->to(['view', 'edit'], Document::class); return $operation; });
The PermissionRevoked and the PermissionForbidden of the flipped cell, and the PermissionGranted of the other two, carry the same $operation: a log can show the flip as one change instead of two. operation() returns what its callback returns. There is no accessor for the id of an open operation: take it from the callback's argument, or from an event.
- An operation inside an operation joins it. The inner callback receives the outer id, and so does every call made inside it: a helper that wraps its own writes in
Warden::operation()still belongs to its caller's act. - A listener that writes through warden joins the operation of the event it hears, because it runs while that operation is open โ unless it waits for the commit (
ShouldHandleEventsAfterCommit,ShouldQueueAfterCommit,$afterCommit = true, or a connection withafter_commit) and the transaction commits after the operation closed: its writes then open their own. - A queued listener receives
$operationin the event. What its own writes join depends on the driver: a queue worker starts every job with no operation open, so they open their own; thesyncdriver runs the listener inside the dispatch, where its writes join the open operation โ unless it waits for the commit the same three ways (ShouldQueueAfterCommit,$afterCommit = true, or a connection withafter_commit), in which case, once the transaction that outlives the call commits, its writes open their own, as above. - It is not a transaction. An operation opens no database transaction, holds back no event and no cache invalidation, and catches nothing: every event goes out as its write finishes, exactly as without it, and an exception passes through
operation()unchanged. When the writes must stand or fall together, open aDB::transaction()as well โ inside the callback or around it; either way every event carries the id.
Implicit creation and the narrowing chain
- Naming something that does not exist creates it. The first
allow()orforbid()that names a permission dispatchesPermissionCreatedbefore itsPermissionGranted, and a role thatassign(),allow(),forbid()orsync()names for the first time dispatchesRoleCreated. A catalog log gets one entry per first use. A name that matches only a row in the trash throwsTrashedCatalogRowinstead of creating a second one: see Deleting a role or a permission. to()->where()is two writes, and it announces both.to()lands the unconstrained grant and announces it;where()re-points it at the constrained twin. It announces every grant row it replaced โ the plain one, and any twin an earlier condition left โ asPermissionRevoked(PermissionUnforbiddenfor a forbid), then the twin'sPermissionGranted(PermissionForbidden) when its row was created or its date moved, and lastPermissionDeletedfor a plain row the chain itself created and left unused. WithSoftDeleteson your permission model, that row โ or a twin the chain created and a secondwhere()leaves unused โ is force-deleted, never sent to the trash, so no later write of the rule finds it there and throwsTrashedCatalogRow: itsPermissionDeletedsayssoftDeleted: false. Over several permissions โto(['view', 'edit'], Document::class)->where(...)โ each event's$grantsfollows the order you named them, and the rows replaced for one of them follow their keys.- The first chain on a permission nobody holds yet dispatches six events, in this order:
PermissionCreatedandPermissionGrantedfor the plain row,PermissionCreatedfor the twin,PermissionRevokedfor the plain grant,PermissionGrantedfor the twin,PermissionDeletedfor the plain row. Running the identical chain again leaves the twin's grant row as it was โ same row, no event about it โ and dispatches the four about the plain row thatto()creates andwhere()retires. All of a chain's events carry one$operation:where()resumes the operation of theto()it refines. - The twin is created before the re-point's transaction, and the unused plain row deleted after it โ after the re-point's announcements, too โ so no event is dispatched from inside that transaction. A re-point that fails leaves the twin in the catalog with no grants; a listener that throws on the re-point's
PermissionRevokedorPermissionGranted(PermissionUnforbiddenorPermissionForbiddenfor a forbid) leaves the plain row there instead, with no grants either. Neither authorizes anything, andwarden:cleanreclaims both โ unless your own transaction rolled them back first. - A listener that throws on the
PermissionGrantedofto()stops the chain beforewhere()runs: the unconstrained grant stays, as a throw insidewhere()would leave it (see Conditional Permissions). Wrap the chain in your own transaction when that matters.
Sync
sync()dispatches one diffed event,RolesSyncedorPermissionsSynced, and silences the per-row events of the writes it delegates. Catalog events are not silenced: a role or permissionsync()creates by name still dispatchesRoleCreatedorPermissionCreated, with the sync's$operation. Each ofroles(),permissions()andforbiddenPermissions()is an operation of its own, chained on onesync()too.- It dispatches even when nothing moved, with everything under
keptโ the one write event that does. detachednames the rows the sync read and then deleted. A permissions sync names plain rules only โ a name resolves to the row with no entity, no condition and no ownership โ so it never deletes a class, instance,toOwn()or conditioned grant, nor reports one asdetached. The read and the delete are two statements: a grant or an assignment another connection writes between them is deleted without appearing indetached.keptnames what the sync left in place, a row whose end date has passed included: the sync neither revives nor removes it.- A sync leaves the trash alone. It declares what is live, so it never deletes the assignments of a role in the trash or the grants of a permission in the trash, nor names them under
detachedorkept, andrestore()still brings them back. A name that matches only a row in the trash throwsTrashedCatalogRow, as it does for the other verbs.
Deleting a role or a permission
- It settles before anyone hears of it. Whatever no foreign key reaches is swept first โ a role's own grants, and the nested edges it held as an authority โ and the cache is invalidated, even when the sweep throws. Then
RoleDeletedorPermissionDeletedgoes out, then the cascade's events, all with one$operationโ the call's, when the delete runs inside one, as the plain row awhere()retires does. A listener that throws stops the announcements after it, and nothing else. - A
deletinglistener that halts the dispatch leaves less settled. One of yours that returns anything butnullorfalseโfalsecancels the delete โ stops Eloquent's dispatch before warden reads what the delete reaches, and the delete goes ahead. A role's own grants and nested edges are still swept, a permission still invalidates its own scope, and a role going to the trash every scope it reaches; but a role's hard delete then invalidates nothing, itsRoleDeletedcarries empty held lists, and no cascade event goes out. Follow such a delete withWarden::refresh(), or holders keep its cached grants until the TTL. - Deleting a permission dispatches
PermissionDeleted, then onePermissionRevokedโ orPermissionUnforbidden, for a forbid โ per grant its foreign key took, in grant-key order, with that row'sGrantRemoval, expired rows included with their date. A grantallowEveryone()wrote arrives with anullauthority: it was everyone's. - Deleting a role dispatches
RoleDeleted, carrying$heldGrantsand$heldRoles: what the role itself held, as snapshots with their polarity, scope, context and end date, expired rows included โ the record of rows swept rather than announced one by one. Then oneRoleRetractedper holder and scope, in assignment-key order:$rolesis the deleted role,$restrictedToisnullbecause no call named a context, and$assignmentshas anAssignmentRemovalper row, with its context and date. A role that held the deleted one through nesting arrives as the authority, nesting on or off. - What the cascade announces is read before the delete. A foreign key removes the rows inside the engine, where no model event fires, so warden reads them first, and the read is the announcement: if the foreign key is not enforced, the events describe a deletion that did not happen. The read and the delete are two statements, too: a row another connection writes between them goes with no event, and one it deletes meanwhile can still be announced.
- The cascade is blind to the active tenant. Rows and holders are read with
withoutGlobalScopes(), on purpose: the delete destroys every tenant's rows whichever one is active, so counting only the current tenant would promise a smaller loss than the real one. A holder under another tenant, or soft-deleted, arrives named โ never as anullauthority, which would read as everyone. A holder whose row is already gone is not announced โ it authorized nobody โ and neither is a row with a type and no key, or a key and no type; one whose morph alias maps to no class in this process is skipped with a warning in the log. A restriction context whose row is gone arrives as an unsaved model carrying only its key (existsisfalse), never asnull, which would read as no restriction. A row whose restriction context maps to no class, or names only half of one, gets no entry, and a holder left with no nameable row gets noRoleRetracted. - A role in the trash neither grants nor forbids. With
SoftDeleteson your role model,delete()takes the role out of every check at once:can(), the Gate,@can,@forbidden,authorize(), thewarden.permissionmiddleware,whereCan(),getPermissions(),explain()and the cached checks, and the role checks โisA(),isAll(),Warden::is(),whereIs(),whereIsAll(),whereIsNot()andwarden.roleโ stop seeing it and, with nesting on, what its holders reached through it. What it forbade lifts too, which can widen access where a broader grant stands. Nothing is swept: its grants, holders and nested edges stay, andrestore()brings all of it back at once. The cached checks of every scope the role reaches are invalidated beforeRoleDeletedgoes out, and again beforeRoleRestored. Only the trash counts: a global scope of your own on the role model does not hide the role fromcan(),whereCan(),getPermissions(),explain()or the cached checks, yet the role checks โisA(),isAll(),Warden::is()andwhereIs()and its variants โ read roles through your model and apply it, as they always did. - In an audit log, the soft delete is where access ends.
RoleDeletedgoes out withsoftDeleted: trueand empty$heldGrantsand$heldRolesโ nothing was swept, which is not to say the role held nothing โ and noRoleRetractedfollows, because no holder's row was destroyed.restore()dispatchesRoleRestored. A laterforceDelete()from the trash dispatches a secondRoleDeleted, withsoftDeleted: falseand the held lists, then aRoleRetractedper holder and scope, and sweeps as usual: those events record the rows destroyed, for access that ended at the soft delete. - A name never reaches the trash.
findRole('editor')and the verbs that remove by name look past a role in the trash:retract('editor')takes nothing anddisallow('editor')throwsRoleDoesNotExist. A name that would create the role โassign('editor'), async()naming it, theallow('editor')orforbid('editor')that creates it โ throwsTrashedCatalogRowrather than create a secondeditorbeside it: restore the trashed one, or force-delete it, first. Only warden's verbs check: your ownRole::create()orWarden::role()->firstOrCreate()still writes a secondeditorwherever the unique index lets it. Under a tenant, a globaleditorin the trash does not stopassign('editor')from creating the tenant's own, since only the row the insert would stand in for counts โ unlessonlyRelations()keeps the catalog global. Andallow('editor')->to('publish')resolvespublishbefore it refuses, so apublishit had to create stays behind, unused, forwarden:clean. The trashed model itself is accepted (Role::withTrashed()):assign($trashed)andallow($trashed)write rows that stay inert untilrestore(), andretract($trashed)anddisallow($trashed)remove them. - A permission in the trash neither grants nor forbids. It keeps its grant rows and announces no cascade โ only
PermissionDeleted, withsoftDeleted: trueโ but every check stops seeing it at once, and the cached checks of every scope its grants live in are invalidated beforePermissionDeletedgoes out: a prohibition it carried lifts, which can widen access where a broader grant stands.restore()brings it back everywhere at once, invalidating the same scopes beforePermissionRestoredgoes out. A laterforceDelete()from the trash dispatches a secondPermissionDeleted, withsoftDeleted: false, then the cascade. A name that matches only a permission in the trash โallow($user)->to('publish'), or the twin of awhere()โ throwsTrashedCatalogRow; the twin's refusal comes afterto()wrote the unconstrained grant, which stays: see Available operators. WithSoftDeleteson your permission model,warden:cleansends unused permissions to the trash rather than away, and the next write of such a name throwsTrashedCatalogRowuntil you restore the row or force-delete it: see Maintenance commands. - With events off, a delete still settles. Turning
warden.events_enabledoff stops the announcements, not the cache invalidation or the sweep โ nor the invalidation that moving a row in or out of the trash causes. A role's or a permission's delete then skips the reads that only feed its events. - A bulk delete announces nothing.
Role::query()->where(...)->delete(),DB::table()and raw statements fire no model event: noRoleDeleted, no cascade events, no sweep and no cache invalidation. Delete model by model to keep all four โRole::query()->where(...)->lazyById()->each->delete()โ or follow a bulk delete withWarden::refresh()andwarden:clean --strandedโ not with one users database per tenant: see Landlord vs tenant databases. WithSoftDeletes, the same query sends the rows to the trash instead, andRole::onlyTrashed()->restore()brings them back โ both as silently, cache included: see Writes that announce nothing.
Maintenance commands
warden:cleandeletes unused permissions one by one through the model: each dispatchesPermissionDeleted, with the actor your resolver returns โnullin the console with the default one. A run is one operation: every event it dispatches,--duplicatesincluded, carries the same$operation. WithSoftDeleteson your permission model, each goes to the trash rather than away, and the next write that names one throwsTrashedCatalogRowuntil you restore it or force-delete it.--duplicatesre-points each duplicate's grants to the surviving row by query, without events โ when one collides with a grant the survivor already holds, the survivor keeps the later end date โ no end date beats any โ and the duplicate's grant is dropped โ then deletes the duplicate through the model: onePermissionDeletedeach, and no cascade events, because by then it points at nothing. While a rule has a live row, its rows in the trash stay out of the collapse: none is kept, none is deleted, and their grants are never moved onto the live row, which would give back access the trash ended. A rule whose rows are all in the trash collapses as before.--expiredand--strandeddelete by query and dispatch nothing: those rows authorize no saved model.warden:retitlerewrites titles with the query builder, on purpose, so it dispatches noRoleUpdatedorPermissionUpdated;warden:upgradedispatches nothing either.
Editing the catalog
- A model save that changes a role's or a permission's snapshot dispatches
RoleUpdatedorPermissionUpdated, with the snapshot before and after, and$changed: the keys that differ, in snapshot order โ nevervorkey. The title counts โ relabellingdelete-accountsas "View accounts" changes what an administrator believes they are granting โ and$changed === ['title']is how to filter those out. - A save that leaves the snapshot as it was dispatches nothing:
touch(), the same conditions stored with their keys in another order, a recomputed identity key. restore()is not an edit. The snapshot has no deleted-at column, so restoring a role or a permission from the trash dispatchesRoleRestoredorPermissionRestored, notRoleUpdatedorPermissionUpdatedโ unless the same save changes a column the snapshot does carry: that edit dispatches its ownRoleUpdatedorPermissionUpdatedfirst, under an operation of its own (see One call, one operation). Arestore()on a row that was not in the trash restores nothing and announces no restore, and neither does one insideEvent::defer().- A
restoringlistener of your own still gets its restore announced. One registered on the model before warden's โ aSoftDeletes::restoring()that assigns a column and returns it, say โ returns non-nulland halts Eloquent'suntil()dispatch before warden's ownrestoringlistener notes the trash, the same way adeletinglistener can.RoleRestoredorPermissionRestoredstill goes out, from whether the save actually cleared the deleted-at column; only a listener that also returnsfalsecancels the restore, and with it the announcement. - A partially read row is completed first. Saving a role or a permission fetched with a partial
select()reads the snapshot columns it is missing from its row โ one query, for partial rows only, and none with events off โ so$beforedescribes the whole row, not half of it. - Columns your own model adds are not in the snapshot. Listen to Eloquent's
updatedfor those. - The cache is up to date when the event goes out, as for every other event.
Writes that announce nothing
- Relation writes.
attach(),detach(),sync(),toggle(),syncWithoutDetaching()andupdateExistingPivot()onroles(),permissions()and a permission'sroles()go through Eloquent's pivot models: they invalidate the cache, and no warden event reports them โ flippingforbiddenor arestricted_to_*column included. Write through the verbs when a log has to see it.nestedRoles()refuses writes altogether. - The query builder,
DB::table()and raw statements, on any warden table. - Anything run with model events off:
saveQuietly(),deleteQuietly(),restoreQuietly(),Model::withoutEvents(). On warden's own models that skips more than the event: a quiet delete leaves cached checks answering for the row and a role's grants unswept, and a quiet save of a permission skips the hook that computes its identity key. Follow one withWarden::refresh()โ andwarden:clean --strandedafter a quiet role delete, not with one users database per tenant (see Landlord vs tenant databases) โ or, better, don't. - A trip to the trash without model events.
deleteQuietly()andrestoreQuietly()on a role or a permission, adelete()orrestore()insideModel::withoutEvents(), and a query'sdelete()orrestore()on aSoftDeletesmodel โRole::query()->where(...)->delete(),Role::onlyTrashed()->restore()โ change access in the database at once, andexplain()sees it, but cached checks keep answering as before: a role trashed that way keeps granting, one restored that way stays denied. Follow one withWarden::refresh()orphp artisan warden:cache-reset. - A trip to the trash through
save().$role->forceFill(['deleted_at' => now()])->save()sends a role to the trash, and saving the column back tonullrestores it: the cache is invalidated as fordelete()andrestore(), but noRoleDeleted,RoleRestoredorRoleUpdatedgoes out โ the snapshot has no deleted-at column. The same holds for a permission. Usedelete()andrestore()when a log has to see it. - The clock. A row stops counting at its end date without an event.
Testing with Event::fake()
Event::fake() with no list replaces the dispatcher Eloquent's model events go through, so warden's model hooks stop running with it: identity keys, generated titles, the tenant stamp, the catalog events, cache invalidation, and a delete's sweep and cascade. In a test suite it shows up as a unique-constraint violation on the second permission of a name, as a permission created global under a tenant, or as an Event::assertDispatched(PermissionCreated::class) that fails because the hook that dispatches it never ran. Fake warden's events by name instead:
use ElPandaPe\Warden\Events; use Illuminate\Support\Facades\Event; Event::fake([ Events\PermissionGranted::class, Events\PermissionForbidden::class, Events\PermissionRevoked::class, Events\PermissionUnforbidden::class, Events\RoleAssigned::class, Events\RoleRetracted::class, Events\RolesSynced::class, Events\PermissionsSynced::class, Events\RoleCreated::class, Events\RoleUpdated::class, Events\RoleDeleted::class, Events\RoleRestored::class, Events\PermissionCreated::class, Events\PermissionUpdated::class, Events\PermissionDeleted::class, Events\PermissionRestored::class, ]);
Add the pre-action events you assert on โ AssigningRole, RetractingRole, GrantingPermission, ForbiddingPermission, RevokingPermission, UnforbiddingPermission โ the same way. A faked pre-action event never vetoes.
Queued listeners
A queued listener receives the event serialized, and its values come back in two ways:
- Read again when the job runs: top-level models โ
$authority,$actor,$restrictedTo, and the$roleor$permissionofRoleCreated,RoleUpdated,RoleRestored,PermissionCreated,PermissionUpdatedandPermissionRestoredโ with the relations they had loaded. One hard-deleted in the meantime fails the job withModelNotFoundException; a soft-deleted one comes back as it is, in the trash. - By value, as they were at dispatch:
$roles,$permissions, a sync's$changes, every$grantsand$assignmentsentry, every snapshot, and$operation. They outlive the rows they describe, and the models among them arrive without the relations they had loaded.
RoleDeleted and PermissionDeleted differ on both counts. The deleted row travels by value, without the relations it had loaded, so the listener gets it as it was when it went, with $softDeleted and $operation. The actor travels as an identifier and is read again when the job runs; if its row is gone by then, it arrives as an unsaved stand-in carrying only its key, on the connection the actor came from (exists is false), instead of failing the job.
A queued listener therefore always knows which operation dispatched its event. What its own warden writes join depends on the queue driver: see One call, one operation.
โ ๏ธ A model that travels by value keeps every column,
$hiddenincluded โ$hiddenonly shapes arrays and JSON. The deleted row ofRoleDeletedandPermissionDeletedis one, as are the roles and permissions in the lists, the entries and a sync's$changes, and the context anAssignmentRemovalnames. If your own models (warden.models.*, or a context model) hold a sensitive column, make the queued listeners of any warden event that carries them implementShouldBeEncrypted. None of them takes its loaded relations along: each travels as a copy without them โ one copy per model and event, so after the queue$event->roles[0] === $event->assignments[0]->rolestill holds โ and a queued listener that walks one loads it again, which underModel::preventLazyLoading()can throw. A synchronous listener gets your instances as you had them, relations and all, with two exceptions: the context you hand toon()and the deleted role a cascade names reach it as copies too, so an entry's context is neither the instance you handed toon()nor=== $event->restrictedTo, which stays yours, relations and all, and a cascade's role is not the model you calleddelete()on. Walk a relation from$event->restrictedTo: underModel::preventLazyLoading(), loading one from the copy can throw.
When an event is dispatched
- Synchronously, as the call that wrote it finishes, inside whatever transaction you have open. Warden opens none around your call โ
Warden::operation()included; the only one it opens is the re-point insidewhere(), and nothing is dispatched from inside it. - Your transaction is the audit's transaction. A listener writing on the same connection commits or rolls back with the change, and one that throws inside your
DB::transaction()rolls the write back with everything else in it. Outside a transaction the rows are already written when a listener runs: if it throws, the exception leaves the call with the change made. - A catalog row a verb names for the first time is created one level deeper. Warden creates it with
firstOrCreate(), which opens a savepoint when a transaction is already open, so itsRoleCreatedorPermissionCreatedis dispatched inside that savepoint: a listener that throws there rolls back its savepoint, and the exception keeps rising. The twin awhere()creates is inserted directly, at your transaction's level. - A retried transaction announces every attempt.
DB::transaction($callback, attempts: 3)dispatches the events of the attempts it rolls back, too. - Another connection is another transaction. With
warden.connectionpointing elsewhere, your transaction on the default connection does not cover warden's writes: they commit on their own, and rolling yours back leaves them โ and their announcements โ standing. Likewise a listener writing to another connection, a queue or an HTTP endpoint can record a change your transaction later rolls back. - A listener's
can()sees the write it hears about. Pending cache invalidations are applied before every event is dispatched, catalog and cascade events included, so no listener answers from a payload cached before the write. - Not inside
Event::defer(). Laravel holds back every event dispatched in its callback until the callback returns โ Eloquent's model events too, so warden's model hooks run after the rows are written instead of while they are. A pre-action listener'sfalsethen vetoes nothing, the write being made already; a role or permission created in the callback is stored without the title and tenant stamp those hooks give it โ a permission without its identity key too โ as under a bareEvent::fake(); a catalog delete reads its cascade after the foreign keys removed it, so theRoleRetractedorPermissionRevokedit owes never go out; withSoftDeletes, aforceDelete()passes for a soft delete โRoleDeletedorPermissionDeletedsayssoftDeleted: true, and a role's own grants and nested edges stay behind forwarden:clean --strandedโ and arestore()dispatches noRoleRestoredorPermissionRestored; and unlessWarden::operation()encloses the wholeEvent::defer(), the catalog events carry an operation of their own. Keep warden's writes out of it, or name the events to hold back:Event::defer($callback, [OrderShipped::class]).
Waiting for the commit
Warden dispatches synchronously on purpose: a listener that writes its audit row in the same transaction, or that vetoes a write by throwing, depends on it. When a listener should only hear about committed changes, Laravel lets that listener class wait:
use ElPandaPe\Warden\Events\PermissionGranted; use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit; final class RecordGrant implements ShouldHandleEventsAfterCommit { public function handle(PermissionGranted $event): void { // Runs once the open transaction commits; never if it rolls back. } }
A queued listener implements ShouldQueueAfterCommit instead. Know the limits:
- Only listener classes can wait. A closure passed to
Event::listen()runs at once. - Laravel waits for the most recent transaction still open, on any connection. With
warden.connectionpointing elsewhere, a transaction on your default connection holds the listener although warden's rows are already committed, and discards it if that transaction rolls back. - Outside any transaction it runs at once. Inside one it runs after the outermost commit, and for a retried transaction only once, for the attempt that committed.
- It no longer shares your transaction. An audit write that fails cannot undo the change, and a listener that throws raises after the data is committed.
- The event was built at the write. Its actor, scope and entries are those of that moment, not of the commit.
Snapshots
A snapshot names a role or a permission as it was, as a plain array whose shape is frozen. RoleUpdated and PermissionUpdated carry one from before the edit and one from after, RoleDeleted one for each row the role held, and you can take your own:
use ElPandaPe\Warden\Support\Snapshots\PermissionSnapshot; use ElPandaPe\Warden\Support\Snapshots\RoleSnapshot; PermissionSnapshot::of($permission); // or $permission->snapshot() RoleSnapshot::of($role); // or $role->snapshot()
The twin that Warden::allow($user)->to('view', Document::class)->where('status', 'published') writes reads:
[
'v' => 1,
'key' => 12,
'name' => 'view',
'title' => 'View documents',
'entity_type' => 'App\Models\Document',
'entity_id' => null,
'only_owned' => false,
'scope' => null,
'conditions' => [
'g' => ['i' => [['and', ['c' => 'status', 'o' => '=', 't' => 'value', 'v' => 'published']]], 't' => 'group'],
'v' => 1,
],
]
and a role reads ['v' => 1, 'key' => 3, 'name' => 'editor', 'title' => 'Editor', 'scope' => null].
vis the shape's version:PermissionSnapshot::VERSIONandRoleSnapshot::VERSION. Keys, order and types are frozen; a new shape will be a new version, never an edit of this one.key,entity_idandscopecompare by value. An integer, or a string holding one ('7'), reads as an integer; anything else โ a UUID,'007'โ stays a string.entity_typeis what the row stores: a morph alias, a class name, or'*'.conditionshas three states, each matching what the engine does with the row:nullโ the column is SQLNULL: no conditions;- the rule, in canonical form โ keys sorted, types kept (
'1'is not1), an empty group still a rule; ['unreadable' => '<the stored text>']โ something is stored and does not decode: text that is not JSON, an empty string, the JSON literalnull, or JSON of a shape warden does not know. Nevernull: a rule nobody can read is not the absence of one, and the engine fails closed on it.
- A snapshot survives JSON.
json_decode(json_encode($snapshot), true)gives it back unchanged, so it can be stored as it is. - Take it from a whole row. A column a partial
select()left out photographs as its default โ'',null,falseโ without a word, and underModel::preventAccessingMissingAttributes()it throwsMissingAttributeExceptioninstead โ except a permission's conditions: the snapshot reads them from the rawoptionscolumn, so a row selected without it photographs them as none, even in strict mode. Warden's own hooks read the missing columns before they take one; a snapshot you take yourself does not. RoleDeletedwraps them.$heldGrantslists['permission' => <snapshot>, 'forbidden' => bool, 'scope' => โฆ, 'expires_at' => ?CarbonImmutable], and$heldRoleslists['role' => <snapshot>, 'scope' => โฆ, 'restricted_to_type' => ?string, 'restricted_to_id' => โฆ, 'expires_at' => ?CarbonImmutable].
โ ๏ธ
snapshot()on a model comes from warden's trait, and in PHP a trait method wins over one inherited from a parent class. If your role or permission model extends a base class that already definessnapshot(), the trait's hides it โ and if the two signatures are incompatible, PHP refuses to load the model class. Warden itself always calls theSupport\Snapshotsclasses.
โ ๏ธ Exceptions
All typed, all catchable the Laravel way:
Warden::findRole('ghost'); // RoleDoesNotExist (ModelNotFoundException) Warden::findPermission('ghost'); // PermissionDoesNotExist Warden::authorize('publish', $post); // UnauthorizedException (AuthorizationException)
| Exception | Extends | Notes |
|---|---|---|
RoleDoesNotExist |
ModelNotFoundException |
โ |
PermissionDoesNotExist |
ModelNotFoundException |
โ |
UnauthorizedException |
AuthorizationException |
getRequiredPermissions() / getRequiredRoles() |
ConfigurationException |
โ | Fail-fast on bad config |
TrashedCatalogRow |
ConfigurationException |
A name matches only a role or permission in the trash: restore it or force-delete it first |
๐
UnauthorizedExceptionmessages are translatable (shipped in English and Spanish). Displaying the missing permission/role name in the message is opt-in viawarden.exceptions.display_*.
๐ข Enums
Every public signature that takes a permission or role name also accepts a string-backed enum:
enum Permission: string { case EditSite = 'edit-site'; } enum Role: string { case Admin = 'admin'; } Warden::allow($user)->to(Permission::EditSite); Warden::assign(Role::Admin)->to($user); $user->isAn(Role::Admin); Warden::authorize(Permission::EditSite);
๐พ Caching
Enabled by default. One minimal payload per authority, O(1) automatic invalidation, anti-stampede locking, Octane-safe.
// config/warden.php 'cache' => [ 'enabled' => true, 'store' => 'default', 'prefix' => 'warden', 'expiration_time' => DateInterval::createFromDateString('24 hours'), ],
Manual invalidation
Warden::refresh(); // O(1) version bump โ invalidates everything Warden::refreshFor($user); // Drop one authority's payload
Best Practices
โ Do โ write through Warden and let invalidation take care of itself:
Warden::disallow($user)->to('publish'); // next check is already correct
โ Don't โ raw database edits (seeders, manual SQL) bypass invalidation. After hand-editing rows, call Warden::refresh() โ or better, make the edit through the API.
๐ "Through the API" includes the models. Editing a
Grant, anAssignedRoleor a catalog row through Eloquent invalidates too โ renaming a permission or rewriting itsoptionsreaches every cached check, in every tenant its grants live in, because a permission's own columns are baked into the payload. Moving a row'sscopethat way invalidates the tenant it left as well as the one it joined, and moving a role or a permission in or out of the trash invalidates every scope it reaches โ less when adeletinglistener of yours halts the dispatch: see Deleting a role or a permission. What still needsWarden::refresh()is a write that fires no model event: the query builder (a query'sdelete()andrestore()on aSoftDeletesmodel included),DB::table(), a raw statement, and a model write with its events off โsaveQuietly(),deleteQuietly(),restoreQuietly(),Model::withoutEvents(), or a bareEvent::fake()in a test.
โ ๏ธ The in-memory matcher compares permission names byte-exactly, while a case-insensitive database collation may match
Edittoedit. Use exact, consistent names.
๐งช Testing
Fake mode
$fake = Warden::fake(); $fake->allow('edit-site')->forbid('delete'); $fake->assertChecked('edit-site'); $fake->assertGranted('edit-site'); $fake->assertForbidden('delete'); $fake->assertNothingChecked();
A scripted rule answers for every authority unless you narrow it. Each verb below narrows the rule scripted just before it, so they chain:
$fake->allow('publish')->for($editor); // this authority only $fake->allow('edit', Post::class)->owned(); // only what they own $fake->allow('edit', Post::class)->where('status', 'draft'); $fake->allow('edit', Post::class)->whereColumn('author_id', 'id'); $fake->allow('publish')->inScope(5); // only inside tenant 5 $fake->allow('*', '*'); // everything, everywhere
Ownership, conditions and tenancy are decided by the same pieces the database engine uses, and a test suite asserts the fake and the engine answer alike across the shapes a rule can take. Narrowing before scripting a rule throws.
๐ The fake is not looser than the engine. A rule with no entity answers entity-less checks only, a condition abstains where it has no instance to read, and an unscripted check abstains so your app's policies still decide. Where the fake cannot express something, it denies rather than granting.
โ ๏ธ
Warden::fake()is notEvent::fake(). A bareEvent::fake()also stops the model hooks warden depends on โ identity keys, titles, cache invalidation. Fake warden's events by name instead: the list is under Events.
WithPermissions trait
use ElPandaPe\Warden\Testing\WithPermissions; $this->allowUser($user, 'view', Document::class); $this->assignRoles($user, 'admin');
Artisan commands
php artisan warden:show [Class:id] # Show permissions for an authority php artisan warden:cache-reset # Reset cache php artisan warden:clean --dry-run # Clean orphaned permissions php artisan warden:retitle --dry-run # Converge titles an older Warden wrote php artisan warden:doctor # Audit the catalog for rules that can never be true
๐
warden:doctorexits non-zero when it finds something, so it works as a CI gate. It reads every stored condition back through the rule the write path enforces and reports the ones that would be refused today, with the permission and how many grants and forbids point at it. It changes nothing: adding the missing cast and rewriting the condition mean different things, and only you know which one you meant.
๐ก๏ธ Middleware & Blade
Off by default. Enable via config:
'warden.register_middleware_aliases' => true, 'warden.register_blade_directives' => true,
Middleware
Route::get('/admin', ...)->middleware('warden.role:admin,editor'); // any of Route::put('/site', ...)->middleware('warden.permission:edit-site'); // all of
๐
warden.roleanswers exactly likeisA(). An assignment past its end date does not count, and withwarden.roles.nestedon, a role reached through another one does. A denial throwsUnauthorizedException, which Laravel renders as a 403.
Blade
@forbidden('publish') You are explicitly banned from publishing. @endforbidden
๐๏ธ Schema & Models
Four tables:
| Table | Purpose |
|---|---|
permissions |
The catalog |
roles |
Role definitions |
assigned_roles |
Role โ authority pivot |
grants |
Permission โ authority (with forbidden flag) |
๐ Revoking removes the grant, never the catalog row. The row is shared, so pruning it inline would destroy a rule other holders point at.
warden:cleanis the supported way to reclaim rows nothing points at, and--duplicatescollapses rows that identify the same permission. When a grant it re-points collides with one the surviving row already holds, the survivor keeps the later end date โ no end date beats any.
๐ Both pivot relations mix granted and forbidden rows.
$role->permissions()and$permission->roles()return every pivot row, whichever polarity it carries โ filter to read one side:$role->permissions()->wherePivot('forbidden', false)->get(); // what it can do $role->permissions()->wherePivot('forbidden', true)->get(); // what it is denied
๐ Load the whole permission row before changing what identifies it.
entity_type,entity_id,only_owned,scopeandoptionsmake up its identity key, so changing any of them on a permission fetched with a partialselect()throws aConfigurationExceptioninstead of saving a key computed from half a row. A permission created in the same request counts as whole: what it left unset holds the column default. An edit that leaves those five alone โ its title, say โ still saves, in strict mode too, and a save never moves an existing row into the active tenant: the tenant is stamped on creation only.
๐ Titles are generated once, on creation, and only when none was given. A rename keeps the old title, and setting
titletonullon an update leaves itnull. Recompute one deliberately withSupport\Titles\PermissionTitle::generate()orRoleTitle::generate()โ the same calls the hook makes.
๐ Ask before you rewrite a title.
PermissionTitle::generations()andRoleTitle::generations()return every title Warden could have written for a name, current first โ each generator this package has published is transcribed and frozen. A stored title inside that list was Warden's; one outside it was typed by a person and is not yours to overwrite.PermissionTitle::generations('viewAny', Post::class, null, false); // ['View any posts', 'ViewAny posts'] โ current, then the pre-2.0 reading
php artisan warden:retitleapplies exactly that rule across the catalogue: a title an older Warden generated converges on the current wording, a title someone wrote stays, and anullstaysnull. Run it with--dry-runfirst.
Any model can hold roles and permissions:
use ElPandaPe\Warden\Concerns\HasRolesAndPermissions; class User extends Authenticatable { use HasRolesAndPermissions; }
Swap models via config
// config/warden.php 'models' => [ 'role' => App\Models\Role::class, ],
// app/Models/Role.php class Role extends Model { use ElPandaPe\Warden\Models\Concerns\IsRole; }
๐ Never hardcode package classes in relations. Always resolve via config.
๐ The grant or assignment a verb creates is inserted unguarded. Warden creates the row with its end date in a single
INSERT, insideModel::unguarded()โ as Laravel'sforceCreate()does โ so no$fillableor$guardedon your grant or assignment model can split it into an insert and a later update. That coversallow()andforbid()withto()ortoOwn()โ andeverything(),toManage()andtoOwnEverything(), which go through them โ the grant awhere(),orWhere(),whereColumn()ororWhereColumn()moves onto its constrained twin, andassign()->to(),sync()->roles()included. The switch is global while Warden looks the row up and, if it is missing, inserts it: an observer or listener that runs in that window โ your pivot model'sretrieved,saving,creating,createdorsavedโ fills any model without mass-assignment protection, andretrievedruns unguarded even when the row is already there and nothing is inserted, as on a secondallow()->to()of the same grant, a re-date, or async()that keeps it. A grant thatsync()->permissions()orsync()->forbiddenPermissions()creates carries no end date, and is inserted as before, through your model's mass-assignment rules.
โ๏ธ Configuration
Everything lives in config/warden.php:
| Section | Controls |
|---|---|
models |
Swappable Role, Permission, Grant, AssignedRole models |
tables |
Table names and database connection |
morphs |
Morph aliases (warden.role, warden.permission) |
gate |
Gate behavior (run_before_policies, register) |
ownership |
Global/per-class ownership attribute |
scope |
Multi-tenancy semantics |
cache |
Store, prefix, TTL |
events |
Enable/disable events, cancellable pre-action events |
exceptions |
Display permission/role names in messages |
๐ Recipes
Authorize someone other than the current user
Gate::forUser($tenantUser)->allows('edit', $post); Warden::explain($tenantUser, 'edit', $post);
Ownership through a pivot table
Warden::ownedVia(Business::class, fn ($business, $user) => $business->owners()->whereKey($user->getKey())->exists() ); Warden::allow($user)->toOwn(Business::class, ['manage']);
โ ๏ธ Closure-resolved ownership cannot compile into
whereCan().
Default role for new users
// In your User model or observer: protected static function booted(): void { static::created(fn (User $user) => Warden::assign('member')->to($user)); }
๐ก There is no "role for everyone" by design. Use
Warden::allowEveryone()->to(...)for global grants.
Landlord vs tenant databases
Point warden tables at their own connection with warden.connection. The published migration honors it (Schema::connection(...)), and the migration class is anonymous to avoid collisions. warden:clean --stranded follows the split: when an authority model lives on another connection, it looks for that authority's rows there rather than on warden's. It asks the connection the authority model resolves while the command runs, so it assumes one users database: with one per tenant, every other tenant's holders would look gone, so do not run --stranded in that layout.
Replace a role instead of stacking
Warden::sync($user)->roles(['editor']); // declarative Warden::retract('viewer')->from($user); // or surgical Warden::assign('editor')->to($user);
Long-lived processes (Tinker, Octane, queues)
Writes through the API invalidate caches automatically. What still needs Warden::refresh() is a write that fires no model event โ the query builder, DB::table(), a raw statement, or a model write with its events off: see Caching. Tenant state and the open operation live in container-scoped bindings, so Octane requests and queue jobs reset themselves; the sync queue driver runs a job inside the request that queued it, and shares both โ unless the listener waits for the commit (ShouldQueueAfterCommit, $afterCommit = true, or a connection with after_commit), in which case it runs after, with its own operation.
๐ Migrating from silber/bouncer
composer require elpandape/warden # replaces silber/bouncer (conflict enforced) php artisan warden:upgrade --dry-run # report php artisan warden:upgrade # in-place schema transform vendor/bin/rector process app --config vendor/elpandape/warden/stubs/rector-silber-upgrade.php
The fluent API is intentionally compatible. The schema upgrades in place (abilities โ permissions, permissions pivot โ grants). See MIGRATING-FROM-BOUNCER.md for the full equivalence table.
๐งช Development
No local PHP or Composer needed โ everything runs through Docker:
make build # build the dev image make install # composer install make ci # pint + phpstan + rector + tests (100% coverage) + type coverage make test-dbs # run suite against MySQL 9 and Postgres 16 make mutation # mutation testing over the core make shell # shell inside the container
๐ค Credits & License
- Original concept & API design: Joseph Silber โ this project started as an evolution of his Bouncer and keeps his copyright notice.
- Maintainer: Carlos Mayorga
Licensed under the MIT License.
Authorization that explains itself.