elpandape/filament-bouncer

Roles and abilities for Filament, built on silber/bouncer

Maintainers

Package info

github.com/elpandape/filament-bouncer

pkg:composer/elpandape/filament-bouncer

Transparency log

Statistics

Installs: 24

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v4.1.0 2026-08-10 21:29 UTC

README

Roles and abilities for Filament, built on silber/bouncer.

Stable from 1.0.0. Semantic versioning from here: breaking the public API takes a major bump. That promise covers the names abilities are stored under as well as the classes — those names are rows in your database, and changing how one is spelled would silently drop every grant that pointed at it.

Why Bouncer

There is already an excellent package for roles and permissions on Filament built on spatie/laravel-permission, and for most projects that is the right answer. This one exists for the two things Bouncer does that spatie does not:

  • Explicit denials. forbid() beats an allow, so "everything except deleting for good" is expressed as the exception it is, and survives the catalogue growing later.
  • Per-model and per-instance abilities. "This editor only touches their own posts" is something Bouncer can store; spatie cannot.

If you need neither, you probably do not need this package.

What this does that a permissions screen usually does not

A wall of checkboxes is the easy part. These are the parts that are not:

  • The abilities are derived from code, and only from code. A resource offers exactly the actions its policy declares — there is no fixed list of actions this package invented. Delete a policy method and its switch disappears; add one and it appears. An ability that nothing ever consults cannot exist, which matters because Bouncer answers a name nobody created without ever complaining.
  • Abilities are stored against models, not as strings. view on App\Models\Post, not view_any_post. Renaming a resource orphans nothing, two models with the same basename in different namespaces do not collide, and grants on a single record remain possible later without migrating anything already stored.
  • A rule can be narrowed from the screen. "This editor, but only their own posts" is something Bouncer stores in columns a policy method has no way to name, so the abilities screen composes it: the same rule, held down to what its holder owns or to one record.
  • The panel refuses to boot with a page or a widget that authorises nobody.
  • A build goes red when the store has drifted from the catalogue, or when a resource has no policy and is therefore open to everybody.
  • There is a way back in. Handing out abilities is itself an ability, so it can be handed away; the privileged role is put back on every reconcile.
  • A denial is a state, not an absence, and it beats a grant from anywhere else.

Between them those form a loop: the code declares, the catalogue derives, the store is reconciled, and the guard and the check refuse to let any of the three drift apart.

Requirements

  • PHP 8.5
  • Laravel 13
  • Filament 5.7
  • silber/bouncer 1.0.4

Installation

composer require elpandape/filament-bouncer

The service provider is registered through package discovery. Register the plugin on the panel that should carry the roles screen:

use ElPandaPe\FilamentBouncer\Filament\FilamentBouncerPlugin;

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

Publish the configuration if you want to change how the package presents itself in the panel:

php artisan vendor:publish --tag=filament-bouncer-config

Bouncer's own tables

Bouncer does not load its migrations automatically and ships no install command. Publish and run them yourself:

php artisan vendor:publish --tag=bouncer.migrations
php artisan migrate

If the application already uses spatie/laravel-permission, that migration will fail. Bouncer creates abilities, roles, assigned_roles and permissions, and the middle two collide with spatie's. Rename Bouncer's side before migrating — spatie's tables are usually the ones already in production, and an executed migration should not be edited:

Bouncer::tables([
    'roles' => 'bouncer_roles',
    'permissions' => 'bouncer_permissions',
]);

The rename has to happen before the migration runs, so it belongs in a service provider that registers early.

The catalogue

The catalogue is the list of abilities your panel is able to ask about. It is derived from your code on every build and never read back from the store, so an ability that nothing consults cannot survive in it.

Where it comes from What it contributes
A resource One ability per method its model's policy declares, stored against that model
A model listed in models The same, for a model that has a policy but no resource
A page One ability, page:<class>, standing for reaching it at all
A widget One ability, widget:<class>, standing for seeing it at all
An entry in custom One ability under exactly the name you gave it

