elpandape/filament-warden

Roles and permissions for Filament, built on elpandape/warden โ€” a permission grid derived from your policies, explicit denials, and conditional grants.

Maintainers

Package info

github.com/elpandape/filament-warden

pkg:composer/elpandape/filament-warden

Transparency log

Statistics

Installs: 46

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.1 2026-08-22 12:50 UTC

README

Advanced roles and permissions for Filament
Built on elpandape/warden โ€” a permission grid derived from your policies, explicit denials, and conditional grants.

Packagist Version Total Downloads License PHP 8.5 Laravel 13 Filament 5.7

๐Ÿ“– Table of Contents

โœจ Features

Feature Description
๐ŸŽฏ Policies as the source of truth The permission grid is automatically derived from your policies. Zero manual configuration.
๐Ÿ”’ Explicit denials A hard "no" beats any grant. Distinguishes between abstention and denial.
๐Ÿ” Built-in inspector Every cell explains why it has that value: which role, which rule, and which permission decided it.
๐Ÿ—๏ธ Advanced conditions Restrict permissions with SQL-like conditions (name = editor AND scope >= 2).
๐Ÿงช Test bench Verify permissions in real time from the panel without writing code.
๐Ÿ›ก๏ธ Security guard The panel refuses to boot if there are unprotected pages or widgets.
๐Ÿ“Š Automatic audit Detects unguarded screens, missing policies, and permissions nothing declares.
๐Ÿ”„ Smart cache Automatic invalidation when assigning roles. No ghost permissions.
๐Ÿข Multi-tenancy Native support for tenant scopes across all warden tables.

๐Ÿ“‹ Requirements

Requirement Version
PHP ^8.5
Laravel ^13.0
Filament ^5.7
elpandape/warden ^1.0

๐Ÿš€ Installation

# 1. Install the package
composer require elpandape/filament-warden

# 2. Register the plugin in your Panel
use ElPandaPe\FilamentWarden\FilamentWardenPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugin(FilamentWardenPlugin::make());
}

# 3. Install warden (creates tables)
php artisan warden:install --migrate

# 4. Publish assets
php artisan filament:assets

๐Ÿ’ก Tip: Add php artisan filament:assets to Composer's post-autoload-dump so it runs on every deploy.

Optional publishes

# Configuration
php artisan vendor:publish --tag=filament-warden-config

# Translations
php artisan vendor:publish --tag=filament-warden-translations

# Views (โš ๏ธ see Stability section)
php artisan vendor:publish --tag=filament-warden-views

โšก Quick Start

Follow these 5 steps to get a working permissions panel in minutes. We'll use an Order model as an example.

1. Create the Policy

// app/Policies/OrderPolicy.php
use App\Models\Order;
use App\Models\User;
use ElPandaPe\FilamentWarden\Policies\WardenPolicy;

final class OrderPolicy extends WardenPolicy
{
    public function viewAny(User $user): bool
    {
        return $this->allows($user, 'viewAny', Order::class);
    }

    public function view(User $user, Order $order): bool
    {
        return $this->allows($user, 'view', $order);
    }

    public function update(User $user, Order $order): bool
    {
        return $this->allows($user, 'update', $order);
    }
}

๐Ÿ“Œ Important: Only actions declared in the policy will appear in the grid. Remove update and its cell disappears.

2. Lock Panel Access

// app/Models/User.php
use ElPandaPe\FilamentWarden\Concerns\AccessesPanels;
use Filament\Models\Contracts\FilamentUser;

final class User extends Authenticatable implements FilamentUser
{
    use AccessesPanels;
}

3. Enable Strict Authorization

// app/Providers/Filament/AdminPanelProvider.php
return $panel
    ->strictAuthorization()
    ->plugin(FilamentWardenPlugin::make());

4. Lock Custom Pages & Widgets

use ElPandaPe\FilamentWarden\Filament\Concerns\AuthorizesPageAccess;
use ElPandaPe\FilamentWarden\Filament\Concerns\AuthorizesWidgetView;

final class Reports extends Page
{
    use AuthorizesPageAccess;  // Generates: page:App\Filament\Pages\Reports
}

final class RevenueChart extends ChartWidget
{
    use AuthorizesWidgetView;  // Generates: widget:App\Filament\Widgets\RevenueChart
}

