← Volver al blog

Cómo implementar roles y permisos en Laravel con Spatie

2026-05-27

Cómo implementar roles y permisos en Laravel con Spatie

Introducción: El problema de la autorización

Cuando una aplicación crece, el control de acceso basado únicamente en auth()->check() se vuelve insuficiente. Necesitás diferenciar qué puede hacer cada usuario. Laravel ofrece Gates y Policies, pero para sistemas complejos con múltiples roles y permisos granulares, Spatie Permission es la solución estándar de la industria.

En este artículo vas a ver cómo implementar un sistema completo de roles y permisos: desde la instalación hasta patrones avanzados que uso en producción.

¿Por qué Spatie Permission?

La biblioteca de Spatie es la más popular de Laravel por razones concretas:

Según datos de Packagist, supera los 150 millones de descargas. Es código battle-tested.

Instalación y configuración inicial

Comenzá con la instalación vía Composer:

composer require spatie/laravel-permission

Publicá la configuración y ejecutá las migraciones:

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate

Esto crea tres tablas esenciales:

En config/permission.php podés ajustar el modelo de usuario, caché y nombres de tablas. Para la mayoría de los casos, los defaults funcionan bien.

Preparación del modelo User

Agregá el trait HasRoles a tu modelo User:

<?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',
    ];
}

Este trait inyecta todas las relaciones y métodos necesarios: assignRole(), givePermissionTo(), hasRole(), hasPermissionTo(), entre otros.

Estrategia de diseño: roles vs permisos directos

Antes de codear, definí tu estrategia. Yo recomiendo este patrón:

¿Por qué? Mantenés el principio de single responsibility. El rol es el contrato, los permisos son la implementación. Si necesitás que un usuario tenga algo especial, creá un rol específico. La excepción validan la regla: permisos directos para feature flags o acceso temporal.

Seeders: la fuente de verdad

Los roles y permisos deben vivir en seeders, no en migraciones. Esto permite recrear el entorno consistentemente:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;

class RolePermissionSeeder extends Seeder
{
    public function run(): void
    {
        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']);

        Permission::create(['name' => 'users.view']);
        Permission::create(['name' => 'users.create']);
        Permission::create(['name' => 'users.edit']);
        Permission::create(['name' => 'users.delete']);

        $adminRole = Role::create(['name' => 'admin']);
        $editorRole = Role::create(['name' => 'editor']);
        $viewerRole = Role::create(['name' => 'viewer']);

        $adminRole->givePermissionTo(Permission::all());

        $editorRole->givePermissionTo([
            'posts.view',
            'posts.create',
            'posts.edit',
            'posts.publish',
            'users.view',
        ]);

        $viewerRole->givePermissionTo(['posts.view']);
    }
}

Ejecutalo con:

php artisan db:seed --class=RolePermissionSeeder

Asignación de roles en tu aplicación

Tipicamente asignás el rol al crear el usuario, o mediante un panel de administración:

// En un UserController o evento registrado
$user = User::create([
    'name' => 'María García',
    'email' => 'maria@empresa.com',
    'password' => Hash::make('password123'),
]);

$user->assignRole('editor');

// O múltiples roles
$user->assignRole(['editor', 'revisor']);

Importante: assignRole() acumula roles. Si necesitás sincronizar, usá syncRoles().

Protección de rutas con middleware

Spatie registra middlewares automáticamente. Protegé tus rutas:

// routes/web.php
use Illuminate\Support\Facades\Route;

Route::middleware(['auth', 'role:admin'])->group(function () {
    Route::get('/usuarios', [UserController::class, 'index'])->name('users.index');
    Route::delete('/usuarios/{user}', [UserController::class, 'destroy'])->name('users.destroy');
});

Route::middleware(['auth', 'permission:posts.create'])->group(function () {
    Route::get('/articulos/crear', [PostController::class, 'create'])->name('posts.create');
    Route::post('/articulos', [PostController::class, 'store'])->name('posts.store');
});

// Múltiples condiciones con lógica
Route::middleware(['auth', 'role_or_permission:admin|posts.edit'])->group(function () {
    Route::get('/articulos/{post}/editar', [PostController::class, 'edit'])->name('posts.edit');
});

Los middlewares disponibles son role, permission y role_or_permission. Para múltiples valores, usá el separador | o , según la configuración.

Autorización en controladores

Para lógica más compleja, verificá en el controlador:

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function update(Request $request, Post $post)
    {
        // Verificar permiso específico
        if (! $request->user()->can('posts.edit')) {
            abort(403, 'No tenés permiso para editar artículos.');
        }

        // O verificar si puede editar ESTE artículo específico
        if ($request->user()->id !== $post->author_id && ! $request->user()->can('posts.edit.any')) {
            abort(403, 'Solo podés editar tus propios artículos.');
        }

        $post->update($request->validated());

        return redirect()->route('posts.show', $post);
    }

    public function destroy(Post $post)
    {
        $this->authorize('posts.delete');

        $post->delete();

        return redirect()->route('posts.index');
    }
}

Fijate cómo combino permisos de Spatie con lógica de negocio propia. El permiso posts.edit es genérico; posts.edit.any es el override para editar artículos ajenos.

