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
Conflicts
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. |
| ๐ข 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 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.
๐ 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']);
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);
๐ 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);
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.
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 constrained grant never matches instance-less checks (
can('view'),can('view', Document::class)) โ they fail closed.
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.
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 $why->role; // the role that carried it, when one did (string) $why; // "Explicitly forbidden by permission [edit] via role [banned]."
๐ 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 |
PermissionRevoked / PermissionUnforbidden |
disallow(), unforbid() |
Same shape |
RoleAssigned / RoleRetracted |
assign(), retract() |
Model $authority, Collection $roles, $scope, ?Model $restrictedTo |
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) { audit('granted', $event->authority, $event->permissions->pluck('name')); });
Pre-action events (opt-in)
Enable with warden.cancellable_events. A listener returning false aborts the write:
// GrantingPermission, ForbiddingPermission, AssigningRole
๐
sync()never fires nor honors pre-action events โ its declarative diff events tell the whole story.
โ ๏ธ 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.
โ ๏ธ 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();
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
๐ก๏ธ 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) |
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) |
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 UPGRADE.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.
