Free·

Pragmatic Roles and Permissions in FilamentPHP

Learn how to implement a clean and maintainable roles and permissions system in FilamentPHP using Laravel Ladder, with practical examples for multi-role restaurant management.

The Problem

When building a FilamentPHP application, you often need to manage different user roles with varying levels of access. In a restaurant management system, for example, you might have administrators who can do everything, office staff who manage orders, kitchen staff who view kitchen-related data, and dining room staff who need access to orders and some kitchen information.

Laravel's native Policies and Gates are powerful, but at times, we may desire a solution where we may change permissions at database level.

The Solution

From the popular packages available, there are some who tackle a complex approach for very granular permissions, but that may be overkill for many applications. We need something simple, maintainable, and easy to understand.

The solution involves three key components working together: Laravel Ladder for permission management, PHP Enums for type-safe role definitions, and Filament's built-in integration with Laravel's authorization system.

Check Ladder documentation at: https://github.com/eneadm/ladder

Step 1: Define Roles as Enums

First, we create a clean, type-safe Role enum that implements Filament's HasLabel and HasColor interfaces. This gives us autocomplete, validation, and beautiful badge displays:

app/Enums/Role.php
<?php

namespace App\Enums;

use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
use Illuminate\Contracts\Support\Htmlable;

enum Role: string implements HasLabel, HasColor
{
    case Admin = 'admin';
    case Office = 'office';
    case Kitchen = 'kitchen';
    case DiningRoom = 'dining_room';

    public function getLabel(): string|Htmlable|null
    {
        return match ($this) {
            self::Admin => 'Administrator',
            self::Office => 'Office Area',
            self::Kitchen => 'Kitchen Area',
            self::DiningRoom => 'Dining Room Area',
        };
    }

    public function getColor(): string|array|null
    {
        return match ($this) {
            self::Admin => 'danger',
            self::Office => 'info',
            self::Kitchen => 'success',
            self::DiningRoom => 'warning',
        };
    }
}

Step 2: Configure Permissions with Laravel Ladder

Next, we use Laravel Ladder to define what each role can do. The beauty of Ladder is its simple, declarative API. Create a service provider to configure your permissions:

app/Providers/LadderServiceProvider.php
<?php

namespace App\Providers;

use App\Enums\Role;
use Illuminate\Support\ServiceProvider;
use Ladder\Ladder;

class LadderServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configurePermissions();
    }

    /**
     * Configure the permissions that are available within the application.
     */
    protected function configurePermissions(): void
    {
        Ladder::role(Role::Admin->value, 'Administrator', [
            '*',
        ])->description('Administrator users can perform any action.');

        Ladder::role(Role::DiningRoom->value, 'Dining Room Area', [
            'orders:*',
            'kitchen:read',
        ])->description('Editor users have the ability to read, create, and update.');
    }
}

The permission syntax is intuitive: * means everything, orders:* means all order operations, and kitchen:read means read-only access to kitchen data.

Step 3: Add the Roles feature to Your User Model

Simply add the HasRoles trait from Ladder to your User model:

app/Models/User.php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Ladder\HasRoles;

class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasFactory, Notifiable;
    use HasRoles;

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

Step 4: User Roles management

Within your Filament user resource, you can now easily manage user roles. We'll have a Select field that allows assigning multiple roles to a user, using our Role enum for options:

app/Filament/Resources/Users/Schemas/UserForm.php
<?php

namespace App\Filament\Resources\Users\Schemas;

use App\Enums\Role;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Operation;

class UserForm
{
    public static function configure(Schema $schema): Schema
    {
        return $schema
            ->components([
                TextInput::make('name')
                    ->readOnly(),
                TextInput::make('email')
                    ->label('Email address')
                    ->readOnly(),
                Select::make('roles')
                    ->hiddenOn(Operation::Create)
                    ->multiple()
                    ->options(Role::class),
            ]);
    }
}

The options(Role::class) automatically populates the select field with all our enum values:

Step 5: Roles persistence

We'll take care of persisting the roles when editing, and loading them as well.

app/Filament/Resources/Users/Pages/EditUser.php
<?php

namespace App\Filament\Resources\Users\Pages;

use App\Enums\Role;
use App\Filament\Resources\Users\UserResource;
use App\Models\User;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Ladder\Models\UserRole;

class EditUser extends EditRecord
{
    protected static string $resource = UserResource::class;

    protected function getHeaderActions(): array
    {
        return [
            ViewAction::make(),
            DeleteAction::make(),
        ];
    }

