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();- 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.
- No way to more access. An operator grants and revokes only the permissions they hold in effect, and never changes their own permissions or a role they hold — both on by default, both checked on the server at every write. See Who may change what.
- 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.
| Package | Versions |
|---|---|
| PHP | 8.3 – 8.5 |
| Laravel | 12, 13 |
| Filament | 4, 5 |
| happenv-com/laravel-access-control | 3.1+ |
Install the package via Composer:
composer require happenv-com/filament-access-controlImportant
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.
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.
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,
),
);
}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"
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. To keep the page and change the grid, extend Livewire\RolePermissionMatrix and name your class: ->matrixComponent(MyMatrix::class) — the page draws it, and so does PermissionMatrix::make(). To refuse more per holder than the package does, override refusalFor(string $holderKey): ?string and return the reason, or null to allow: a click goes through mutableHolder(), which asks it, and a deferred Save permissions asks it directly — overriding mutableHolder() alone would not stop a deferred save.
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 componentUnset, 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]).
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).
An application that grants permissions through roles only has nothing to show in a user's Granted column: ->showDirectGrants(false) hides it, and the editor then shows what the user's roles grant and what is In effect — and changes nothing. Shown read-only (->disabled(), or on a view page), the editor also takes an account that does not implement HasEditablePermissions, as long as laravel-access-control can answer for it (AuthControllable, usually with HasRoles):
PermissionEditor::make()->disabled()->showDirectGrants(false);Edit a record other than the schema's own through the component's data: PermissionEditor::make()->data(fn (User $record) => ['record' => $record->apiKey]).
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.
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);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) or a laravel-access-control permission enum — asked with the record only, as voters expect. In roleAbilities() it can also be a closure that decides by itself: it receives record, model and user and returns a boolean or a Response. 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.
PermissionEditor::ability() takes a closure too, but there the closure only picks the ability: it is evaluated when the component renders, with the schema's usual parameters (record, …), and must return an ability name, a permission enum or null. It is not a verdict — never return a boolean: false means "the default ability", not "deny". To decide in code, register a gate or a policy method and return its name:
PermissionEditor::make()->ability(fn (User $record): string => $record->is_api_key ? 'manageApiKey' : 'update');Two guards keep the permission screens from becoming a way to more access. Both are on by default, and both are checked on the server at every write — a cell's click, a subject's click, Save permissions, PermissionSelector's validation; the cells only hint at them.
Escalation. An operator grants and revokes only the permissions they hold in effect: what laravel-access-control's AccessControl::effectivePermissions() lets through for them — permissions implied by what they hold included, runtime restrictions and conditions such as #[RequiresMFA] applied. Revoking counts too. Every other cell is switched off, with a tooltip saying why, and a subject's click changes only what the operator may change. An operator holding the super-admin role is not narrowed. The operator is the panel's user; nobody signed in, or a user that is not AuthControllable, may change nothing while the guard is on.
// Instead of what the operator holds in effect — slugs or permission enums; null for anything:
FilamentAccessControlPlugin::make()
->grantableBy(fn (User $operator): ?array => $operator->is_support ? [OrderPermission::Refund] : null);
// Or switch the guard off:
FilamentAccessControlPlugin::make()->preventEscalation(false);grantableBy() applies to everybody but a super-admin. Its closure receives the operator as $operator, or by type (Authenticatable or your user class).
Self-editing. An operator does not change the permissions of a role they hold, nor their own direct permissions: that role's column on the access control page, and the editor of their own record or role, are read-only for them, and say so. Even a super-admin cannot change an ordinary role they also hold. PermissionSelector keeps the same line: on the operator's own record, or a role they hold, it is read-only and a changed list fails validation with You cannot change your own permissions or those of a role you hold.
FilamentAccessControlPlugin::make()->preventSelfEditing(false);Both guards read the plugin through FilamentAccessControlPlugin::current(), which is the plugin registered on the current panel. On a panel without the plugin they apply with the defaults — no superAdminRole, no grantableBy() — so register the plugin on that panel to configure them.
A deferred screen asks again at Save permissions: changes it may no longer make — the operator lost the permission, came to hold the role, or the role is gone — are discarded with a notification saying why, instead of staying staged.
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.
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.
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.
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}"),
);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.
Every screen writes through Happenv\FilamentAccessControl\Support\PermissionWriter, resolved from the container. To refuse some lists — a grant that needs a choice made elsewhere first — bind a subclass and override ensureMayWrite(): it runs inside the write's transaction, under the row lock, with the locked record, the whole list about to be written and what it adds and takes away. Throw Happenv\FilamentAccessControl\Exceptions\PermissionWriteRefused and nothing is written; the screen shows its message (or its title and body) as a notification. A live click simply changes nothing; at Save permissions the refused record keeps its changes staged, so the operator can put things right and save again, while the other records' changes are saved.
class ScopedPermissionWriter extends PermissionWriter
{
protected function ensureMayWrite(Model $locked, Collection $resulting, Collection $granted, Collection $revoked): void
{
if ($granted->contains(OrderPermission::View->value) && ! $locked->channels()->exists()) {
throw new PermissionWriteRefused(__('app.roles.choose_channels_first'));
}
}
}
$this->app->bind(PermissionWriter::class, ScopedPermissionWriter::class);A role's grant can mean more than granted or not — a role holding View orders may be limited to some sales channels. holderCellNotes() puts a short note under a holder's cell of a permission row (never a subject's or a group's), on the access control page and in PermissionEditor alike; actions() registers the actions such a note opens. Clicking a note mounts its action with the note's arguments plus holder (the holder's key) and permission (the slug), without toggling the cell; once the action has run, the screen reads its holders again.
use Filament\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Happenv\FilamentAccessControl\Support\CellNote;
FilamentAccessControlPlugin::make()
->holderCellNotes(fn (Model $holder, PermissionDto $permission): ?CellNote => match (true) {
$permission->enum !== OrderPermission::View => null,
$holder->channels->isEmpty() => new CellNote(__('app.scope.none'), color: 'warning', action: 'channelScope'),
default => new CellNote(trans_choice('app.scope.channels', $holder->channels->count()), tooltip: $holder->channels->pluck('name')->join(', '), action: 'channelScope'),
})
// A closure, evaluated per request: labels translate in the request's locale.
->actions(fn (): array => [
Action::make('channelScope')
->slideOver()
->authorize(fn (array $arguments): bool => Gate::allows('update', Role::find($arguments['holder'])))
->fillForm(fn (array $arguments): array => ['channels' => Role::find($arguments['holder'])->channels->modelKeys()])
->schema([CheckboxList::make('channels')->options(Channel::pluck('name', 'id'))])
->action(fn (array $arguments, array $data) => Role::find($arguments['holder'])->channels()->sync($data['channels'])),
]);An action's handler must be a Closure: Filament uses ->action('methodName') only as a direct Livewire click handler, while a plugin action is reached through mountAction(), which runs the action's function and finds none for a string — so even a method on your own subclass of the matrix or editor component would never be called. The plugin therefore throws an InvalidArgumentException naming the action instead of letting it run nothing. An action with no handler at all (modal-only, URL-only) is fine.
The screens do not authorise these actions: each one authorises itself, as above — and treats holder and permission as what they are, arguments sent by the browser. On a screen that edits nothing (read-only, or disabled()), notes are still shown, as plain text, and their actions cannot be mounted.
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"composer test # unit and feature tests
composer phpstan # static analysis
composer cs # fix code style: composer normalize, Rector, Pint
composer ci # everything CI checks, locallyBreaking changes and how to migrate are described in UPGRADING for every major version.
See CHANGELOG and GitHub releases for what has changed recently.
See CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). See License File for more information.