5. Create Your First Role (from console)

// database/seeders/WardenSeeder.php
use ElPandaPe\Warden\Facades\Warden;

$role = Warden::role(['name' => 'super-admin']);
$role->save();

Warden::allow($role)->everything();

โš ๏ธ This is also your only way back. The grid can hand out every action it finds on every entity it knows about, one row at a time, but never the wildcard over the wildcard โ€” entity_type = '*', the one permission everything() writes โ€” because that row answers no check the grid asks and draws no cell (nothing in this package calls everything()). Keep this seeder: it is what you run again if a role ever locks you out of the panel itself.

# Assign the role to your user
php artisan filament-warden:assign super-admin "App\Models\User:1"

๐ŸŽ‰ Done! Open /admin/roles and you'll see the permission grid. Click a cell to grant, click again to deny, save, and you're set.

๐Ÿ”Œ Setup

Policies

All your policies must extend WardenPolicy. The allows() method resolves directly from warden's store, avoiding infinite loops with the Gate.

use ElPandaPe\FilamentWarden\Policies\WardenPolicy;

final class OrderPolicy extends WardenPolicy
{
    public function viewAny(User $user): bool
    {
        // For listings: pass the class
        return $this->allows($user, 'viewAny', Order::class);
    }

    public function view(User $user, Order $order): bool
    {
        // For individual records: pass the instance
        return $this->allows($user, 'view', $order);
    }
}

Lock the Panel

The panel permission is automatically derived from its ID. A panel named admin generates the permission panel:admin.

use ElPandaPe\FilamentWarden\Concerns\AccessesPanels;

final class User extends Authenticatable implements FilamentUser
{
    use AccessesPanels;
}

An installation that already stores another name maps it in the config instead of renaming rows:

'guard' => [
    'panel' => ['admin' => 'viewAdminPanel'],
],

To add a condition of your own, alias the trait's method rather than replacing it. AccessesPanels is a trait: declaring canAccessPanel() on the class silently overrides the trait's copy, and there is no parent::canAccessPanel() to fall back to โ€” Authenticatable has no such method, so that call is a fatal error at login.

use AccessesPanels {
    canAccessPanel as wardenCanAccessPanel;
}

public function canAccessPanel(Panel $panel): bool
{
    return $this->isActive() && $this->wardenCanAccessPanel($panel);
}

Be careful what you fold in here. Filament calls canAccessPanel() from four places and only one of them is the middleware that answers with a 403:

  • Login throws the same validation exception as a wrong password, so the account is told its credentials do not match;
  • both password-reset pages fail silently โ€” no link is sent, and the screen still says one was.

For a condition the account is supposed to resolve rather than simply fail, that is a dead end with no way to read it. Email verification, for instance, belongs in Filament's own ->emailVerification(), which lets them in and then routes them to the prompt.

Lock Pages & Widgets

Filament returns true by default for Page::canAccess() and Widget::canView(). strictAuthorization() does not cover them.

Type Trait Generated Permission
Page AuthorizesPageAccess page:App\Filament\Pages\Name
Widget AuthorizesWidgetView widget:App\Filament\Widgets\Name

Assign Roles to Users

Add the field to your user resource:

use ElPandaPe\FilamentWarden\Filament\Forms\RoleAssignment;

RoleAssignment::make('roles')->columnSpanFull(),

๐Ÿšซ Don't use CheckboxList::make('roles')->relationship(...). That saves through sync(), and sync(), attach() and detach() all skip warden's cache bump โ€” only warden's own actions make it. A role handed out that way goes on answering the old way, silently and with no expiry. RoleAssignment writes through warden's fluent API instead.

๐Ÿ”’ A role assigned outside the tenant you are viewing from cannot be handed back here, from v1.3.0. Warden's own tenant scope reads a role as held from global or this tenant, so a globally assigned role shows as ticked from inside any tenant โ€” but a retract targets one exact scope. Unticking that box now locks instead of quietly deleting nothing (and reporting success) or, worse, deleting a real tenant-scoped row while the global one keeps it looking held. Switch tenant to change it.

Query Permissions Manually

use ElPandaPe\FilamentWarden\Support\Access;