    protected function mutateFormDataBeforeFill(array $data): array
    {
        /** @var User $this->record */
        $userRoles = UserRole::query()
            ->where('user_id', $this->record->id)
            ->pluck('role')
            ->toArray();

        $data['roles'] = $userRoles;

        return $data;
    }

    public function handleRecordUpdate(Model $record, array $data): Model
    {
        $rolesToPersist = fluent($data)
            ->collect('roles');

        // Delete Roles that are not in the form data
        /** @var User $record */
        UserRole::query()
            ->where('user_id', $record->id)
            ->whereNotIn('role', $rolesToPersist->toArray())
            ->delete();

        // Add new roles, ignoring the ones that already exist
        $rolesToPersist
            ->each(fn(Role $role) => $record->roles()->firstOrCreate([
                'role' => $role,
            ]));

        return $record;
    }
}

Step 6: Display Roles Beautifully in the View

Create an infolist to display user information with their assigned roles as colored badges:

app/Filament/Resources/Users/Schemas/UserInfolist.php
<?php

namespace App\Filament\Resources\Users\Schemas;

use App\Enums\Role;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;

class UserInfolist
{
    public static function configure(Schema $schema): Schema
    {
        return $schema
            ->columns(1)
            ->components([
                Section::make('User Information')
                    ->description('Basic user details and contact information')
                    ->icon('heroicon-o-user')
                    ->columns(2)
                    ->schema([
                        TextEntry::make('name')
                            ->label('Full Name')
                            ->icon('heroicon-m-user')
                            ->weight('medium')
                            ->copyable()
                            ->size('lg'),
                        TextEntry::make('email')
                            ->label('Email Address')
                            ->icon('heroicon-m-envelope')
                            ->copyable()
                            ->copyMessage('Email address copied!')
                            ->color('gray'),
                        TextEntry::make('roles')
                            ->label('Assigned Roles')
                            ->formatStateUsing(fn ($state): string => Role::tryFrom($state->role)->getLabel())
                            ->badge()
                            // We have to calculate the color because this comes from a relationship, not an Enum cast
                            ->color(fn ($state): string => Role::tryFrom($state->role)?->getColor())
                            ->icon('heroicon-m-shield-check'),
                        IconEntry::make('email_verified_at')
                            ->label('Email Verified')
                            ->boolean()
                            ->trueIcon('heroicon-o-check-badge')
                            ->falseIcon('heroicon-o-x-circle')
                            ->trueColor('success')
                            ->falseColor('danger'),
                    ]),

                Section::make('Account Timestamps')
                    ->description('Account creation and modification history')
                    ->icon('heroicon-o-clock')
                    ->columns()
                    ->schema([
                        TextEntry::make('email_verified_at')
                            ->label('Email Verified At')
                            ->placeholder('Not verified')
                            ->icon('heroicon-m-envelope-open')
                            ->color(fn ($state) => $state ? 'success' : 'warning'),
                        TextEntry::make('updated_at')
                            ->columnStart(1)
                            ->label('Last Updated')
                            ->icon('heroicon-m-pencil-square')
                            ->since()
                            ->color('gray')
                            ->placeholder('Never updated'),
                        TextEntry::make('created_at')
                            ->label('Account Created')
                            ->icon('heroicon-m-plus-circle')
                            ->since()
                            ->placeholder('N/A'),
                    ]),
            ]);
    }
}

Step 7: Display Roles Beautifully in the Table

Let's use convenient badges to show user roles in the users table:

app/Filament/Resources/Users/Tables/UsersTable.php
<?php

namespace App\Filament\Resources\Users\Tables;

use App\Enums\Role;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;

class UsersTable
{
    public static function configure(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('name')
                    ->searchable(),
                TextColumn::make('email')
                    ->label('Email address')
                    ->searchable(),
                TextColumn::make('roles')
                    ->badge()
                    // We have to calculate the color because this comes from a relationship, not an Enum cast
                    ->color(fn ($state): string => Role::tryFrom($state->role)?->getColor())
                    ->formatStateUsing(fn ($state): string => Role::tryFrom($state->role)->getLabel()),
                TextColumn::make('email_verified_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
                TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
                TextColumn::make('updated_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                //
            ])
            ->recordActions([
                ViewAction::make(),
                EditAction::make(),
            ]);
    }
}

With this setup, we can have a sensible extensible roles and permissions system in our FilamentPHP application. We simply just add a role or more to the user, and the permissions are automatically applied based on our Ladder configuration. It's more than enough for plenty of applications, and it's easy to maintain and understand.