Key takeaways
- ✓ Filament authorises resources through Laravel policies. Spatie’s package stores roles and permissions, and your policies check them.
- ✓ Manual setup (policies plus a small Role resource) gives you full control. Filament Shield generates the permissions, policies and role UI for you.
- ✓ Check permissions in policies, not just with
->visible(). Hiding a button isn’t authorisation. - ✓ Current versions:
spatie/laravel-permissionv8 (PHP 8.3+, Laravel 12/13). Shield 4.x supports Filament 4 and 5 and permission v6–v8.
Sooner or later, “everyone who can log in can do everything” stops being acceptable. Support staff shouldn’t delete invoices, and contractors shouldn’t see revenue. This guide adds role-based access control (RBAC) to a Filament v5 panel with spatie/laravel-permission, the most widely used permissions package in the Laravel world (118 million+ installs). It covers two routes: building it yourself, and using Filament Shield.
Also Read: Laravel: Best Filament Themes
How authorisation works in Filament
Filament doesn’t have its own permission system. It asks Laravel’s policies:
| Filament does… | Policy method checked |
|---|---|
| Show a resource in navigation, open the list page | viewAny() |
| Open a view page | view() |
| Show the Create button, open the create page | create() |
| Show Edit, save a record | update() |
| Delete / bulk delete | delete() / deleteAny() |
| Restore / force delete | restore(), forceDelete() and the *Any variants |
| Reorder rows | reorder() |
If a model has no policy, Filament allows everything. That’s convenient for the first day of development and dangerous in production. Spatie’s package answers the question “does this user have permission X?”, and your policies turn the answer into allow or deny.
Step 1: Install spatie/laravel-permission
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrateAdd the trait to your user model, and gate panel access:
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements FilamentUser
{
use HasRoles;
public function canAccessPanel(Panel $panel): bool
{
return match ($panel->getId()) {
'admin' => $this->hasAnyRole(['super_admin', 'admin', 'support']),
default => true,
};
}
}Version note: v7 and v8 modernised the package (type hints, enum support in findByName(), events renamed with an Event suffix), but Spatie\Permission\Models\Role, Permission and the HasRoles trait kept their names. v8 requires PHP 8.3+ and Laravel 12 or 13.
Step 2: Seed roles and permissions
Name permissions after actions on models. You’ll thank yourself later:
// database/seeders/RolesAndPermissionsSeeder.php
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
public function run(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
$models = ['customer', 'invoice', 'user'];
$abilities = ['view_any', 'view', 'create', 'update', 'delete', 'restore', 'force_delete'];
foreach ($models as $model) {
foreach ($abilities as $ability) {
Permission::findOrCreate("{$ability}_{$model}");
}
}
Role::findOrCreate('super_admin');
Role::findOrCreate('admin')->syncPermissions(Permission::all());
Role::findOrCreate('support')->syncPermissions([
'view_any_customer', 'view_customer', 'update_customer',
'view_any_invoice', 'view_invoice',
]);
}Step 3: Write policies that check permissions
php artisan make:policy InvoicePolicy --model=Invoicenamespace App\Policies;
use App\Models\Invoice;
use App\Models\User;
class InvoicePolicy
{
public function viewAny(User $user): bool
{
return $user->can('view_any_invoice');
}
public function view(User $user, Invoice $invoice): bool
{
return $user->can('view_invoice');
}
public function create(User $user): bool
{
return $user->can('create_invoice');
}
public function update(User $user, Invoice $invoice): bool
{
// Permission check plus a business rule: paid invoices are locked
return $user->can('update_invoice') && ! $invoice->isPaid();
}
public function delete(User $user, Invoice $invoice): bool
{
return $user->can('delete_invoice') && $invoice->isDraft();
}
public function deleteAny(User $user): bool
{
return $user->can('delete_invoice');
}
}This is where policies earn their keep. update() combines a permission (“may edit invoices”) with a rule (“not once paid”). Filament respects both: the Edit button disappears for paid invoices, and requests to the edit URL are refused.
Laravel auto-discovers App\Policies\InvoicePolicy for App\Models\Invoice. For non-standard locations, register it with Gate::policy() or the #[UsePolicy] attribute on the model.
Also Read: Laravel: Filament Blueprint Review
Step 4: A super-admin bypass
Instead of giving super_admin every permission (and remembering to add new ones), short-circuit the gate:
// AppServiceProvider::boot()
use Illuminate\Support\Facades\Gate;
Gate::before(function ($user, string $ability) {
return $user->hasRole('super_admin') ? true : null;
});Return null, not false, for everyone else, so normal policy checks still run.
Step 5: A Role resource to manage everything in the panel
php artisan make:filament-resource Role --model-namespace="Spatie\\Permission\\Models"If the generator’s model-namespace option gives you trouble, generate a plain resource and set protected static ?string $model = \Spatie\Permission\Models\Role::class; by hand.
The form is the part that matters:
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
public static function form(Schema $schema): Schema
{
return $schema->components([
TextInput::make('name')
->required()
->unique(ignoreRecord: true)
->disabled(fn (?Role $record) => $record?->name === 'super_admin'),
Section::make('Permissions')
->columnSpanFull()
->schema([
CheckboxList::make('permissions')
->relationship('permissions', 'name')
->searchable()
->bulkToggleable()
->columns(3)
->gridDirection('row'),
]),
]);
}And on the User resource, assign roles:
use Filament\Forms\Components\Select;
Select::make('roles')
->relationship('roles', 'name')
->multiple()
->preload()
->searchable(),Protect these two resources with their own policies (RolePolicy, UserPolicy). Otherwise a support user could promote themselves.
Step 6: Authorise actions, pages and widgets
Resource CRUD is covered by policies. Custom actions need an explicit check:
use Filament\Actions\Action;
Action::make('refund')
->requiresConfirmation()
->authorize('refund') // calls InvoicePolicy::refund()
->action(fn (Invoice $record) => $record->refund());For custom pages and widgets, use their static access methods:
public static function canAccess(): bool // custom pages
{
return auth()->user()->can('view_revenue_reports');
}
public static function canView(): bool // widgets
{
return auth()->user()->can('view_revenue_reports');
}->visible() and ->hidden() are for UX. ->authorize() and policies are for security. Use both, and never rely on visible() alone.
The shortcut: Filament Shield
Writing policies for 30 resources by hand is tedious. Filament Shield builds on the same Spatie package and generates permissions, policies and a Role resource for you. Version 4.x supports Filament 4 and 5 and accepts laravel-permission ^6, ^7 and ^8.
composer require bezhansalleh/filament-shield
php artisan vendor:publish --tag="filament-shield-config"
php artisan shield:setup
php artisan shield:install admin// AdminPanelProvider
use BezhanSalleh\FilamentShield\FilamentShieldPlugin;
->plugins([
FilamentShieldPlugin::make(),
])php artisan shield:generate --all --panel=admin
php artisan shield:super-adminShield 4.x uses Pascal-case permission keys with a colon separator by default: ViewAny:Invoice, Update:Invoice, View:Dashboard, View:RevenueWidget, plus custom ones like Refund:Invoice. The case and separator are configurable. Pages and widgets get permissions too. Shield also supports tenancy (shield:install admin --tenant) and can generate a seeder (shield:seeder) so environments stay in sync.
Also Read: Claude Code & Cursor on Filament: AI Agent Rules That Work - Laravel
A caveat worth knowing: Shield 4.x is a complete rewrite of 3.x and isn’t backwards compatible. If you’re coming from Filament v3 with Shield 3, regenerate permissions and migrate role assignments deliberately.
Manual or Shield?
| Manual policies | Filament Shield | |
|---|---|---|
| Setup time | Hours | Minutes |
| Business rules in policies | Natural | Possible (edit the generated policies) |
| Page and widget permissions | Write canAccess() yourself | Generated |
| Permission naming | Yours | Shield’s convention |
| Upgrade surface | Only Spatie | Spatie + Shield |
| Best for | Few resources, complex rules | Many resources, admin-managed roles |
Many teams use both: Shield for the grid of standard permissions, then hand-edited policies for the few rules that need business logic.
Teams and tenancy
In a multi-tenant Filament panel, a user might be an admin in one team and a viewer in another. Enable Spatie’s teams feature ('teams' => true in config/permission.php before running the migrations), then set the team ID for each request from Filament’s current tenant, in middleware registered with ->tenantMiddleware([...], isPersistent: true):
public function handle(Request $request, Closure $next): Response
{
if ($tenant = Filament::getTenant()) {
setPermissionsTeamId($tenant->getKey());
}
return $next($request);
}Shield’s --tenant option sets up the equivalent middleware for you.
Performance and caching
Spatie caches permissions for 24 hours by default. After changing roles in a seeder or deployment, clear it:
php artisan permission:cache-resetEager load roles on user tables (->modifyQueryUsing(fn ($q) => $q->with('roles'))) to avoid N+1 queries when showing a roles column.
Test your permissions
it('prevents support staff from deleting invoices', function () {
$support = User::factory()->create()->assignRole('support');
$invoice = Invoice::factory()->draft()->create();
$this->actingAs($support);
livewire(EditInvoice::class, ['record' => $invoice->getRouteKey()])
->assertActionHidden(DeleteAction::class);
expect($support->can('delete', $invoice))->toBeFalse();
});More patterns are in our guide to testing Filament with Pest.
Also Read: What Is Jev? TypeSafe AI's 'System One' Model Explained (Pricing, API & Use Cases)
FAQ
Does Filament have built-in roles and permissions?
No. Filament uses Laravel policies for authorisation. Packages like spatie/laravel-permission store roles and permissions, and your policies (or Filament Shield’s generated ones) check them.
Is Filament Shield compatible with Filament v5?
Yes. Shield 4.x supports Filament 4 and 5, Laravel 11.28 to 13, and spatie/laravel-permission 6, 7 and 8.
Why can users still see a resource after I removed their permission?
Either Spatie’s permission cache is stale (run php artisan permission:cache-reset), the model has no policy (Filament then allows access), or a Gate::before callback is granting access.
How do I hide a navigation item based on permission?
Implement viewAny() on the model’s policy. Filament hides the resource from navigation when viewAny() returns false. For custom pages, override canAccess().
Sources and further reading
- spatie/laravel-permission documentation
- laravel-permission upgrade notes (v7–v8)
- Filament Shield on GitHub
- Laravel authorisation and policies
- Filament resources overview: authorisation
Related on The Web Tier: Multi-tenant SaaS with Filament v5 · Audit logging in Filament