// Loose permission
Access::grantedToCurrentUser('export-reports');

// Over a class
Access::grantedToCurrentUser('viewAny', Invoice::class);

// Over a specific record
Access::granted($otherUser, 'view', $invoice);

Use this rather than $user->can(). The two agree until they do not:

$user->can('export-reports') Access::grantedToCurrentUser(โ€ฆ)
not granted false false
granted true true
granted, with warden.gate.register off false true

That last row is why Access exists. Warden ships that switch so an application can register its own gate callback, and the day one does, every $user->can('export-reports') starts answering false with no error to read โ€” a loose permission has no policy to answer for it, so if warden's hook is gone there is nobody left. Access goes straight to the resolver, and picks the account up through Filament::auth(), which is not necessarily the default guard.

Permission Names

use ElPandaPe\FilamentWarden\Catalog\PermissionName;

PermissionName::page(Reports::class);        // page:App\Filament\Pages\Reports
PermissionName::widget(RevenueChart::class);   // widget:App\Filament\Widgets\RevenueChart
PermissionName::panel($panel);                 // panel:admin

๐Ÿ–ฅ๏ธ The Screens

The Permission Grid

The roles screen shows a grid where:

  • Rows = Entities (models, pages, widgets, panel)
  • Columns = Actions declared in policies
  • Cells = Cycle through: abstain โ†’ grant โ†’ deny

Shortcuts:

  • ๐Ÿ–ฑ๏ธ Normal click โ†’ Cycle forward
  • โ‡ง Shift + click โ†’ Cycle backward (useful for quick denials)
  • โŒจ๏ธ Arrow keys move between tabs; every cell and tab carries a name a screen reader can announce on its own, not one shared word for all seven states.

๐Ÿšซ A grid that cannot be operated says so. From v1.1.0, a protected role's grid, a field your application called ->disabled() on, and the read-only screen (ViewRole) all print one sentence above the table โ€” "This grid cannot be changed from here: its cells select, they do not cycle." โ€” instead of silently accepting clicks that never save or, on the read-only screen, saying nothing at all. A protected role keeps its own stronger notice naming roles.protected; the other two share this one, because neither route lets the package know why it cannot write.

Permission Inspector

Click any cell to see:

  • Cause: Why does this cell have this value?
  • Permission: Which specific rule decided it
  • Role: Which role it came from

๐Ÿ” The inspector is queried on demand (not automatically) to avoid hundreds of queries.

Cell Reach

Each cell can reach:

Reach Description
Every row Permission applies globally
Only owned Restricted to records where user_id matches
With conditions Custom SQL-like rules

Example conditions:

name = editor OR (scope >= 2 AND title = account.name)

๐Ÿ”’ A locked cell lights none of the three. From v1.1.0, a cell the grid cannot let you set โ€” more than one rule for the same action, a condition it cannot parse, or a grant that belongs to another tenant โ€” draws its actual reach and highlights none of "Every row", "Only owned" or "With conditions", instead of defaulting to "Every row" as it did before. The inspector names which of the three it is and, when there is a stored rule, shows it read-only underneath the note. A row that is both "only what it owns" and carries conditions is drawn the same way: read-only, with its stored rule shown, never silently narrowed to plain ownership.

โš ๏ธ A grant pinned to a single record is not a cell. Warden filters a check made against a class down to entity_id is null, so a rule with a record key on it answers nothing the grid asks โ€” and it is not a wider rule either. The grid lists those rules above the tabs, read-only: this screen shows them, and cannot remove them.

๐Ÿ”’ A rule this screen cannot write back exactly locks too, from v1.3.0. A value stored as the string '2', '2.5', 'true' or 'false' reads back as another type the moment this builder parses it, and a rule whose first line is or reads back as and โ€” both change what the row means for everybody holding it, so it is drawn, explained, and left alone instead of silently rewritten on the next save. It can still be edited from warden's own fluent API. And a true/false value compared against a column the model has not cast to boolean gets its own warning โ€” in the builder and in the grid's inspector alike โ€” because that comparison is stored and then never matches a single row.

Permissions Screen

