Crea un menú dinámico con roles y permisos en Laravel

Crea un menú dinámico con roles y permisos en Laravel

En este blog aprenderás cómo implementar roles y permisos en un menú de navegación para que los usuarios solo vean las opciones de acuerdo a sus permisos.

¿Por qué es importante los roles y permisos?

En una aplicación web, es importante que cada usuario tenga un conjunto de permisos específicos que determinen qué acciones pueden realizar en la plataforma. Los roles son un conjunto de permisos que se asignan a un usuario específico. Por ejemplo:

La asignación de permisos a un rol permite controlar quién puede realizar ciertas acciones en la aplicación.

Requisitos Previos

He preparado un pequeño proyecto base que contiene todo lo necesario para empezar el tutorial, lo puedes descargar aquí. Lo que contiene es:

  • Una tabla usuario con la columna “role”.
  • La clase User (Administrator, Teacher y Student) tiene 3 roles estáticos.
  • Un seeder para la tabla usuarios en el cual nos registrará automáticamente los usuarios con sus respectivos roles.

Luego de clonar el repositorio, nos vamos al branch starter con el siguiente comando.

1git checkout starter

Luego creamos las tablas con información de prueba en la base de datos con el siguiente comando.

1php artisan migrate --seed

Asignando Permisos o Acciones

Laravel tiene una característica llamada Gates, el cual nos sirve para poder crear acciones o permisos y determinar qué usuario está autorizado para ejecutarlo.

En este caso vamos a crear 2 acciones:

  • see-reports: solo el administrador será el autorizado para ver los reportes..
  • register-attendance: solo el profesor será el autorizado para registrar la asistencia.

Lo hacemos de la siguiente manera en el archivo app/Providers/AuthServiceProvider.php.

1<?php
2 
3namespace App\Providers;
4 
5use App\Models\User;
6use Illuminate\Support\Facades\Gate;
7use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
8 
9class AuthServiceProvider extends ServiceProvider
10{
11 /**
12 * The model to policy mappings for the application.
13 *
14 * @var array<class-string, class-string>
15 */
16 protected $policies = [
17 // 'App\Models\Model' => 'App\Policies\ModelPolicy',
18 ];
19 
20 /**
21 * Register any authentication / authorization services.
22 */
23 public function boot(): void
24 {
25 Gate::define('see-reports', fn(User $user) =>
26 $user->role == User::ROLE_ADMINISTRATOR
27 );
28 
29 Gate::define('register-attendance', fn(User $user) =>
30 $user->role == User::ROLE_TEACHER
31 );
32 }
33}

Procedemos a crear las rutas en el archivo routes/web.php.

1<?php
2 
3use App\Http\Controllers\ProfileController;
4use Illuminate\Support\Facades\Route;
5 
6/*
7|--------------------------------------------------------------------------
8| Web Routes
9|--------------------------------------------------------------------------
10|
11| Here is where you can register web routes for your application. These
12| routes are loaded by the RouteServiceProvider and all of them will
13| be assigned to the "web" middleware group. Make something great!
14|
15*/
16 
17Route::get('/', function () {
18 return view('welcome');
19});
20 
21Route::get('/dashboard', function () {
22 return view('dashboard');
23})->middleware(['auth', 'verified'])->name('dashboard');
24 
25Route::middleware('auth')->group(function () {
26 Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
27 Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
28 Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
29 
30 Route::get('reports', function () {
31 return view('reports');
32 })->name('reports');
33 
34 Route::get('register-attendance', function () {
35 return view('register-attendance');
36 })->name('register-attendance');
37});
38 
39require __DIR__.'/auth.php';

Además también creamos las vistas, duplicaremos la vista del resources/views/dashboard.blade.php y crearemos dos vistas. Esta es la vista resources/views/reports.blade.php

1<x-app-layout>
2 <x-slot name="header">
3 <h2 class="font-semibold text-xl text-gray-800 leading-tight">
4 {{ __('Reportes') }}
5 </h2>
6 </x-slot>
7 
8 <div class="py-12">
9 <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
10 <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
11 <div class="p-6 text-gray-900">
12 {{ __("You're logged in!") }}
13 </div>
14 </div>
15 </div>
16 </div>
17</x-app-layout>

Esta es la vista resources/views/register-attendance.blade.php.

1<x-app-layout>
2 <x-slot name="header">
3 <h2 class="font-semibold text-xl text-gray-800 leading-tight">
4 {{ __('Registrar Asistencia') }}
5 </h2>
6 </x-slot>
7 
8 <div class="py-12">
9 <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
10 <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
11 <div class="p-6 text-gray-900">
12 {{ __("You're logged in!") }}
13 </div>
14 </div>
15 </div>
16 </div>
17</x-app-layout>

Integración en el menú

Ya tenemos las rutas y las vistas creadas, vamos a actualizar nuestro menú de navegación en el archivo resources/views/layouts/navigation.blade.php.

