A complete role and permission management plugin for Filament, built on Spatie Laravel Permission. Drop it into any panel and get a fully-featured RoleResource with a tabbed permission UI out of the box — no boilerplate required.
Roles and permissions are automatically scoped to each panel's auth guard, so multi-panel apps stay isolated without any extra configuration. Multi-tenancy is supported too, with roles scoped per tenant when your panel uses it. A built-in super-admin role bypasses all permission checks, and direct per-user permission overrides let you go beyond what roles alone can express.
Works with Filament v4 and v5.
- 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"Important: If you plan to use multi-tenancy, enable teams in
config/permission.phpbefore running the migration — this setting affects the database schema and cannot be changed after the fact.// config/permission.php 'teams' => true, 'column_names' => [ 'team_foreign_key' => 'tenant_id', // match your tenant's primary key column ],
php artisan migrateAdd the trait to your User model:
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}Spatie ships with its own Role and Permission models out of the box — no extra setup needed for most apps. If you need to customize them (for example, to use UUIDs or add extra relationships), create your own models that extend Spatie's and point to them in the config:
// config/permission.php
'models' => [
'role' => App\Models\Role::class,
'permission' => App\Models\Permission::class,
],The plugin reads these from Spatie's registrar automatically — no additional configuration needed. For anything beyond this — custom primary keys, extra columns, or complex relationships — refer to the Spatie documentation for the full picture.
Register the plugin in your panel provider:
use Waguilar\FilamentGuardian\FilamentGuardianPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugins([
FilamentGuardianPlugin::make(),
]);
}This is the minimum setup. It registers a RoleResource in your panel where you can create roles, assign permissions to them, and attach users. Roles are scoped to whatever auth guard the panel uses — if you don't configure one explicitly, Filament falls back to the application's default guard.
For single-panel apps that's usually fine. For multi-panel apps — or any time you want roles to be isolated per panel — you'll want to set an explicit guard as described in the next section.
This is optional. If you don't configure a guard, Filament defaults to the web guard and everything works as expected for single-panel apps.
Guard configuration becomes relevant when you have multiple panels and want their roles to be completely separate. The plugin scopes roles and permissions to whichever guard the panel uses, so two panels with different guards will have independent role sets.
To set an explicit guard, add authGuard() to your panel provider:
// AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->authGuard('admin') // <-- roles scoped to this guard
->plugins([
FilamentGuardianPlugin::make(),
]);
}// AppPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->authGuard('web') // <-- completely separate set of roles
->plugins([
FilamentGuardianPlugin::make(),
]);
}With this setup, roles created in the admin panel are invisible to the app panel and vice versa. If two panels share the same guard, they share the same roles — which is sometimes intentional, but usually not what you want when the panels serve different audiences.
Important: Any custom guard must be registered in
config/auth.phpbefore it can be used. Laravel will throw an error if you reference a guard that isn't defined there.// config/auth.php 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'admin' => [ 'driver' => 'session', 'provider' => 'users', ], ],
Filament has built-in multi-tenancy support that automatically scopes the panel to the current tenant — resource queries, record resolution, and new record associations are all handled by Filament itself. The plugin integrates with this by reading the active tenant from Filament's context and using Spatie's teams feature to scope roles and permissions to that tenant accordingly.
What this means in practice: once you complete the setup below, role management in tenant panels just works — roles created within a tenant are only visible within that tenant.
The Role model needs a relationship back to your tenant. If you already have a custom Role model from the setup above, add the relationship to it. If you're starting fresh, create one that extends Spatie's:
// 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');
}
}The relationship method name must match your Filament panel's tenant ownership relationship — by default this is the lowercase version of your tenant model name:
| Tenant model | Method name |
|---|---|
Tenant |
tenant() |
Team |
team() |
Organization |
organization() |
If you need a custom name, configure it in your panel: ->tenantOwnershipRelationshipName('workspace').
If you created a new model, register it in Spatie's config:
// config/permission.php
return [
'models' => [
'role' => App\Models\Role::class,
],
];This step is only required when you have both tenant and non-tenant panels in a single application.
Since Laravel runs all panels against the same database, non-tenant panels need to store roles with no tenant (tenant_id = NULL). But when Spatie's teams feature is enabled, the team_foreign_key column on the model_has_permissions and model_has_roles pivot tables is created as NOT NULL — which means inserting a role with no tenant fails.
There's also a primary key concern: both pivot tables include team_foreign_key in their composite primary key by default. Keeping a nullable column in a primary key causes inconsistent behavior across databases (MySQL treats two NULL values as distinct, others don't). The fix is to remove team_foreign_key from the primary key and use a unique constraint instead, which handles both the tenant and non-tenant cases cleanly.
This is a key difference from other role management plugins that only support a single panel configuration.
Publish and run the migration included with this package:
php artisan vendor:publish --tag="filament-guardian-multitenancy"
php artisan migrateNote: The published migration uses
unsignedBigIntegerby default, matching Spatie's default integer IDs. If your application uses UUID primary keys, open the published migration and replace->unsignedBigInteger()with->uuid()before running it.
Important: If you already have data in these tables, the migration is safe to run as long as your existing records don't violate the new unique constraint. On large production tables, consider running it during a maintenance window.
use App\Models\Tenant;
public function panel(Panel $panel): Panel
{
return $panel
->authGuard('app')
->tenant(Tenant::class)
->plugins([
FilamentGuardianPlugin::make(),
]);
}Once everything is set up, the plugin automatically filters roles and permissions based on two things: the panel's auth guard and the current tenant context.
When a user opens the Roles section in a panel, the plugin queries only the roles that belong to that panel's guard and tenant combination. This means:
- A role created in the admin panel (no tenancy,
guard = admin) is invisible to the app panel and to other tenants - A role created in the app panel for Tenant A is invisible to Tenant B, even though they share the same database and the same guard
Internally, this translates to:
| Panel | Query scope |
|---|---|
| With tenancy | guard_name = 'app' AND tenant_id = <current tenant> |
| Without tenancy | guard_name = 'admin' AND tenant_id IS NULL |
The tenant_id IS NULL condition is exactly why step 2 is necessary — without making that column nullable, non-tenant panel roles can't be stored at all.
This only applies when your application has both tenant and non-tenant panels running side by side.
In tenant panels, Filament scopes the Users relation manager automatically — it queries users through the tenant's ownership relationship, so only users belonging to the current tenant are shown. Non-tenant panels have no equivalent mechanism. There's no ownership relationship, no tenant column on users, and no guard column — nothing on the User model signals which panel a user belongs to. The result: the Users relation manager in your non-tenant panel shows every user in the database.
The fix is to add a discriminator to your User model and apply it in two places within your non-tenant panel: the UserResource and the UsersRelationManager.
Since nothing on the User model inherently ties a user to a specific panel, you need to add something that does. What that looks like depends on your app — a boolean flag, a role string, an email domain check, or anything else that reliably separates your non-tenant panel users from tenant users. The examples below use a simple boolean, but adapt it to whatever fits your data model.
// database/migrations/xxxx_add_is_admin_to_users_table.php
$table->boolean('is_admin')->default(false);Wrap the discriminator in a scope so the filtering logic lives in one place and can be reused across both steps below.
// app/Models/User.php
use Illuminate\Database\Eloquent\Builder;
public function scopeAdmins(Builder $query): Builder
{
return $query->where('is_admin', true);
}// app/Filament/Admin/Resources/UserResource.php
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->admins();
}The relation manager needs the scope in two places — the table rows and the attach dropdown. Both are required; missing either one leaves a gap.
// 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->admins())
->headerActions([
AttachAction::make()
->recordSelectOptionsQuery(fn (Builder $query) => $query->admins())
->preloadRecordSelect()
->multiple(),
]);
}
}modifyQueryUsing filters the users shown in the table. recordSelectOptionsQuery filters the users shown in the attach dropdown.
The super-admin concept comes from Spatie Laravel Permission. A super-admin is a user who bypasses all permission checks entirely — instead of assigning every possible permission to that user, you designate them as super-admin and Laravel's Gate handles the rest.
This is useful for the first user in a new panel (who needs access to everything before any roles are configured), internal admin accounts, or any user who should never be blocked by permission rules.
Important: The super-admin bypass only works through Laravel's Gate system — meaning
can(), policies, and@can()directives. It does not apply to direct Spatie method calls like->hasPermissionTo(). If your code calls those directly, super-admin users will still need those permissions assigned explicitly. Use Gate-based checks throughout your application to get the full benefit.
The plugin ships with super-admin enabled by default. You can adjust the global defaults in the published config:
// config/filament-guardian.php
'super_admin' => [
'enabled' => true,
'role_name' => 'Super Admin',
'intercept' => 'before',
],| Option | Description |
|---|---|
enabled |
Enable or disable the super-admin feature entirely |
role_name |
The name of the super-admin role in the database |
intercept |
When to intercept the Gate — see below |
The intercept option controls when the super-admin bypass runs relative to your normal authorization logic:
before (recommended) — The Gate is intercepted before any permission check runs. If the user is a super-admin, access is granted immediately, no policy or permission is consulted. This is the right choice for most applications.
after — The Gate is intercepted after normal authorization runs. Access is only granted if no policy explicitly denied it. Use this when you have specific gates or policies that should block even super-admins — for example, a system-level record that nobody should be able to delete.
The global config applies to all panels by default. If you need different behaviour on a specific panel — a different role name, a different intercept mode, or super-admin disabled entirely — configure it on the plugin directly:
FilamentGuardianPlugin::make()
->superAdmin() // enable for this panel (default: from config)
->superAdminRoleName('Administrator') // custom role name for this panel
->superAdminIntercept('after') // intercept mode for this panelPer-panel settings always take priority over the global config. Any option not set on the plugin falls back to the global config value.
For panels with tenancy, the super-admin role is created automatically every time a new tenant is created. The plugin listens for Eloquent's created event on your tenant model and creates a scoped super-admin role for that tenant in the background — no command to run, no migration to write, nothing to wire up manually.
Once a tenant is created, the plugin automatically creates a scoped super-admin role for it, ready to be assigned to whoever should have full access to that tenant.
For panels without tenancy, the role is not created automatically because there is no tenant lifecycle event to hook into. Depending on your situation, you have two ways to create the role and assign it.
Use the provided Artisan command, typically as part of your deployment process:
# Create the super-admin role for the panel
php artisan guardian:super-admin --panel=admin
# Or create it and immediately assign it to an existing user
php artisan guardian:super-admin --panel=admin --email=admin@example.comIf you are setting up a fresh environment from scratch, the full recommended sequence is:
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"If you need to create the role or promote a user to super-admin from within your application — for example, in an onboarding flow, a user management screen, or a seeder — use the Guardian facade instead:
use Waguilar\FilamentGuardian\Facades\Guardian;
// Create the super-admin role for a panel (if it doesn't exist yet)
Guardian::createSuperAdminRole('admin');
// Assign the super-admin role to a user
Guardian::assignSuperAdminTo($user, 'admin');
// Or if you need to retrieve the role first
$role = Guardian::getSuperAdminRole('admin');All methods accept an optional $panelId. When omitted, the method resolves configuration from the current Filament panel.
use Waguilar\FilamentGuardian\Facades\Guardian;
Guardian::isSuperAdminEnabled(?string $panelId); // bool
Guardian::getSuperAdminRoleName(?string $panelId); // string
Guardian::getSuperAdminIntercept(?string $panelId); // 'before'|'after'For non-tenant panels, use these methods to create, retrieve, and assign the super-admin role.
Guardian::createSuperAdminRole(?string $panelId); // Role
Guardian::getSuperAdminRole(?string $panelId); // ?Role
Guardian::assignSuperAdminTo($user, ?string $panelId); // voidFor tenant panels, $tenant and $guard are optional and resolve from Filament::getTenant() and the current panel when omitted. Pass them explicitly when calling outside a Filament request — console commands, observers, queued jobs.
Guardian::createSuperAdminRoleForTenant(
?Model $tenant,
?string $guard,
?string $panelId
); // Role
Guardian::getSuperAdminRoleForTenant(
?Model $tenant,
?string $guard,
?string $panelId
); // ?Role
Guardian::assignSuperAdminToForTenant(
Authenticatable $user,
?Model $tenant,
?string $guard,
?string $panelId
); // voidTo check whether a user or role has super-admin status:
Guardian::userIsSuperAdmin($user); // bool
Guardian::isSuperAdminRole($role); // boolTo customise how users are created by the guardian:create-user command:
Guardian::createUserUsing(Closure $callback); // void
Guardian::createUser(string $userModel, array $data); // ModelThe super-admin role is protected at two levels: the Eloquent model and the Filament UI.
Model-level — When super-admin is enabled, the plugin registers updating and deleting observers on your Role model. Any attempt to modify or delete the super-admin role — whether from the UI, a seeder, or application code — throws a SuperAdminProtectedException. The updating observer fires on any field change, not just renames, so the role cannot be modified in any way once created.
UI-level — The edit and delete actions are hidden for the super-admin role in the RoleResource. This is a UX convenience on top of the model-level guard, not a replacement for it.
Both layers respect the enabled flag — if you disable super-admin in the config or on the plugin, the protection is lifted and the role behaves like any other.
SuperAdminProtectedException extends RuntimeException, so you can catch it if you need to handle the error gracefully in application code.
Every permission the package generates and checks follows a consistent action:subject format — for example ViewAny:User or Access:Dashboard. The separator and case are configurable, and the entire key-building algorithm can be swapped out if you need something the config options alone can't express.
// config/filament-guardian.php
'permission_key' => [
'builder' => Waguilar\FilamentGuardian\Support\PermissionKeyBuilder::class,
'separator' => ':',
'case' => 'pascal',
],| Case | Example |
|---|---|
pascal |
ViewAny:User |
camel |
viewAny:user |
snake |
view_any:user |
kebab |
view-any:user |
upper_snake |
VIEW_ANY:USER |
lower_snake |
view_any:user |
The builder key lets you replace the default key-building logic entirely — useful when separator and case alone aren't enough. For example, if two resources share the same model name but live in different namespaces, you can include the navigation group or namespace in the key to make them distinct.
Implement Waguilar\FilamentGuardian\Contracts\PermissionKeyBuilder:
use Waguilar\FilamentGuardian\Contracts\PermissionKeyBuilder as PermissionKeyBuilderContract;
class CustomPermissionKeyBuilder implements PermissionKeyBuilderContract
{
public function __construct(
private readonly string $separator = ':',
private readonly string $case = 'pascal',
) {}
public function build(string $action, string $subject, ?string $entity = null): string
{
// your custom key generation logic
return $this->format($action) . $this->separator . $this->format($subject);
}
public function format(string $value): string { /* ... */ }
public function getSeparator(): string { return $this->separator; }
public function getCase(): string { return $this->case; }
public function extractSubject(string $permissionKey): string { /* ... */ }
}The constructor must accept $separator and $case — the service provider passes those from config when instantiating the builder. Register your implementation in the config:
'permission_key' => [
'builder' => App\Support\CustomPermissionKeyBuilder::class,
],For permissions that don't map to any resource, page, or widget — feature flags, cross-cutting actions, or anything purely app-defined — define them directly in the config. They'll be picked up by guardian:sync and appear in the Custom tab of the role form.
// 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 shown in the UI. For multi-language support, add translations under the custom key in the lang file — those override the labels defined here.
The package ships with four Artisan commands. Two belong in your deployment pipeline, one is a development-time generator, and one handles initial user and role setup.
Scans your Filament resources, pages, widgets, and custom permissions and syncs them to the database. Run this after every deploy — it creates any permissions that don't exist yet and leaves existing ones untouched, so it's safe to run repeatedly.
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 -v| 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 zero-downtime deployment:
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, wired to the permissions synced by guardian:sync. Run this during development when you add a new resource or need to regenerate existing 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 and overwrite existing policies
php artisan guardian:policies --panel=admin --all-resources --forceGenerated policies check permissions using $user->can(), keyed to the format defined in your config:
// 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. Most useful on first deployment when your database is empty and you need an initial 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"If your User model has additional required fields, register a creation callback in your AppServiceProvider to handle them:
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_admin' => true, // any additional fields your model requires
]);
});
}First deployment sequence:
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 for a non-tenant panel and optionally assigns it to a user. For tenant panels, this role is created automatically when a tenant is created — this command is only needed for panels without tenancy.
# Create the super-admin role for a panel
php artisan guardian:super-admin --panel=admin
# Create the role and assign it to an existing user
php artisan guardian:super-admin --panel=admin --email=admin@example.com| Panel type | Role creation |
|---|---|
| Non-tenant | Run this command manually |
| Tenant | Automatic when a tenant is created |
Controls how guardian:policies generates Laravel policy classes. The defaults cover the full set of Filament resource actions — adjust them to match what your application actually uses.
'policies' => [
'path' => app_path('Policies'), // where policy files are written
'merge' => true, // merge resource-specific methods with defaults (false = replace)
'methods' => [
'viewAny', 'view', 'create', 'update', 'delete',
'restore', 'forceDelete', 'deleteAny', 'restoreAny',
'forceDeleteAny', 'replicate', 'reorder',
],
'single_parameter_methods' => [ // methods that receive only $user, not $model
'viewAny', 'create', 'deleteAny', 'restoreAny',
'forceDeleteAny', 'reorder',
],
],merge — when true, any methods added via resources.manage for a specific resource are combined with the global methods list. When false, they replace it entirely for that resource.
single_parameter_methods — collection-level actions (e.g., viewAny, create) only receive the authenticated user; they have no model instance to work with. Methods not in this list receive both $user and $model.
Override how permissions and policies are generated for specific resources, or skip them entirely:
'resources' => [
'subject' => 'model', // 'model' uses the model name; 'class' uses the resource class name
'manage' => [
App\Filament\Resources\Blog\CategoryResource::class => [
'subject' => 'BlogCategory', // override the permission key subject
],
App\Filament\Resources\RoleResource::class => [
'methods' => ['viewAny', 'view', 'create'], // limit generated policy methods
],
],
'exclude' => [
App\Filament\Resources\SettingsResource::class, // skip entirely
],
],Pages and widgets each get a single permission by default — typically a view prefix applied to the page or widget class name. Both sections follow the same structure:
'pages' => [
'subject' => 'class', // derive subject from the class name
'prefix' => 'view', // action prefix: View:Dashboard, View:Settings, etc.
'exclude' => [
Filament\Pages\Dashboard::class, // excluded by default
],
],
'widgets' => [
'subject' => 'class',
'prefix' => 'view',
'exclude' => [
Filament\Widgets\AccountWidget::class, // excluded by default
Filament\Widgets\FilamentInfoWidget::class, // excluded by default
],
],Filament's built-in Dashboard, AccountWidget, and FilamentInfoWidget are excluded by default — they're framework-level components that most apps don't need to permission-gate.
Publish the config file to customize global defaults, permission key format, custom permissions, and policy generation settings:
php artisan vendor:publish --tag="filament-guardian-config"Publish the translation files to customize any label the package outputs:
php artisan vendor:publish --tag="filament-guardian-translations"Only needed when your application has both tenant and non-tenant panels. Publishes the migration that makes the Spatie pivot tables compatible with mixed-panel setups — see the Multi-Tenancy section for full context.
php artisan vendor:publish --tag="filament-guardian-multitenancy"
php artisan migrateAll configurable values resolve top-down — the first source that has a value wins.
-
Local override — If you've published the RoleResource and declared a static property on your subclass (e.g.
protected static ?string $navigationIcon = 'heroicon-o-lock-closed'), that value takes priority over everything else. This uses PHP late static binding, so the subclass declaration wins at the class level without any runtime checks. -
Fluent API — Values set via
FilamentGuardianPlugin::make()->navigationIcon(...)in your panel provider. These are per-panel, so different panels can have different values independently. -
Config file — Global defaults from
config/filament-guardian.php. These apply to all panels unless overridden by the fluent API or a local static property. -
Translation file — Applies to labels only (navigation label, model label, etc.). If no value is set above, the package looks for a translation key before falling through to the hardcoded default.
-
Hardcoded default — The value the package ships with. You'll only reach this if nothing above provides a value.
Configure how the RoleResource appears in your panel — navigation placement, labels, URLs, form sections, tabs, and permission checkboxes — all through the plugin fluent API without publishing the resource.
Configure the RoleResource's position and appearance in the sidebar:
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')
->registerNavigation(true)Place the RoleResource inside a Filament cluster so it appears under that cluster's sub-navigation. Pass the cluster class directly:
FilamentGuardianPlugin::make()
->cluster(\App\Filament\Clusters\Settings::class)Closures are supported for conditional assignment:
FilamentGuardianPlugin::make()
->cluster(fn () => auth()->user()->isAdmin()
? \App\Filament\Clusters\Settings::class
: null
)Or set it globally in the config file:
// config/filament-guardian.php
'role_resource' => [
'navigation' => [
'cluster' => \App\Filament\Clusters\Settings::class,
],
],Defaults to null — no cluster.
Customize how the resource is named in the UI and what URL it uses:
FilamentGuardianPlugin::make()
->modelLabel('Role')
->pluralModelLabel('Roles')
->slug('access-roles') // URL: /admin/access-rolesThe role form is divided into two sections — a role details section at the top containing the name field, and a permissions section below containing the tabs and the select-all toggle. Both can be configured independently.
Role section — label, description, icon, and layout:
FilamentGuardianPlugin::make()
->roleSectionLabel('Role Details')
->roleSectionDescription('Configure basic role settings')
->roleSectionIcon(Heroicon::OutlinedIdentification)
->roleSectionAside() // renders the section in an aside layoutPermissions section — label, description, and icon:
FilamentGuardianPlugin::make()
->permissionsSectionLabel('Permissions')
->permissionsSectionDescription('Select which actions this role can perform')
->permissionsSectionIcon(Heroicon::OutlinedLockClosed)Pass false to any icon method to remove it entirely:
FilamentGuardianPlugin::make()
->roleSectionIcon(false)
->permissionsSectionIcon(false)All methods accept closures for dynamic values.
The permissions section renders up to four tabs — Resources, Pages, Widgets, and Custom. The Resources tab is always shown. The other three only appear when there is something to display: Pages and Widgets tabs require those permission types to exist in the database (synced via guardian:sync), and the Custom tab only appears when custom permissions are defined in your config.
You can force-hide any tab regardless of whether it has content:
FilamentGuardianPlugin::make()
->showResourcesTab() // default: true
->showPagesTab() // default: true
->showWidgetsTab() // default: true
->showCustomPermissionsTab() // default: true
// Or hide specific tabs
->hidePagesTab()
->hideWidgetsTab()Customize the icon shown on each tab:
use Filament\Support\Icons\Heroicon;
FilamentGuardianPlugin::make()
->resourcesTabIcon(Heroicon::OutlinedRectangleStack)
->pagesTabIcon(Heroicon::OutlinedDocument)
->widgetsTabIcon(Heroicon::OutlinedPresentationChartBar)
->customTabIcon(Heroicon::OutlinedWrench)| Tab | Default icon |
|---|---|
| Resources | Heroicon::OutlinedSquare3Stack3d |
| Pages | Heroicon::OutlinedDocumentText |
| Widgets | Heroicon::OutlinedChartBar |
| Custom | Heroicon::OutlinedCog6Tooth |
Controls how permission checkboxes are arranged within each tab — how many columns they use and which direction they flow. Global settings apply to all tabs; per-tab values take priority over the global ones.
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)Responsive arrays are supported for all column methods:
FilamentGuardianPlugin::make()
->permissionCheckboxColumns([
'sm' => 2,
'md' => 3,
'lg' => 4,
])Within the Resources tab, permissions are grouped by resource — each resource gets its own collapsible section. You can control whether sections start collapsed, how many columns their permission checkboxes span, and whether the resource's navigation icon is shown in the section header.
FilamentGuardianPlugin::make()
->collapseResourceSections() // start all resource sections collapsed
->resourceSectionColumns(2) // permission checkboxes span 2 columns per resource
->showResourceSectionIcon() // show the resource's navigation icon in the headerEach tab includes a search input to filter permissions by name. The permissionAssignedIcon appears next to each assigned permission in the view infolist. Pass false to either to hide it.
FilamentGuardianPlugin::make()
->searchIcon(Heroicon::OutlinedMagnifyingGlass)
->permissionAssignedIcon(Heroicon::OutlinedCheckCircle)The Select All toggle in the permissions section header selects or deselects all permissions at once. You can customize the icon for each state or hide them by passing false.
FilamentGuardianPlugin::make()
->selectAllOnIcon(Heroicon::OutlinedCheckCircle)
->selectAllOffIcon(Heroicon::OutlinedXCircle)Or via config:
// config/filament-guardian.php
'role_resource' => [
'select_all_toggle' => [
'on_icon' => 'heroicon-o-check',
'off_icon' => 'heroicon-o-x-mark',
],
],The label shown on each permission checkbox is derived automatically based on type:
| Type | Label source |
|---|---|
| Resources | Resource::getPluralModelLabel() |
| Pages | Page::getNavigationLabel() |
| Widgets | Widget::getHeading() or humanized class name |
| Custom | Translation file or permission key |
All fluent API methods throughout this section accept closures for dynamic values.
The fluent API above lets you configure the role resource without touching PHP files. When you need deeper customization — overriding form schemas, page layouts, table actions, or anything else the plugin API doesn't expose — publish the resource files into your application:
php artisan filament-guardian:publish-role-resource {panel?}Published classes 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(),
]),
];
}
}When building a custom role form, use Guardian::uniqueRoleValidation() to enforce unique role names while correctly ignoring the current record on edit:
use Waguilar\FilamentGuardian\Facades\Guardian;
TextInput::make('name')
->required()
->unique(
ignoreRecord: true,
modifyRuleUsing: Guardian::uniqueRoleValidation(),
)The Role resource includes a Users tab on the view page, backed by a relation manager that lets you attach and detach users directly from a role — no need to navigate to each user individually.
The tab shows users assigned to the role with Name and Email columns, supports search and bulk operations, and automatically excludes users who already hold the super-admin role from the attach dropdown.
When you publish the Role resource, a UsersRelationManager stub is included. You can extend it to customize the table or override any part of the relation manager:
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';
}
}ManageUserPermissionsAction is a table action you add to your UserResource to open a slide-over for managing a user's direct permissions — permissions assigned specifically to that user, on top of what they already inherit through roles.
use Waguilar\FilamentGuardian\Actions\ManageUserPermissionsAction;
public function table(Table $table): Table
{
return $table
->columns([...])
->recordActions([
ViewAction::make(),
ManageUserPermissionsAction::make(),
]);
}The slide-over displays the user's name and email at the top so it's always clear whose permissions you're editing. The permission UI follows the same tab format as the role resource — Resources, Pages, Widgets, and Custom — with the same search and select-all toggle.
A few things happen automatically:
- Role permissions excluded — permissions already granted through roles are not shown; they're managed at the role level
- Role permissions notice — a warning shows how many permissions the user already has from their roles
- Hidden for super-admins — the action doesn't appear for super-admin users since they bypass all permission checks
- Automatic cleanup — when saved, any direct permissions that are now also covered by a role are removed to avoid redundancy
The action extends Filament's standard Action, so all fluent methods are available:
ManageUserPermissionsAction::make()
->label('Custom Label')
->icon('heroicon-o-key')
->color('primary')English and Spanish translations ship by default. Publish the translation files to override any string the package outputs — including permission action labels, custom permission display names, and all role resource UI text:
php artisan vendor:publish --tag=filament-guardian-translationsThis publishes to lang/vendor/filament-guardian/{locale}/filament-guardian.php.
| Key | What it controls |
|---|---|
roles.* |
Role resource labels, section titles, tab names, messages |
users.permissions.* |
User direct permissions modal labels |
actions.* |
Permission action labels (viewAny, create, update, etc.) |
custom.* |
Custom permission display names (overrides config labels) |
super_admin.* |
Super admin role messages and error strings |
composer test # run the test suite
composer analyse # run PHPStan static analysis
composer lint # run code style checksPlease 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.