Lists the permissions table โ€” the rows warden has actually created โ€” and says where each one came from:

  • Provenance: derived from a policy, loose, the wildcard, or an entity nothing declares any more
  • Reach: every row, only what the account owns, with conditions โ€” or one record only, when the row is pinned to a single record
  • Holders: how many roles hold it, with denials counted apart
  • Test bench: ask warden about a real account, from the screen

โ„น๏ธ On a fresh install this screen is empty, and that is correct. Warden creates a permission row the first time something is granted, so nothing exists until you hand something out. The roles screen is the one that shows the whole catalogue derived from your policies, row or no row.

๐Ÿ›ก๏ธ Security

The Guard

From v0.8.0, the panel refuses to boot if it finds an unguarded page or widget. That is what stops a custom screen from being left open to everyone by accident.

It is on by default, and the plugin takes no options: FilamentWardenPlugin accepts make(), getId(), register() and boot(), and nothing else. The switches are config keys, one per kind:

// config/filament-warden.php
'guard' => [
    'pages'   => true,   // refuse to start on an unguarded page
    'widgets' => true,   // refuse to start on an unguarded widget
],

Turn one off only to get the panel up while you close the screens โ€” php artisan filament-warden:audit lists what is still open without stopping anything.

Audit

# View report
php artisan filament-warden:audit

# CI mode (fails with exit code 1 on an actionable finding)
php artisan filament-warden:audit --check

It writes nothing, and reports seven things:

  • screens nobody guards โ€” the same finding the guard throws on, which is how it reaches CI at all: no artisan command ever starts a panel;
  • resources whose model has no policy โ€” the case Filament fails open on, told apart from a policy that declares nothing and from a resource pointing at a class that does not exist;
  • permissions the catalogue declares that no grant points at โ€” informational: this one never turns --check red. Turning a grid cell off revokes the grant and leaves the row, because warden's revoke() only touches grants, so a build that failed on this would fail on every save and stay failing. php artisan warden:clean is what removes them, and --dry-run shows the list first;
  • permissions nothing declares that no grant points at โ€” a rename left them behind: they can never match again, and nothing will ever create them;
  • grants for actions nothing declares any more โ€” a renamed policy method, a typo in a seeder, a screen that was deleted: the silent mistake warden has no way to detect;
  • whole entity types nothing declares โ€” a morph alias that moved, reported apart because the fix is the opposite one;
  • models only a relation manager reaches, with the catalog.models line that settles it.

--check returns 1 for every finding above except the informational one.

The word "orphaned" is wider on the permissions screen than it is here. The Orphaned filter there means what warden:clean means โ€” no grant points at the row, declared or not โ€” which is the whole of the third and fourth bullets together. This command splits that same population in two, because only half of it is worth failing a build over. Nothing on the screen is renamed: those rows are exactly the rows warden:clean will delete, and that is the meaning the screen exists to act on.

๐Ÿ”ง Advanced

How Far a Permission Reaches

To see how many rows a user can view:

use ElPandaPe\Warden\Concerns\QueriesByPermission;

class Document extends Model
{
    use QueriesByPermission;
}

โš ๏ธ The number shown is a lower bound. Roles assigned in specific contexts are not included in whereCan().

Multi-tenancy

Warden supports multi-tenancy through a scope column on its tables. It does not know about Filament::getTenant() โ€” you must create a TenantResolver in your application.

This package's resources declare protected static bool $isScopedToTenant = false;, so Filament's tenancy never reaches warden's own models. Without it, a panel with ->tenant() puts a global scope on the Role and Permission models โ€” not on the resources โ€” and that scope demands a relationship named after your tenant class: Role::query()->count() then throws LogicException for the whole request, warden's internals included.

It is written as the property and never through scopeToTenant(false), which is static and would un-scope every resource of your application โ€” a cross-tenant leak this package would have caused.

Deleting a role looks across every tenant. From v1.0.2, roles.delete => 'unassigned' counts assignments with warden's tenant scope lifted. A role held only under a tenant you are not currently in would otherwise read as unassigned, and deleting it takes that tenant's assigned_roles and grants rows with it through the foreign key โ€” which never looked at scope, and neither does $record->delete().

Catalog

use ElPandaPe\FilamentWarden\Catalog\Catalog;
use Filament\Facades\Filament;