A model with no policy contributes nothing, on purpose. Its abilities would be ones no code ever consults, and a switch that decides nothing is worse than no switch at all. If a resource is missing from the grid, the answer is to write its policy.

Every action is sorted into one of four scopes — read, write, withdraw, irreversible — which is what lets a screen stop "see a list" from looking like the same decision as "delete for good". Anything the scopes map does not name counts as a write.

Keeping the store in step

php artisan filament-bouncer:reconcile

Creates every ability the catalogue declares and the store is missing, in a single insert, and clears Bouncer's cache afterwards. Run it after migrate on every deploy.

Option What it does
--panel= Walk a named panel instead of the default one
--prune Delete stored abilities the catalogue no longer declares
--check Write nothing, report the differences, and exit non-zero if there are any

Without --prune an undeclared ability is reported and left alone, because deleting an ability takes every grant that pointed at it with it. --check is the shape for continuous integration: it fails a build whose catalogue and store have drifted apart.

Three kinds of row are never touched, in either direction: abilities about one record, abilities restricted to what their holder owns, and the wildcards that a blanket grant such as everything() leaves behind. The catalogue does not declare them, so it does not get to delete them either — which is exactly why the abilities screen is allowed to make the first two of them.

The abilities screen

The other axis of the same table: not what a role may do, but who may do a thing. It lists every stored row with its title, the name the code asks the Gate, the model, how far it reaches, who holds it and how, and where it stands with the reconciliation — declared by the code, declared by nothing (which is drift, and --prune will take it), or outside the catalogue altogether, which is no danger at all.

Opening a row gives the roles grid read the other way round: one ability, every role, each a cell walking the same three stances. It writes the same rows the roles screen writes.

Narrowing a rule

The plain rule — "may change posts" — comes from the code that asks about it, so the screen does not invent one. What the code has no way to say is how far a rule reaches, and that is what the composer makes:

Narrowed to What Bouncer stores What it means
What its holder owns only_owned = true The rule holds for the records that belong to whoever has it
One record entity_id = 7 The rule holds for that record and no other

Pick the model, pick the action, say how far it reaches, and the title writes itself as you go — yours to change, because the title is read by people and by nothing else. The name and the model are taken from the catalogue entry the first two choices land on, never from the request, so a narrowed rule is always spelled the way the code spells the rule it narrows. A rule that narrows nothing is refused: that one is the reconciliation's to write.

A narrowed row has no cell on the roles grid, because the grid matches the plain row and only the plain row. Handing it out therefore happens here, and writes exactly that row.

The roles screen

Subjects down the side, actions across the top. The columns are grouped by scope and the group headings are tinted, so that "see a list" and "delete for good" cannot look like the same decision.

Each cell holds one of three stances, and the middle one is not a quieter way of saying no:

Stance What the role is saying
Granted This ability is given
Not granted This role says nothing; the answer comes from whatever else the person holds
Forbidden Nobody holding this role has it, whatever else grants it

A denial beats a grant from another role and one made straight to the user. That is the one thing Bouncer can express and spatie/laravel-permission cannot, and it is why "everything except deleting for good" survives the catalogue growing later instead of quietly picking up whatever gets added.

Forbidding is offered on exactly the abilities granting is offered on, and no others. It would be arguable that restricting is a smaller power than granting, and the decision here went the other way: a denial you cannot lift afterwards is a way to lock people out of something you were never trusted with, so both go through the same gate.

Whoever the policy lets onto this screen hands out everything the panel declares, whether or not they hold it themselves — including the wildcard, and including to themselves. That is a deliberate choice, and the same one yadahan/nova-bouncer makes: being trusted to edit roles is the whole of the trust. If that is more than you want to give somebody, the answer is not to let them onto the screen.

Two things it still refuses, and neither is only a hidden button — each is checked again where the write happens, so a request built by hand meets the same refusal:

  • Nobody edits a role they hold themselves. Otherwise raising your own reach is one save away.
  • Nobody edits the role that holds everything. It is the way back in, and a way back in that can be edited is not one.

