elpandape/filament-permission

Roles and permissions for Filament, built on spatie/laravel-permission

Maintainers

Package info

github.com/elpandape/filament-permission

pkg:composer/elpandape/filament-permission

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-09 21:43 UTC

This package is auto-updated.

Last update: 2026-08-09 21:52:54 UTC


README

elpandape/filament-permission

Leer esto en español

FILAMENT 5.x Packagist Downloads License

tests phpstan pint

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

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 PanelDiscovery walks the panel and produces a PermissionCatalog: Entity → Ability[]. No queries, no instantiating classes — class_basename() and is_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 is entity.ability, with the entity first (security/users.update), which is what would make a security/users.* meaningful if spatie's wildcard were ever switched on — its WildcardPermission splits on . and ,.
  • 🔁 Syncs and prunes. filament-permission:sync is the only place in the package that writes permissions. It creates what is missing with a bulk insert, 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 AuthorizesPage trait, and Widgets with AuthorizesWidget.
  • 💥 Closes the fail-open. PanelGuard walks 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::before or 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\Category and App\Models\Shop\Category produce the same key and silently grant each other. When two entities claim the same key with different labels, the catalogue throws EntityCollision at 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.

  1. 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 permissions table: 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: the grants group, right below, relaxes it in one direction and tightens it in the other.
  2. 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(), which EditRecord invokes from mount() and from hydrate(), 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 and Super-Admin next to super-admin would be two rows.
  3. 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 and syncPermissions() deletes them.
  4. 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: a CheckboxList validates 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 is PermissionGrant, and that is why submitted() flattens with Arr::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'],
],
  • unrestricted names 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 in custom before filament-permission:sync will 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 --prune deletes.

  • reserved names permissions nobody is offered unless they hold them, not even a holder of unrestricted. Exact names or wildcards, matched with Str::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 PermissionLabeldeleteAny, 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 paints x-bind:class="{ 'fi-hidden': … }" on the component wrapper and leaves the row in the DOM with its state; a card uses x-show. A card none of whose entities match hides whole, through groupSearchAttributes().
  • Tab highlighting goes through x-bind:style, never x-bind:class: Filament's own tab button already carries its own x-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 componentViewComponent::toHtml() only falls back to its embedded HTML when hasView() is false, and CheckboxList declares no $defaultView. Nothing about the field's state moves: same state path, same options, same validation, so fillForm() and assertSchemaComponentStateSet() reach it exactly as before.
  • Both views keep Filament's x-load wrapper and its checkboxListFormComponent rather than reimplementing them, which is what keeps bulkToggleable() alive: that Alpine component finds its boxes by .fi-fo-checkbox-list-option and 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-option either, because the toggle reads an input out 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 calls wrapEmbeddedHtml() on its way out and render() 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 is flex: <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 a CheckboxList that Filament mounts in a wrapper of its own. A repeat(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-scroll around the read-only view, and [id^="permission-card-grid-"] .fi-section-content for the edit one, where the only box holding every row is the section's own content container.
  • Text::make() renders an inline-block span, which shrinks to its content, and an auto-fit grid 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-scroll and .fp-grid-row), since the wrapper is Filament's, and pins the ink on those same roots because Text tints 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:

  1. It keeps PermissionDoesNotExist as a typo detector. With the wildcard, a Policy asking about a misspelled permission would return true.
  2. The database says exactly what the role can do.
  3. It does not change the semantics of any other permission: enable_wildcard_permission is global.
  4. Gate::before would give a super-admin that runs at two speeds, because Filament asks canAccessPanel() 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
  • --check is 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 with findOrCreate() in a loop. Measured: 30 new permissions one at a time cost 179 queries; the same 30 in bulk, 2. Every create() fires RefreshesPermissionCache's saved hook, and the next findOrCreate() 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 (RefreshesPermissionCache hooks saved and deleted), which an insert or a delete does not fire by definition, so after the writes the list held in memory is stale and nothing has said so. The first flush is inside PrivilegedRole::sync(), immediately before its syncPermissions(): 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, and getStoredPermission() throws PermissionDoesNotExist for 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 of sync()'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\Userapp/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:

  1. 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.
  2. resources/lang/{locale}/roles.php — this package's own default wording, for the sets it already knows in full: the twelve policies.methods abilities, the four EntityOrigin cases and the five AbilityScope ones.
  3. 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, sync fills 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:

  1. composer update elpandape/filament-permission.
  2. Diff the published configuration against the package's config/filament-permissions.php, and the published translations against resources/lang/. Neither of the two is overwritten on its own, which is exactly what makes a new key go unnoticed if nobody looks.
  3. php artisan filament-permission:sync — and --prune if 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 over tests/, because the package does not split Unit from Feature; tests/Fixtures/** and tests/TestCase.php fall outside it for free, since neither matches the Test.php suffix. APP_ENV=testing is load-bearing: Filament only registers its testing macros — assertFormSet, assertSchemaComponentStateSet, assertActionVisible, TestAction — under runningUnitTests(). The locale is pinned to English here and again in TestCase, because the suite compares dozens of English literals and both branches of trans_choice.
  • pint.json — the laravel preset with twenty-two rules on top of it. It is also a hard dependency of rector.php rather than a matter of taste: Rector skips SafeDeclareStrictTypesRector precisely because Pint is what seeds declare(strict_types=1). Turn that Pint rule off and nothing puts the line back.
  • phpstan.neon — its includes point into the package's own vendor/, and it runs from the repository root as plain vendor/bin/phpstan, with no --configuration=. It analyses resources/lang on top of src and tests, 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.phpPestSetList::CODING_STYLE over the prepared sets, and no Laravel sets at all, on purpose: the package does not depend on driftingly/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\SupportServiceProvider does bind(DataStore::class, DataStoreOverride::class), a non-shared binding: register Livewire first and that bind() buries Livewire's own instance, so every app(DataStore::class) hands back a freshly built override with an empty WeakMap and every store($component)->set(…) is lost. The symptom says nothing about ordering — it is ViewErrorBag::put(): Argument #2 ($bag) must be of type MessageBag, null given in every test that renders a Livewire component.
  • The migrations run in defineDatabaseMigrationsAfterDatabaseRefreshed(), not in defineDatabaseMigrations(), 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 a no such table halfway 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.jsonphp: ^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

License

MIT. See LICENSE.md.