$entries = Catalog::for(Filament::getPanel('admin'))->entries;

foreach ($entries as $entry) {
    $entry->name;         // 'viewAny', 'page:App\...'
    $entry->entityType;   // Morph alias or null
    $entry->model;        // Model class or null
    $entry->scope;        // Read | Write | Withdraw | Irreversible
    $entry->origin;       // Resource | Model | Page | Widget | Custom | Panel
    $entry->key();        // 'viewAny|order'
}

Custom Permissions

// config/filament-warden.php
'catalog' => [
    'models' => [\Laravel\Passkeys\Passkey::class],
    'custom' => ['export-reports' => 'read'],
],

๐Ÿšซ Custom names cannot contain dots (.) โ€” they break Livewire state.

โš™๏ธ Configuration Reference

Permissions (permissions screen)

'permissions' => [
    'create'      => false,        // manual creation of permissions
    'update'      => 'loose',      // false | 'title' | 'loose' | 'all'
    'delete'      => 'orphaned',   // false | 'orphaned' | 'all'
    'constraints' => true,         // the condition builder
    'only_owned'  => true,         // the ownership checkbox
    'probe'       => true,         // the test bench, built on explain()
],

A row somebody holds cannot be re-pointed. From v1.0.2, the name and the entity of a permission that at least one role already holds are locked on its edit screen, and put back on the server if the payload says otherwise โ€” at every setting of update except 'all'. Moving them moves what those holders hold without revoking anything and without writing a single row to grants: the check they used to pass simply starts answering something else. 'loose' still mints and edits the rows nobody holds yet, and the conditions and the ownership checkbox stay editable wherever they were before โ€” those narrow what a row means, they do not re-point it.

Roles (roles screen)

'roles' => [
    'create'    => true,             // true | false
    'delete'    => 'unassigned',     // false | 'unassigned' | 'all'
    'protected' => ['super-admin'],  // names that cannot be taken, renamed onto, or deleted
],

A protected role keeps its name and its grid: both are shown, neither can be edited, and it cannot be deleted. Its title is left editable โ€” nothing resolves by it.

From v1.0.2 a role cannot arrive at a protected name either. Creating a role called super-admin, or renaming an ordinary one onto it, is refused by the form โ€” before 1.0.2 both succeeded and the role was born protected, which is a way of minting an unremovable role by typing. The role that already carries the name keeps it: only the arrival is closed. From v1.1.0 the refusal names which list the name is on, instead of the framework's generic validation wording.

โš ๏ธ The merge is shallow, on purpose. Declaring roles.protected => [] in your published config genuinely unprotects every role. A recursive merge would blend lists by index and silently keep 'super-admin' in there โ€” so it is not used, and a test holds that line.

Guard

'guard' => [
    'panel'   => ['admin' => 'viewAdminPanel'], // Override by panel ID
    'pages'   => true,   // true | false
    'widgets' => true,   // true | false
],

Grid

'grid' => [
    'explain'     => true,  // Inspector
    'constraints' => true,  // Show scope
],

Catalog

'catalog' => [
    'models' => [],   // models with a policy and no resource
    'custom' => [],   // loose permissions, as name => scope
    'scopes' => [
        'read'         => ['viewAny', 'view'],
        'write'        => ['create', 'update'],
        'withdraw'     => ['delete', 'deleteAny', 'restore', 'restoreAny'],
        'irreversible' => ['forceDelete', 'forceDeleteAny'],
    ],
],

Navigation

'navigation' => [
    'group' => null,           // falls back to this package's own translated group
    'roles' => [
        'slug' => 'roles',     // what the URL says
        'icon' => null,        // falls back to a shield
        'sort' => null,        // navigation order
    ],
    'permissions' => [
        'slug' => 'permissions',
        'icon' => null,        // falls back to a key
        'sort' => null,
    ],
],

๐Ÿค” Why this package?

There's already a well-known permissions plugin for Filament, and for most projects it's the right answer. Filament Warden exists for what Warden does that others don't:

Feature Filament Warden Others
Catalog derived from policies โœ… Automatic โŒ Manual
Permissions against models โœ… By class โŒ Plain strings
Explicit denials โœ… Real state โŒ Absence = denial
Cause inspector โœ… Built-in โŒ Not available
SQL-like conditions โœ… Native โŒ Not available
Renaming resources โœ… No breakage โŒ Orphan permissions

