Cómo implementar roles y permisos en Laravel con Spatie
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:
- Flexibilidad: Soporta roles y permisos directos sobre usuarios
- Caché integrado: Las consultas se cachean automáticamente
- Blade directives: Directivas propias para vistas
- Middleware: Protección de rutas por roles o permisos
- Migraciones limpias: Todo viaja en control de versiones
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:
roles: Almacena los roles del sistemapermissions: Almacena los permisosmodel_has_rolesymodel_has_permissions: Relaciones polimórficasrole_has_permissions: Matriz de permisos por rol
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:
- Roles para agrupar:
admin,editor,viewer - Permisos para acciones:
posts.create,posts.edit,users.delete - Nunca asignés permisos directos a usuarios excepto casos excepcionales
¿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:
- Seeders como contrato: Definí roles y permisos ahí, documentá explícitamente
- Middleware para rutas, Policies para modelos, Blade para UI: Capas separadas
- Prefijá tus permisos con el recurso:
posts.create, nocreate_posts. Facilita búsqueda y autocompletado - Testeá los boundaries: Qué pasa con usuarios sin roles, con roles múltiples, con permisos revocados
- Evitá la tentación de
isAdmin()en tu código. Eso es un rol hardcodeado. Usá capacidades
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.