← Back to blog

How to implement roles and permissions in Laravel with Spatie

2026-05-27

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:

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.