📦 Laravel Package Guide

Master Spatie Roles &
Permissions
in Laravel

A complete, beginner-friendly reference covering installation, configuration, advanced usage, Blade directives, API guards, and real-world patterns — with live UI previews at every step.

Laravel 10 / 11 spatie/laravel-permission PHP 8.2+ RBAC Middleware Blade Directives
01

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

User
Auth Model
Role
admin · editor
Permission
edit posts · delete users
Gate / Policy
can() · @can

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
02

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.

bash terminal
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:

php 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 app/Models/User.php
<?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.

03

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

bash terminal
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

bash terminal
php artisan migrate

Database schema created

After migration you'll have exactly these 5 tables — here's how they relate:

roles
◆ idbigint PK
namevarchar
guard_namevarchar
role_has_permissions
◇ permission_idFK
◇ role_idFK
permissions
◆ idbigint PK
namevarchar
guard_namevarchar
model_has_permissions
◇ permission_idFK
model_typevarchar
◇ model_idbigint
model_has_roles
◇ role_idFK
model_typevarchar
◇ model_idbigint

Understanding the config file

Open config/permission.php — here are the most important settings:

php config/permission.php
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.
    ],

];
04

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.

php Creating permissions
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().

php Creating roles
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
05

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

php Assigning roles to users
$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.

php Direct permissions on a user
// 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.

06

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

php Role check methods
$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

php Permission check methods
// 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 app/Http/Controllers/PostController.php
<?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.

07

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:

php app/Http/Kernel.php (Laravel 10)
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)

php bootstrap/app.php (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

php routes/web.php
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.

08

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

bash terminal
php artisan make:seeder RolesAndPermissionsSeeder
php database/seeders/RolesAndPermissionsSeeder.php
<?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

bash terminal
php artisan db:seed --class=RolesAndPermissionsSeeder

Step 3 — The Admin Controller

php app/Http/Controllers/AdminController.php
<?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:

php Querying users by role
// 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);
09

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

blade resources/views/example.blade.php
{{-- ── 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

https://myapp.test/posts
MyApp Blog
🛡 Admin John Smith

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

12
Total Users
3
Admins
47
Posts
5
Roles

Editor View — edit allowed, delete & admin panel hidden

https://myapp.test/posts
MyApp Blog
✏️ Editor Sarah Jones

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

https://myapp.test/posts
MyApp Blog
👁 Viewer Mike Lee

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

https://myapp.test/admin
🚫

403 — Access Denied

You don't have permission to access this page. Contact your administrator.

The Blade template that powers these views

blade resources/views/posts/index.blade.php
@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
10

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

php database/seeders/DatabaseSeeder.php
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:

bash terminal
# Clear the Spatie permission cache
php artisan permission:cache-reset

# Or clear all application cache
php artisan cache:clear
php Clearing cache in code
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.

11

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

php config/permission.php
// 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 app/Http/Controllers/Api/PostController.php
<?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

php Return user + roles in login 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.

12

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 tests/Feature/AdminAccessTest.php
<?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

php Cheat Sheet
// ── 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

Remember: Access control is a security boundary. Always validate on the server side — Blade directives only hide UI elements, they do not prevent direct HTTP requests to protected endpoints.

📦
Further Reading
Official resources to go deeper