Search

Roles and Permissions in Filament with spatie/laravel-permission

Roles and Permissions in Filament with spatie/laravel-permission

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-permission v8 (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 pageviewAny()
Open a view pageview()
Show the Create button, open the create pagecreate()
Show Edit, save a recordupdate()
Delete / bulk deletedelete() / deleteAny()
Restore / force deleterestore(), forceDelete() and the *Any variants
Reorder rowsreorder()

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 migrate

Add 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=Invoice
namespace 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-admin

Shield 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 policiesFilament Shield
Setup timeHoursMinutes
Business rules in policiesNaturalPossible (edit the generated policies)
Page and widget permissionsWrite canAccess() yourselfGenerated
Permission namingYoursShield’s convention
Upgrade surfaceOnly SpatieSpatie + Shield
Best forFew resources, complex rulesMany 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-reset

Eager 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

Related on The Web Tier: Multi-tenant SaaS with Filament v5 · Audit logging in Filament

TWT Staff

TWT Staff

Writes about Programming, tech news, discuss programming topics for web developers (and Web designers), and talks about SEO tools and techniques

Your experience on this site will be improved by allowing cookies Cookie Policy