Welcome to this course about Roles and Permissions. There are many ways to implement them in Laravel, so the goal of this course is to cover the most common scenarios, starting from simple ones and finishing with a complex project.
We will cover:
The lessons are quite long. Each lesson contains one full project about Task Management with different roles/permissions logic and with a link to the repository/branch at the end.
Each repository includes automated Pest tests. I want to emphasize how important it is to cover permission logic through tests because it's one of the main security risks for projects, with the most significant consequences if it is not tested properly.
So, let's dive into our first project!
The functionality of a Laravel project may be visible to different roles in various ways. In this lesson, we will start with the most typical one: separate areas.
What I mean is to separate ALL Laravel files by role in their sub-folders: for admin and simple user:
Important notice: every lesson in this course will have a repository at the end of the lesson, with automated tests included.
For example, by the end of this first lesson, we will have THIS as a proof that the functions work:
In this course, our users will manage Tasks like a to-do list.
I deliberately chose a very simple object: we're focusing on roles and permissions here, whatever those users actually use. In real life, you would still need to apply that role-permission logic to your specific projects.
Migration:
Schema::create('tasks', function (Blueprint $table) { $table->id(); $table->string('name'); $table->date('due_date')->nullable(); $table->foreignId('user_id')->nullable()->constrained(); $table->timestamps();});
app/Models/Task.php:
namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsTo; class Task extends Model{ use HasFactory; public function user(): BelongsTo { return $this->belongsTo(User::class); }}
is_admin
ColumnWe will create the users.is_admin
column for this simple example.
Migration:
Schema::table('users', function (Blueprint $table) { $table->boolean('is_admin')->default(false);});
Also, we added it as a fillable column in the model.
app/Models/User.php:
class User extends Authenticatable{ protected $fillable = [ 'name', 'email', 'password', 'is_admin', ];
We will cover more complex examples later in this course. For now, our goal is to showcase the separated areas.
We will use Laravel Breeze as a starter kit for this project. So, we have typical Auth Controllers covered, we just need to create Controllers for viewing/managing the Tasks.
First, the Controller for the Admin, in its own subfolder/namespace:
php artisan make:controller Admin/TaskController
Inside, we load all the tasks with their users:
app/Http/Controllers/Admin/TaskController.php:
namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller;use App\Models\Task;use Illuminate\View\View; class TaskController extends Controller{ public function index(): View { $tasks = Task::with('user')->get(); return view('admin.tasks.index', compact('tasks')); }}
We don't have that Blade file; that's the next step.
Meanwhile, we will create the second Controller for the regular user.
php artisan make:controller User/TaskController
app/Http/Controllers/User/TaskController.php:
namespace App\Http\Controllers\User; use App\Http\Controllers\Controller;use Illuminate\View\View; class TaskController extends Controller{ public function index(): View { $tasks = auth()->user()->tasks; return view('user.tasks.index', compact('tasks')); }}
See the main difference? We're loading only the tasks that belong to the logged-in user.
So, we have DB structure and Controllers. The next step is routing.
Again, our goal is to have separate admin/user areas; that's why we have sub-groups for those users inside of already-existing Route::group()
from Laravel Breeze:
routes/web.php:
use App\Http\Controllers\Admin;use App\Http\Controllers\User; // ... Route::middleware('auth')->group(function () { Route::prefix('admin') ->name('admin.') ->group(function () { Route::resource('tasks', Admin\TaskController::class); }); Route::prefix('user') ->name('user.') ->group(function () { Route::resource('tasks', User\TaskController::class); }); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); // ...});
A few things to mention here:
prefix('admin')
is for the URL: /admin/tasks
VS /user/tasks
name('admin.')
is for route naming everywhere else in the project: route('admin.tasks.index')
Also, see that use App\Http\Controllers\Admin;
on top and then Admin\TaskController::class
below? Did you know this syntax? Let me explain.
We have two TaskController
files with the same name but different subfolder/namespace. To use both in the Routes file, we have two common options:
Route::get()
use ... as AdminTaskController
But I like this third option; instead, we can add use
with namespaces above, not just the Controller name.
Ok, we have the Routes now. Let's fix the navigation to use them.
Laravel Breeze comes with a navigation file that has links at the top in two places: the "regular" and "mobile" sections.
We add a link to Tasks, with Blade if-statement in both of those.
resources/views/layouts/navigation.blade.php:
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100"> <!-- Primary Navigation Menu --> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> ... <!-- Navigation Links --> <div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex"> @if (auth()->user()->is_admin) <x-nav-link :href="route('admin.tasks.index')" :active="request()->routeIs('admin.tasks.index')"> {{ __('Tasks') }} </x-nav-link> @else <x-nav-link :href="route('user.tasks.index')" :active="request()->routeIs('user.tasks.index')"> {{ __('My Tasks') }} </x-nav-link> @endif </div> <!-- Responsive Navigation Menu --> <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden"> <div class="pt-2 pb-3 space-y-1"> @if (auth()->user()->is_admin) <x-responsive-nav-link :href="route('admin.tasks.index')" :active="request()->routeIs('admin.tasks.index')"> {{ __('Tasks') }} </x-responsive-nav-link> @else <x-responsive-nav-link :href="route('user.tasks.index')" :active="request()->routeIs('user.tasks.index')"> {{ __('Tasks') }} </x-responsive-nav-link> @endif </div> ... </div></nav>
Ok, great, we protected the links: Admin/Users only see what they can see.
But we also need to protect the back end if anyone manually enters the link like /admin/tasks
in the browser!
We create a Middleware which will be assigned to the Admin routes.
php artisan make:middleware IsAdminMiddleware
Inside, we have this check:
app/Http/Middleware/IsAdminMiddleware.php:
use Closure;use Illuminate\Http\Request;use Symfony\Component\HttpFoundation\Response; class IsAdminMiddleware{ /** * Handle an incoming request. * * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next */ public function handle(Request $request, Closure $next): Response { if (!auth()->check() || !auth()->user()->is_admin) { abort(Response::HTTP_FORBIDDEN); } return $next($request); }}
Finally, we assign this Middleware to a Route Group:
routes/web.php:
use App\Http\Middleware\IsAdminMiddleware; // ... Route::middleware('auth')->group(function () { Route::prefix('admin') ->name('admin.') ->middleware(IsAdminMiddleware::class) ->group(function () { Route::resource('tasks', Admin\TaskController::class); });
Okay, so our Routes are working and protected, but they will show errors because Blade files don't exist yet. This is our next step.
Default Laravel Breeze comes from the Dashboard page, which extends the default Breeze layout:
resources/views/dashboard.blade.php:
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight"> {{ __('Dashboard') }} </h2> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900 dark:text-gray-100"> {{ __("You're logged in!") }} </div> </div> </div> </div></x-app-layout>
In our pages for Tasks, we need to use the same layout (for now, we'll change that later) and change the content inside. That's why we open that Dashboard file and do File -> Save as
into two files:
The contents are very similar. There will be only two differences:
<h2>
title: "All Tasks" vs "My Tasks"resources/views/admin/tasks/index.blade.php
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('All Tasks') }} </h2> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> <div class="overflow-hidden overflow-x-auto p-6 bg-white border-b border-gray-200"> <div class="min-w-full align-middle"> <table class="min-w-full divide-y divide-gray-200 border"> <thead> <tr> <th class="px-6 py-3 bg-gray-50 text-left"> <span class="text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">Name</span> </th> <th class="px-6 py-3 bg-gray-50 text-left"> <span class="text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">User</span> </th> <th class="px-6 py-3 bg-gray-50 text-left"> <span class="text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">Due Date</span> </th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200 divide-solid"> @foreach($tasks as $task) <tr class="bg-white"> <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900"> {{ $task->name }} </td> <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900"> {{ $task->user->name }} </td> <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900"> {{ $task->due_date }} </td> </tr> @endforeach </tbody> </table> </div> </div> </div> </div> </div> </div></x-app-layout>
We removed the "User" column for regular users, as it doesn't make sense: users only see their own tasks.
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('My Tasks') }} </h2> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> <div class="overflow-hidden overflow-x-auto p-6 bg-white border-b border-gray-200"> <div class="min-w-full align-middle"> <table class="min-w-full divide-y divide-gray-200 border"> <thead> <tr> <th class="px-6 py-3 bg-gray-50 text-left"> <span class="text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">Name</span> </th> <th class="px-6 py-3 bg-gray-50 text-left"> <span class="text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">Due Date</span> </th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200 divide-solid"> @foreach($tasks as $task) <tr class="bg-white"> <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900"> {{ $task->name }} </td> <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900"> {{ $task->due_date }} </td> </tr> @endforeach </tbody> </table> </div> </div> </div> </div> </div> </div></x-app-layout>
As mentioned above, we're using the same layout/design for both Admin and User areas. But what if you want them to look differently?
The default Laravel Breeze layout file looks like this:
resources/views/layouts/app.blade.php:
<!DOCTYPE html><html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="csrf-token" content="{{ csrf_token() }}"> <title>{{ config('app.name', 'Laravel') }}</title> <!-- Fonts --> <link rel="preconnect" href="https://fonts.bunny.net"> <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" /> <!-- Scripts --> @vite(['resources/css/app.css', 'resources/js/app.js']) </head> <body class="font-sans antialiased"> <div class="min-h-screen bg-gray-100"> @include('layouts.navigation') <!-- Page Heading --> @isset($header) <header class="bg-white dark:bg-gray-800 shadow"> <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8"> {{ $header }} </div> </header> @endisset <!-- Page Content --> <main> {{ $slot }} </main> </div> </body></html>
Let's perform File -> Save As
and save it as two different component layout files: one for Admin and one for User.
One file will be completely identical, with no changes: Just do the Save as
into resources/views/components/layouts/user.blade.php
.
But for admin, we will change one color. First, we do the Save as
into resources/views/components/layouts/admin.blade.php
. Then, let's change one line of code there to have a visually different background. So, we will clearly see we're in the Admin or User area.
resources/views/components/layouts/admin.blade.php:
<!DOCTYPE html><html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> ... </head> <body class="font-sans antialiased"> <div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-amber-50"> @include('layouts.navigation') ... </div> </body></html>
Those both files in the resources/views/components/layouts
will automatically be accessible as <x-layouts.xxxxx>
in other Blade files.
So, in the Tasks Blade files, we change the x-app-layout
to our new layouts.
resources/views/admin/tasks/index.blade.php:
<x-layouts.admin> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('All Tasks') }} </h2> </x-slot> <div class="py-12"> ...
And the User's page:
resources/views/user/tasks/index.blade.php:
<x-layouts.user> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('My Tasks') }} </h2> </x-slot> <div class="py-12"> ...
The final part. Hope you're not tired yet?
In this course, we will perform many experiments on this project. So, it is crucial to have automated tests that tell us if we break anything.
We will use Pest for these tests, but a similar syntax would be for PHPUnit if you prefer to use it.
First, we uncomment two lines in phpunit.xml
to enable the database operations in SQLite memory database:
<php> ... <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/> ...</php>
We will write three tests for the most important features:
For that, we generate one feature test:
php artisan make:test UserTaskTest
And inside, we have three Pest functions:
tests/Feature/UserTaskTest.php:
use App\Models\User;use App\Models\Task;use function Pest\Laravel\actingAs; it('allows users to access tasks page', function () { $user = User::factory()->create(); actingAs($user) ->get(route('user.tasks.index')) ->assertOk();}); it('does not allow users to access admin task page', function () { $user = User::factory()->create(); actingAs($user) ->get(route('admin.tasks.index')) ->assertForbidden();}); it('allows administrator to access tasks page', function () { $user = User::factory()->create(['is_admin' => true]); actingAs($user) ->get(route('admin.tasks.index')) ->assertOk();});
User model Factory comes with the default Laravel, so we don't need to change anything else.
Now, we run the php artisan test
specifying that exact test, which works!
Or, you can run all the tests, including the default ones from Laravel Breeze:
Good, so we separated the admin/user areas with totally separate files where needed:
Of course, this is just a simple example, but you can continue adding other features in their appropriate areas based on that.
Complete code in repository