bezhansalleh/filament-shield
Shield adds complete access management to Filament panels: resource, page, and widget permissions, custom permissions, and automatic policy generation, with per-resource policies and seamless role integration.
Install and wire up in three steps (about five minutes).
composer require bezhansalleh/filament-shield
php artisan vendor:publish --tag="filament-shield-config"
php artisan shield:setup # interactive: publishes migrations, picks guard, offers super-admin, runs generate
Then make sure your auth model implements Spatie's contract:
// config/filament-shield.php
'auth_provider_model' => 'App\\Models\\User',
// app/Models/User.php
use Spatie\Permission\Traits\HasRoles;
Finally register the plugin on each panel so admins get the Roles/Permissions UI:
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(ShieldPlugin::make());
}
Where to look first, in this order:
config/filament-shield.php — this file is the package. Every behaviour (permission key casing, policy paths, method lists, super admin, tenancy) is a config key. Read it top to bottom before writing any code.app/Policies/* — see exactly what Shield produced for your resources./admin/shield/roles).First real use case: you have 30 resources and no authorization. Run php artisan shield:generate --all, open the Roles resource, create a "Editor" role, tick ViewAny:Post, View:Post, Create:Post, Update:Post, and assign it to a user. No policy code written by hand, and PostPolicy now returns true/false correctly because Shield generated the policy methods that map to those permission names. That loop — generate → tick boxes → assign — is the 90% path.
Treat permission generation as a build step, not a one-off. New resource? Add it to your workflow:
php artisan shield:generate --all --no-interaction
php artisan shield:seeder --no-interaction # roles + direct permissions as a class
Commit both the generated policies and the generated seeder. The seeder is the artifact that keeps staging/production in sync with the code; running shield:generate directly against production is a smell because it mutates the permission table without review. Instead, ship the seeder and run it in your deploy pipeline (php artisan db:seed --class=ShieldSeeder).
Decide your permission key convention on day one. separator (:) + case (pascal) produce ViewAny:Post. Changing this later renames every permission row and silently orphans every role→permission pivot. If your team has naming standards, set it now:
'permissions' => [
'separator' => '.',
'case' => 'snake', // view_any.post, create.post
'generate' => true,
],
Override key composition for colliding resource names. Two CategoryResource classes handling Blog\Category and Shop\Category both want ViewAny:Categories. Use the facade hook in AppServiceProvider::boot() and return null for everything else so the default builder still applies:
FilamentShield::buildPermissionKeyUsing(
function (string $entity, ?string $affix, string $subject, string $case, string $separator) {
if ($entity === 'custom') {
return $subject; // IdP-managed keys, use verbatim
}
return null; // default builder
}
);
Custom (ad-hoc) permissions for non-Filament actions. Anything outside a panel — "export reports", "impersonate user", API-only abilities — belongs in the config's custom permissions list. They show up in the same checkbox UI, so a role is defined in one place instead of half in Shield and half in a bespoke admin screen. For keys owned by an external identity provider (Keycloak, Auth0), mark them externally managed so Shield never tries to create or rename them.
Super admin as a gate interception, not a role. For "the owner can do anything", prefer the gate interception so new permissions don't require re-ticking boxes on the super-admin role every time you add a resource. Reserve the role-based approach when you need an auditable, enumerable list of what the super admin technically holds. Keep at least one non-super-admin test account — with interception on, every policy returns true and your authorization tests become meaningless.
Pages and widgets need explicit enforcement. Resources get it for free via the generated policy; pages and widgets merely get permissions generated. Check the "Permission Enforcement" section and wire the check yourself (visibility/canAccess-style guard) — the classic bug is a custom dashboard widget that renders for everyone while a perfectly good View:StatsWidget permission sits unused in the DB.
Policies: generate once, then own them. Use policies.merge => true so custom methods survive regeneration, and set policies.generate => false globally (or exclude specific resources via the resources config) once a policy has real business rules — e.g. "editors only update their own posts". Generated policies are a scaffold; the moment you add tenancy scoping or ownership checks, regenerating over them is data loss.
Multi-tenancy. Enable Spatie teams before the first shield:setup (the migration needs the team foreign key column), then configure Shield's tenancy options so roles are per-team. Retrofitting teams after roles exist means migrating every pivot row by hand.
Testing pattern. In Pest/PHPUnit, seed your Shield seeder in setUp, then actingAs($editor)->get(PostResource::getUrl())->assertOk() and assert a 403 for a role missing the permission. This catches the far more common real-world failure: a permission exists but was never granted, or was granted to a role that isn't attached.
composer update. The config file shape, command names, and policy generation all changed. Budget a half-day, not ten minutes._ with snake/lower_snake/upper_snake, or - with kebab, raises InvalidArgumentException — the affix and subject become indistinguishable. Pick a separator outside the case's own delimiter set.case, separator, resources.subject, or a permission-key callback after go-live, and every existing role_has_permissions row points at a dead key. Roles quietly lose access, and no error is raised. Either lock the convention early or write a migration that rewrites the name column.app/Models land in app/Policies (nesting preserved). Vendor models land flat in app/Policies — two vendor models with the same basename will collide. Models in a non-app/Models Models directory get a sibling Policies directory, which Laravel's default discovery may not find; you must register them (Gate::guessPolicyNamesUsing or the $policies array). Check the placement table and verify with Gate::getPolicyFor($model) rather than assuming.php artisan permission:cache-reset (or optimize:clear). "The permission is in the DB but can() returns false" is almost always this.$user->getDirectPermissions() before touching policies.--panel=admin) or disable global discovery; otherwise you're ticking boxes for screens the admin will never see.--no-interaction, --all, explicit --option=policies / --option=permissions) and expect a refused command rather than a hung prompt in CI.shield:setup needs its flags. It's smart and interactive; in a Docker build it will block forever. Publish config via env-specific config files instead, and reserve shield:setup for local/dev.FilamentShield facade (permission catalogue retrieval, key builder), the publishable Shield resource (for layout/navigation/labels/global-search changes — publish rather than fork), and the resources/pages/widgets config arrays (subject, manage, exclude) which are the surgical way to keep a noisy or internal resource out of the permission UI without writing code.How can I help you explore Laravel packages today?