PHPackages                             elpandape/filament-permission - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. elpandape/filament-permission

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

elpandape/filament-permission
=============================

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

v1.0.0(today)00MITPHPPHP ^8.5CI passing

Since Aug 9Pushed todayCompare

[ Source](https://github.com/elpandape/filament-permission)[ Packagist](https://packagist.org/packages/elpandape/filament-permission)[ Docs](https://github.com/elpandape/filament-permission)[ RSS](/packages/elpandape-filament-permission/feed)WikiDiscussions main Synced today

READMEChangelog (1)Dependencies (23)Versions (2)Used By (0)

elpandape/filament-permission
=============================

[](#elpandapefilament-permission)

[![elpandape/filament-permission](https://repository-images.githubusercontent.com/1327354355/9c3894c5-812c-47f3-ad69-a974812b0f0b)](https://github.com/elpandape/filament-permission)*[Leer esto en español](README.es.md)*

[![FILAMENT 5.x](https://camo.githubusercontent.com/3ec960aa304b803da9f1192f01e82da976a3d220ea85931fec930003e0532c77/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f46494c414d454e542d352e782d4542423330343f7374796c653d666f722d7468652d6261646765)](https://filamentphp.com/docs/5.x/panels/installation)[![Packagist](https://camo.githubusercontent.com/8fc5084b277b9d2099b6ea2ce6bac7506881601bb6a466832cb24705b99f57e0/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f656c70616e646170652f66696c616d656e742d7065726d697373696f6e2e7376673f7374796c653d666f722d7468652d6261646765)](https://packagist.org/packages/elpandape/filament-permission)[![Downloads](https://camo.githubusercontent.com/b5ca9bb812efd8abaf420b30a5ab121cc7b9971d60e8c634ac99827ed09331ef/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f656c70616e646170652f66696c616d656e742d7065726d697373696f6e2e7376673f7374796c653d666f722d7468652d6261646765)](https://packagist.org/packages/elpandape/filament-permission)[![License](https://camo.githubusercontent.com/7f2033d3c999161e83a2e052dabe7faceb69421d66102bf47526e79262b25ada/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f656c70616e646170652f66696c616d656e742d7065726d697373696f6e2e7376673f7374796c653d666f722d7468652d6261646765)](LICENSE.md)

[![tests](https://camo.githubusercontent.com/e6fecad0bea26e837e1a72279085a308c01fa77249497ef498a1140a9237ca3e/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f656c70616e646170652f66696c616d656e742d7065726d697373696f6e2f74657374732e796d6c3f6272616e63683d6d61696e267374796c653d666f722d7468652d6261646765266c6162656c3d7465737473)](https://github.com/elpandape/filament-permission/actions/workflows/tests.yml)[![phpstan](https://camo.githubusercontent.com/790c665fda4e5b2f0e65c3c93c4e24fcd2366b0f1cec0544039cb0b58e5e59f7/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f656c70616e646170652f66696c616d656e742d7065726d697373696f6e2f7374617469632d616e616c797369732e796d6c3f6272616e63683d6d61696e267374796c653d666f722d7468652d6261646765266c6162656c3d7068707374616e)](https://github.com/elpandape/filament-permission/actions/workflows/static-analysis.yml)[![pint](https://camo.githubusercontent.com/b741b2e6d8c8d121772d6f4dac939370d13a78e160b5daa98795f7a15ec64c01/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f656c70616e646170652f66696c616d656e742d7065726d697373696f6e2f636f64652d7374796c652e796d6c3f6272616e63683d6d61696e267374796c653d666f722d7468652d6261646765266c6162656c3d70696e74)](https://github.com/elpandape/filament-permission/actions/workflows/code-style.yml)

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`](https://github.com/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`](#differences-from-filament-shield).

Table of contents
-----------------

[](#table-of-contents)

- [Features](#features)
- [Compatibility](#compatibility)
- [Installation](#installation)
- [Usage](#usage)
    - [The catalogue](#the-catalogue)
    - [Custom permissions](#custom-permissions)
    - [Permissions declared by a class](#permissions-declared-by-a-class)
    - [Resources: a Policy](#resources-a-policy)
    - [Pages and Widgets: two traits](#pages-and-widgets-two-traits)
    - [The boot guard](#the-boot-guard)
    - [The role screen](#the-role-screen)
    - [The matrix](#the-matrix)
    - [Optional super-admin](#optional-super-admin)
- [Configuration](#configuration)
- [Commands](#commands)
    - [`filament-permission:sync`](#filament-permissionsync)
    - [`make:permission-policy`](#makepermission-policy)
- [Localization](#localization)
- [Why it throws instead of denying](#why-it-throws-instead-of-denying)
- [What it deliberately leaves out](#what-it-deliberately-leaves-out)
- [Differences from `filament-shield`](#differences-from-filament-shield)
- [Known limitations](#known-limitations)
- [Versioning and upgrades](#versioning-and-upgrades)
- [Development and testing](#development-and-testing)
    - [The gates](#the-gates)
    - [The files that govern them](#the-files-that-govern-them)
    - [The test application](#the-test-application)
    - [CI](#ci)
- [Changelog](#changelog)
- [Contributing](#contributing)
- [Security](#security)
- [Credits](#credits)
- [License](#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
--------

[](#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](#the-role-screen).
- 👑 **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
-------------

[](#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](#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
------------

[](#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 `` 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](#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
-----

[](#usage)

### The catalogue

[](#the-catalogue)

OriginEntityAbilitiesResourceits **slug** (`security/users`)those of `policies.methods`Standalone pagethe kebab-cased basename`view`Widgetthe kebab-cased basename`view`A class implementing `DefinesPermissions`whatever it declareswhatever it declares, nothing inferredA `custom` key in the configurationitselfwhatever it declaresWhat 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.`, 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

[](#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](#the-matrix), 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

[](#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 */
    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

[](#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`](#makepermission-policy) for the generator that writes it.

### Pages and Widgets: two traits

[](#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

[](#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](#why-it-throws-instead-of-denying).

### The role screen

[](#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`](#custom-permissions)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)

```

EditorIs offeredHolds the entire catalogue (a super-admin)All of it: `reserved − held` is empty for themHolds `unrestricted`, holds none of the reservedThe whole catalogue **minus** the reserved namesHolds one reserved permissionThat one too, because they hold itAnybody elseExactly what they holdNone 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

[](#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.`, 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:**

PieceWhere it is drawn todayThe entity card, checkboxes in bandsPages, Widgets and Custom, for whatever declares more than one abilityThe gathered card, one row per entityAll four tabs, for whatever declares exactly oneThe grant barThe 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 insteadThe `granted/total` badgeEvery card; on the grid card it counts the whole originA scope's word, its colour and its ruleOn a card, down a band of checkboxes; on the grid, across a group of columnsThe scope legend under the tabsBoth screens, naming only the scopes actually on screenThe role summaryThe detail screen, untouchedThe 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 component** — `ViewComponent::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 `` 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:  0 calc( * 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

[](#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](#known-limitations) for what that means the day somebody deletes it.

---

Configuration
-------------

[](#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`.

GroupWhat 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-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
--------

[](#commands)

### `filament-permission:sync`

[](#filament-permissionsync)

```
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`

[](#makepermission-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](#resources-a-policy).

Localization
------------

[](#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//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
--------------------------------

[](#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:

WhoOn failureWhy`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 discoversThe 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
-------------------------------

[](#what-it-deliberately-leaves-out)

Left outWhyMulti-tenancyPostponed, 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 grantsA role that "can access the panel" via model hooks`canAccessPanel()` is a one-line decision for the consumer, and seeding roles belongs to its deploymentGuessing a translation for an ability the package never shippedA 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](#localization)Rewriting somebody else's source codeIn a repository with Pint, Rector and PHPStan at the maximum level, a text patch is not a reviewable changeInstall and setup commandsThe ones that exist in the ecosystem are destructive over spatie's tables and over the project's configurationSeeder generatorThe right home for that is a deployment operation the consumer ownsA command to publish the roles ResourceThe Resource is edited, not configured`Gate::before` / hooking the GateA 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-wideA facade with dozens of methods, static helpers, contract-free configurationNone of that passes `level: max`, and none of it is injectable or mockableCustom actions, bespoke Livewire components, relation managers, endpointsNothing 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 handDifferences from `filament-shield`
----------------------------------

[](#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:

HereShield**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 offeredIts `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`CheckboxList`s 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 divergedNo 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 bootLeaves 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 includedAnd 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
-----------------

[](#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
-----------------------

[](#versioning-and-upgrades)

[Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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
-----------------------

[](#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](#ci).

### The gates

[](#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:

ScriptWhat it runsWhat 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 reportImportant

**`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

[](#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.php`** — `PestSetList::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-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

[](#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)

[CHANGELOG.md](CHANGELOG.md), in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.

Contributing
------------

[](#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](#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
--------

[](#security)

If you find a vulnerability, do not open a public issue: report it through [GitHub's private vulnerability reporting](https://github.com/elpandape/filament-permission/security)on this repository.

Credits
-------

[](#credits)

- [ElPandaPe](https://github.com/elpandape)
- [`spatie/laravel-permission`](https://github.com/spatie/laravel-permission) — the tables, the model API and the cache this package is built on top of
- [Filament](https://filamentphp.com) — the panel this discovers, guards and authorizes
- [`bezhanSalleh/filament-shield`](https://github.com/bezhanSalleh/filament-shield) — the package that established what this problem looks like, and the reference this one argues with in [Differences from `filament-shield`](#differences-from-filament-shield)

License
-------

[](#license)

MIT. See [LICENSE.md](LICENSE.md).

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance100

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity50

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/b270ce37c9e50f3be130225b85b7b8df6c52f9bc0395555610f23c951299ef79?d=identicon)[makoto2805](/maintainers/makoto2805)

---

Top Contributors

[![carlos-mayorga](https://avatars.githubusercontent.com/u/4119035?v=4)](https://github.com/carlos-mayorga "carlos-mayorga (1 commits)")

---

Tags

spatielaravelauthorizationrolespermissionsfilamentfilament-pluginlaravel-permission

###  Code Quality

TestsPest

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/elpandape-filament-permission/health.svg)

```
[![Health](https://phpackages.com/badges/elpandape-filament-permission/health.svg)](https://phpackages.com/packages/elpandape-filament-permission)
```

###  Alternatives

[laravel/cashier

Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.

2.6k31.8M158](/packages/laravel-cashier)[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M354](/packages/psalm-plugin-laravel)[laravel/sail

Docker files for running a basic Laravel application.

1.9k212.4M1.4k](/packages/laravel-sail)[laravel/ai

The official AI SDK for Laravel.

1.1k4.6M293](/packages/laravel-ai)[spatie/laravel-health

Monitor the health of a Laravel application

88212.7M185](/packages/spatie-laravel-health)[althinect/filament-spatie-roles-permissions

3491.2M12](/packages/althinect-filament-spatie-roles-permissions)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