The way back in

Handing out abilities is itself an ability, so a panel can be talked into a state where nobody left is able to hand anything out. Name a role in privileged_role and filament-bouncer:reconcile will make sure it exists and holds Bouncer's wildcard on every run — including after somebody deletes it. --check fails while it is missing.

A role nobody holds opens no doors, so there is a command for the last step too:

php artisan filament-bouncer:assign owner amaru@example.com

It takes a key or an email address, and it refuses a role that does not exist rather than creating one. That refusal is the point: Bouncer::assign() creates a role it cannot find, so a misspelling otherwise leaves somebody holding a brand new role that grants nothing at all, under a line of output saying it worked.

The wildcard is granted rather than every ability the catalogue holds today, so that a resource added tomorrow is covered without anybody remembering to come back. That the wildcard also grants abilities nobody ever declared is exactly what is wanted for this role, and for no other.

Closing the panel

Filament decides what a reader may do by asking a policy, and when there is no policy it asks nobody and lets everybody through. Three things close that, and none of them is optional if you want the panel actually shut.

Policies

php artisan filament-bouncer:policy

Writes a policy for every resource of the panel whose model has none, leaving alone anything already there unless you pass --force. Name models on the command line to write for something that has no resource. Publish the stub with --tag=filament-bouncer-stubs to write them in your own house style.

The generated methods are the declaration: the catalogue reads them straight back, so what an administrator is offered for a model is exactly what its policy is prepared to answer. Delete a method you do not want and its column goes with it.

The roles screen is governed the same way, by a policy this package registers for the role model. Nothing about it is special-cased. Register your own policy for that model from a provider of your own if you want a different answer — yours boots afterwards and wins.

Pages and widgets

They have no policy to ask, so they decide for themselves:

use ElPandaPe\FilamentBouncer\Filament\Concerns\AuthorizesPage;

class Reports extends Page
{
    use AuthorizesPage;
}

AuthorizesWidget does the same for a widget. Writing canAccess() or canView() by hand satisfies the guard just as well — what it objects to is neither.

The guard

A panel carrying a page or a widget that authorises nobody does not boot. It throws in production too, and that is the decision rather than an oversight: such a component looks exactly like one that was meant to be open, so nothing about the screen gives it away. A deployment that falls over loudly gets reverted within the hour; a hole of this kind is found by whoever goes looking, and they are not on your side.

The way to say "this one really is for everybody" is the ignore list, which is that decision written down where the next reader will find it. A component named there is also left out of the catalogue, so there is no ability to grant and none to withhold.

The check

php artisan filament-bouncer:reconcile --check

Fails on any of three things: an ability the catalogue declares and the store lacks, an ability the store holds and the catalogue no longer declares, or a resource whose model has no policy at all. Put it in continuous integration and the panel cannot drift away from its own authorisation without a build going red.

The words on the screen

Three sources are asked, in this order:

  1. What you put in labels, because you know what your own people call these things and should not have to publish a language file to say so.
  2. The package's translations. English and Spanish ship with it; publish them with --tag=filament-bouncer-translations to add or change a language.
  3. The method name, made readable. An action your own policy invented shows up reading sensibly with nothing translated first.
'labels' => [
    'actions' => ['viewAny' => 'Browse'],
],

Everything a person reads — in the panel and in the console — goes through that chain. Exception messages do not: they are for whoever is reading a stack trace, and a translated one cannot be searched for.

The title Bouncer stores alongside each ability follows the locale in force when filament-bouncer:reconcile ran, since that is when the column is written.

Configuration

Key What it decides
panel The panel whose components declare the catalogue. null uses the default one
navigation.icon The icon of the roles resource. null leaves it without one
navigation.group The navigation group it belongs to. null leaves it ungrouped
navigation.sort Its position. null leaves Filament's own ordering
navigation.slug The path under the panel. Defaults to security/roles
scopes Which actions count as reading, withdrawing and irreversible
models Models with a policy but no resource, which would otherwise never be reached
custom Abilities no component declares, as a map of name to scope
ignore Resources, pages and widgets the catalogue leaves out
privileged_role The role that holds everything, and that the screen refuses to edit
labels Your own words for the actions, scopes and stances

