Introduction to Spatie Permission
Almost every real-world web application needs to answer one question repeatedly:
"Is this user allowed to do this?" Laravel ships with a built-in
Gate and Policy system, but it requires you to write and
manage your own role/permission storage from scratch.
The Spatie Laravel Permission package gives you a fully-tested,
production-ready solution on top of Laravel's native auth. It stores roles and
permissions in your database, caches them for performance, and integrates seamlessly
with Laravel's Gate so that all existing helpers (can(),
@can, policies, etc.) just work.
RBAC vs ABAC: Spatie uses Role-Based Access Control (RBAC). A user gets one or more roles, and each role has one or more permissions. Users can also be given permissions directly, bypassing roles.
Key Concepts at a Glance
Why not use Laravel Gates directly?
| Feature | Laravel Gates | Spatie Permission |
|---|---|---|
| Stored in database | ✕ Code only | ✓ Yes, dynamic |
| Roles & groups | ✕ Manual | ✓ First-class |
| Cache support | ✕ None built-in | ✓ Automatic |
| Blade directives | ✓ @can |
✓ @role, @hasanyrole |
| Middleware | ✕ Write yourself | ✓ Built-in |
| Multiple guards | ✓ | ✓ Full support |
Installation via Composer
Spatie Permission is installed with Composer — the standard PHP dependency manager. Make sure you have an existing Laravel project (version 10 or 11) before running the commands below.
Step 1 — Require the package
The --with-all-dependencies flag tells Composer to automatically resolve
any version constraints with your existing packages, avoiding conflicts.
composer require spatie/laravel-permission
Requires PHP 8.1+ and Laravel 10+. For older Laravel versions, check the package's GitHub releases for the matching version tag.
Step 2 — Register the service provider (Laravel < 11 only)
In Laravel 11 the package auto-discovers itself via composer.json's
extra.laravel section. For Laravel 10, add it to your
config/app.php:
// In the 'providers' array:
Spatie\Permission\PermissionServiceProvider::class,
Step 3 — Add the trait to your User model
This single trait wires your User model up to the
full permission API. It defines relationships to the roles
and permissions tables, and registers all helper methods
like hasRole(), givePermissionTo(), etc.
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles; // ← import this trait
class User extends Authenticatable
{
use HasRoles; // ← add it here
// The rest of your model stays unchanged …
protected $fillable = ['name', 'email', 'password'];
}
HasRoles automatically pulls in HasPermissions as well,
so you get everything from a single trait.
Publishing Config & Running Migrations
The package ships with its own migration files and a configuration file. "Publishing" means copying those files from the package's source into your project so you can inspect and customise them.
Publish the config and migration files
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
This creates two new files in your project:
-
1
config/permission.php
Controls table names, cache TTL, model classes, and which guard to use.
-
2
database/migrations/create_permission_tables.php
Creates all 5 tables the package needs to store roles, permissions, and their relationships.
Run the migration
php artisan migrate
Database schema created
After migration you'll have exactly these 5 tables — here's how they relate:
Understanding the config file
Open config/permission.php — here are the most important settings:
return [
/*
| Models: point these to your own model classes if you
| extend the default Role / Permission models.
*/
'models' => [
'permission' => Spatie\Permission\Models\Permission::class,
'role' => Spatie\Permission\Models\Role::class,
],
/*
| Table names: rename these if they conflict with existing tables.
*/
'table_names' => [
'roles' => 'roles',
'permissions' => 'permissions',
'model_has_permissions' => 'model_has_permissions',
'model_has_roles' => 'model_has_roles',
'role_has_permissions' => 'role_has_permissions',
],
/*
| Cache: permissions are cached automatically.
| expiration_time is in minutes. Set to null to disable.
*/
'cache' => [
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
'key' => 'spatie.permission.cache',
'store' => 'default', // 'redis', 'memcached', etc.
],
];
Creating Roles and Permissions
Roles and permissions are regular Eloquent models. You can create them anywhere: seeders (recommended for production), tinker, controllers, or migrations. The most maintainable approach is a dedicated seeder.
Creating Permissions
A permission is simply a named string stored in the database. By convention,
use a verb noun or action resource format so the
name is self-descriptive.
use Spatie\Permission\Models\Permission;
// Simple creation
Permission::create(['name' => 'view posts']);
Permission::create(['name' => 'create posts']);
Permission::create(['name' => 'edit posts']);
Permission::create(['name' => 'delete posts']);
Permission::create(['name' => 'manage users']);
Permission::create(['name' => 'view dashboard']);
// Or batch-create using firstOrCreate (safe to run multiple times)
$permissions = [
'view posts', 'create posts', 'edit posts', 'delete posts',
'manage users', 'view dashboard', 'publish posts',
];
foreach ($permissions as $perm) {
Permission::firstOrCreate(['name' => $perm]);
}
Creating Roles
A role bundles multiple permissions together. After creating a role you assign
permissions to it using givePermissionTo() or
syncPermissions().
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
// 1. Create the roles
$admin = Role::create(['name' => 'admin']);
$editor = Role::create(['name' => 'editor']);
$viewer = Role::create(['name' => 'viewer']);
// 2. Give the admin role ALL permissions at once
$admin->givePermissionTo(Permission::all());
// 3. Give the editor role only content permissions
$editor->givePermissionTo([
'view posts',
'create posts',
'edit posts',
'publish posts',
]);
// 4. Viewer can only see
$viewer->givePermissionTo(['view posts']);
// syncPermissions() replaces all existing permissions:
$editor->syncPermissions(['view posts', 'edit posts']);
// Remove a single permission from a role:
$editor->revokePermissionTo('delete posts');
Permission Matrix (example)
| Permission | Admin | Editor | Viewer |
|---|---|---|---|
| view posts | ✓ | ✓ | ✓ |
| create posts | ✓ | ✓ | ✕ |
| edit posts | ✓ | ✓ | ✕ |
| delete posts | ✓ | ✕ | ✕ |
| publish posts | ✓ | ✓ | ✕ |
| manage users | ✓ | ✕ | ✕ |
| view dashboard | ✓ | ✓ | ✕ |
Assigning Roles & Permissions to Users
Once roles and permissions exist in the database you can attach them to any
user (or any other model that uses HasRoles). All methods work
with strings (names) or model instances.
Assigning roles
$user = User::find(1);
// Assign a single role (by name string)
$user->assignRole('admin');
// Assign multiple roles at once
$user->assignRole(['editor', 'viewer']);
// Assign by Role model instance
$role = Role::findByName('editor');
$user->assignRole($role);
// syncRoles() removes all old roles then assigns new ones
$user->syncRoles(['editor']);
// Remove a specific role
$user->removeRole('viewer');
// Remove ALL roles
$user->syncRoles([]); // empty array clears all
Assigning direct permissions (bypassing roles)
Sometimes you need to grant an individual user a permission without changing their role — for example, a one-off approval to manage a single resource.
// Grant a direct permission
$user->givePermissionTo('publish posts');
// Grant multiple direct permissions
$user->givePermissionTo(['edit posts', 'view dashboard']);
// Revoke a direct permission
$user->revokePermissionTo('publish posts');
// Replace all direct permissions (does NOT affect role-based ones)
$user->syncPermissions(['view posts']);
// Get all permissions a user has (direct + via roles)
$allPermissions = $user->getAllPermissions();
// Get only direct permissions
$direct = $user->getDirectPermissions();
// Get permissions inherited from roles only
$viaRole = $user->getPermissionsViaRoles();
Tip: Prefer role-based permissions for maintainability. Direct permissions make auditing harder — use them sparingly and only for genuine one-off scenarios. Most access logic should live in roles.
Checking Permissions in Controllers & Services
Spatie integrates with Laravel's Gate, so $user->can(),
auth()->user()->can(), and the authorize() helper in
controllers all work out of the box alongside Spatie's own helpers.
Role checks
$user = auth()->user();
// Single role
$user->hasRole('admin'); // bool
// Any of the listed roles
$user->hasAnyRole(['admin', 'editor']); // bool
// All of the listed roles
$user->hasAllRoles(['editor', 'viewer']); // bool
// Get list of role names for this user
$user->getRoleNames(); // Collection ['admin', 'editor']
Permission checks
// Using Spatie helper (checks both direct + via role)
$user->hasPermissionTo('edit posts'); // bool
// Using Laravel Gate (identical result, works in policies/gates)
$user->can('edit posts'); // bool
// Any of these permissions
$user->hasAnyPermission(['edit posts', 'delete posts']);
// All of these permissions
$user->hasAllPermissions(['view posts', 'edit posts']);
// Check if permission exists (regardless of user)
Permission::findByName('edit posts')->exists; // bool
Using inside a Controller
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index()
{
// Abort with 403 if the user cannot view posts
abort_unless(auth()->user()->can('view posts'), 403);
return view('posts.index', ['posts' => Post::all()]);
}
public function create()
{
// Laravel's authorize() uses the Gate (works with Spatie)
$this->authorize('create posts');
return view('posts.create');
}
public function destroy(Post $post)
{
// Admin can delete any post; editor can only delete their own
if (!auth()->user()->hasPermissionTo('delete posts')) {
abort(403, 'You do not have permission to delete posts.');
}
$post->delete();
return redirect()->route('posts.index')
->with('success', 'Post deleted.');
}
/**
* Only admins can access user management.
*/
public function adminDashboard()
{
abort_unless(auth()->user()->hasRole('admin'), 403);
return view('admin.dashboard');
}
}
All available check methods
hasRole($role)True if user has this exact role.
hasAnyRole([...])True if user has at least one of the listed roles.
hasAllRoles([...])True if user has every listed role.
hasPermissionTo($perm)True if user can do this (direct or via role).
can($perm)Laravel Gate check — works identically.
hasAnyPermission([...])True if user has at least one listed permission.
hasAllPermissions([...])True if user has every listed permission.
getDirectPermissions()Collection of directly-assigned permissions.
getPermissionsViaRoles()Collection of permissions inherited from roles.
getAllPermissions()Combined collection (direct + via roles).
getRoleNames()Collection of the user's role name strings.
syncRoles([...])Replace all roles with the given list.
Middleware Usage
Middleware lets you protect entire routes or route groups without writing
any controller logic. Spatie ships with two middleware classes out of the box:
RoleMiddleware and PermissionMiddleware.
Register the middleware (Laravel 10)
In Laravel 11 the middleware is auto-registered. In Laravel 10, add aliases
to your app/Http/Kernel.php:
protected $middlewareAliases = [
// ... existing entries ...
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
];
Register the middleware (Laravel 11)
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->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,
]);
})->create();
Protecting routes
use App\Http\Controllers\PostController;
use App\Http\Controllers\AdminController;
use App\Http\Controllers\UserController;
// ── Single role ──────────────────────────────────────────────
Route::get('/admin', [AdminController::class, 'index'])
->middleware('role:admin');
// ── Multiple roles (user must have one of them) ──────────────
Route::get('/dashboard', [AdminController::class, 'dashboard'])
->middleware('role:admin|editor');
// ── Permission check ─────────────────────────────────────────
Route::post('/posts', [PostController::class, 'store'])
->middleware('permission:create posts');
// ── Route group with shared middleware ───────────────────────
Route::middleware(['auth', 'role:admin'])->prefix('admin')->group(function () {
Route::get('/', [AdminController::class, 'index']);
Route::get('/users', [UserController::class, 'index']);
Route::delete('/users/{user}', [UserController::class, 'destroy']);
});
// ── Role OR Permission (either satisfies the guard) ──────────
Route::get('/reports', [AdminController::class, 'reports'])
->middleware('role_or_permission:admin|view dashboard');
Always put auth middleware before role/permission
middleware in your chain. If the user isn't logged in, Spatie can't check their
roles and will throw an error.
Full Example: Admin Role with Restricted Access
Let's walk through a complete, real-world scenario: setting up an
admin role that controls access to a user management panel, while
regular users can only view posts.
Step 1 — Create the seeder
php artisan make:seeder RolesAndPermissionsSeeder
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
class RolesAndPermissionsSeeder extends Seeder
{
public function run(): void
{
// Reset cached roles and permissions
app()[PermissionRegistrar::class]->forgetCachedPermissions();
// ── Create all permissions ────────────────────────────────
$permissions = [
'view posts', 'create posts', 'edit posts',
'delete posts', 'publish posts',
'manage users', 'view dashboard', 'view reports',
];
foreach ($permissions as $perm) {
Permission::firstOrCreate(['name' => $perm]);
}
// ── Admin role: gets everything ───────────────────────────
$admin = Role::firstOrCreate(['name' => 'admin']);
$admin->givePermissionTo(Permission::all());
// ── Editor role ───────────────────────────────────────────
$editor = Role::firstOrCreate(['name' => 'editor']);
$editor->syncPermissions([
'view posts', 'create posts',
'edit posts', 'publish posts', 'view dashboard',
]);
// ── Viewer role ───────────────────────────────────────────
$viewer = Role::firstOrCreate(['name' => 'viewer']);
$viewer->syncPermissions(['view posts']);
// ── Assign admin role to user #1 ──────────────────────────
$adminUser = \App\Models\User::find(1);
if ($adminUser) {
$adminUser->assignRole('admin');
}
}
}
Step 2 — Run the seeder
php artisan db:seed --class=RolesAndPermissionsSeeder
Step 3 — The Admin Controller
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Spatie\Permission\Models\Role;
class AdminController extends Controller
{
/**
* Constructor-level middleware: only admins may enter this controller.
*/
public function __construct()
{
$this->middleware(['auth', 'role:admin']);
}
public function index()
{
$stats = [
'users' => User::count(),
'admins' => User::role('admin')->count(),
'editors' => User::role('editor')->count(),
'roles' => Role::count(),
];
return view('admin.index', compact('stats'));
}
public function assignRole(\Illuminate\Http\Request $request, User $user)
{
$request->validate(['role' => 'required|exists:roles,name']);
$user->syncRoles($request->role);
return back()->with('success', "Role '{$request->role}' assigned to {$user->name}.");
}
}
Step 4 — Query users by role (Spatie scope)
Spatie adds a role() scope to your User model for convenient database queries:
// Get all admin users
$admins = User::role('admin')->get();
// Get users with any of these roles
$editors = User::role(['admin', 'editor'])->get();
// Get all users who have a specific permission
$canDelete = User::permission('delete posts')->get();
// Paginate admin users
$adminsPaged = User::role('admin')->paginate(15);
Blade Directives & View-Level Access Control
Spatie registers custom Blade directives so your templates stay clean and
readable. These directives check the currently authenticated user
automatically — no need to pass $user around.
All available Blade directives
{{-- ── Role checks ─────────────────────────────────────── --}}
@role('admin')
<p>Only admins see this.</p>
@endrole
@hasrole('admin') {{-- alias of @role --}}
<a href="/admin">Admin Panel</a>
@endhasrole
@hasanyrole('admin|editor') {{-- any of these roles --}}
<a href="/dashboard">Dashboard</a>
@endhasanyrole
@hasallroles('admin|editor') {{-- must have both roles --}}
<p>Super-editor.</p>
@endhasallroles
@unlessrole('admin') {{-- inverse: NOT admin --}}
<p>You are not an admin.</p>
@endunlessrole
{{-- ── Permission checks ───────────────────────────────── --}}
@can('edit posts') {{-- native Laravel gate --}}
<a href="/posts/edit">Edit</a>
@endcan
@cannot('delete posts')
<p class="muted">No delete access.</p>
@endcannot
{{-- ── Combining role + permission ─────────────────────── --}}
@role('admin')
@can('delete posts')
<button class="btn-danger">Delete Post</button>
@endcan
@endrole
🖥️ Live UI Preview — How a Page Looks for Different Roles
Here's a browser simulation showing how the same posts page renders for an Admin vs an Editor vs a Viewer:
Admin View — full controls visible
Getting Started with Laravel
A beginner-friendly guide to Laravel framework…
Mastering Eloquent ORM
Deep dive into relationships, scopes, and query builder…
Admin Stats Panel
Editor View — edit allowed, delete & admin panel hidden
Getting Started with Laravel
A beginner-friendly guide to Laravel framework…
Mastering Eloquent ORM
Deep dive into relationships, scopes, and query builder…
Viewer — read-only, no buttons at all
Getting Started with Laravel
A beginner-friendly guide to Laravel framework…
Mastering Eloquent ORM
Deep dive into relationships, scopes, and query builder…
Access Denied — what non-admins see at /admin
403 — Access Denied
You don't have permission to access this page. Contact your administrator.
The Blade template that powers these views
@extends('layouts.app')
@section('content')
{{-- Navigation header with role badge --}}
<nav>
<span>MyApp Blog</span>
@hasrole('admin')
<span class="badge badge-admin">🛡 Admin</span>
@endhasrole
@hasrole('editor')
<span class="badge badge-editor">✏️ Editor</span>
@endhasrole
</nav>
{{-- Post list --}}
@foreach ($posts as $post)
<div class="card">
<h3>{{ $post->title }}</h3>
{{-- Only editors and admins see the edit button --}}
@can('edit posts')
<a href="{{ route('posts.edit', $post) }}">✏️ Edit</a>
@endcan
{{-- Only admins see the delete button --}}
@can('delete posts')
<form method="POST" action="{{ route('posts.destroy', $post) }}">
@csrf @method('DELETE')
<button type="submit">🗑 Delete</button>
</form>
@endcan
</div>
@endforeach
{{-- Admin stats panel — only admins see this entire section --}}
@role('admin')
<section class="admin-stats">
<h4>Admin Stats Panel</h4>
<p>Users: {{ $stats['users'] }} | Posts: {{ $stats['posts'] }}</p>
</section>
@endrole
@endsection
Database Seeder Pattern & Cache Management
In production you almost never want to create roles/permissions manually. A well-structured seeder lets you version-control your permission setup, re-run safely, and keep all environments in sync.
Call from DatabaseSeeder
public function run(): void
{
$this->call([
RolesAndPermissionsSeeder::class, // always first
UserSeeder::class,
]);
}
Cache management
Spatie caches permissions for performance. Whenever you programmatically change roles or permissions (outside a web request), you must clear the cache manually:
# Clear the Spatie permission cache
php artisan permission:cache-reset
# Or clear all application cache
php artisan cache:clear
use Spatie\Permission\PermissionRegistrar;
// In a seeder, command, or service — reset before bulk changes
app()[PermissionRegistrar::class]->forgetCachedPermissions();
Cache is automatically refreshed on the next request after you call
forgetCachedPermissions(). In high-traffic apps, pair this with
Redis for fast cache invalidation.
API Guard — Sanctum / JWT
When building an API (e.g. with Laravel Sanctum or JWT), your users
authenticate via a different guard (usually api). Spatie
supports multiple guards — you just need to tell it which guard to use.
Setting the guard in config
// Each permission/role is tied to a guard.
// If your API uses 'api' guard, create permissions for it:
Permission::create([
'name' => 'view posts',
'guard_name' => 'api',
]);
Role::create([
'name' => 'admin',
'guard_name' => 'api',
]);
API Controller with permission check
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index()
{
abort_unless(auth('api')->user()->can('view posts'), 403);
return Post::all();
}
public function store(Request $request)
{
abort_unless(auth('api')->user()->can('create posts'), 403);
$post = Post::create($request->validated());
return response()->json($post, 201);
}
}
// In routes/api.php:
Route::middleware(['auth:sanctum', 'role:admin'])->group(function () {
Route::apiResource('posts', PostController::class);
});
Returning user permissions in API response
public function login(Request $request)
{
// ... validate credentials ...
$user = auth()->user();
$token = $user->createToken('api-token')->plainTextToken;
return response()->json([
'token' => $token,
'user' => $user,
'roles' => $user->getRoleNames(),
'permissions' => $user->getAllPermissions()->pluck('name'),
]);
}
Returning permissions in the login response lets frontend
clients (React, Vue, mobile apps) conditionally show/hide UI elements
without making extra API calls.
Conclusion & Best Practices
You now have everything you need to implement a robust, database-driven access control system with Spatie Laravel Permission. Here are the key best practices to carry into production:
Seed roles in version control
Always define roles and permissions in a seeder. This keeps your setup reproducible across environments and reviewable in Git.
Prefer role-based over direct permissions
Assign permissions to roles, not individual users. Direct permissions make auditing and debugging much harder at scale.
Protect at multiple layers
Use middleware on routes AND checks in controllers. Defense-in-depth ensures a missed middleware doesn't expose sensitive logic.
Use Redis for the cache store
The default file cache works but Redis is far faster. Set 'store' => 'redis' in config/permission.php.
Reset cache after bulk changes
Call forgetCachedPermissions() at the top of every seeder and any command that bulk-modifies roles.
Write feature tests for access
Test that protected routes return 403 for unauthorised users. actingAs($user)->get('/admin') makes this easy.
Testing example
<?php
namespace Tests\Feature;
use App\Models\User;
use Spatie\Permission\Models\Role;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminAccessTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_access_dashboard(): void
{
$admin = User::factory()->create();
$admin->assignRole(Role::create(['name' => 'admin']));
$this->actingAs($admin)
->get('/admin')
->assertOk();
}
public function test_viewer_cannot_access_dashboard(): void
{
$viewer = User::factory()->create();
$viewer->assignRole(Role::create(['name' => 'viewer']));
$this->actingAs($viewer)
->get('/admin')
->assertForbidden(); // expects 403
}
public function test_editor_can_edit_posts(): void
{
$editor = User::factory()->create();
$role = Role::create(['name' => 'editor']);
$role->givePermissionTo('edit posts');
$editor->assignRole($role);
$this->assertTrue($editor->can('edit posts'));
$this->assertFalse($editor->can('delete posts'));
}
}
Quick Reference Card
// ── INSTALL ────────────────────────────────────────────────
// composer require spatie/laravel-permission
// php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
// php artisan migrate
// ── CREATE ─────────────────────────────────────────────────
Permission::create(['name' => 'edit posts']);
$role = Role::create(['name' => 'admin']);
$role->givePermissionTo('edit posts');
// ── ASSIGN ─────────────────────────────────────────────────
$user->assignRole('admin');
$user->givePermissionTo('edit posts');
// ── CHECK ──────────────────────────────────────────────────
$user->hasRole('admin');
$user->can('edit posts');
// ── BLADE ──────────────────────────────────────────────────
// @role('admin') … @endrole
// @can('edit posts') … @endcan
// @hasanyrole('admin|editor') … @endhasanyrole
// ── MIDDLEWARE ─────────────────────────────────────────────
// Route::get('/admin')->middleware('role:admin');
// Route::post('/posts')->middleware('permission:create posts');
// ── CACHE ──────────────────────────────────────────────────
// php artisan permission:cache-reset