elpandape/warden

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

Maintainers

Package info

github.com/elpandape/warden

pkg:composer/elpandape/warden

Transparency log

Statistics

Installs: 120

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-18 19:35 UTC

This package is auto-updated.

Last update: 2026-08-20 22:25:56 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.
๐Ÿข 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 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.

๐ŸŽ 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: AND binds tighter than OR.
  • 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

๐Ÿ“Œ 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.

โš ๏ธ 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();

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

Licensed under the MIT License.

Authorization that explains itself.