The navigation keys are presentation decisions that belong to the application, not to the package, which is why they are read from configuration rather than from a static property.

What this deliberately does not do

Each of these was considered and turned down. They are written here so that the next person to want one finds the reasoning rather than the silence.

  • Deleting an ability from a screen. A row goes away when the reconciliation stops declaring it, and --prune says how many it took. Offering a button would be offering to take every grant pointing at the row with it, on one click and no second question.
  • Narrowing anything but a model. A page or a widget is reached or it is not: there is no record to point at and nothing to own, so the composer only offers subjects that stand for a model.
  • Bouncer's Constraints. They are persisted and never evaluated: an ability with an impossible constraint still passes. Offering them would be writing decorative JSON. Making them real means a clipboard of our own, which is a component and not an adapter.
  • Bulk actions on the roles table. Filament authorises a bulk delete once for the whole selection, and the two refusals that keep the privileged role and your own role out of reach live on the resource. A bulk delete would walk past both.
  • Bouncer's multi-tenant scope. The reads this package makes pass the configured scope through, but nothing here is tested against a scoped installation, and assigned_roles.restricted_to_id is a dead column in Bouncer 1.0.4 whatever the schema suggests.

Things about Bouncer worth knowing before you build on it

All of these were measured by running its code, not read from documentation. They are not defects of this package, but they shape what it can honestly offer.

  • Bouncer always hooks the Gate and there is no way to switch that off. Both the before and the after closure are registered unconditionally; which one answers is decided by a slot that defaults to after, so your policies decide first and Bouncer only speaks when none of them did.

  • The cache is not invalidated by writes. Check an ability, grant it, check again, and the second check still returns the old answer until Bouncer::refreshFor($user). Within a single Livewire request — which is how Filament works — a write followed by a read already lies.

  • The Constraints subsystem is persisted but never evaluated. An ability with an impossible constraint still passes Gate::allows().

  • The wildcard grants abilities that do not exist. With allow($user)->everything(), Gate::allows('a-name-nobody-ever-created') returns true. There is no equivalent of spatie's PermissionDoesNotExist, so a typo in a policy fails silently rather than loudly.

  • assigned_roles.restricted_to_id / restricted_to_type are dead columns in 1.0.4: they are created and nothing in the source ever reads or writes them. There is no per-tenant role restriction implemented.

  • Ownership is guessed from a column that often does not exist, and under Model::shouldBeStrict() the guess throws. Bouncer asks whether the authority owns the record on every check it answers, and with nothing configured it looks for a column named after whoever is asking — user_id. Ask about a record that has no such column and a lax application gets null, while a strict one gets a MissingAttributeException naming a column nobody ever wrote, from inside a Blade view. This package tells Bouncer that nobody owns a role, which covers its own screen; for your own models, say so once:

    Bouncer::ownedVia('*', fn (Model $model, Model $authority): bool => array_key_exists('user_id', $model->getAttributes())
        && $model->getAttribute('user_id') === $authority->getKey());
  • ownedVia() cannot be called with one argument. The single-argument form is documented as the way to set a global rule, and it assigns the closure to $ownership['*'] and then immediately uses that same closure as an array key, which is a TypeError. Pass '*' explicitly as the first argument instead, as above.

Testing

The package carries its own toolchain and does not rely on any consuming application:

composer test:all

That runs, in order: code style, line coverage, type coverage, profanity, static analysis at its maximum level, and a refactoring dry run. All of them are thresholds, not diagnostics.

If the type coverage step dies with a segmentation fault on your machine, it is Xdebug and not the code: run that step with php -d xdebug.mode=off.

Credits

License

MIT. See LICENSE.md.