Search by

elpandape / warden

makoto2805

Roles & permissions for Laravel โ€” instance-level grants, explicit forbids, ownership, multi-tenancy, ABAC. A modernized evolution of Joseph Silber's Bouncer.

Package info

github.com/elpandape/warden

pkg:composer/elpandape/warden

Statistics

Installs: 358

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v3.0.0 2026-09-07 18:36 UTC

This package is auto-updated.

Last update: 2026-09-07 18:39:13 UTC


README

Warden

Warden

Roles & permissions for Laravel
Instance-level grants, explicit forbids, ownership, multi-tenancy, and ABAC.
Authorization that explains itself.

Packagist Version Total Downloads License PHP 8.4+ Laravel 13+

๐Ÿ“– Table of Contents

โœจ 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:upgrade to 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-v2 then migrate โ€” 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_policies to 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.register off, 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 the warden.permission middleware 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 โ€” set warden.roles.nested to true and they inherit the inner role's grants, to warden.roles.max_depth levels 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() and whereIs() all nest together: a split would let can('publish') say yes while Warden::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 before to(), like on(): 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, and until() 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 with unforbid().

๐Ÿ“Œ 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. Use notOwned() to take one class out, or 'default_attribute' => null in 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. TenantScope filters 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 through warden:clean.

โš ๏ธ Pivot tenancy is a plain predicate, not a registered scope, so withoutGlobalScopes() does not lift it. Widen deliberately with Warden::tenant()->removeOnce(...), which is the supported escape hatch.

Under null_behavior => 'strict' it narrows instead. With no active tenant, strict reads only global rows, so removeOnce() turns (scope is null or scope = $tenant) into scope 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() and updateExistingPivot() on roles() and permissions() touch only rows at the active write scope, and attach() 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, and sync([]) 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 mirrors Warden::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 through Warden::assign() / retract(), which resolve the scope per call โ€” and which also dispatch the typed events and report retractedCount().

๐Ÿ“Œ A role is global unless the write mints a tenant one. Under an active tenant, allow('editor') attaches to a global editor if 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 global publish row when that is all there is, and picks the tenant's own the moment one exists. Set Warden::tenant()->onlyRelations() to keep the catalog global on purpose.

๐Ÿ“Œ Tenancy::writeScope() takes forRoleGrant, and it defaults to false. A bare call therefore reports the scope of an authority grant; ask with forRoleGrant: true when 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: AND binds tighter than OR.
  • 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: under Model::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:doctor lists 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, and explain() 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, and where() 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 in where() โ€” 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 dynamic where against a column named can, 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 โ€” โ€”

๐Ÿ“Œ ConditionsNotMet and NoMatchingGrant are 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 returning false aborts 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 โ€” or PermissionUnforbidden โ€” 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

๐Ÿ“Œ UnauthorizedException messages are translatable (shipped in English and Spanish). Displaying the missing permission/role name in the message is opt-in via warden.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, an AssignedRole or a catalog row through Eloquent invalidates too โ€” renaming a permission or rewriting its options reaches every cached check, because a permission's own columns are baked into the payload. What still needs Warden::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 Edit to edit. 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:doctor exits 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:clean is the supported way to reclaim rows nothing points at, and --duplicates collapses 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 title to null on an update leaves it null. Recompute one deliberately with Support\Titles\PermissionTitle::generate() or RoleTitle::generate() โ€” the same calls the hook makes.

๐Ÿ“Œ Ask before you rewrite a title. PermissionTitle::generations() and RoleTitle::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:retitle applies exactly that rule across the catalogue: a title an older Warden generated converges on the current wording, a title someone wrote stays, and a null stays null. Run it with --dry-run first.

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

Licensed under the MIT License.

Authorization that explains itself.