Por ello utilizaremos la directiva blade llamada @can/@endcan que nos permite usar las acciones o gates que hemos definido anteriormente y usarlas como condicional.

1<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
2 <!-- Primary Navigation Menu -->
3 <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
4 <div class="flex justify-between h-16">
5 <div class="flex">
6 <!-- Logo -->
7 <div class="shrink-0 flex items-center">
8 <a href="{{ route('dashboard') }}">
9 <x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
10 </a>
11 </div>
12 
13 <!-- Navigation Links -->
14 <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
15 <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
16 {{ __('Dashboard') }}
17 </x-nav-link>
18 @can('see-reports')
19 <x-nav-link :href="route('reports')" :active="request()->routeIs('reports')">
20 {{ __('Reportes') }}
21 </x-nav-link>
22 @endcan
23 @can('register-attendance')
24 <x-nav-link :href="route('register-attendance')" :active="request()->routeIs('register-attendance')">
25 {{ __('Registrar Asistencia') }}
26 </x-nav-link>
27 @endcan
28 </div>
29 </div>
30 
31 <!-- Settings Dropdown -->
32 <div class="hidden sm:flex sm:items-center sm:ml-6">
33 <x-dropdown align="right" width="48">
34 <x-slot name="trigger">
35 <button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
36 <div>{{ Auth::user()->name }}</div>
37 
38 <div class="ml-1">
39 <svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
40 <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
41 </svg>
42 </div>
43 </button>
44 </x-slot>
45 
46 <x-slot name="content">
47 <x-dropdown-link :href="route('profile.edit')">
48 {{ __('Profile') }}
49 </x-dropdown-link>
50 
51 <!-- Authentication -->
52 <form method="POST" action="{{ route('logout') }}">
53 @csrf
54 
55 <x-dropdown-link :href="route('logout')"
56 onclick="event.preventDefault();
57 this.closest('form').submit();">
58 {{ __('Log Out') }}
59 </x-dropdown-link>
60 </form>
61 </x-slot>
62 </x-dropdown>
63 </div>
64 
65 <!-- Hamburger -->
66 <div class="-mr-2 flex items-center sm:hidden">
67 <button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
68 <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
69 <path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
70 <path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
71 </svg>
72 </button>
73 </div>
74 </div>
75 </div>
76 
77 <!-- Responsive Navigation Menu -->
78 <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
79 <div class="pt-2 pb-3 space-y-1">
80 <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
81 {{ __('Dashboard') }}
82 </x-responsive-nav-link>
83 @can('see-reports')
84 <x-responsive-nav-link :href="route('reports')" :active="request()->routeIs('reports')">
85 {{ __('Reportes') }}
86 </x-responsive-nav-link>
87 @endcan
88 @can('register-attendance')
89 <x-responsive-nav-link :href="route('register-attendance')" :active="request()->routeIs('register-attendance')">
90 {{ __('Registrar Asistencia') }}
91 </x-responsive-nav-link>
92 @endcan
93 </div>
94 
95 <!-- Responsive Settings Options -->
96 <div class="pt-4 pb-1 border-t border-gray-200">
97 <div class="px-4">
98 <div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
99 <div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
100 </div>
101 
102 <div class="mt-3 space-y-1">
103 <x-responsive-nav-link :href="route('profile.edit')">
104 {{ __('Profile') }}
105 </x-responsive-nav-link>
106 
107 <!-- Authentication -->
108 <form method="POST" action="{{ route('logout') }}">
109 @csrf
110 
111 <x-responsive-nav-link :href="route('logout')"
112 onclick="event.preventDefault();
113 this.closest('form').submit();">
114 {{ __('Log Out') }}
115 </x-responsive-nav-link>
116 </form>
117 </div>
118 </div>
119 </div>
120</nav>

Si entramos a la web con un usuario “Administrador” podemos ver el menú “Ver Reportes”. El usuario es “administrador@laraveleando.com” y la contraseña es “password”.

Y si ingresamos con un usuario “Profesor” podemos ver el menú “Registrar Asistencia”. El usuario es “profesor@laraveleando.com” y la contraseña es “password”.

Hasta ahora hemos validado nuestro frontend, pero nuestro backend está expuesto 😱 y te voy a mostrar por qué. Si como profesor queremos ver los reportes escribiendo la ruta en el navegador nos daremos cuenta que podemos ingresar y eso pasa porque no hemos protegido nuestras rutas, solo las vistas.

Protegiendo nuestras Rutas

Gracias a los Gates, podemos usar la función “authorize” lo cual validará si el usuario en cuestión tiene el permiso a evaluar. Para ello validaremos en la ruta “/reports” si el usuario es un administrador y en la ruta “/register-attendance” si el usuario es profesor. Agregamos las validaciones en cada ruta de nuestro routes/web.php.

