elpandape / filament-permission
Roles and permissions for Filament, built on spatie/laravel-permission
Requires
- php: ^8.5
- filament/filament: ^5.7
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/support: ^13.0
- spatie/laravel-permission: ^8.0
- symfony/console: ^8.0
- symfony/http-kernel: ^8.0
Requires (Dev)
- fakerphp/faker: ^1.24
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- mockery/mockery: ^1.6
- nunomaduro/collision: ^8.9
- orchestra/testbench: ^11.1
- pestphp/pest: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
- pestphp/pest-plugin-livewire: ^5.0
- pestphp/pest-plugin-phpstan: ^5.0
- pestphp/pest-plugin-profanity: ^5.0
- pestphp/pest-plugin-rector: ^5.0
- pestphp/pest-plugin-type-coverage: ^5.0
- phpunit/phpunit: ^13.2.6
- rector/rector: ^2.6
README
Roles and permissions for Filament 5 on top of spatie/laravel-permission 8.
The permission catalogue is declared by the code: the package walks the Resources, Pages and
Widgets a panel has registered and produces a map of entities and abilities without a single query
and without instantiating a single component. A command reconciles that catalogue with the
permissions table in both directions — it creates what is missing and deletes what nothing
declares anymore — and a guard stops the panel from booting if a registered Page or Widget does not
say which permission it depends on.
Note
This package is inspired by bezhanSalleh/filament-shield,
which is the package that established what a permission plugin for Filament looks like: discovery
from the panel, a generated catalogue, a role screen with a permission matrix. Shield is
compatible with this stack and this package's reason to exist is not compatibility — it is five
concrete disagreements, each of them named and argued in
Differences from filament-shield.
Table of contents
- Features
- Compatibility
- Installation
- Usage
- Configuration
- Commands
- Localization
- Why it throws instead of denying
- What it deliberately leaves out
- Differences from
filament-shield - Known limitations
- Versioning and upgrades
- Development and testing
- Changelog
- Contributing
- Security
- Credits
- License
Tip
Reading the next three sections — Features, Compatibility and Installation — leaves the package installed, wired and synced. Everything after those is reference: it is looked up when it is needed, not read straight through.
Features
- 🔍 Discovers. A
PanelDiscoverywalks the panel and produces aPermissionCatalog:Entity → Ability[]. No queries, no instantiating classes —class_basename()andis_a($fqcn, X::class, true)answer the same thing mounting the object would. - 🔑 Names it in one place.
PermissionKey::for()is the only place the format lives. By default it isentity.ability, with the entity first (security/users.update), which is what would make asecurity/users.*meaningful if spatie's wildcard were ever switched on — itsWildcardPermissionsplits on.and,. - 🔁 Syncs and prunes.
filament-permission:syncis the only place in the package that writes permissions. It creates what is missing with a bulkinsert, reports the orphans, and only deletes them with an explicit--prune. - 🛡️ Authorizes three surfaces: Resources through a Policy (with a generator that writes one of
four lines), Pages with the
AuthorizesPagetrait, and Widgets withAuthorizesWidget. - 💥 Closes the fail-open.
PanelGuardwalks everything the panel has registered at boot and throws if something declares no permission and has not been excluded by hand. There is no silent mode: either the panel boots or it does not. - ⚖️ Distributes power with invariants. The role CRUD ships a permission matrix with tabs by origin, a grid of entities against abilities for Resources and cards for everything else, columns and checkboxes banded by how far each ability reaches, readable names, search and collapsing — and four rules no screen can skip.
- 👑 Optional super-admin, off by default, implemented by syncing every permission to the role
rather than with
Gate::beforeor the wildcard. - 🌐 Speaks whichever language you want. The panel's fixed wording lives in
resources/lang/, English by default with Spanish included out of the box; publish it and edit it, or add a new language, the same way you would the configuration.
Compatibility
filament-permission |
PHP | filament/filament |
illuminate/* |
spatie/laravel-permission |
symfony/console, symfony/http-kernel |
|---|---|---|---|---|---|
1.x |
^8.5 |
^5.7 |
^13.0 |
^8.0 |
^8.0 |
The package requires the Laravel components it imports directly (illuminate/console,
illuminate/contracts, illuminate/database and illuminate/support) and the two Symfony ones it
imports directly as well. The last few arrive transitively in any complete Laravel application, but
a package declares what it imports: relying on someone else's dependency graph is a break waiting
for the day that graph changes.
The Filament floor is ^5.7 because it was measured, not estimated. It read ^5.0 until CI
started resolving the dependency tree with --prefer-lowest, which installs the oldest version
every constraint still allows: on Filament 5.6.5 four of the permission matrix's tests fail, and on
5.7.0 the suite comes back green. A floor nobody has ever installed is a claim, not a requirement —
see CI for the leg of the matrix whose whole job is to keep these numbers honest.
Important
The package does not require any specific spatie/laravel-permission configuration, but it is
written and tested with register_permission_check_method => false, i.e. without hooking the
Gate. With that key set to true, a permission named the same as an ability short-circuits the
Policy before it ever runs.
Installation
Six steps. When they are done the package is working; everything after this section is reference.
1. Require it.
composer require elpandape/filament-permission
The provider registers itself (extra.laravel.providers).
2. Publish the configuration.
php artisan vendor:publish --tag=filament-permissions-config
And, if you are going to reword anything in the panel or add a language, the translations:
php artisan vendor:publish --tag=filament-permissions-translations
And, only if you are going to redraw the matrix itself, its Blade views:
php artisan vendor:publish --tag=filament-permissions-views
Warning
That third tag is the one to think twice about. A published view stops tracking the package,
and two of those files carry markup that looks decorative and is not:
permission-list.blade.php, which draws a card's banded checkboxes, and
permission-grid-row.blade.php, which draws one row of the Resources grid. Both keep Filament's
.fi-fo-checkbox-list-option and .fi-fo-checkbox-list-option-label on every box because that is
what Filament's own Alpine component queries, so dropping either leaves bulkToggleable() painted
on screen and dead in practice; in the grid the same rule runs backwards, and a cell with no
checkbox in it must not carry those classes either. And both mount
<x-filament-forms::field-wrapper> by hand because a custom view never reaches
wrapEmbeddedHtml(), so without it the field loses its validation message in silence.
3. Publish Filament's assets.
php artisan filament:assets
This step is not optional. The matrix ships a stylesheet of its own, which the provider registers
with FilamentAsset::register(), and Filament's panel CSS is published purged to the classes its own
views reference: a class this package invents arrives with no rule behind it. Skip the command and
the role screen falls back to an unstyled list of checkboxes — no exception, no warning, nothing
in the log, just a screen that looks broken. Run it again after every update of the package: what the
browser loads is the copy under public/, not the file in resources/css/.
4. Wire the panel.
use ElPandaPe\FilamentPermission\Filament\FilamentPermissionPlugin; public function panel(Panel $panel): Panel { return $panel // … ->plugin(FilamentPermissionPlugin::make()); }
The plugin does two things: it registers the roles Resource — panel discovery only reaches what
lives under app/, so if it is not handed over here it does not exist — and it boots the
PanelGuard.
Important
From this point on the panel will not boot until every registered Page and Widget declares its permission or is excluded. That is the intended behaviour, not a rough edge; step 5 is how to declare it, and Why it throws instead of denying is why it refuses to boot rather than quietly deny.
5. Declare a permission on every component.
Resources are authorized through a Policy, and there is a generator for it:
php artisan make:permission-policy "App\Filament\Resources\Security\Users\UserResource"
Standalone Pages and Widgets compose a trait each:
use ElPandaPe\FilamentPermission\Filament\AuthorizesPage; use ElPandaPe\FilamentPermission\Filament\AuthorizesWidget; final class Invoices extends Page { use AuthorizesPage; } final class Revenue extends Widget { use AuthorizesWidget; }
And whatever should carry no permission at all — the Dashboard, the account widget — is named in the configuration:
// config/filament-permissions.php 'discovery' => [ 'exclude' => [ Filament\Pages\Dashboard::class, Filament\Widgets\AccountWidget::class, ], ],
If the guard throws, the exception names the offending classes and states the three ways out.
6. Sync.
php artisan filament-permission:sync
That is what writes the permissions the catalogue declares into the permissions table. Run it on
every deployment: the catalogue changes with the code, and until this runs the rows behind it do not
exist.
Usage
The catalogue
| Origin | Entity | Abilities |
|---|---|---|
| Resource | its slug (security/users) |
those of policies.methods |
| Standalone page | the kebab-cased basename | view |
| Widget | the kebab-cased basename | view |
A class implementing DefinesPermissions |
whatever it declares | whatever it declares, nothing inferred |
A custom key in the configuration |
itself | whatever it declares |
What that produces, end to end. Given a panel registering these:
// A Resource whose slug is 'security/users' App\Filament\Resources\Security\Users\UserResource::class // A standalone page and a widget App\Filament\Pages\Reports\Overview::class // class basename: Overview App\Filament\Widgets\StatsOverview::class // class basename: StatsOverview
// config/filament-permissions.php 'custom' => [ 'billing' => ['view', 'export'], ],
filament-permission:sync writes exactly these rows into spatie's permissions table:
security/users.viewAny security/users.restore
security/users.view security/users.restoreAny
security/users.create security/users.forceDelete
security/users.update security/users.forceDeleteAny
security/users.delete security/users.reorder
security/users.deleteAny security/users.replicate
overview.view stats-overview.view
billing.view billing.export
Twelve for the Resource, one each for the page and the widget, two written by hand — 17 rows, and
not one of them typed twice. The page and the widget get view and nothing else, because a screen
either opens or it does not; the twelve come from policies.methods, so adding a thirteenth there
adds a column to every Resource at once.
Three decisions worth knowing:
- A permission's suffix is the Policy method name, not a separately invented list. A permission and its ability cannot drift apart by accident.
- A Resource's entity comes from its slug, not the model's
class_basename. With the basename,App\Models\Blog\CategoryandApp\Models\Shop\Categoryproduce the same key and silently grant each other. When two entities claim the same key with different labels, the catalogue throwsEntityCollisionat construction rather than merging them.
Warning
An entity key cannot contain a dot, and Entity refuses one in its constructor. This is not
tidiness: the role matrix draws every list at the state path permissions.<key>, and Livewire
splits a state path on dots. A key of shop.orders would point at permissions → shop → orders
while the array filling in the form is written under shop.orders whole: the two halves stop
meeting each other, the list is born empty, and the very first save leaves the role with no
permissions at all. Measured. A slash in a slug (security/users) is harmless; a dot is not.
Custom permissions
// config/filament-permissions.php 'custom' => [ 'security/users' => ['impersonate'], // merges into the entity that already exists 'reports' => ['view', 'export'], // opens its own card, "Custom" tab ],
They are discovered last, after Resources, Pages and Widgets, which is exactly what lets a key that already exists find its entity in the catalogue and have its abilities added to it without colliding.
Merging onto a Resource costs more than merging onto a Page, and it is worth knowing before writing the line: the Resources tab is a grid, so an ability only one entity declares still opens a column across every row, and the rest of them draw a dot under it. That is intended — a column nobody else declares is exactly how the screen says who has it — but it is why an ability worth giving to several Resources is worth giving to them at the same time.
Permissions declared by a class
use ElPandaPe\FilamentPermission\Catalog\Ability; use ElPandaPe\FilamentPermission\Catalog\Entity; use ElPandaPe\FilamentPermission\Contracts\DefinesPermissions; final class Invoices extends Page implements DefinesPermissions { public static function permissionEntity(): Entity { return new Entity('billing/invoices', 'Invoices'); } /** @return array<int, Ability> */ public static function permissionAbilities(): array { return [new Ability('view'), new Ability('export')]; } }
Implementing it counts as declaring permission: the guard accepts it with no trait needed. The origin is not declared — whoever discovers the class stamps it, since it is the only one who knows.
Resources: a Policy
Filament authorizes Resources with Gate::forUser($user)->inspect($ability, …), so the chain
Filament → Gate → Policy → hasPermissionTo() works even with spatie unhooked from the Gate. The
generated Policy is thin and does not grow with the project:
final class UserPolicy extends ResourcePolicy { protected function entity(): Entity { return new Entity('security/users', 'User'); } }
See make:permission-policy for the generator that writes it.
Pages and Widgets: two traits
use ElPandaPe\FilamentPermission\Filament\AuthorizesPage; use ElPandaPe\FilamentPermission\Filament\AuthorizesWidget; final class Invoices extends Page { use AuthorizesPage; } final class Revenue extends Widget { use AuthorizesWidget; }
There are two traits, not one, because the hierarchies are not symmetric: canAccess() governs a
page and canView() a widget, and that second method lives on Filament\Widgets\Widget, not on its
own trait.
Important
AuthorizesPage declares canAccess(array $parameters = []): bool, with the wide signature,
and that is not cosmetic. PHP inserts a trait's methods into the class's own method table, so
LSP applies against the parent: narrowing the signature is a compile-time fatal, widening it
is legal. The wide signature matches exactly
Filament\Resources\Pages\Page::canAccess(array $parameters = []) and widens
Filament\Pages\Concerns\CanAuthorizeAccess::canAccess(), so the same trait serves both
hierarchies. A trait declaring canAccess(): bool would blow up with a fatal on any Resource page.
Even so, the page trait only makes sense on standalone pages: a Resource page's own
canAccess() ignores its parameters and delegates, and the real gate for those pages is
Resource::canAccess(), which already goes through a Policy.
The boot guard
PanelGuard runs in the plugin's boot() and throws UndeclaredComponent if a registered Page
or Widget does not compose one of the two traits, does not implement DefinesPermissions, and is
not in discovery.exclude. The exception names the classes and states the three ways out.
The escape hatch for what should not carry a permission — the Dashboard, the account widget — is a configuration line, deployable without touching code:
'discovery' => [ 'exclude' => [ Filament\Pages\Dashboard::class, Filament\Widgets\AccountWidget::class, ], ],
Why it throws at boot rather than denying at runtime, and why the runtime path does the opposite, is a section of its own.
The role screen
It is the screen that hands out power, so it is the one that carries the most invariants. Each one has its own test and each one has been broken on purpose.
- Nobody grants a permission they do not hold. Each list's options are filtered by what the
editor themselves holds, and they come from the catalogue, not from the
permissionstable: a row somebody smuggled into the database never even gets offered. The screen says so as well — a callout counts how many permissions are shown out of how many exist, because a matrix with three boxes out of twenty-four looks broken if nobody explains why. That is the default and not the ceiling: thegrantsgroup, right below, relaxes it in one direction and tightens it in the other. - The role of maximum privilege is not edited, emptied, or deleted from the screen. Neither is
the editor's own role. Both guards run in
authorizeAccess(), whichEditRecordinvokes frommount()and fromhydrate(), so also on the request that saves. That name is further reserved in the form's validation, compared case-insensitively, because the database does distinguish case andSuper-Adminnext tosuper-adminwould be two rows. - What the screen does not show cannot be deleted from the screen.
PermissionGrant::reconcile()carries over intact everything the form did not offer and only touches what it did. It is the direct fix to the reference package's silent destruction, where a disabled tab's checkboxes never dehydrate andsyncPermissions()deletes them. - What arrives from the browser is intersected against what was offered before it ever touches
the database. Never a blocklist over
$data. And Filament's own validation is not that containment: aCheckboxListvalidates its own state against its own options, so a group key with no component behind it generates no rule at all and passes through clean. Measured. The only thing that stops it isPermissionGrant, and that is whysubmitted()flattens withArr::flatten()instead of a single-level loop: what arrives is a browser payload, and a hand-made one can nest whatever it wants.
Delegating without holding. An administrator whose role carries only security/roles.* holds
nothing else, so by invariant #1 the matrix draws them no box at all and they cannot build a profile
for anybody. That is what the grants group is for:
'custom' => ['security/roles' => ['delegate']], 'grants' => [ 'unrestricted' => 'security/roles.delegate', 'reserved' => ['billing/*.forceDelete'], ],
-
unrestrictednames a permission. Whoever holds it is offered the whole catalogue instead of only what they hold.null, the default, disables the lift. There is nothing special about the name — but naming it here does not bring it into being. The catalogue is declared by the code, so a permission no component declares has to be declared by hand incustombeforefilament-permission:syncwill create it:'custom' => [ 'security/roles' => ['delegate'], ],
Skip that and the lift never fires, because nobody can hold a permission that does not exist — and a row created by hand instead is exactly what
sync --prunedeletes. -
reservednames permissions nobody is offered unless they hold them, not even a holder ofunrestricted. Exact names or wildcards, matched withStr::is().
The two steps run in this order, and the subtraction is last so that no configuration lets
unrestricted reach a reserved permission:
offered = holds unrestricted ? whole catalogue : catalogue ∩ held
offered = offered − (reserved − held)
| Editor | Is offered |
|---|---|
| Holds the entire catalogue (a super-admin) | All of it: reserved − held is empty for them |
Holds unrestricted, holds none of the reserved |
The whole catalogue minus the reserved names |
| Holds one reserved permission | That one too, because they hold it |
| Anybody else | Exactly what they hold |
None of this touches the second layer: PermissionGrant::reconcile() still intersects what the
browser sent against what was offered, and what was offered is what this rule produced.
A consequence of #3 that is surprising and correct: deleting a role demands more than editing it. If unchecking everything visible still leaves something behind, that role reaches further than whoever is looking can see, and deletion is not offered.
The roles table has no edit action and no bulk actions, and that is a decision. A Filament
action with url() is still mountable from the server if it also has action(): what decides
is isDisabled(), not isVisible(), nor whether it renders as a link. A table EditAction runs
its own $record->update($data) without ever going through EditRole or its guards. Rows link to
the detail screen, which has nothing to mount, and from there it links on to editing when that is
allowed.
The matrix
Four tabs, one per origin — Resources, Pages, Widgets, Custom — each with a count of what that group offers. There is search over labels and keys, collapse-all and expand-all, and an inactive tab that contains a match gets highlighted, because otherwise a result sitting outside the open tab is indistinguishable from no results at all. What gets drawn inside a tab is not the same for all four, and that difference is the whole of this section.
The Resources tab is a grid: entities down the rows, abilities across the columns, one card for
the entire origin. The other three tabs keep the card — an entity declaring several abilities gets
one of its own, with its readable label, its key underneath, a coloured granted/total counter and
its checkboxes in bands; an entity declaring exactly one becomes a row of the gathered card.
Why the grid stops at Resources. The heterogeneity is between origins and not inside them, and
the tabs already separate origins: a Resource declares the twelve of policies.methods, a Page
declares one, a Widget declares one, and a custom entity declares whatever the configuration says.
Rows against columns only pays for itself where every row declares the same abilities. Resources do,
so their grid comes out solid; a grid over Pages would be one column wide, which is a list with a
header bolted on top; a grid over custom would be mostly holes. A single predicate decides it,
PermissionMatrixLayout::drawsGrid(), and three callers ask it: the edit schema, the detail schema,
and the list of card ids that collapse-all dispatches to. A fourth place writing
$origin === EntityOrigin::Resource by hand is exactly how those three would drift apart, and the
symptom is undramatic enough to ship — an expand-all that leaves one card shut.
Two decisions, and the order between them is load-bearing: partition() runs first, drawsGrid()
second. Anything declaring exactly one ability goes to the gathered card whatever its origin, so a
project that trims policies.methods down to a single method never gets a one-column grid; what is
left over is what the predicate then draws either as a grid or as one card each. A Resources tab can
therefore carry both cards at once.
What the grid buys. "Who can permanently delete?" becomes one glance down a column. With one card per entity the same question meant opening every one of them and reading the same twelve labels once per entity, with the answer spread over a screen-height each — present, and unreadable.
A column header carries the short form and a bulk mark. Twelve full labels do not fit: a
column's track is 3.5rem of basis and grows with its group, never with its content, while
"Permanently delete in bulk" is 26 characters. But they are not twelve words either — they are nine
and a modifier. delete and deleteAny are both "Delete", restore and restoreAny both
"Restore", forceDelete and forceDeleteAny both "Permanent", and what tells each pair apart is a
small bulk chip drawn in the column's own scope colour. PermissionLabel::abilityShort() reads
labels.abilities_short first — the consumer's override, and the only link written after they
already know what their own ability is called — then resources/lang/{locale}/roles.php, and
falls back to the full label rather than to a blank: an ability nobody shortened reads as a long
word in a narrow column, which is untidy, whereas an empty column header is unusable. The full
sentence stays one hover away on the header's title, and it is that sentence, not the short word,
that goes into every cell's screen-reader label — a cell shows no words of its own, and the two
headers naming it are one row up and one column across.
viewAny carries no bulk mark, deliberately. The set is three names written out by hand in
PermissionLabel — deleteAny, restoreAny, forceDeleteAny — instead of derived from the Any
suffix, which would also catch viewAny. That one lists; it does not act on every record at once.
Marking it would misname what the permission grants, on the one screen whose entire job is handing
out power, and the test pins both directions: three columns at data-bulk="true" and viewAny at
false.
The columns are grouped and tinted by scope, in the same read, write, remove, destroy,
other order the bands use, each group carrying its scope's word above the headers and a rule down
its leading edge in its colour, thickened on the two that can lose data and dashed on other. This
is where the grid pays better than the cards did: a band tinted one entity's checkboxes, a column
group tints every row at once, and a granted cell is washed in its scope's colour — so a block of
danger tint down the right-hand edge reads as "this role reaches the irreversible on everything",
without a single word.
A cell under a column its row does not declare is a dot, never an empty checkbox. An unmarked
box would read as "declared and denied", which is a different fact from "does not exist here". The
two tooltips differ by screen as well: while editing, that it is not among the ones you can grant;
on the detail screen, that this entity does not declare it. The case is not hypothetical — a
custom ability merged onto a single Resource opens a column across the whole grid, and every other
row shows the dot underneath it.
What did not change is the shape of the state. Every row is a whole CheckboxList at
permissions.<entity>, exactly what a card's list was, so PermissionGrant::reconcile(),
RoleForm::stateFor(), fillForm() and assertSchemaComponentStateSet() reach it the way they
always did: the redesign is drawing and nothing else. It is also why a row keeps a select-all and a
deselect-all of its own, as two icon buttons that say "this whole row" — in a grid, a control that
marks everything in sight sits one row above one that marks the whole matrix, so the row's control
names the row and the toolbar's names all permissions.
What survives from the earlier drawing, and where it applies now:
| Piece | Where it is drawn today |
|---|---|
| The entity card, checkboxes in bands | Pages, Widgets and Custom, for whatever declares more than one ability |
| The gathered card, one row per entity | All four tabs, for whatever declares exactly one |
| The grant bar | The entity card's header only — a grid has no per-entity header to put it in, and each row carries its own granted/total tally instead |
The granted/total badge |
Every card; on the grid card it counts the whole origin |
| A scope's word, its colour and its rule | On a card, down a band of checkboxes; on the grid, across a group of columns |
| The scope legend under the tabs | Both screens, naming only the scopes actually on screen |
| The role summary | The detail screen, untouched |
The detail screen mirrors the edit one: same origins, same entities, same order, the same columns and the same wording, with every ability marked granted or not. It is the only screen that also shows a permission the catalogue no longer declares — an orphan — because the edit screen only ever offers what the catalogue knows, and there that leftover would vanish from the one place that could ever report it.
On a card, the checkboxes are sorted into bands by scope. Not a flat list: one band per
AbilityScope present, always in the order read, write, remove, destroy, other, each one
carrying its scope's word and a rule down its leading edge in its colour — grey for read and for
other, the panel's info tint for write, its warning tint for remove, its danger tint for
destroy. The rule thickens on the two that can lose data and turns dashed on other. This is the
point of the whole redesign, in either drawing: without it, a screen whose only job is to hand out
power drew "View list" and "Permanently delete in bulk" as the same checkbox. An empty band is not
drawn at all — while editing, a card's bands cover what the editor may grant, so a scope they can
hand out nothing in never appears; on the detail screen they cover what the catalogue declares. The
grid subtracts the same way, one dimension over: a column exists only if some row offers that
ability, so an editor who can grant nothing irreversible is shown no irreversible column. The legend
under the tabs names only the scopes actually on screen, with a caption for each.
The grant bar answers "how far does this role reach?" without opening anything. In the header of
every entity card — which since the redesign means the Pages, Widgets and Custom tabs — next to the
granted/total badge, one segment per ability that card draws, filled when granted and hollow when
not, grouped by scope in the same order as the bands underneath, so the bar and the list are the
same shape twice. Each segment carries its own tooltip, ability — state. On the edit screen the
bar is computed from the form's own state through Get, the same source as the badge beside it; on
the detail screen, from the permissions the role holds.
Entities with a single ability are gathered into one card per origin. A Page, a Widget or a
custom entity that declares one ability is a card with one checkbox inside it, and a tab full of
those is mostly card chrome. PermissionMatrixLayout::partition() pulls them out and each becomes a
row in a single card per origin: the label, the key, the box. The ability is named once, in that
card's description, and that is what keeps a Page from being labelled "View record" — a sentence
about records that says nothing true about a screen. When the gathered card holds more than one
distinct ability the description says so instead, and every row carries its own.
That split counts what the catalogue declares, never what the viewer may grant. Partitioning by
the grantable subset laid the same panel out differently for two people, sliding a twelve-ability
Resource into the gathered card for whoever happened to hold one of them — and now it would slide it
out of the grid as well, so two administrators would not even be reading the same shape. That is why
partition() is handed count($catalog->abilitiesFor(…)) as its counter and not the offered list,
on both screens.
The detail screen opens with the role's composition. A summary heads it: the name, the guard and
when it was created — exact date in the tooltip — on one side, one column per scope the catalogue
declares in the middle (granted / declared, the scope's word, a rule in its colour), and the total
on the other. A scope declared but not granted keeps its column, at zero, with the rule drawn
hollow: dropping it would make "this role does not reach the irreversible" — which is an answer —
look exactly like "this panel declares nothing irreversible". The tally counts against the catalogue,
so a permission the catalogue no longer declares does not inflate it; that one has a section of its
own further down.
Implementation details that should not be undone without reading their reasoning in the code first:
- Collapsing a card does not remove its checkboxes from the DOM (
collapsible()is CSS), so collapsing never loses data on save. - Search and collapsing live in Alpine, with no Livewire binding: a bound field would make a
round trip to the server on every keystroke just to filter twenty cards client-side. A grid row is
filtered through Filament's own
visibleJs(), which paintsx-bind:class="{ 'fi-hidden': … }"on the component wrapper and leaves the row in the DOM with its state; a card usesx-show. A card none of whose entities match hides whole, throughgroupSearchAttributes(). - Tab highlighting goes through
x-bind:style, neverx-bind:class: Filament's own tab button already carries its ownx-bind:class, and two attributes with the same name leave the second one silently discarded. - A tab is identified with
->key($origin->value)and only labelled with the wording. Filament derives a tab's key from its label unless told otherwise, so two origins a published configuration happened to word the same would collapse into one and an entire group of checkboxes would become unreachable. - The banded list and the grid row both replace
CheckboxList's markup through->view(), an override for that one component —ViewComponent::toHtml()only falls back to its embedded HTML whenhasView()is false, andCheckboxListdeclares no$defaultView. Nothing about the field's state moves: same state path, same options, same validation, sofillForm()andassertSchemaComponentStateSet()reach it exactly as before. - Both views keep Filament's
x-loadwrapper and itscheckboxListFormComponentrather than reimplementing them, which is what keepsbulkToggleable()alive: that Alpine component finds its boxes by.fi-fo-checkbox-list-optionand reads their text from.fi-fo-checkbox-list-option-label, so both classes stay on every row and on every cell even though the markup is ours. In the grid it also cuts the other way: a cell with no checkbox in it must not carry.fi-fo-checkbox-list-optioneither, because the toggle reads aninputout of every option it finds and one dead cell disables that row's select-all. Both views also mount<x-filament-forms::field-wrapper>by hand, because the embedded path callswrapEmbeddedHtml()on its way out andrender()does not — and without it the field loses its validation message with no sign that it did. - The grid's tracks are flex bases, not
grid-template-columns. Each column group isflex: <n> 0 calc(<n> * var(--fp-cell)), so a track's width comes from how many columns it holds and never from what is inside them — which is what keeps rows that are separate DOM elements aligned under one header, and while editing every row is separate, each one aCheckboxListthat Filament mounts in a wrapper of its own. Arepeat(var(--fp-cols), …)that fails to substitute collapses the entire grid into one column and nothing reports it. - The header is a sibling of the rows while editing, and part of the same view on the detail
screen. Each edit row is its own form component and none of them can carry a header for all of
them, so
gridHead()renders on its own; the read-only grid is not a form component at all, so it ships header and rows in one view. - The grid scrolls sideways instead of widening the page:
.fp-grid-scrollaround the read-only view, and[id^="permission-card-grid-"] .fi-section-contentfor the edit one, where the only box holding every row is the section's own content container. Text::make()renders an inline-blockspan, which shrinks to its content, and anauto-fitgrid inside a box that shrinks resolves to a single column — precisely the defect the bands were drawn to remove. The stylesheet undoes it by selecting that wrapper for what it contains (.fi-sc-text:has(> .fp-bands), and the same for.fp-grid-scrolland.fp-grid-row), since the wrapper is Filament's, and pins the ink on those same roots becauseTexttints its content a muted grey. No test catches either of the two: the suite passed with the grid in one column.
Optional super-admin
'super_admin' => [ 'enabled' => true, 'role' => 'super-admin', ],
Off by default: installing the package creates no role and grants nothing, and a project that never enables it never even finds out the feature exists.
It is implemented by syncing every permission to the role, not with Gate::before nor the *
wildcard, in this order of reasons:
- It keeps
PermissionDoesNotExistas a typo detector. With the wildcard, a Policy asking about a misspelled permission would returntrue. - The database says exactly what the role can do.
- It does not change the semantics of any other permission:
enable_wildcard_permissionis global. Gate::beforewould give a super-admin that runs at two speeds, because Filament askscanAccessPanel()directly on the model, without going through the Gate.
The price is re-syncing whenever a new permission appears, and a parity test covers that — one that only means anything because pruning exists: without it, "the role has as many permissions as exist" always passes, because the numerator and the denominator grow together.
Note
The package does not create the role. It syncs whichever one it finds under that name and does nothing if it does not exist: creating it belongs to the consumer's deployment. See Known limitations for what that means the day somebody deletes it.
Configuration
Nine groups, and nothing more. Everything not here is a deliberate decision made in code: putting
it in a configuration file would not make it more flexible, it would make it reversible by a
vendor:publish --force.
| Group | What it decides |
|---|---|
policies.methods |
The suffixes, which are Policy method names. This is what a Resource declares |
discovery.exclude |
The classes that carry no permission. It is the guard's escape hatch |
custom |
Permissions no component declares: entity and abilities, written by hand |
navigation |
icon, group, sort and slug for the roles Resource. These are the consumer's decisions, not the package's — that is why they are read from here rather than from a static property. The default icon comes from Filament\Support\Icons\Heroicon, which already ships with filament/support, so the default drags in no icon package. slug defaults to security/roles and is the Resource's route — and its permissions' key; see the warning below |
super_admin |
enabled (false) and role (null). The consumer decides where the name comes from |
grants |
Who may hand out what. unrestricted names the permission whose holder grants anything in the catalogue and not only what they hold — null disables that lift; reserved lists what nobody grants unless they hold it, not even that holder. Both empty by default, which is the plain "nobody grants what they do not hold" |
key |
Optional closure to change the name's format. null = entity.ability; see the example below |
labels |
A consumer's own override of the wording, in four keys — abilities, abilities_short, origins and scopes — all empty by default. abilities_short is the one the grid's column headers read, where the full label does not fit; see The matrix. The package's own default wording lives in resources/lang/, not here; see "Localization" |
scopes |
How far an ability reaches, written as 'ability' => 'scope', and the only input AbilityScope takes: read, write, remove, destroy, other, in that order of blast radius. It is what sorts a card's checkboxes into bands and what groups and tints the grid's columns, what groups and colours the grant bar, and what the role summary counts by. Empty by default and, unlike labels, worth filling in — the package classifies the twelve abilities it ships and a line here outranks that classification, so a project whose delete really is final writes 'delete' => 'destroy' |
key is how a project already holding permissions adopts this package without renaming a row.
The closure receives the entity's key and the ability's name, and whatever it returns is the
permission's name everywhere — what sync writes, what the Policy asks for, what the matrix draws:
// config/filament-permissions.php 'key' => fn (string $entity, string $ability): string => $ability.'_'.$entity,
That turns security/users.update into update_security/users. null, the default, is
entity.ability with the entity first, which is what would make security/users.* meaningful
if spatie's wildcard were ever switched on — its WildcardPermission splits on . and ,.
Warning
Changing this on a database that already holds permissions renames every one of them. The old rows
keep the old name, match nothing the catalogue offers and are orphaned, and the roles holding them
lose access. It is the same move as changing navigation.slug: run
filament-permission:sync --prune right after, remembering that the prune cascades into role and
user assignments. Returning something that is not a string throws UnexpectedValueException
rather than writing a mangled name.
Note
An ability nobody classified lands in other, never in write. AbilityScope::for() reads
the configuration first, the twelve shipped abilities second, and falls to other last — and that
last step is a decision rather than a default: an unclassified ability has an unknown reach,
impersonate is not a write and neither is export, so guessing write would draw an invented
boundary on the one screen that hands out power. other gets a place of its own — a band on a
card, a group of columns on the grid — with a dashed rule and the word "unclassified", which reads
as an invitation to add a line here instead of as a quiet lie. A mangled group leaves by the same
door: a scope that is not a string, or one naming a case that does not exist, falls through to the
shipped table rather than throwing — Config::array() would throw on a malformed group and take
the whole screen down with it.
Important
Adding a method to policies.methods creates its permission in the catalogue, but
ResourcePolicy does not grow with it: that method still has to be declared on whichever
Policy needs it, or the Gate will find nobody to ask.
Warning
Moving navigation.slug renames permissions. A Resource's entity is its slug — that is how
PanelDiscovery reads every Resource, this one included — so a slug of admin/roles turns
security/roles.view into admin/roles.view. The rows already in the database keep the old name,
stop matching anything the catalogue offers and become orphans, and the roles holding them lose
the screen. Re-run filament-permission:sync --prune right after the change, remembering that
pruning cascades into the role and user assignments of whatever it deletes.
Commands
filament-permission:sync
php artisan filament-permission:sync # creates what is missing, reports the orphans php artisan filament-permission:sync --prune # also deletes them php artisan filament-permission:sync --check # writes nothing; exits 1 if they have diverged php artisan filament-permission:sync --panel=admin # a different panel; the default panel otherwise
--checkis for CI. It exits non-zero if the catalogue and the database disagree, without that breaking a normal deployment.- If the super-admin is enabled, everything is synced onto it at the end.
Warning
--prune is explicit because deleting a permission cascades into its role and user
assignments, and that cannot be undone.
Two things about the command that are decisions, not details:
- It writes with a bulk
insert, not withfindOrCreate()in a loop. Measured: 30 new permissions one at a time cost 179 queries; the same 30 in bulk, 2. Everycreate()firesRefreshesPermissionCache'ssavedhook, and the nextfindOrCreate()reloads the entire permissions table along with its roles. - For that same reason it has to flush spatie's cache itself, and twice. The command never reads
through that cache at any point: it reads the stored names with the query builder too, so the
comparison is made against the table and a stale cache cannot skew it. What the query builder does
break is the other direction — that cache only invalidates through Eloquent hooks
(
RefreshesPermissionCachehookssavedanddeleted), which aninsertor adeletedoes not fire by definition, so after the writes the list held in memory is stale and nothing has said so. The first flush is insidePrivilegedRole::sync(), immediately before itssyncPermissions(): a process that had already loaded the permission list — a page that checked something, an artisan process that has synced before — would otherwise hand it an id its own cache has never heard of, andgetStoredPermission()throwsPermissionDoesNotExistfor a row that is right there in the table. The second is the command's own, at the very end, because that first one is skipped by both ofsync()'s early returns — the super-admin switched off, or the role not in the database — while the inserts and deletes happened either way.
make:permission-policy
php artisan make:permission-policy "App\Filament\Resources\Security\Users\UserResource" php artisan make:permission-policy "App\Filament\…\UserResource" --force # overwrites
The generator does not overwrite without --force, and exits with code 1 when the file already
exists. The path mirrors the model's namespace: App\Models\Security\User →
app/Policies/Security/UserPolicy.php. What it writes is the four-line Policy shown in
Resources: a Policy.
Localization
The package ships in English and includes Spanish out of the box. What Filament paints on the panel
— buttons, callouts, tooltips, a field's label, the search box's placeholder, the name the role
resource itself goes by in every heading, breadcrumb and navigation entry, the words the grid puts
around its boxes, and the default wording for every one of the twelve policies.methods abilities
in both its long and its short form, all four EntityOrigin cases and all five AbilityScope ones
— comes from resources/lang/{locale}/roles.php, under the filament-permissions domain:
php artisan vendor:publish --tag=filament-permissions-translations
Publish that tag to reword something without touching src/, or to add a language the package does
not ship by default: just drop the matching file at
lang/vendor/filament-permissions/<locale>/roles.php.
Ability, origin and scope wording is a chain, not a single lookup, and PermissionLabel climbs
it in this order:
filament-permissions.labels— a consumer override, empty by default. The only link that can ever name a policy method the package never shipped, because it is the only one written after the consumer already knows what that method is called.resources/lang/{locale}/roles.php— this package's own default wording, for the sets it already knows in full: the twelvepolicies.methodsabilities, the fourEntityOrigincases and the fiveAbilityScopeones.Str::headline()— for an ability nobody, not the package and not the consumer, ever named.
origins never needs link 3: EntityOrigin is a closed enum this package owns, so all four of its
cases are always nameable ahead of time, and scopes does not need it either, for the same reason.
abilities still needs it underneath link 2, because policies.methods is a consumer-editable list, and a
method added there — or declared by a component through DefinesPermissions — has no entry this
package could have shipped.
abilities_short, the word a grid column header carries, climbs the same first two links and then
changes its last one. labels.abilities_short first, resources/lang/ second, and underneath
those not Str::headline() but the full label, itself resolved through the whole chain above.
An ability nobody shortened therefore shows its long name in a narrow column — untidy, and legible —
where Str::headline() would have been no shorter anyway and a blank would have left a column of
checkboxes nobody can name. Shortening it is one line in labels, which is why the fallback is
allowed to be merely ugly.
The one-line caption the legend puts beside each scope goes further still: scope_captions is read
from resources/lang/ and from nowhere else, with no consumer override above it and no fallback
below, so a caption nobody wrote renders as nothing rather than as a guess.
Note
One surface stays outside this whole mechanism: console and exception messages nobody
administering roles from the browser ever sees. The descriptions and console output of
filament-permission:sync and make:permission-policy, and UndeclaredComponent, the boot
guard's exception, stay in English with no translation file. They are read by whoever deploys or
whoever codes, in a terminal or a log, not by whoever uses the panel — the same distinction
between words meant for a person and text meant for whoever reads code that already runs through
the rest of the package. What does get translated, because it does reach the browser, is
everything Filament renders: the two authorization exceptions EditRole can throw mid-save, and
ReservedRoleName's validation message, travel through resources/lang/ exactly like any other
label.
Why it throws instead of denying
The package fails in two opposite directions on purpose, and which one applies depends on when the failure happens:
| Who | On failure | Why |
|---|---|---|
ComponentAuthorization and ResourcePolicy (runtime) |
propagate outside production, deny inside | Filament evaluates canAccess() while building navigation, i.e. on almost every request: whatever escapes that in production takes down the whole menu, not one entry. A broken component closes its own door |
PanelGuard (boot) |
always throws, in production too | A component with no permission declared is a hole, and a loud crash that gets reverted is worth more than a gap nobody discovers |
The asymmetry is deliberate and has a test on each side. The concrete failure the first row covers
is that hasPermissionTo() throws PermissionDoesNotExist — which extends
InvalidArgumentException, and Laravel renders it as a 500, not a 403 — when the permission is
not in the table. A Policy written bare with that method blows up after a migrate:fresh with no
sync run. checkPermissionTo() catches that exception and returns false, but it also swallows the
typo: if the point is for a misspelled permission to actually show up, the one to call is the first
one, wrapped the way it is here.
What it deliberately leaves out
| Left out | Why |
|---|---|
| Multi-tenancy | Postponed, not rejected — it is planned for a later version. Nothing here blocks it: the catalogue is declared by code and stays global either way, so what would become per-tenant is only the grants |
| A role that "can access the panel" via model hooks | canAccessPanel() is a one-line decision for the consumer, and seeding roles belongs to its deployment |
| Guessing a translation for an ability the package never shipped | A consumer's own custom-declared ability, or a method added to policies.methods, has no entry this package could have written ahead of time. It falls to Str::headline() unless the consumer's own filament-permissions.labels names it — see Localization |
| Rewriting somebody else's source code | In a repository with Pint, Rector and PHPStan at the maximum level, a text patch is not a reviewable change |
| Install and setup commands | The ones that exist in the ecosystem are destructive over spatie's tables and over the project's configuration |
| Seeder generator | The right home for that is a deployment operation the consumer owns |
| A command to publish the roles Resource | The Resource is edited, not configured |
Gate::before / hooking the Gate |
A super-admin defined there would run at two speeds: Filament calls canAccessPanel() directly on the model, without going through the Gate. And after mode denies any ability no Policy rules on, application-wide |
| A facade with dozens of methods, static helpers, contract-free configuration | None of that passes level: max, and none of it is injectable or mockable |
| Custom actions, bespoke Livewire components, relation managers, endpoints | Nothing covers them, and Filament's own documentation is explicit about that. The package exposes the catalogue so permissions can be declared and asked for by hand |
Differences from filament-shield
Shield is compatible with this stack, and its own default super-admin also syncs permissions instead of hooking the Gate. This package's reason to exist is not compatibility — it is five concrete things:
| Here | Shield | |
|---|---|---|
| Privilege escalation from the role CRUD | Four invariants: nobody grants what they do not hold, the privileged role and one's own are not editable, what is not shown is not deletable, and what is submitted gets intersected against what was offered | Its RoleResource carries not a single guard: whoever reaches the screen opens their own role, flips the select-all toggle, saves, and grants themselves the entire system |
| Silent destruction on save | PermissionGrant::reconcile() carries over intact whatever the screen did not show |
CheckboxLists hidden by a disabled tab do not dehydrate, and syncPermissions() is destructive |
| Orphaned permissions | --prune deletes them; --check lets CI require that the catalogue and the database have not diverged |
No reconciliation. An orphan is permanent and keeps granting — and since the entity comes from class_basename, an orphan for Category grants against a new Category from a different module |
| A Page or Widget with no permission | The guard throws and the panel does not boot | Leaves the screen accessible and says nothing, through four different paths. In two of them the interface still paints the checkbox, so whoever administers it unchecks it and believes they closed the page |
| Generating the Policy | The generator does not overwrite without --force, and Policies ask with hasPermissionTo() |
Its stubs emit $user->can(), which with the Gate unhooked denies everything, super-admin included |
And a difference of size that shows when you read them: here a permission name's format lives in a
seventeen-line class; there it is spread across Stringer.php (576 lines), HasEntityTransformers,
Support/Utils::normalize(), validateSeparatorCaseCompatibility(), buildPermissionKeyUsing()
and defaultPermissionKeyBuilder().
Known limitations
- There is no out-of-band recovery path. If somebody empties or deletes the role of maximum
privilege,
syncfills it back in if the role exists; if it was deleted, it has to be recreated some other way. The package deliberately ships no command for that: creating the role belongs to the consumer.
Versioning and upgrades
Semantic Versioning. The version is the git tag and nothing else.
Note
The package's composer.json declares no "version", on purpose. Distributed over VCS or
Packagist the version is the git tag, and a fixed field would override every one of them: the
release would keep calling itself whatever that line said, no matter what was tagged.
There is no upgrade guide yet because there is nothing to upgrade from: 1.0.0 is the first release
and the changelog's first entry, and the package carries no git tag and no Packagist presence yet.
Anything that would break a consumer's install — a constraint that moves, a renamed contract, a
permission format that changes shape — lands in a major and is written down in the changelog before
it is tagged.
The routine for any future bump is three steps, in this order:
composer update elpandape/filament-permission.- Diff the published configuration against the package's
config/filament-permissions.php, and the published translations againstresources/lang/. Neither of the two is overwritten on its own, which is exactly what makes a new key go unnoticed if nobody looks. php artisan filament-permission:sync— and--pruneif the bump withdrew permissions. With the super-admin enabled this is not optional: permissions that appear only reach the role when it is re-synced.
In CI, filament-permission:sync --check answers for the third without writing anything: it exits
non-zero exactly when the catalogue and the database have diverged.
Development and testing
git clone git@github.com:elpandape/filament-permission.git
cd filament-permission
composer install
Note
composer.lock is versioned, and that is a decision. It changes nothing for whoever installs
the package — Composer ignores a dependency's lock and resolves with the consuming project's own —
so the only tree it pins is the one this repository's six gates run against. That is the point: a
coverage threshold at 100% and a PHPStan run at level: max both answer differently depending on
which version of which dependency they are handed, and a failure that nobody can reproduce because
the tree moved underneath is not a failure anyone can fix.
What the lock would hide if nothing else looked — whether the constraints in composer.json are
still honest — is what the tests workflow checks instead, by throwing the lock away and resolving
from scratch twice, once with --prefer-lowest and once with --prefer-stable. The gates install
from the lock; the matrix ignores it. See CI.
The gates
composer test # the suite and nothing else composer test:all # the six gates, in order
test:all chains six scripts, and they are six independent verdicts rather than one:
| Script | What it runs | What it decides |
|---|---|---|
test:lint |
pint --test |
Formatting, over every PHP file in the repository — 86 of them today. It writes nothing and exits non-zero; the script that does rewrite is composer lint, and that one has no place in CI |
test:coverage |
pest --coverage --min=100 |
The suite — 303 tests, 741 assertions — and line coverage, which here is a floor of 100%, not a report |
test:type-coverage |
pest --type-coverage --min=100 |
That every parameter, property and return has a declared type. Also 100%, also a floor |
test:profanity |
pest --profanity |
That nothing in the source, a test name or a comment carries wording nobody wants to find inside somebody else's vendor/ in six months |
test:static |
phpstan |
src, tests and resources/lang at level: max, with reportUnmatchedIgnoredErrors, so an ignore that stops matching is itself an error |
test:refactor |
rector process --dry-run |
That Rector has nothing left to change. The dry run exits non-zero when it would touch a file, and that is what makes it a gate instead of a report |
Important
test:type-coverage is the only one prefixed with an environment variable,
__PEST_PLUGIN_ENV=1, and dropping it breaks more than the run it is dropped from. The prefix
forces the analysis to run in sequence: with the forked processes Pest uses by default,
pest-plugin-type-coverage corrupts its own .temp/v3.php cache, and the damage does not surface
where it was done — the run that corrupts it finishes fine, and the next one dies with a fatal
before printing a single percentage. The gate stops answering at all until that file is deleted by
hand, which reads like a broken tool rather than a stale cache.
The files that govern them
phpunit.xml.dist— a single testsuite overtests/, because the package does not split Unit from Feature;tests/Fixtures/**andtests/TestCase.phpfall outside it for free, since neither matches theTest.phpsuffix.APP_ENV=testingis load-bearing: Filament only registers its testing macros —assertFormSet,assertSchemaComponentStateSet,assertActionVisible,TestAction— underrunningUnitTests(). The locale is pinned to English here and again inTestCase, because the suite compares dozens of English literals and both branches oftrans_choice.pint.json— thelaravelpreset with twenty-two rules on top of it. It is also a hard dependency ofrector.phprather than a matter of taste: Rector skipsSafeDeclareStrictTypesRectorprecisely because Pint is what seedsdeclare(strict_types=1). Turn that Pint rule off and nothing puts the line back.phpstan.neon— itsincludespoint into the package's ownvendor/, and it runs from the repository root as plainvendor/bin/phpstan, with no--configuration=. It analysesresources/langon top ofsrcandtests, and that third path is the deliberate one: a translation file is a single array literal, so the only way to break it is to write a key twice and lose the first string without a word. PHPStan sees that; none of the other five gates does.rector.php—PestSetList::CODING_STYLEover the prepared sets, and no Laravel sets at all, on purpose: the package does not depend ondriftingly/rector-laravel. Its cache lives under/tmp, so CI always starts cold — correct, since there is nothing to invalidate between runs.
The test application
The suite boots an orchestra/testbench of its own, and everything the tests take for granted is
declared by this package under tests/Fixtures/: a panel with the id admin, a UserResource with
the slug security/users and its ListUsers page, and an authenticatable User with HasRoles
plus its factory. Two things in tests/TestCase.php look arbitrary and are not:
getPackageProviders()reproduces Composer's package-discovery order, with Filament before Livewire.Filament\Support\SupportServiceProviderdoesbind(DataStore::class, DataStoreOverride::class), a non-shared binding: register Livewire first and thatbind()buries Livewire's own instance, so everyapp(DataStore::class)hands back a freshly built override with an emptyWeakMapand everystore($component)->set(…)is lost. The symptom says nothing about ordering — it isViewErrorBag::put(): Argument #2 ($bag) must be of type MessageBag, null givenin every test that renders a Livewire component.- The migrations run in
defineDatabaseMigrationsAfterDatabaseRefreshed(), not indefineDatabaseMigrations(), which is where they look like they belong. Testbench calls the latter before triggering the database refresh, and with sqlite in memory that refresh raises a brand-new empty database: everything created earlier goes with it. The symptom is ano such tablehalfway through the suite, not a failure at boot.
tests/Arch/PackageBoundaryTest.php is the file that holds the core/adapter boundary — that
Catalog, Contracts, Authorization and Commands import no Filament namespace, which is the
whole reason the core can run in a project with no Filament installed. Its first test is not an
architecture rule but a plain expectation over the list of installed Filament PSR-4 prefixes the
rule is then fed: an architecture rule handed an empty list passes without checking anything, and
that test is what makes noise the day a Filament package stops being installed.
CI
Three workflows, all on push and pull request against main, all with contents: read and nothing
more, because none of them writes anything back: tests, static analysis — PHPStan and Rector as
separate jobs, so the first failure does not hide the second — and code style.
tests resolves the matrix prefer-lowest × prefer-stable, with fail-fast: false. The two
resolutions answer different questions and neither answer can be deduced from the other: if
prefer-lowest fails, the package is lying in one of its composer.json constraints; if
prefer-stable fails, it has broken against what a consumer would install today. Cancelling one
because the other fell throws away exactly half the diagnosis. That first leg is not theoretical —
it is what moved the Filament floor from ^5.0 to ^5.7. It is also the only job that ignores
composer.lock: every other one installs from it, so that what a gate decides depends on the
repository and not on what happened to be released that morning. Line and type coverage run in a job
of their own, from the lock, for that same reason.
There is no laravel axis, and that is not an omission. The ceiling is already real in
composer.json — php: ^8.5, illuminate/*: ^13.0 — so a second value would only produce work the
solver rejects, and a single-value axis that has to be forced with
composer require laravel/framework checks nothing the constraint did not check first.
.github/dependabot.yml watches composer and github-actions weekly. On the Composer side it
does both things: it bumps the versions pinned in the lock — the ones the six gates run against, so
a patch release that breaks them surfaces here and not in some consumer's project — and it opens a
pull request when a constraint blocks a new version and the constraint has to be widened by
hand. The second is the one that cannot be noticed on its own: the day Filament 6 or Laravel 14
ships.
Changelog
CHANGELOG.md, in Keep a Changelog format.
Contributing
Open an issue before writing anything that changes behaviour — most of what looks like a missing feature here is in What it deliberately leaves out, with the reason attached.
composer test:all has to pass locally before a pull request, because CI runs those same six gates
and then resolves the dependency tree twice on top. Two of them fail on additions that would pass
anywhere else: line coverage and type coverage are floors at 100%, so an untested line or an
undeclared type fails the build rather than lowering a number.
Security
If you find a vulnerability, do not open a public issue: report it through GitHub's private vulnerability reporting on this repository.
Credits
- ElPandaPe
spatie/laravel-permission— the tables, the model API and the cache this package is built on top of- Filament — the panel this discovers, guards and authorizes
bezhanSalleh/filament-shield— the package that established what this problem looks like, and the reference this one argues with in Differences fromfilament-shield
License
MIT. See LICENSE.md.