happenv-com / filament-access-control
Filament UI for happenv-com/laravel-access-control: a roles × permissions matrix and a permission editor for one role or user, saving live or on demand.
Package info
github.com/happenv-com/filament-access-control
pkg:composer/happenv-com/filament-access-control
Requires
- php: ^8.3
- filament/filament: ^4.13.3 || ^5.8.3
- happenv-com/laravel-access-control: ^3.1
- spatie/laravel-package-tools: ^1.93
Requires (Dev)
- driftingly/rector-laravel: ^2.1
- ergebnis/composer-normalize: ^2.48
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0 || ^5.0
- pestphp/pest-plugin-arch: ^4.0 || ^5.0
- pestphp/pest-plugin-laravel: ^4.0 || ^5.0
- pestphp/pest-plugin-livewire: ^4.0 || ^5.0
- phpstan/phpstan-deprecation-rules: ^2.0
- rector/rector: ^2.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-30 08:16:42 UTC
README
The Filament screens for happenv-com/laravel-access-control: every role against every permission in one grid, and the permissions of a single role or user wherever you want them — a section of the edit form, a tab, a page of their own. Changes are written the moment they are clicked, or collected until the operator presses Save permissions.
use Happenv\FilamentAccessControl\FilamentAccessControlPlugin; use Happenv\FilamentAccessControl\Schemas\Components\PermissionEditor; $panel->plugin( FilamentAccessControlPlugin::make() ->roleModel(Role::class) ->superAdminRole('administrator'), ); // In the role's (or the user's) form: PermissionEditor::make()->deferred();
Key features
- A roles × permissions matrix. One Filament table: modules as collapsible groups, a row per subject and per verb, a column per role — with an optional "granted/total" per role right beside each group's name, folded or open. With dozens of roles, pick the ones shown in the role picker, while the permission column and the role header stay in view. See The access control page, Many roles and Counters.
- An editor for one role or one user. The same table for a single record, as a schema component in a form, a tab or an infolist; for a user it also lists the roles that already grant each permission.
PermissionSelectorcovers create forms as a plain form field. See Editing one record and The form field. - Live or deferred saving. Every click written at once, or staged and saved together with Discard and a warning before leaving; each save replays the operator's intent under a row lock, so two administrators do not overwrite each other. See Live or deferred.
- Why, not just whether. Every cell shows what laravel-access-control resolves: in effect, implied, missing a requirement, blocked by a conflict, restricted, or withheld by a condition — the tooltip names the permissions involved. See Rules and conditions.
#[RequiresMFA]. Withhold a permission from any account without multi-factor authentication; the user editor says what it withholds. See Rules and conditions.- Your authorization, asked every time. Laravel abilities, policies or access-control permission enums decide who may see, create and change roles; a voter's refusal is shown in the operator's language. See Authorization.
- Surfaces. Narrow a screen to what a surface offers (an API key's screen, say); grants held outside it stay listed and revocable. See Surfaces.
- Tested. Covered by a Pest suite on every supported version combination.
Requirements
| Package | Versions |
|---|---|
| PHP | 8.3 – 8.5 |
| Laravel | 12, 13 |
| Filament | 4, 5 |
| happenv-com/laravel-access-control | 3.1+ |
Installation
Install the package via Composer:
composer require happenv-com/filament-access-control
Important
If you have not set up a custom theme and are using Filament Panels, follow the instructions in the Filament docs first.
Add the package's views to your theme's CSS file, so Tailwind generates the classes they use:
@source '../../../../vendor/happenv-com/filament-access-control/resources/**/*.blade.php';
The matrix brings a small stylesheet of its own (see Many roles). php artisan filament:assets publishes it — Filament's filament:upgrade, which a Filament application runs after every composer update, does that already.
Preparing your models
A record whose permissions the screens edit — a role, or a user holding permissions directly — implements HasEditablePermissions: the same two methods laravel-access-control's HasPermissions trait asks for, made public. setPermissions() must persist.
use Happenv\FilamentAccessControl\Contracts\HasEditablePermissions; use Happenv\LaravelAccessControl\Contracts\AuthControllable; use Happenv\LaravelAccessControl\Traits\HasPermissions; use Illuminate\Support\Collection; class Role extends Model implements AuthControllable, HasEditablePermissions { use HasPermissions; protected $casts = ['permissions' => 'array']; public function getPermissions(): Collection { return collect($this->permissions ?? [])->filter(fn ($slug) => is_string($slug))->values(); } public function setPermissions(Collection $permissions): void { $this->permissions = $permissions->values()->all(); $this->save(); } }
A user edited the same way can also expose getRoles(): iterable (as HasRoles does) — the editor then shows which of the user's roles already grant each permission.
Registering the plugin
use Happenv\FilamentAccessControl\FilamentAccessControlPlugin; public function panel(Panel $panel): Panel { return $panel ->plugin( FilamentAccessControlPlugin::make() ->roleModel(Role::class) ->superAdminRole('administrator') // the `code` of the role that holds everything ->roleAbilities( viewAny: RolePermission::View, create: RolePermission::Create, update: RolePermission::Update, ), ); }
Configuration
The package has no config file: everything is set on the plugin — see Registering the plugin and The access control page — or per component.
Optionally, publish the views and translations:
php artisan vendor:publish --tag="filament-access-control-views" php artisan vendor:publish --tag="filament-access-control-translations"
Usage
The access control page
With a role model, the plugin registers an Access control page: the matrix and an Add role action. Roles are deleted where your application manages them — its role resource, for instance. Configure it through the plugin:
FilamentAccessControlPlugin::make() ->roleModel(Role::class) ->roleTitleAttribute('name') // or fn (Role $role): string ->modifyRolesQueryUsing(fn (Builder $query) => $query->orderBy('name')) ->superAdminRole(fn (Role $role): bool => $role->is_admin) ->rolesShownByDefault(8) // see "Many roles" below ->modifyCreateRoleActionUsing(fn (CreateAction $action) => $action->schema([ TextInput::make('name')->required(), TextInput::make('code')->required()->unique(), ])) ->navigationGroup('Settings') ->navigationSort(10) ->slug('permissions') ->cluster(SettingsCluster::class);
Groups start folded, and a group's row opens and folds it. Only open groups are drawn, so a catalogue of hundreds of permissions stays a light page; Expand all and a search open what they show.
The super-admin role is drawn fully granted and read-only. Pass ->accessControlPage(false) to register no page, or ->accessControlPage(MyPage::class) with a class extending Pages\AccessControl to replace it.
Many roles
The list icon next to the search opens the role picker: every role with a checkbox, a search, and Select all / Deselect all. The choice is kept for the session, and while roles are hidden the table says how many it shows (Roles shown: 8 of 50). A role added from the page is shown straight away.
FilamentAccessControlPlugin::make() ->rolesShownByDefault(8) // the first eight roles, in the roles query's order, until the operator picks others ->deferRolePicker(false); // apply every tick at once instead of on Apply PermissionMatrix::make()->rolesShownByDefault(8)->deferRolePicker(false); // or per component
Unset, every role shows and the picker waits for Apply. Fewer columns also make a lighter page: each column is a cell in every row, and each click redraws the table. The picker is built from Filament's own table filters — the trigger, the modal, Apply and Reset — and narrows the columns, never the rows.
However many are shown, the matrix keeps its bearings as it scrolls: the permission column stays at the start while the roles scroll past it, and the row of role names stays at the top while the permissions scroll under it. Filament's table has no sticky column or header, so this is the package's one stylesheet — plain CSS on Filament's classes, confined to the matrix.
To put the matrix somewhere else — a page of your own, a tab of a resource — use the schema component:
use Happenv\FilamentAccessControl\Schemas\Components\PermissionMatrix; PermissionMatrix::make()->deferred();
or the Livewire component directly: @livewire(\Happenv\FilamentAccessControl\Livewire\RolePermissionMatrix::class, ['deferred' => true]).
Editing one record
PermissionEditor edits the permissions of the schema's record. Put it wherever the schema allows:
use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Happenv\FilamentAccessControl\Schemas\Components\PermissionEditor; public static function configure(Schema $schema): Schema { return $schema->components([ Tabs::make()->tabs([ Tab::make('Role')->schema([ TextInput::make('name')->required(), ]), Tab::make('Permissions')->schema([ PermissionEditor::make(), ]), ]), ]); }
The editor saves on its own, independently of the form around it — the form's Save changes never touches the permissions. It is hidden while the record does not exist yet (a create page), and read-only in a disabled schema (a view page) or when ->disabled().
For a user, the From roles column lists the roles that already grant each permission, and a super-admin role is called out above the table. Hide the column with ->showInheritedPermissions(false).
Edit a record other than the schema's own through the component's data: PermissionEditor::make()->data(fn (User $record) => ['record' => $record->apiKey]).
Live or deferred
By default every click is written at once. Deferred screens stage the clicks instead — changed cells turn amber — and write them together with Save permissions, or throw them away with Discard:
FilamentAccessControlPlugin::make()->deferred(); // the default for every screen of the plugin PermissionEditor::make()->deferred(); // or per component PermissionEditor::make()->deferred(false);
Leaving a page with staged changes asks for confirmation first.
Counters
Each group's own row can show what every role holds of it — one "granted/total" number per role column (3/7), beside the group's name, so a folded group still tells whether it is worth opening; a subject's tooltip then counts its verbs too. Off by default:
FilamentAccessControlPlugin::make()->counters(); // every screen of the plugin PermissionEditor::make()->counters(); // or per component PermissionMatrix::make()->counters(false);
Authorization
Every change asks the gate, as the panel's user, with the record being changed:
| Screen | Asks | Default |
|---|---|---|
| Access control page | roleAbilities(viewAny:) with the role model class |
viewAny |
| Changing a role | roleAbilities(update:) with the role |
update |
| Add role | roleAbilities(create:) with the role model class |
create |
PermissionEditor (a user) |
->ability(...) with the record |
update |
An ability can be a Laravel ability name (a policy method as often as not), a laravel-access-control permission enum — asked with the record only, as voters expect — or a closure receiving record, model and user. null switches the check off. When a voter refuses, its own message reaches the operator; the library's generic Unauthorized for <slug> is translated into the permission's name.
Surfaces
laravel-access-control lets a permission declare the surfaces it is available on with #[AvailableFor]. Narrow a screen to one:
PermissionEditor::make()->surface(PermissionSurface::Api);
Only what the surface offers can be granted there; what the record already holds outside of it is listed in a group of its own — revocable, never grantable again. A surface enum that implements OffersEveryPermission and returns true offers the whole catalogue.
Rules and conditions
laravel-access-control 3 lets permissions depend on each other (#[Requires], #[ImpliedBy], #[ConflictsWith]) and on the account (conditions). The screens show all of it; they never decide anything themselves.
Cells. A role's cell shows what the rules make of the role's grants; a user's In effect column what its roles, the rules, runtime restrictions and its conditions leave it:
| Icon | Colour | Means |
|---|---|---|
| check-circle | success | stored and in effect |
| check-circle | info | in effect, implied by another permission (a click grants it explicitly) |
| exclamation-triangle | warning | granted, but a permission it requires is not in effect |
| no-symbol | danger | granted, but blocked by a permission it conflicts with |
| lock-closed | gray | granted, but the application restricts it right now |
| shield-exclamation | warning | granted, but the account does not meet a condition |
| x-circle | danger | not granted |
The In effect column shows only where it can differ from Granted: the account holds a role, a permission of the screen takes part in a rule or carries a condition, or the application restricts one right now. An API key holding nothing but direct grants, in a catalogue without rules, gets no column that would repeat Granted.
The tooltip names the permissions involved. In deferred mode a changed cell takes the primary colour, and every other cell already shows the consequence of the change.
The user editor counts a super-admin role as holding every permission with its conditions still applied — an unmet #[RequiresMFA] still shows. But an application that implements its super-admin through Gate::before() skips conditions at the gate along with everything else, so there the column overstates what is actually enforced.
Dependencies. A column next to the permission's name lists every rule from that permission's side — Requires / Required by, Implied by / Implies, Blocked by / Blocks — and every condition. The rule's reason is its tooltip. Searching also finds the permissions a rule ties to what you typed.
Conditions — #[RequiresMFA]. Put it on a permission enum or case to withhold the permission from any account without multi-factor authentication enabled on the panel:
use Happenv\FilamentAccessControl\Attributes\RequiresMFA; use Happenv\LaravelAccessControl\Contracts\PermissionDefinition; enum OrderPermission: string implements PermissionDefinition { #[RequiresMFA] case Refund = 'order.refund'; // The providers of a named panel, rather than the current one: #[RequiresMFA(panel: 'admin')] case Export = 'order.export'; }
It fails closed: an account without MFA, an account the panel's providers cannot ask (an API key) and a panel without multi-factor authentication do not meet it. The user editor says above the table how many permissions a condition withholds. Your own conditions are attributes implementing laravel-access-control's PermissionCondition — see its README; implement DescribesPermissionCondition to name them on these screens. A Gate::before() that answers first skips conditions like any other gate check.
Declaration problems. A permission declared so that it can never be allowed (it requires what it conflicts with), or a rule pointing at an enum nobody registered, is listed above the screens and marked Invalid declaration. ->declarationProblems(false) hides both.
For the In effect column to tell implied permissions from stored ones, roles using HasPermissions should implement laravel-access-control's HoldsGrants.
The form field
PermissionSelector is a form field holding the slugs as a flat list, saved with the form like any other field — for create forms, or anything that must save in one go:
use Happenv\FilamentAccessControl\Forms\Components\PermissionSelector; PermissionSelector::make('permissions')->surface(PermissionSurface::Api);
It validates what arrives, keeps grants the deployment cannot draw (a module left out of the build), and never lets a slug outside the surface in.
Naming verbs
A permission row shows its verb — View, Update — translated from filament-access-control::permissions.actions.<case_name_in_snake_case>, or the case name when there is no translation. Name your own verbs with a resolver, or by publishing the translations:
use Happenv\FilamentAccessControl\Support\PermissionTree; PermissionTree::resolveActionLabelsUsing( fn (PermissionDto $permission): ?string => __("app.permission-verbs.{$permission->enum->name}"), );
Reacting to changes
Every write dispatches Happenv\FilamentAccessControl\Events\PermissionsUpdated with the record and what was actually granted and revoked — for an audit log, a cache to clear.
Translations
The package ships in every locale Filament ships:
am ar az bg bn bs ca ckb cs da de el en es et eu fa fi fil fr he hi hr hu hy id it ja ka km ko ku lt lus lv mk mn ms my nb ne nl pl pt pt_BR ro ru sk sl sq sr_Cyrl sr_Latn sv sw tg th tr uk ur uz vi zh_CN zh_HK zh_TW
The test suite keeps it that way: a locale Filament adds and this package lacks fails it, and so does a key missing from any locale.
Publish them to change the wording:
php artisan vendor:publish --tag="filament-access-control-translations"
Development
composer test # unit and feature tests composer phpstan # static analysis composer cs # fix code style: composer normalize, Rector, Pint composer ci # everything CI checks, locally
Upgrading
Breaking changes and how to migrate are described in UPGRADING for every major version.
Changelog
See CHANGELOG and GitHub releases for what has changed recently.
Contributing
See CONTRIBUTING for details.
Security vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). See License File for more information.
