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
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
- ๐ 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 | Every write dispatches a typed event with hydrated models โ never raw IDs. |
| ๐ข 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']);
๐ 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(),is()andwhereIs()all nest together: 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.
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.
๐
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.
โ ๏ธ 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.
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.
โ ๏ธ 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 โ 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.
๐ก Events
Every write dispatches a typed, readonly event with hydrated models (never raw IDs). Disable globally with warden.events_enabled.
| Event | Fired By | Payload |
|---|---|---|
PermissionGranted / PermissionForbidden |
allow(), forbid() |
?Model $authority, Collection $permissions, $scope, ?Model $actor |
PermissionRevoked / PermissionUnforbidden |
disallow(), unforbid() |
Same shape |
RoleAssigned / RoleRetracted |
assign(), retract() |
Model $authority, Collection $roles, $scope, ?Model $restrictedTo, ?Model $actor |
RolesSynced / PermissionsSynced |
sync() |
SyncResult diff: attached / detached / kept |
RoleCreated/Deleted, PermissionCreated/Deleted |
Model layer | The model |
use ElPandaPe\Warden\Events\PermissionGranted; Event::listen(PermissionGranted::class, function (PermissionGranted $event) { // $authority receives the permission; $actor is who granted it. audit('granted', $event->actor, $event->authority, $event->permissions->pluck('name')); });
$actor defaults to the authenticated user. 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.
๐ Deleting a catalog row announces what the cascade was predicted to reach. A foreign key removes a permission's grants inside the engine, where no model event fires, so warden reads the doomed rows before the delete and dispatches one
PermissionRevokedโ orPermissionUnforbiddenโ per row afterwards. The read is the announcement: if the foreign key is not enforced, the events describe a deletion that did not happen.
๐ That cascade is blind to the active tenant. The doomed rows are read with
withoutGlobalScopes(), on purpose โ the delete destroys every tenant's grants regardless of which one is active, so counting only the current tenant would promise a smaller loss than the real one.
โ ๏ธ 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 |
๐
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, because a permission's own columns are baked into the payload. What still needsWarden::refresh()is a write that fires no model event: the query builder,DB::table(), and a raw statement.
โ ๏ธ 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.
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
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.
๐ 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
๐ 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.
โ๏ธ 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.
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. Only raw DB edits need Warden::refresh(). Tenant state lives in container-scoped bindings, so Octane requests and queue jobs reset themselves.
๐ 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.