Back to Course |
Roles and Permissions in Laravel 11

Simple Example: Separate Admin/User Areas

Quick Intro

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:

  • Gates/Policies
  • Popular spatie/laravel-permission package
  • Single/multiple Roles per User
  • Single/multiple Teams per User

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:

  • Separate Controllers
  • Separate Blade Views
  • Separate URL groups/prefixes/names
  • Middleware to check area permissions

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:


Project Preparation: Task Management System

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);
}
}

Admin/User: with is_admin Column

We 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.


Separate Controllers

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.


Separate Routes

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:

  • Separate prefix('admin') is for the URL: /admin/tasks VS /user/tasks
  • Separate 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:

  • Either use the full path with namespace in Route::get()
  • Or put an alias on top like 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.


Navigation with IF-Statement

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!


Middleware to Check Access

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.


Separate Blade Views

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:

  • resources/views/admin/tasks/index.blade.php
  • resources/views/user/tasks/index.blade.php

The contents are very similar. There will be only two differences:

  • The <h2> title: "All Tasks" vs "My Tasks"
  • Admins will see the extra column of the User name

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?


Separate Layouts

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">
...


Pest Tests

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:

  • Authenticated users can access the User Tasks page
  • Regular users cannot access the Admin Tasks page
  • Administrators can access the Admin Tasks page

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