Directivas Blade para interfaces condicionales

Una de las ventajas más prácticas son las directivas de Blade. Mostrá u ocultá elementos de UI sin lógica adicional:

<nav>
    <ul>
        @can('posts.view')
            <li><a href="{{ route('posts.index') }}">Artículos</a></li>
        @endcan

        @role('admin')
            <li><a href="{{ route('users.index') }}">Usuarios</a></li>
            <li><a href="{{ route('settings.index') }}">Configuración</a></li>
        @endrole

        @canany(['posts.create', 'posts.edit'])
            <li><a href="{{ route('posts.drafts') }}">Borradores</a></li>
        @endcanany

        @hasrole('editor')
            <span class="badge">Editor</span>
        @else
            <span class="badge">Lector</span>
        @endhasrole
    </ul>
</nav>

Las directivas disponibles incluyen: @can, @cannot, @canany, @role, @hasrole, @hasanyrole, @hasallroles. Todas aceptan condiciones en array o string separado por comas.

Policies + Spatie: la combinación ganadora

Spatie no reemplaza las Policies de Laravel. Las potencia. Mi patrón preferido:

<?php

namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{
    public function viewAny(User $user): bool
    {
        return $user->hasPermissionTo('posts.view');
    }

    public function view(User $user, Post $post): bool
    {
        if ($user->hasPermissionTo('posts.view.any')) {
            return true;
        }

        return $user->id === $post->author_id;
    }

    public function create(User $user): bool
    {
        return $user->hasPermissionTo('posts.create');
    }

    public function update(User $user, Post $post): bool
    {
        if ($user->hasPermissionTo('posts.edit.any')) {
            return true;
        }

        return $user->id === $post->author_id && $user->hasPermissionTo('posts.edit');
    }

    public function delete(User $user, Post $post): bool
    {
        return $user->hasPermissionTo('posts.delete') && $post->status !== 'published';
    }

    public function forceDelete(User $user, Post $post): bool
    {
        return $user->hasRole('admin');
    }
}

Registrá la policy en AuthServiceProvider o usando el atributo #[AsPolicy] en Laravel 11:

// En el modelo Post
protected $policies = [
    Post::class => PostPolicy::class,
];

// O con atributo en Laravel 11+
#[AsPolicy(Post::class)]
class PostPolicy { ... }

Protección por defecto con authorizeResource

Para las rutas resource, Laravel tiene un atajo elegante:

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Models\Post;

class PostController extends Controller
{
    public function __construct()
    {
        $this->authorizeResource(Post::class, 'post');
    }

    public function index()
    {
        $posts = Post::paginate(20);
        return view('posts.index', compact('posts'));
    }

    public function create()
    {
        return view('posts.create');
    }

    public function store(StorePostRequest $request)
    {
        $post = Post::create($request->validated() + ['author_id' => auth()->id()]);
        return redirect()->route('posts.show', $post);
    }

    // ... resto de métodos resource
}

El método authorizeResource automáticamente mapea index, create, store, show, edit, update y destroy a los métodos correspondientes de la PostPolicy. Reducción de código repetitivo garantizada.

Precarga de roles y permisos: evitando N+1

Spatie cachea por defecto, pero en bucles o listados masivos precargá relaciones:

// En tu controlador o repositorio
$users = User::with(['roles', 'permissions'])->paginate(50);

// Si solo necesitás verificar capacidades sin mostrar
$user->load(['roles', 'permissions']);

La configuración de caché está en config/permission.php con driver default por defecto (usa el de Laravel). En alta concurrencia, considerá Redis para la cache de permisos.

Testing: asegurando tu autorización

Nunca dejes la seguridad sin tests. Algunos ejemplos con Pest o PHPUnit:

<?php

use App\Models\User;
use Spatie\Permission\Models\Role;

it('permite a editores crear posts', function () {
    $user = User::factory()->create();
    $user->assignRole('editor');

    $this->actingAs($user)
        ->get(route('posts.create'))
        ->assertOk();
});

it('bloquea a viewers de crear posts', function () {
    $user = User::factory()->create();
    $user->assignRole('viewer');

    $this->actingAs($user)
        ->get(route('posts.create'))
        ->assertForbidden();
});

it('asigna rol admin durante el registro con código secreto', function () {
    $response = $this->postJson(route('register'), [
        'name' => 'Admin Test',
        'email' => 'admin@test.com',
        'password' => 'password123',
        'password_confirmation' => 'password123',
        'admin_code' => config('app.admin_registration_code'),
    ]);

    $user = User::where('email', 'admin@test.com')->first();
    expect($user->hasRole('admin'))->toBeTrue();
});

Conclusión

Spatie Permission transforma la autorización de un dolor de cabeza en una implementación estructurada y mantenible. Los puntos clave para llevar:

La implementación que mostré acá es la que uso en sistemas con miles de usuarios activos diariamente. Es predecible, auditable y extensible. ¿Tenés un caso de uso particular donde Spatie te generó dudas? El código fuente es limpio y la documentación oficial es sólida, pero siempre hay edge cases interesantes en autorización.