spatie/laravel-permission
Manage user permissions and roles in a database with spatie/laravel-permission. Assign permissions directly or through roles, then check access using Laravel's built-in can() gate. Includes migrations, caching, Blade helpers, team support, and multiple guards.
Install and publish config + migrations in one step:
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
Add the trait to the model that should hold permissions (usually User):
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}
Look at config/permission.php first. It is the whole map of the package: model class overrides, table/column names, the teams switch, wildcard_permissions, and the cache block (expiration_time, key, store). Second stop is the docs at spatie.be/docs/laravel-permission — the README is deliberately thin.
First real use case — seed roles/permissions, then authorize:
// database/seeders/RolesAndPermissionsSeeder.php
foreach (['edit articles', 'publish articles', 'delete articles'] as $name) {
Permission::findOrCreate($name, 'web');
}
$writer = Role::findOrCreate('writer', 'web');
$writer->syncPermissions(['edit articles', 'publish articles']);
User::first()->assignRole('writer');
User::first()->can('edit articles'); // true
Because every permission is registered on Laravel's Gate, $user->can('edit articles'), @can, policies, and Gate::allows() all work unchanged — that is the key design win and the thing to lean on rather than inventing your own checks.
Register the route middleware aliases in bootstrap/app.php (Laravel 12 style):
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
]);
})
Idempotent seeding, always. Roles/permissions live in the database, so every deploy needs a reconcile step. Use findOrCreate() + syncPermissions() (not givePermissionTo(), which accumulates stale grants), and reset the cache at the end of the deploy:
php artisan db:seed --class=RolesAndPermissionsSeeder
php artisan permission:cache-reset
sync vs give is the main API decision to standardise on.
assignRole() / givePermissionTo() — additive, never removes.syncRoles() / syncPermissions() — declarative; anything not in the array is detached.revokePermissionTo() — single removal.For admin UIs where a form posts the full checkbox set, syncRoles() / syncPermissions() are almost always what you want.
Route protection:
Route::middleware('permission:edit articles')->group(...);
Route::middleware('role:admin|editor')->group(...); // OR
Route::middleware('role_or_permission:admin|publish articles')->group(...);
Names are pipes for OR; the comma-separated second segment is the guard, e.g. role:admin|editor,web. Enums are supported as middleware parameters too (contracts broadened for BackedEnum in v8), which pairs nicely with a PermissionEnum to eliminate string typos across controllers, policies, seeders and tests.
Blade stays declarative:
@role('admin')
@hasrole('writer|editor')
@can('publish articles')
@canany(['edit articles', 'delete articles'])
Use these only to hide UI; the server-side middleware/policy is the actual control.
Super-admin without a permission explosion. Don't grant the admin role every permission. Use a gate bypass:
// AppServiceProvider::boot()
Gate::before(fn ($user) => $user->hasRole('super-admin') ? true : null);
Returning null (not false) lets normal checks continue for everyone else.
Policies still do the heavy lifting. The package answers "does this user have this named permission"; policies answer "can they touch this record". The common pattern is permission-gated policies:
public function update(User $user, Article $article): bool
{
return $user->can('edit articles') && $article->author_id === $user->id;
}
Teams (multi-tenant) pattern. Enable 'teams' => true, add the team key column to the migration, and set the context before any assignment or check:
app(PermissionRegistrar::class)->setPermissionsTeamId($team->id);
$user->assignRole('manager'); // scoped to that team
Wrap this in a middleware that resolves the team from the route/session so it's set exactly once per request. In queued jobs and console commands there is no request, so set it explicitly at the top of handle() and reset it afterwards — the value is sticky on the registrar instance.
Audit via events. Listen to RoleAttachedEvent, RoleDetachedEvent, PermissionAttachedEvent, PermissionDetachedEvent (v7 renamed these with the Event suffix; v8.2 added PermissionDetachedEvent firing during syncPermissions() for symmetry with syncRoles()). Events are the supported hook for "who changed whose access" — more reliable than custom pivot model deleting hooks, especially under teams.
Custom models as an extension point. Extend Spatie\Permission\Models\Role / Permission, then point config/permission.php at your subclasses. That's the clean place for extra columns, scopes, or HasModels-style helpers (v7.4 added syncModels() on the Role model).
Testing. In Pest/PHPUnit, clear the registrar cache in beforeEach — otherwise permissions leak between tests in the same process:
beforeEach(function () {
app(\Spatie\Permission\Registrar\PermissionRegistrar::class)->forgetCachedPermissions();
});
Prefer actingAs($user) plus real middleware over withoutMiddleware(), which hides exactly the bugs you're trying to catch.
DB::table('permissions')->insert(...) in a migration or a raw import, the cache is stale. Fix: call app(PermissionRegistrar::class)->forgetCachedPermissions() after any raw write, and put php artisan permission:cache-reset in your deploy script.web cannot be assigned to a user on guard api — givePermissionTo() throws GuardDoesNotMatch. Since 8.2, revokePermissionTo() routes through the same guard check and can throw in unusual multi-guard setups, so re-run your auth tests on upgrade. Always pass the guard explicitly in seeders.syncRoles([]) removes everything. Easy to do accidentally when a form posts with no roles selected and your controller does $user->syncRoles($request->roles ?? []). Validate and be explicit about the empty case.admin can exist on web and api. Use Role::findByName('admin', 'api') or findOrCreate($name, $guard) when in doubt.initializeCache() not clearing the in-memory collection on tenant/cache switch. Always reset the team id in middleware/terminate and at the start of jobs. Also note teams() no longer throws when teams are disabled (7.4.1), and a custom pivot model with teams previously could delete assignments across teams (fixed 8.2).User::with('roles', 'permissions')->paginate() avoids N+1s when a table renders role badges per row. The global permission cache does not save you from per-model relation queries.php artisan permission:show prints the roles/permissions matrix, plus permission:create-role, permission:create-permission, and permission:assign-role (team-aware since 6.24.1) for quick manual fixes on staging.'edit*') are opt-in via wildcard_permissions in config, and they interact with a separate index that must be invalidated — 7.2.2 fixed the wildcard index not being cleared on role assignment/removal. Keep wildcards to a small, deliberate set; they make audits harder.enum PermissionEnum: string used everywhere (supported as of v8) turns a typo into a compile/runtime error you notice immediately.PermissionAttached → PermissionAttachedEvent) and commands (CacheReset → CacheResetCommand). Grep for the old class names before bumping.Role/Permission models via config → model events → package events → binding a custom WildcardPermission implementation → replacing PermissionRegistrar in the container (last resort; you inherit cache/teams behaviour you must preserve).How can I help you explore Laravel packages today?