1<?php
2 
3use App\Http\Controllers\ProfileController;
4use Illuminate\Support\Facades\Gate;
5use Illuminate\Support\Facades\Route;
6 
7/*
8|--------------------------------------------------------------------------
9| Web Routes
10|--------------------------------------------------------------------------
11|
12| Here is where you can register web routes for your application. These
13| routes are loaded by the RouteServiceProvider and all of them will
14| be assigned to the "web" middleware group. Make something great!
15|
16*/
17 
18Route::get('/', function () {
19 return view('welcome');
20});
21 
22Route::get('/dashboard', function () {
23 return view('dashboard');
24})->middleware(['auth', 'verified'])->name('dashboard');
25 
26Route::middleware('auth')->group(function () {
27 Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
28 Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
29 Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
30 
31 Route::get('reports', function () {
32 Gate::authorize('see-reports');
33 return view('reports');
34 })->name('reports');
35 
36 Route::get('register-attendance', function () {
37 Gate::authorize('register-attendance');
38 return view('register-attendance');
39 })->name('register-attendance');
40});
41 
42require __DIR__.'/auth.php';

Mensaje que nos aparece si ingresamos como profesor a ver los reportes o como administrador a registrar la asistencia.

Creando tests

Los tests nos ayudarán a poder darle más integridad a nuestra aplicación ya que nos da una mayor fiabilidad de que no rompamos algo cuando estemos añadiendo funcionalidades nuevas.

En este caso usaremos Pest, si quieres saber más sobre este framework de Testing para PHP, te dejaré los enlaces de los videos que he hecho al finalizar el blog.

Laravel nos trae tests ya hechos para que podamos usarlo como referencia, si en el terminal ejecutamos

1./vendor/bin/pest

o

1php artisan test

De esa manera veremos los tests que tenemos por el momento.

El test #1 será que el usuario administrador pueda ver los reportes. Para este tutorial los tests los crearemos en el archivo tests/Feature/ExampleTest.php.

1<?php
2 
3use App\Models\User;
4 
5it('returns a successful response', function () {
6 $response = $this->get('/');
7 
8 $response->assertStatus(200);
9});
10 
11test('admin can see reports', function () {
12 // Creamos un usuario administrador
13 $adminUser = User::factory()
14 ->administrator()
15 ->create();
16 
17 // Nos logueamos con el usuario administrador
18 $response = $this->actingAs($adminUser);
19 
20 // Nos vamos a la ruta "/reports" y esperamos que
21 // la petición sea correcta
22 $response->get('/reports')
23 ->assertOk();
24});

De la misma manera crearemos los siguientes 3 tests:

  • El profesor no pueda entrar a ver los reportes.
  • El profesor pueda entrar a registrar la asistencia.
  • El administrador no pueda entrar a registrar la asistencia.

Entonces nuestro archivo de tests quedaría así.

1<?php
2 
3use App\Models\User;
4 
5it('returns a successful response', function () {
6 $response = $this->get('/');
7 
8 $response->assertStatus(200);
9});
10 
11test('admin can see reports', function () {
12 // Creamos un usuario administrador
13 $adminUser = User::factory()
14 ->administrator()
15 ->create();
16 
17 // Nos logueamos con el usuario administrador
18 $response = $this->actingAs($adminUser);
19 
20 // Nos vamos a la ruta "/reports" y esperamos que
21 // la petición sea correcta
22 $response->get('/reports')
23 ->assertOk();
24});
25 
26test('teacher cannot see reports', function () {
27 $teacherUser = User::factory()
28 ->teacher()
29 ->create();
30 
31 $this->actingAs($teacherUser)
32 ->get('/reports')
33 ->assertForbidden();
34});
35 
36test('teacher can register attendance', function () {
37 $teacherUser = User::factory()
38 ->teacher()
39 ->create();
40 
41 $this->actingAs($teacherUser)
42 ->get('/register-attendance')
43 ->assertOk();
44});
45 
46test('admin cannot register attendance', function () {
47 $adminUser = User::factory()
48 ->administrator()
49 ->create();
50 
51 $this->actingAs($adminUser)
52 ->get('/register-attendance')
53 ->assertForbidden();
54});

Vamos a ejecutar nuestros tests con el siguiente comando:

1php artisan test --filter ExampleTest
  • —filter: nos sirve para solo ejecutar los tests del archivo que queremos.

Conclusiones

Esta es tan sola una manera de poder manejar los roles y permisos en nuestras aplicaciones Laravel, para algunos proyectos sencillos puede funcionar, para otros quizá se necesite del uso de Policies o paquetes como Laravel Permissions.

No olvides suscribirte a mis canales de Youtube, TikTok y también a mi Newsletter para que te enteres del próximo tutorial donde aprenderás sobre cómo usar los Policies y seguir dándole seguridad a tus aplicaciones Laravel.

Versión Video

Repositorio en Github

Regístrate para que cada semana aprendas algo nuevo

Tendrás tutoriales, tips, conceptos y puedas convertirte en un artesano de todo el ecosistema Laravel.

Nuevo Curso
Chat en Tiempo Real con Laravel Broadcasting

Revisa los detalles del nuevo curso en desarrollo

App screenshot