Gestion des rôles & permissions (Spatie)

Guide pratique et cohérent pour mettre en place et appliquer les rôles & permissions avec spatie/laravel-permission dans les routes, contrôleurs, Blade et Livewire.

Pré‑requis rapides

  • use Spatie\Permission\Traits\HasRoles;
    
        class User extends Authenticatable
        {
            use HasRoles;
            // Optionnel si multi-guard:
            // protected $guard_name = 'web';
        }
  • Purger le cache après seed/migration :
    use Spatie\Permission\PermissionRegistrar;
    
        app()[PermissionRegistrar::class]->forgetCachedPermissions();

    À mettre en fin de seeder ou dans une commande artisan de maintenance.

  • Convention de nommage : resource.action (ex: users.view) ou action-resource (ex: view-users). Différencier own vs any si pertinent.

Assigner rôles & permissions

$user = User::find($id);

    // Rôle(s)
    $user->assignRole('admin');              // ajoute
    $user->syncRoles(['admin','editor']);    // remplace

    // Permission(s) directe(s)
    $user->givePermissionTo('manage-settings');
    $user->syncPermissions(['view-users','edit-users']);

    // Vérifier
    $user->hasRole('admin');                 // bool
    $user->can('view-users');                // bool (hérite des rôles)
    $user->hasAnyRole(['admin','manager']);  // bool

Middleware dans les routes

Route::middleware(['auth','role:admin'])->group(function () {
        Route::get('/admin', AdminController::class)->name('admin.dashboard');
    });

    Route::middleware(['auth','permission:view-users'])->group(function () {
        Route::get('/users', [UserController::class, 'index'])->name('users.index');
    });

    Route::middleware(['auth','role_or_permission:admin|manage-settings'])->group(function () {
        Route::get('/settings', SettingsController::class)->name('settings.index');
    });

Avantage : logique d’accès proche de l’URL, lisible et robuste.

Contrôleurs

class UserController extends Controller
    {
        public function __construct()
        {
            $this->middleware(['permission:view-users'])->only('index','show');
            $this->middleware(['permission:create-users'])->only('create','store');
            $this->middleware(['permission:edit-users'])->only('edit','update');
            $this->middleware(['permission:delete-users'])->only('destroy');
        }
    }

    public function destroy(User $user)
    {
        $this->authorize('delete-users');
        // ou bien
        abort_unless(auth()->user()->can('delete-users'), 403);

        $user->delete();
        return back()->with('ok','Utilisateur supprimé.');
    }

Blade


    @role('admin')
    <a href="{{ route('users.create') }}" class="btn">Créer un utilisateur</a>
    @endrole

    @hasanyrole('admin|manager')
    <a href="{{ route('roles.index') }}" class="btn">Rôles</a>
    @endhasanyrole

    @can('edit-users')
    <a href="{{ route('users.edit', $user) }}" class="btn">Éditer</a>
    @endcan

    @cannot('delete-users')
    <span class="text-gray-400">Suppression non autorisée</span>
    @endcannot

    @canany(['view-users', 'view-own-appointments'])
    <x-users.table :users="$users" />
    @endcanany
    

Astuce UI : parfois afficher un bouton grisé avec un tooltip “Autorisation requise : edit-users”.

Livewire 3

class UsersTable extends \Livewire\Component
    {
        public function mount()
        {
            abort_unless(auth()->user()->can('view-users'), 403);
        }

        public function deleteUser(int $userId)
        {
            abort_unless(auth()->user()->can('delete-users'), 403);

            User::findOrFail($userId)->delete();
            $this->dispatch('userDeleted');
        }

        public function render()
        {
            $users = auth()->user()->can('view-users')
                ? User::query()->latest()->paginate(15)
                : collect();

            return view('livewire.users-table', compact('users'));
        }
    }

Policies & Gates

class UserPolicy
    {
        public function viewAny(User $actor): bool
        {
            return $actor->can('view-users');
        }

        public function view(User $actor, User $subject): bool
        {
            return $actor->can('view-users') || $actor->is($subject);
        }

        public function create(User $actor): bool
        {
            return $actor->can('create-users');
        }

        public function update(User $actor, User $subject): bool
        {
            return $actor->can('edit-users');
        }

        public function delete(User $actor, User $subject): bool
        {
            return $actor->can('delete-users');
        }
    }

    // AuthServiceProvider
    protected $policies = [
        \App\Models\User::class => \App\Policies\UserPolicy::class,
    ];

    // Usage
    $this->authorize('update', $user);
    // Blade: @can('delete', $user)
    ...
    @endcan
    

Cas own vs any

// Policy pour Appointment
    public function view(User $actor, Appointment $appt): bool
    {
        if ($actor->can('view-appointments')) { // any
            return true;
        }

        if ($actor->can('view-own-appointments')) {
            return $appt->user_id === $actor->id;
        }

        return false;
    }

    // Dans une requête
    $query = Appointment::query();

    if (!auth()->user()->can('view-appointments') && auth()->user()->can('view-own-appointments')) {
        $query->where('user_id', auth()->id());
    }

Bonnes pratiques

  • Commencer large (view/create/edit/delete), affiner si besoin réel.
  • Vérifier côté UI avec @can/@role mais centraliser la logique côté Policies/contrôleurs.
  • Nommer de manière cohérente et invalider le cache après seed/déploiement.
  • Multi‑guard : préciser guard_name sur Role/Permission/User si nécessaire.

Tests (Pest)

it('lets admin list users', function () {
        $admin = User::factory()->create()->assignRole('admin');

        actingAs($admin)
            ->get(route('users.index'))
            ->assertOk();
    });

    it('forbids regular user to delete users', function () {
        $user = User::factory()->create()->assignRole('user');
        $target = User::factory()->create();

        actingAs($user)
            ->delete(route('users.destroy', $target))
            ->assertForbidden();
    });

Seeder : micro‑ajustements

// Multi‑guard explicite
    Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'web']);
    $role = Role::firstOrCreate(['name' => $roleName, 'guard_name' => 'web']);

    // Purge du cache en fin de seeder
    app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
    $this->command->info('Rôles & permissions rafraîchis.');

Exemples rapides

// Menu admin
    @can('access-admin')
    <x-nav.link href="{{ route('admin.dashboard') }}">Admin</x-nav.link>
    @endcan

    // Route group admin
    Route::prefix('admin')
        ->middleware(['auth','permission:access-admin'])
        ->group(function () {
            Route::get('/', AdminController::class)->name('admin.dashboard');
            Route::resource('users', UserController::class)->middleware('permission:view-users');
        });

    // Boutons d’action
    @can('edit-users')
    <a href="{{ route('users.edit', $user) }}" class="btn btn-sm">Éditer</a>
    @endcan
    @can('delete-users')
    <form method="POST" action="{{ route('users.destroy', $user) }}" class="inline">
    @csrf @method('DELETE')
    <button class="btn btn-sm btn-danger" onclick="return confirm('Supprimer ?')">Supprimer</button>
    </form>
    @endcan

    // Livewire – guard action
    public function promoteToAdmin(int $id)
    {
        abort_unless(auth()->user()->can('edit-users'), 403);

        $u = User::findOrFail($id);
        $u->syncRoles(['admin']);
        $this->dispatch('toast', body: 'Promu admin');
    }

Règles claires, accès maîtrisé

Définissez des permissions lisibles, appliquez‑les partout (routes, contrôleurs, Policies, UI), et purgez le cache après déploiement pour garantir un comportement fiable.