althinect/filament-spatie-roles-permissions
Filament plugin built on Spatie's Laravel Permission package that provides admin resources for managing roles and permissions, automatic permission and policy generation, and support for teams. Requires Spatie migrations and the HasRoles trait on your User model.
1. Install and wire up (5 minutes)
composer require althinect/filament-spatie-roles-permissions
# Spatie's own migrations are a prerequisite — this package ships none
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
# Package config (note: the README uses two different filenames for it)
php artisan vendor:publish --tag="filament-spatie-roles-permissions-config"
php artisan vendor:publish --tag="filament-spatie-roles-permissions-translations" --force
2. Register the plugin in your panel provider:
use Althinect\FilamentSpatieRolesPermissions\FilamentSpatieRolesPermissionsPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->id('admin')
// ...
->plugin(FilamentSpatieRolesPermissionsPlugin::make());
}
3. Add the trait to App\Models\User:
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}
4. Where to look first
config/filament-spatie-roles-permissions-config.php — this is where nearly every customization lives (guard visibility, column span, modal behaviour, user name column, permission generation targets).Super Admin or Editor there first, then assign it to yourself to confirm the flow before touching policies.UserResource — add a roles select so admins can actually assign what you just created:Forms\Components\Select::make('roles')
->relationship('roles', 'name')
->multiple()
->preload()
->searchable(),
First real use case: create permissions from your models automatically, then gate a resource with them.
php artisan permissions:sync
If you have a Post model this generates view-any Post, view Post, create Post, update Post, delete Post, restore Post, force-delete Post, replicate Post, reorder Post. Assign those to a role in the Permissions UI, and the RBAC loop is closed.
The chicken-and-egg problem: you need a role to see the Roles resource, but the resource is what creates roles. Seed it instead of clicking:
// database/seeders/RoleSeeder.php
$admin = Role::create(['name' => 'Super Admin', 'guard_name' => 'web']);
$admin->syncPermissions(Permission::all());
User::where('email', '[email protected]')->first()->assignRole($admin);
Rather than granting every permission explicitly, use the shipped trait plus a Gate::before:
use Althinect\FilamentSpatieRolesPermissions\Concerns\HasSuperAdmin;
class User extends Authenticatable
{
use HasRoles, HasSuperAdmin;
}
// AppServiceProvider::boot() (Laravel 11+ — no AuthServiceProvider)
Gate::before(function (User $user, string $ability) {
return $user->isSuperAdmin() ? true : null;
});
Return null, never false, for non-super-admins — false short-circuits the whole gate chain and breaks everyone else.
The plugin's resources go through Laravel policies. Author two policies and register them:
// AppServiceProvider::boot()
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
Gate::policy(Role::class, \App\Policies\RolePolicy::class);
Gate::policy(Permission::class, \App\Policies\PermissionPolicy::class);
class RolePolicy
{
public function viewAny(User $user): bool
{
return $user->can('view-any Role');
}
// view, create, update, delete...
}
This is the idiomatic way to remove them from the nav — don't override shouldRegisterNavigation().
php artisan permissions:sync -P # create missing policies, leave existing alone
php artisan permissions:sync -O # overwrite existing policy classes
php artisan permissions:sync -COPY -Y # policies + overwrite + yes-to-all prompts
-COPY without -Y will prompt per file. Reserve it for scaffolding new projects; on a live codebase it silently wrecks hand-written policy logic (before(), ownership checks, viewAny overrides).
Any panel that isn't the web guard needs it declared in config/auth.php, and you must keep guard_name consistent between roles, permissions, and the model. In the UI, toggle the guard field via should_show_guard in the package config so admins don't create roles on the wrong guard.
This is the most fragile area. Sequence:
config/permission.php → 'teams' => true, correct team_model and team_foreign_key.Spatie\Permission\Models\Role (and optionally Permission), point the config at your subclass, and add a team() BelongsTo on both.use Althinect\FilamentSpatieRolesPermissions\Middleware\SyncSpatiePermissionsWithFilamentTenants;
$panel->tenantMiddleware([
SyncSpatiePermissionsWithFilamentTenants::class,
], isPersistent: true)
relationship('roles', 'name') select will not write team_id into the pivot. Use the documented override:Select::make('roles')
->relationship(name: 'roles', titleAttribute: 'name')
->saveRelationshipsUsing(function (Model $record, $state) {
$record->roles()->syncWithPivotValues($state, [
config('permission.column_names.team_foreign_key') => getPermissionsTeamId(),
]);
})
->multiple()
->preload()
->searchable(),
Skipping step 4 gives you the classic bug: role assigned successfully, then the user has no permissions because the pivot row belongs to team null.
public static function canViewAny(): bool
{
return auth()->user()->can('view-any Post');
}
// Blade
@can('update Post')
<x-filament::button ... />
@endcan
Since the generated names are space-separated and capitalised (view-any Post), keep a constants class or enum so you don't fat-finger strings across resources:
final class Permissions
{
public const POST_VIEW_ANY = 'view-any Post';
}
| Config key | Effect |
|---|---|
should_show_guard |
Hide the guard select when you only have one guard |
column_span / full-width |
Restores v3 full-width layout under custom themes |
| redirect-to-index | Send users to the list after save instead of the view page |
| simple modal resource | Lighter create/edit modals for roles/permissions |
| remove empty state actions | Hide the "create" CTA on empty tables |
user_name_column |
Column used for user record titles in relationship pickers |
Publishing config on update is destructive. The README explicitly tells you to run vendor:publish --tag="filament-spatie-roles-permissions-config" --force after composer update — and warns it overrides your settings. In real projects: back up or diff the config, or don't publish at all until you actually need to change something. Committing the published config to VCS is your safety net; the --force flow will otherwise wipe guard/column settings in a single CI run.
--clean and --hard break role assignments. permissions:sync -C deletes all permissions (dropping their role_has_permissions pivot rows). -H truncates and resets auto-increment. Both are fine locally, both are an outage in production. Never put permissions:sync in a deploy pipeline without -P being idempotent-safe and with the clean flags omitted.
Spatie caches aggressively. After seeders, config changes, or direct DB edits, permissions appear not to exist. Fix:
php artisan permission:cache-reset
# or, since Filament caches too
php artisan optimize:clear
Wire permission:cache-reset into your deploy script.
Guard mismatch is the #1 silent failure. Permission created on web, user authenticating on admin guard → can() always false, no error. Check guard_name in roles and permissions tables before debugging anything else.
The permissions UI shows all permissions. A role editor can grant themselves anything. Restrict the Permissions resource with a policy, or at minimum gate PermissionPolicy::create/update behind the Super Admin check.
Permission names are generated from model basenames, not table names. A BlogPost model yields view-any BlogPost. Rename a model and you now have orphaned permissions plus new ones — old role assignments quietly stop matching. Diff the permission list after any model rename.
permissions:sync won't delete stale entries for removed models. Combine with -C (in dev only) or prune manually.
Scope mismatch vs Filament Shield. This package manages roles/permissions and generates model policies — it does not generate per-page or per-widget permissions the way Shield does. If you need "can this user see the Dashboard page widget X," plan to hand-roll canView() on those classes or reach for Shield.
Laravel 11+ has no AuthServiceProvider. Both the Gate::policy() registration and the Gate::before() super-admin hook go into AppServiceProvider::boot(). Tutorials referencing AuthServiceProvider are stale.
Resource discoverability. Resources are registered by the plugin, so php artisan make:filament-resource won't show them and you can't just edit them in place. To customize heavily (extra fields, custom table columns), extend the shipped resource classes and register your subclasses, or set the cluster/navigation group via the config — cluster support was added explicitly in v2.2.25.
Translations need --force and a locale set. If the UI renders raw keys (filament-spatie.role), you published without --force or you're missing the package's lang files for your locale.
Debugging checklist when a user "has the role but still can't do anything": (1) permission:cache-reset; (2) check guard_name; (3) for teams, verify the pivot's team_id matches getPermissionsTeamId(); (4) confirm Gate::before returns null not false; (5) confirm the permission string matches exactly, including the space and capitalisation.
How can I help you explore Laravel packages today?