๐Ÿ“ฆ Stability

From v1.0.0, everything below is covered by SemVer: changing any of it is a major release. tests/FrozenTest.php is what says so โ€” it fails when one of them moves.

Two different kinds of thing are in that list, and both matter for the same reason.

The names are rows in your database. A permission called page:App\Filament\Pages\Settings was granted to a role a year ago. Renaming the prefix does not fail: the row stays, stays grantable, and opens nothing.

The keys are lines in your application โ€” a published config, an overridden translation, a command in a deploy script. Removing one is silent here and loud there.

โœ… Frozen (SemVer)

Category Items
Permission prefixes page:, widget:, panel: and PermissionName, which mints them and reads them back
Plugin FilamentWardenPlugin, its ID filament-warden, and its four methods: make(), getId(), register(), boot()
Fields PermissionGrid, PermissionGridEntry, ConditionBuilder, RoleAssignment, and the {stances, narrowing} state envelope a form receives
Traits AuthorizesPageAccess, AuthorizesWidgetView, AccessesPanels
Authorization WardenPolicy, Access
Catalog Catalog::for(), Entry and its key(), Origin, Scope
Guard PanelIsOpen
Config Every key path of config/filament-warden.php โ€” all 27 of them, each pinned with the shape it holds. The pin stops at a key whose value is a list or an empty array: what goes inside those is your data, not our schema
Translations Every key path of lang/*/ui.php, in both locales
Commands filament-warden:assign and filament-warden:audit, with their arguments

Adding a translation key or a config key is a minor, not a major: nothing you wrote stops working. Only removing or renaming one is a break. Both pins list every path and compare in order, so on our side an addition also turns the build red โ€” deliberately, so that a new key is a line somebody typed on purpose rather than a diff nobody read.

โš ๏ธ Not frozen (may change in any release)

Grants\, Conditions\, Filament\Guard, Filament\Forms\Grid\ and everything in Catalog\ other than the classes named above are this package's insides. They move without warning.

Five consequences worth saying out loud, because each one is a place the line is easy to cross by accident:

  • A published view is welded to those insides. filament-warden-views is a real escape hatch and you are welcome to it, but your copy calls $getGrid() and walks a GridView, its tabs, rows and cells โ€” all internal. Expect to re-merge it on a minor. If you want markup that keeps working, wrap the field rather than forking its view.
  • The screens are not an extension point. RoleResource, PermissionResource and their pages are left non-final so you can experiment, not because subclassing them is supported. They change whenever the screens change.
  • whereCan() is warden's, not ours. Its answer can disagree with the panel's, and it never consults the Gate or a policy.
  • DrawsThePermissionGrid is not one of the frozen traits. The three named in the table above are; this one is the shared insides of PermissionGrid and PermissionGridEntry, and it grows a method whenever those two learn a new fact about their own render. v1.1.0 adds gridInteracts(): bool to it. If you composed it into a class of your own, implement it โ€” return false; if your screen does not write โ€” or the upgrade is a fatal.
  • The inspector's bridge is not an API. explainCell() and narrowingFor() are exposed to the browser so the field's own script can ask them one cell at a time. What they answer is internal and it moves: from 1.1.0, an empty array back from explainCell() means only this grid cannot be asked that โ€” the inspector is switched off, or the cell is not in the catalogue. A record that has not been saved yet now gets a real explanation instead. If you call either method from your own component, read the answer, do not assume its shape.

Not frozen: the word account

The condition builder offers the signed-in account's columns as account.id and the like. That word is a placeholder for whatever an application calls its user model, and it may change. Nothing is stored under it โ€” it is a label on a screen.

๐Ÿงช Development

You don't need PHP or Composer locally โ€” everything runs through Docker:

make build    # Build dev image
make install  # composer install
make test     # Test suite
make ci       # Everything CI runs

See CHANGELOG.md for what each version adds, and CONTRIBUTING.md before opening a PR.

๐Ÿ‘ค Credits

๐Ÿ“„ License

Filament Warden is open-source software licensed under the MIT License.

Built with โค๏ธ for the Filament community.