Role and permission management for Filament panels using Spatie Laravel Permission.
- PHP 8.2+
- Laravel 11+
- Filament 4+
- Spatie Laravel Permission 6+
composer require waguilar33/filament-guardianIf you haven't already configured Spatie Laravel Permission:
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrateAdd the trait to your User model:
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}Register the plugin in your panel provider:
use Waguilar\FilamentGuardian\FilamentGuardianPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugins([
FilamentGuardianPlugin::make(),
]);
}This registers a RoleResource in your panel for managing roles and permissions.
The plugin uses Filament's built-in authGuard() configuration to isolate roles between panels:
public function panel(Panel $panel): Panel
{
return $panel
->authGuard('admin') // Filament's auth guard
->plugins([
FilamentGuardianPlugin::make(),
]);
}Each panel can use a different guard to isolate roles and permissions.
If using custom models (e.g., for UUIDs), configure them in Spatie:
// config/permission.php
return [
'models' => [
'role' => App\Models\Role::class,
'permission' => App\Models\Permission::class,
],
];The plugin reads model classes from Spatie's PermissionRegistrar.
For panels with tenancy, roles are automatically scoped to the current tenant.
This must be done before running the Spatie migration:
// config/permission.php
return [
'teams' => true,
'column_names' => [
'team_foreign_key' => 'tenant_id', // Match your tenant column
],
];Create a Role model that extends Spatie's Role and adds the tenant relationship. The relationship name must match your Filament panel's getTenantOwnershipRelationshipName() (defaults to the lowercase tenant model name).
// app/Models/Role.php
namespace App\Models;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Permission\Models\Role as SpatieRole;
class Role extends SpatieRole
{
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_id');
}
}Important: The relationship method name must match your Filament tenant configuration:
- If your tenant model is
Team, the method should beteam()- If your tenant model is
Tenant, the method should betenant()- If your tenant model is
Organization, the method should beorganization()Or configure a custom name in your panel:
->tenantOwnershipRelationshipName('tenant')
Register your custom model in Spatie's config:
// config/permission.php
return [
'models' => [
'role' => App\Models\Role::class,
],
];If you have both tenant and non-tenant panels, modify Spatie's published migration to make tenant_id nullable:
// model_has_permissions table
if ($teams) {
$table->uuid($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary(
[$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary'
);
$table->unique(
[$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_team_unique'
);
}
// model_has_roles table - same pattern
if ($teams) {
$table->uuid($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary(
[$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary'
);
$table->unique(
[$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_team_unique'
);
}use App\Models\Tenant;
public function panel(Panel $panel): Panel
{
return $panel
->authGuard('app')
->tenant(Tenant::class)
->plugins([
FilamentGuardianPlugin::make(),
]);
}| Panel Config | Scope |
|---|---|
| With tenancy | guard_name = X AND tenant_id = current_tenant |
| Without tenancy | guard_name = X AND tenant_id IS NULL |
Users with the super-admin role bypass all permission checks via Laravel's Gate.
// config/filament-guardian.php
'super_admin' => [
'enabled' => true,
'role_name' => 'Super Admin',
'intercept' => 'before', // 'before' or 'after'
],| Option | Description |
|---|---|
enabled |
Enable/disable super-admin feature globally |
role_name |
Name of the super-admin role |
intercept |
before bypasses ALL gates; after only grants if no explicit denial |
Override global config settings per panel using the fluent API:
FilamentGuardianPlugin::make()
->superAdmin() // Enable for this panel (default: from config)
->superAdminRoleName('Administrator') // Custom role name for this panel
->superAdminIntercept('after') // 'before' or 'after' for this panel| Method | Description |
|---|---|
superAdmin(bool) |
Enable/disable super-admin for this panel |
superAdminRoleName(string) |
Set custom role name (default: from config) |
superAdminIntercept(string) |
Set intercept mode: 'before' or 'after' (default: from config) |
For panels with tenancy, the super-admin role is automatically created when a tenant is created.
# Create the super-admin role for a panel
php artisan guardian:super-admin --panel=admin
# Create role and assign to a user
php artisan guardian:super-admin --panel=admin --email=admin@example.comAll methods accept an optional $panelId parameter. When omitted, they use the current Filament panel context. When provided, they use that panel's configuration.
use Waguilar\FilamentGuardian\Facades\Guardian;
// Check if super-admin is enabled
Guardian::isSuperAdminEnabled(); // Uses current panel context
Guardian::isSuperAdminEnabled('admin'); // Uses 'admin' panel config
// Get configuration values
Guardian::getSuperAdminRoleName(); // Uses current panel context
Guardian::getSuperAdminRoleName('admin'); // Uses 'admin' panel config
Guardian::getSuperAdminIntercept('admin'); // Get intercept mode for panel
// Create/get super-admin role (non-tenant panels only)
Guardian::createSuperAdminRole('admin');
Guardian::getSuperAdminRole('admin');
// Assign super-admin role to a user
Guardian::assignSuperAdminTo($user, 'admin');
// For tenant panels (role auto-created, set team context first)
setPermissionsTeamId($tenant->getKey());
$user->assignRole(Guardian::getSuperAdminRoleName());| Method | Description |
|---|---|
isSuperAdminEnabled(?string $panelId) |
Check if super-admin is enabled |
getSuperAdminRoleName(?string $panelId) |
Get the role name |
getSuperAdminIntercept(?string $panelId) |
Get intercept mode |
createSuperAdminRole(?string $panelId) |
Create role for non-tenant panel |
getSuperAdminRole(?string $panelId) |
Get role for non-tenant panel |
assignSuperAdminTo($user, ?string $panelId) |
Assign role to user |
createSuperAdminRoleForTenant($tenant, $guard, ?$panelId) |
Create role for tenant |
createUserUsing(Closure $callback) |
Register custom user creation logic |
createUser(string $userModel, array $data) |
Create a user (uses callback if registered) |
The super-admin role is protected from modification:
- Cannot be edited or deleted (throws
SuperAdminProtectedException) - Edit/delete actions are hidden in the RoleResource
use Waguilar\FilamentGuardian\Facades\Guardian;
Guardian::userIsSuperAdmin($user);
Guardian::isSuperAdminRole($role);Different panels can have different configurations. We recommend using different guards when panels have significantly different configurations, such as one with tenancy and one without. This ensures proper role isolation between panels.
// AdminPanelProvider.php - No tenancy, manages global roles
return $panel
->authGuard('admin')
->plugins([
FilamentGuardianPlugin::make(),
]);
// AppPanelProvider.php - With tenancy, roles scoped per tenant
return $panel
->authGuard('app')
->tenant(Tenant::class)
->plugins([
FilamentGuardianPlugin::make(),
]);When you have multiple panels with some having tenancy and others without, you may encounter an issue where the Users relation manager in non-tenant panels shows all users instead of only the relevant ones.
| Panel Type | User Filtering |
|---|---|
| With tenancy | Filament automatically scopes users via tenant ownership relationship |
| Without tenancy | No automatic scoping - tenant_id is NULL, no way to identify which users belong to this panel |
In non-tenant panels, there's no built-in mechanism to filter users because:
- There's no
tenant_idto filter by - Users don't have a
guardcolumn - The panel has no ownership relationship to users
You need to implement your own logic to distinguish users that belong to a non-tenant panel. Common approaches:
1. Add an identifier column to users table:
// Migration
$table->boolean('is_super_admin')->default(false);
// Or
$table->string('panel_type')->nullable(); // 'admin', 'app', etc.
// Or use email domain2. Create a scope on your User model:
// app/Models/User.php
public function scopeSuperAdmins(Builder $query): Builder
{
return $query->where('is_super_admin', true);
}
// Or filter by email domain
public function scopeAdminUsers(Builder $query): Builder
{
return $query->where('email', 'like', '%@yourcompany.com');
}3. Apply the scope in your published UserResource:
// app/Filament/Admin/Resources/UserResource.php
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->superAdmins();
}4. Override the UsersRelationManager to filter the attach dropdown:
// app/Filament/Admin/Resources/Roles/RelationManagers/UsersRelationManager.php
use Waguilar\FilamentGuardian\Base\Roles\Tables\BaseUsersTable;
class UsersRelationManager extends BaseUsersRelationManager
{
public function table(Table $table): Table
{
return BaseUsersTable::configure($table)
->modifyQueryUsing(fn (Builder $query) => $query->superAdmins())
->headerActions([
AttachAction::make()
->recordSelectOptionsQuery(fn (Builder $query) => $query->superAdmins())
->preloadRecordSelect()
->multiple(),
]);
}
}Note:
modifyQueryUsingfilters users shown in the relation manager table, whilerecordSelectOptionsQueryfilters users shown in the attach dropdown. Both are needed for complete filtering.
All configurable values follow this priority:
- Fluent API - Per-panel in panel provider
- Config file - Global defaults
- Translation file - Fallback labels
- Hardcoded default - Package defaults
Syncs permissions to the database based on your Filament resources, pages, widgets, and custom permissions defined in config.
This command should be part of your deployment process, typically run after migrations. It ensures all permissions exist in the database for your current codebase - new resources get their permissions created, and existing permissions remain untouched.
# Run after migrations in your deployment script
php artisan migrate
php artisan guardian:sync# Sync specific panels only
php artisan guardian:sync --panel=admin --panel=app
# Verbose output to see each permission being created
php artisan guardian:sync -vWhat it creates:
| Type | Permissions Created |
|---|---|
| Resources | ViewAny:User, Create:User, Update:User, Delete:User, etc. |
| Pages | Access:Dashboard, Access:Settings, etc. |
| Widgets | View:StatsOverview, View:RevenueChart, etc. |
| Custom | Whatever you define in config/filament-guardian.php |
Example deployment script:
php artisan down
php artisan migrate --force
php artisan guardian:sync
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan upGenerates Laravel policy classes for your Filament resources. Policies authorize actions based on the permissions synced by guardian:sync.
Run this during development when you add new resources or need to regenerate policies.
# Interactive mode - prompts for panel and resources
php artisan guardian:policies
# Generate for all resources in a panel
php artisan guardian:policies --panel=admin --all-resources
# Generate for all panels at once
php artisan guardian:policies --all-panels
# Regenerate existing policies (overwrites)
php artisan guardian:policies --panel=admin --all-resources --forceGenerated policy example:
// app/Policies/UserPolicy.php
public function viewAny(User $user): bool
{
return $user->can('ViewAny:User');
}
public function update(User $user, User $model): bool
{
return $user->can('Update:User');
}Creates a user account. Essential for first deployment when your database has no users and you need an initial admin account to access the panel.
# Interactive mode - prompts for name, email, password
php artisan guardian:create-user
# Non-interactive (useful for CI/CD or scripts)
php artisan guardian:create-user --name="Admin" --email="admin@example.com" --password="secret"Customizing user creation:
If your User model has additional required fields (e.g., is_super_admin, tenant_id), register a callback in your AppServiceProvider:
use Waguilar\FilamentGuardian\Facades\Guardian;
use Illuminate\Support\Facades\Hash;
public function boot(): void
{
Guardian::createUserUsing(function (string $userModel, array $data) {
return $userModel::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'is_super_admin' => true, // custom field
]);
});
}First deployment example:
php artisan migrate
php artisan guardian:sync
php artisan guardian:create-user --name="Admin" --email="admin@example.com" --password="changeme"
php artisan guardian:super-admin --panel=admin --email="admin@example.com"Creates the super-admin role and optionally assigns it to a user. Required for non-tenant panels where you want a user to bypass all permission checks.
For tenant panels, the super-admin role is automatically created when tenants are created.
# Create the super-admin role for a panel
php artisan guardian:super-admin --panel=admin
# Create role AND assign to existing user
php artisan guardian:super-admin --panel=admin --email=admin@example.comWhen to use:
| Panel Type | Super-Admin Role Creation |
|---|---|
| Non-tenant | Run this command manually |
| Tenant | Automatic when tenant is created |
php artisan vendor:publish --tag="filament-guardian-config"php artisan vendor:publish --tag="filament-guardian-translations"The RoleResource provides a tabbed interface for managing permissions.
FilamentGuardianPlugin::make()
->navigationLabel('Roles')
->navigationIcon('heroicon-o-shield-check')
->activeNavigationIcon('heroicon-s-shield-check')
->navigationGroup('Settings')
->navigationSort(10)
->navigationBadge(fn () => Role::count())
->navigationBadgeColor('success')
->navigationParentItem('settings')
->cluster(\App\Filament\Clusters\Settings::class)
->registerNavigation(true)Place the Role resource inside a cluster so it appears under that cluster's sub-navigation.
FilamentGuardianPlugin::make()
->cluster(\App\Filament\Clusters\Settings::class)Closures are supported:
FilamentGuardianPlugin::make()
->cluster(fn () => auth()->user()->isAdmin()
? \App\Filament\Clusters\Settings::class
: null
)Or via config:
// config/filament-guardian.php
'role_resource' => [
'navigation' => [
'cluster' => \App\Filament\Clusters\Settings::class,
],
],Defaults to null (no cluster).
FilamentGuardianPlugin::make()
->modelLabel('Role')
->pluralModelLabel('Roles')
->slug('access-roles') // URL: /admin/access-rolesFilamentGuardianPlugin::make()
->roleSectionLabel('Role Information')
->roleSectionDescription('Configure basic role settings')
->roleSectionIcon(Heroicon::OutlinedIdentification)
->roleSectionAside()
->permissionsSectionLabel('Access Control')
->permissionsSectionDescription('Select which actions this role can perform')
->permissionsSectionIcon(Heroicon::OutlinedLockClosed)Pass false to remove an icon:
FilamentGuardianPlugin::make()
->roleSectionIcon(false)All methods accept closures for dynamic values.
FilamentGuardianPlugin::make()
->showResourcesTab() // default: true
->showPagesTab() // default: true
->showWidgetsTab() // default: true
->showCustomPermissionsTab() // default: true
// Or hide specific tabs
->hidePagesTab()
->hideWidgetsTab()use Filament\Support\Icons\Heroicon;
FilamentGuardianPlugin::make()
->resourcesTabIcon(Heroicon::OutlinedRectangleStack)
->pagesTabIcon(Heroicon::OutlinedDocument)
->widgetsTabIcon(Heroicon::OutlinedPresentationChartBar)
->customTabIcon(Heroicon::OutlinedWrench)Default icons:
- Resources:
Heroicon::OutlinedSquare3Stack3d - Pages:
Heroicon::OutlinedDocumentText - Widgets:
Heroicon::OutlinedChartBar - Custom:
Heroicon::OutlinedCog6Tooth
use Filament\Support\Enums\GridDirection;
FilamentGuardianPlugin::make()
// Global defaults for all tabs
->permissionCheckboxColumns(3) // default: 4
->permissionCheckboxGridDirection(GridDirection::Row) // default: Column
// Override per tab
->resourceCheckboxColumns(3)
->resourceCheckboxGridDirection(GridDirection::Column)
->pageCheckboxColumns(2)
->pageCheckboxGridDirection(GridDirection::Row)
->widgetCheckboxColumns(2)
->widgetCheckboxGridDirection(GridDirection::Row)
->customCheckboxColumns(1)
->customCheckboxGridDirection(GridDirection::Row)Supports responsive arrays:
FilamentGuardianPlugin::make()
->permissionCheckboxColumns([
'sm' => 2,
'md' => 3,
'lg' => 4,
])FilamentGuardianPlugin::make()
->collapseResourceSections() // Start collapsed
->resourceSectionColumns(2) // Grid layout
->showResourceSectionIcon() // Show navigation iconFilamentGuardianPlugin::make()
->searchIcon(Heroicon::OutlinedMagnifyingGlass)
->permissionAssignedIcon(Heroicon::OutlinedCheckCircle)Pass false to hide the icon.
The permissions form includes a "Select All" toggle to quickly select or deselect all permissions.
FilamentGuardianPlugin::make()
->selectAllOnIcon(Heroicon::OutlinedCheckCircle) // default
->selectAllOffIcon(Heroicon::OutlinedXCircle) // defaultOr via config:
// config/filament-guardian.php
'role_resource' => [
'select_all_toggle' => [
'on_icon' => 'heroicon-o-check',
'off_icon' => 'heroicon-o-x-mark',
],
],Pass false to hide the icons.
| Type | Label Source |
|---|---|
| Resources | Resource::getPluralModelLabel() |
| Pages | Page::getNavigationLabel() |
| Widgets | Widget::getHeading() or humanized class name |
| Custom | Translation file or permission key |
The Role resource includes a Users relation manager out of the box, allowing you to attach/detach users directly from a role.
- Displays users assigned to the role with name and email columns
- Attach action filters out users who already have the super-admin role
- Supports bulk attach and detach operations
When you publish the Role resource, a UsersRelationManager stub is included. You can customize it by:
Custom table configuration:
// App\Filament\Resources\Roles\Tables\UsersTable.php
use Waguilar\FilamentGuardian\Base\Roles\Tables\BaseUsersTable;
class UsersTable extends BaseUsersTable
{
public static function configure(Table $table): Table
{
return parent::configure($table)
->modifyQueryUsing(fn ($query) => $query->where('active', true));
}
}Custom relation manager:
// App\Filament\Resources\Roles\RelationManagers\UsersRelationManager.php
use Waguilar\FilamentGuardian\Base\Roles\RelationManagers\BaseUsersRelationManager;
class UsersRelationManager extends BaseUsersRelationManager
{
protected static BackedEnum|string|null $icon = 'heroicon-o-user-group';
public function table(Table $table): Table
{
return UsersTable::configure($table);
}
public static function getTitle(Model $ownerRecord, string $pageClass): string
{
return 'Team Members';
}
}The package provides a table action for managing user-specific permissions directly, separate from role-based permissions.
use Waguilar\FilamentGuardian\Actions\ManageUserPermissionsAction;
public function table(Table $table): Table
{
return $table
->columns([...])
->recordActions([
ViewAction::make(),
ManageUserPermissionsAction::make(),
]);
}The action opens a slide-over modal showing only permissions that can be assigned directly to the user:
- Role permissions are excluded - Permissions inherited from roles are not shown (they're managed via roles)
- Warning alert - Shows how many permissions the user has from roles
- Super-admin users - The action is hidden entirely for super-admin users
- Automatic cleanup - When saved, direct permissions that are now also granted via roles are automatically removed
The action uses standard Filament action methods:
ManageUserPermissionsAction::make()
->label('Custom Label')
->icon('heroicon-o-key')
->color('primary')Define permissions that don't map to resources, pages, or widgets:
// config/filament-guardian.php
'custom_permissions' => [
'impersonate-user' => 'Impersonate User',
'export-orders' => 'Export Orders',
'manage-settings' => 'Manage Settings',
],The key is the permission name stored in the database, the value is the display label. For multi-language support, add translations under the custom key in the lang file.
// config/filament-guardian.php
'permission_key' => [
'separator' => ':',
'case' => 'pascal',
],| Case | Example |
|---|---|
pascal |
ViewAny:User |
camel |
viewAny:user |
snake |
view_any:user |
kebab |
view-any:user |
'policies' => [
'path' => app_path('Policies'),
'merge' => true,
'methods' => [
'viewAny', 'view', 'create', 'update', 'delete',
'restore', 'forceDelete', 'deleteAny', 'restoreAny',
'forceDeleteAny', 'replicate', 'reorder',
],
'single_parameter_methods' => [
'viewAny', 'create', 'deleteAny', 'restoreAny',
'forceDeleteAny', 'reorder',
],
],'resources' => [
'subject' => 'model',
'manage' => [
App\Filament\Resources\Blog\CategoryResource::class => [
'subject' => 'BlogCategory',
],
App\Filament\Resources\RoleResource::class => [
'methods' => ['viewAny', 'view', 'create'],
],
],
'exclude' => [
App\Filament\Resources\SettingsResource::class,
],
],php artisan filament-guardian:publish-role-resource {panel?}Published resources extend base classes from the package. You only override what you actually need - the base classes handle all the standard logic.
| Base Class | Purpose |
|---|---|
BaseRoleResource |
Resource definition, navigation, model binding |
BaseListRoles |
List page with create action |
BaseCreateRole |
Create page with permission sync |
BaseEditRole |
Edit page with permission hydration and sync |
BaseViewRole |
View page with header actions |
BaseRoleForm |
Form schema with tabbed permissions |
BaseRoleInfolist |
Infolist schema for view page |
BaseRolesTable |
Table columns and record actions |
namespace App\Filament\Admin\Resources\Roles\Tables;
use Filament\Actions\ViewAction;
use Filament\Tables\Table;
use Waguilar\FilamentGuardian\Base\Roles\Tables\BaseRolesTable;
class RolesTable extends BaseRolesTable
{
public static function configure(Table $table): Table
{
return parent::configure($table)
->recordActions([
ViewAction::make(),
]);
}
}namespace App\Filament\Admin\Resources\Roles\Pages;
use App\Filament\Admin\Resources\Roles\RoleResource;
use Filament\Actions\ActionGroup;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Waguilar\FilamentGuardian\Base\Roles\Pages\BaseViewRole;
class ViewRole extends BaseViewRole
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
ActionGroup::make([
EditAction::make(),
DeleteAction::make(),
]),
];
}
}use Waguilar\FilamentGuardian\Facades\Guardian;
TextInput::make('name')
->required()
->unique(
ignoreRecord: true,
modifyRuleUsing: Guardian::uniqueRoleValidation(),
)Filament Guardian ships with English and Spanish translations. To customize labels, publish the translation files:
php artisan vendor:publish --tag=filament-guardian-translationsThis publishes to lang/vendor/filament-guardian/{locale}/filament-guardian.php.
The translation file includes:
roles.*- Role resource labels, sections, tabs, and messagesusers.permissions.*- User direct permissions modal labelsactions.*- Permission action labels (viewAny, create, update, etc.)custom.*- Custom permission label overridessuper_admin.*- Super admin role messages
composer test
composer analyse
composer lintPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.