How to implement roles and permissions in Laravel with Spatie
Introduction
Implementing a robust role and permission system is essential for any application that requires different levels of access control. Whether you are building a content management system, an e-commerce platform, or an internal dashboard, you need to ensure users can only perform actions they are authorized to do. In Laravel, the most popular and battle-tested solution for this is the Spatie Laravel Permission package. This package provides a flexible way to manage roles and permissions through database-driven access control.
In this guide, you will learn how to install, configure, and implement roles and permissions in your Laravel application using Spatie. We will cover model setup, creating roles and permissions, middleware configuration, blade directives, and best practices for production environments.
Why Choose Spatie Laravel Permission
Before diving into implementation, it is worth understanding why Spatie's package has become the de facto standard in the Laravel ecosystem. The package offers native support for caching permission and role lookups, which significantly reduces database queries on subsequent requests. It integrates seamlessly with Laravel's built-in authorization features including Gates and Policies. The package supports both roles and direct permissions simultaneously, allowing you to assign permissions directly to users or through roles. Additionally, it provides blade directives and middleware for convenient access control in views and routes.
Installation and Configuration
Installing the Package
Start by installing the package via Composer. Run the following command in your terminal:
composer require spatie/laravel-permission
After installation, publish the migration and configuration files:
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
This command publishes a migration file that creates the necessary tables for roles and permissions, and a configuration file located at config/permission.php.
Running Migrations
Execute the migrations to create the following tables in your database:
php artisan migrate
The migration creates four tables: permissions, roles, model_has_permissions, and model_has_roles. The polymorphic relationship design allows multiple user models to share the same permission infrastructure if needed.
Updating the User Model
Next, add the HasRoles trait to your User model. This trait provides all the necessary methods for working with roles and permissions:
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
}
The HasRoles trait automatically sets up the relationships between users, roles, and permissions, and provides methods such as assignRole(), removeRole(), givePermissionTo(), and revokePermissionTo().
Creating Roles and Permissions
Seeding Initial Data
For a production application, you should define your roles and permissions in a database seeder. This ensures consistency across development, staging, and production environments. Create a seeder with the following structure:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
class RolesAndPermissionsSeeder extends Seeder
{
public function run(): void
{
// Reset cached roles and permissions
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
// Create permissions
Permission::create(['name' => 'users.view']);
Permission::create(['name' => 'users.create']);
Permission::create(['name' => 'users.edit']);
Permission::create(['name' => 'users.delete']);
Permission::create(['name' => 'posts.view']);
Permission::create(['name' => 'posts.create']);
Permission::create(['name' => 'posts.edit']);
Permission::create(['name' => 'posts.delete']);
Permission::create(['name' => 'posts.publish']);
// Create roles and assign permissions
$adminRole = Role::create(['name' => 'admin']);
$adminRole->givePermissionTo(Permission::all());
$editorRole = Role::create(['name' => 'editor']);
$editorRole->givePermissionTo([
'posts.view',
'posts.create',
'posts.edit',
'posts.publish',
]);
$authorRole = Role::create(['name' => 'author']);
$authorRole->givePermissionTo([
'posts.view',
'posts.create',
'posts.edit',
]);
$viewerRole = Role::create(['name' => 'viewer']);
$viewerRole->givePermissionTo([
'posts.view',
]);
}
}
Notice the call to forgetCachedPermissions() at the beginning. Spatie caches permissions and roles to optimize performance, so you must clear the cache when modifying permission data programmatically.
Assigning Roles to Users
With your seed data in place, you can assign roles to users in your application logic. Here is how to assign a role during user registration or through an admin panel:
use App\Models\User;
// Assign role during user creation
$user = User::create([
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => bcrypt('password'),
]);
$user->assignRole('editor');
// Assign multiple roles
$user->assignRole(['author', 'viewer']);
// Remove a role
$user->removeRole('viewer');
// Sync roles (removes all existing and sets the given)
$user->syncRoles(['editor']);
Protecting Routes with Middleware
Role-Based Middleware
Spatie registers middleware automatically that you can use in your routes. To restrict access based on roles, use the role middleware:
use Illuminate\Support\Facades\Route;
Route::group(['middleware' => ['role:admin']], function () {
Route::get('/admin/dashboard', [AdminController::class, 'dashboard']);
Route::get('/admin/settings', [AdminController::class, 'settings']);
});
// Multiple roles with OR logic
Route::group(['middleware' => ['role:admin|editor']], function () {
Route::get('/posts/manage', [PostController::class, 'manage']);
});
Permission-Based Middleware
For more granular control, use the permission middleware. This approach is generally preferred because it is more flexible and aligns better with the principle of least privilege:
Route::group(['middleware' => ['permission:posts.create']], function () {
Route::get('/posts/create', [PostController::class, 'create']);
Route::post('/posts', [PostController::class, 'store']);
});
// Multiple permissions with OR logic
Route::group(['middleware' => ['permission:posts.edit|posts.delete']], function () {
Route::get('/posts/{post}/edit', [PostController::class, 'edit']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});
// Multiple permissions with AND logic
Route::group(['middleware' => ['permission:posts.publish,posts.edit']], function () {
Route::post('/posts/{post}/publish', [PostController::class, 'publish']);
});
Role or Permission Middleware
You can also combine both checks with the role_or_permission middleware, which is useful for edge cases where you want to allow access based on either criteria:
Route::get('/special-feature', [SpecialController::class, 'index'])
->middleware(['role_or_permission:admin,special-feature.access']);
Authorization in Controllers
Beyond middleware, you can perform permission checks directly in your controllers using Laravel's authorization methods. This is useful when you need conditional logic or when the authorization decision depends on runtime data:
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PostController extends Controller
{
public function update(Request $request, Post $post)
{
if (!Auth::user()->can('posts.edit')) {
abort(403, 'Unauthorized action.');
}
// Alternative using authorize helper
$this->authorize('posts.edit');
// Or using policy with permission check
$this->authorize('update', $post);
$post->update($request->validated());
return redirect()->route('posts.index');
}
public function destroy(Post $post)
{
$this->authorize('posts.delete');
$post->delete();
return redirect()->route('posts.index');
}
}
Blade Directives for UI Control
Spatie provides blade directives that allow you to conditionally render UI elements based on the current user's roles and permissions. This prevents users from seeing actions they cannot perform:
@role('admin')
<a href="/admin/dashboard">Admin Dashboard</a>
@endrole
@hasrole('editor')
<p>Welcome, Editor!</p>
@endhasrole
@hasanyrole(['editor', 'admin'])
<a href="/posts/manage">Manage Posts</a>
@endhasanyrole
@can('posts.create')
<a href="/posts/create" class="btn btn-primary">Create New Post</a>
@endcan
@can('posts.edit')
<a href="/posts/{{ $post->id }}/edit">Edit</a>
@endcan
@can('posts.delete')
<form action="/posts/{{ $post->id }}" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">Delete</button>
</form>
@endcan
@cannot('posts.publish')
<p class="text-muted">Publishing requires editor privileges.</p>
@endcannot
Always remember that blade directives only control UI visibility. They do not replace server-side authorization checks, as malicious users could bypass the UI and send direct requests to your endpoints.
Working with Policies and Gates
For complex authorization logic, combine Spatie permissions with Laravel's policies. You can check permissions within policy methods to create reusable, testable authorization rules:
<?php
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
public function view(User $user, Post $post): bool
{
return $user->can('posts.view');
}
public function create(User $user): bool
{
return $user->can('posts.create');
}
public function update(User $user, Post $post): bool
{
if ($user->can('posts.edit')) {
return true;
}
// Authors can edit their own posts
return $user->id === $post->user_id && $user->can('posts.create');
}
public function delete(User $user, Post $post): bool
{
return $user->can('posts.delete');
}
public function publish(User $user, Post $post): bool
{
return $user->can('posts.publish');
}
}
Register the policy in AuthServiceProvider:
protected $policies = [
Post::class => PostPolicy::class,
];
Performance Optimization and Caching
Spatie automatically caches permissions and roles to minimize database queries. By default, cache duration is set in the configuration file. For high-traffic applications, consider these optimization strategies:
- Keep permission names short but descriptive to reduce cache payload size
- Use eager loading when querying users with their roles and permissions
- Schedule periodic cache clears if your permission structure changes frequently
- Monitor cache hit rates using Laravel Telescope or your monitoring solution
To manually clear the permission cache when deploying changes:
php artisan permission:cache-reset
Testing Your Authorization Logic
Write comprehensive tests for your authorization rules. Here is an example using PHPUnit:
<?php
namespace Tests\Feature;
use App\Models\User;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostAuthorizationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Permission::create(['name' => 'posts.create']);
Permission::create(['name' => 'posts.edit']);
Permission::create(['name' => 'posts.delete']);
Role::create(['name' => 'editor'])->givePermissionTo(['posts.create', 'posts.edit']);
Role::create(['name' => 'admin'])->givePermissionTo(Permission::all());
}
public function test_editor_can_create_posts(): void
{
$editor = User::factory()->create();
$editor->assignRole('editor');
$response = $this->actingAs($editor)->get('/posts/create');
$response->assertOk();
}
public function test_unauthorized_user_cannot_delete_posts(): void
{
$editor = User::factory()->create();
$editor->assignRole('editor');
$response = $this->actingAs($editor)->delete('/posts/1');
$response->assertForbidden();
}
}
Conclusion
Implementing roles and permissions in Laravel with Spatie provides a powerful, flexible, and performant foundation for application security. By following the patterns outlined in this guide, seeding your initial data, protecting routes with middleware, using blade directives for UI control, and combining with Laravel policies for business logic, you can build authorization systems that scale with your application requirements.
The key practices to remember are: always verify permissions server-side regardless of UI state, organize your permissions around specific actions rather than vague roles, keep your seeders maintained asyour application evolves, and leverage caching while understanding when to invalidate. With these principles in place, your Laravel application will have a security model that is both maintainable and robust.