In the previous lesson, we separated all the MVC files for each role. Now, let's look at the opposite example.
What if you want to have the same set of routes/controllers/views for all roles, just control who can access what inside the code itself?
Imagine a list of tasks everyone sees, but only admins can see the button to create a new task.
So, let's explore this example step-by-step.
By the end of this lesson, our repository will have THESE tests passing:
users.is_admin
The starting point would be similar to the project from the first lesson.
users.is_admin
boolean columnWe will have one common Controller for both admins and regular users.
php artisan make:controller TaskController
Fill it in with the list:
app/Http/Controllers/TaskController.php:
namespace App\Http\Controllers; use App\Models\Task;use Illuminate\View\View; class TaskController extends Controller{ public function index(): View { $tasks = Task::with('user')->get(); return view('tasks.index', compact('tasks')); }}
Finally, let's add the Route, assigning the Resource right away for all the future Tasks CRUD methods.
routes/web.php:
use App\Http\Controllers\TaskController; Route::middleware('auth')->group(function () { // ... Breeze default routes Route::resource('tasks', TaskController::class); });
The final thing is to add the link to that route. In the Breeze navigation file, we need to add that item to both regular and mobile menus using the Blade components provided by Laravel Breeze:
resources/views/layouts/navigation.blade.php
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100"> ... <!-- Navigation Links --> <div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex"> <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> {{ __('Dashboard') }} </x-nav-link> <x-nav-link :href="route('tasks.index')" :active="request()->routeIs('tasks.index')"> {{ __('Tasks') }} </x-nav-link> </div> <!-- Responsive Navigation Menu --> <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden"> <div class="pt-2 pb-3 space-y-1"> <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> {{ __('Dashboard') }} </x-responsive-nav-link> <x-responsive-nav-link :href="route('tasks.index')" :active="request()->routeIs('tasks.index')"> {{ __('Tasks') }} </x-responsive-nav-link> </div> </div></nav>
The next step is to show the task list with if-statements indicating which features the user can access.
resources/views/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"> <a href="{{ route('tasks.create') }}" class="underline">Add new task</a> <br /><br /> <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> <th class="px-6 py-3 bg-gray-50 text-left"> </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> <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900"> <a href="{{ route('tasks.edit', $task) }}" class="underline">Edit</a> | <form action="{{ route('tasks.destroy', $task) }}" class="inline-block" onsubmit="return confirm('Are you sure?')"> @method('DELETE') @csrf <button type="submit" class="underline text-red-600">Delete</button> </form> </td> </tr> @endforeach </tbody> </table> </div> </div> </div> </div> </div> </div></x-app-layout>
We can add our Gates into an AppServiceProvider:
app/Providers/AppServiceProvider.php
// ... public function boot(): void{ Gate::define('create-task', function (User $user) { return true; }); Gate::define('update-task', function (User $user, Task $task) { return $user->is_admin || $task->user_id === $user->id; }); Gate::define('delete-task', function (User $user, Task $task) { return $user->is_admin || $task->user_id === $user->id; });}
Now we can go and add them to our Views
Adding the Gates is pretty simple:
resources/views/tasks/index.blade.php
<div class="min-w-full align-middle"> @can('create-task') <a href="{{ route('tasks.create') }}" class="underline">Add new task</a> <br/><br/> @endcan <table class="min-w-full divide-y divide-gray-200 border"> <thead> <tr> {{-- ... --}} </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"> @can('update-task', $task) <a href="{{ route('tasks.edit', $task) }}" class="underline">Edit</a> @endcan @can('delete-task', $task) | <form action="{{ route('tasks.destroy', $task) }}" method="POST" class="inline-block" onsubmit="return confirm('Are you sure?')"> @method('DELETE') @csrf <button type="submit" class="underline text-red-600">Delete</button> </form> @endcan </td> </tr> @endforeach </tbody> </table></div>
Now, opening the page, we can see that the buttons are gone for records our User does not own:
But there's a problem - if someone knows the URL, they can still access it!
This is not good, as we have a HUGE security issue! Let's fix that.
The easiest fix for this is to add Gate checks into our Controller:
app/Http/Controllers/TaskController.php
use Illuminate\Support\Facades\Gate; // ... public function index(): View{ $tasks = Task::with('user')->get(); return view('tasks.index', compact('tasks'));} public function create(): View{ Gate::authorize('create-task'); return view('tasks.create');} public function store(Request $request): RedirectResponse{ Gate::authorize('create-task'); Task::create($request->only('name', 'due_date') + ['user_id' => auth()->id()]); return redirect()->route('tasks.index');} public function edit(Task $task): View{ Gate::authorize('update-task', $task); return view('tasks.edit', compact('task'));} public function update(Request $request, Task $task): RedirectResponse{ Gate::authorize('update-task', $task); $task->update($request->only('name', 'due_date')); return redirect()->route('tasks.index');} public function destroy(Task $task): RedirectResponse{ Gate::authorize('delete-task', $task); $task->delete(); return redirect()->route('tasks.index');}
Now, if we open the same URL - we can see that it's not allowed anymore:
That's great! We are now secure.
But wait... This means that we have to create a lot of gates into our AppServiceProvider, which can lead to a really long file... That's not good!
This is where Policy comes! We can quickly create Policies for each of our Models in an isolated class:
php artisan make:policy TaskPolicy
Inside of it, we will add three checks - Create, Update, and Delete:
app/Policies/TaskPolicy.php
use App\Models\Task;use App\Models\User;use Illuminate\Auth\Access\Response; class TaskPolicy{ public function create(User $user): bool { return true; } public function update(User $user, Task $task): bool { return $user->is_admin || $task->user_id === $user->id; } public function delete(User $user, Task $task): bool { return $user->is_admin || $task->user_id === $user->id; }}
Of course, now we have to change our Controller and View gate checks:
app/Http/Controllers/TaskController.php
use App\Models\Task;use Illuminate\Http\RedirectResponse;use Illuminate\Http\Request;use Illuminate\Support\Facades\Gate;use Illuminate\View\View; class TaskController extends Controller{ public function index(): View { $tasks = Task::with('user')->get(); return view('tasks.index', compact('tasks')); } public function create(): View { Gate::authorize('create-task'); Gate::authorize('create', Task::class); return view('tasks.create'); } public function store(Request $request): RedirectResponse { Gate::authorize('create-task'); Gate::authorize('create', Task::class); Task::create($request->only('name', 'due_date') + ['user_id' => auth()->id()]); return redirect()->route('tasks.index'); } public function edit(Task $task): View { Gate::authorize('update-task', $task); Gate::authorize('update', $task); return view('tasks.edit', compact('task')); } public function update(Request $request, Task $task): RedirectResponse { Gate::authorize('update-task', $task); Gate::authorize('update', $task); $task->update($request->only('name', 'due_date')); return redirect()->route('tasks.index'); } public function destroy(Task $task): RedirectResponse { Gate::authorize('delete-task', $task); Gate::authorize('delete', $task); $task->delete(); return redirect()->route('tasks.index'); }}
And change the View check, too:
resources/views/tasks/index.blade.php
<div class="min-w-full align-middle"> @can('create-task') @can('create', \App\Models\Task::class) <a href="{{ route('tasks.create') }}" class="underline">Add new task</a> <br/><br/> @endcan <table class="min-w-full divide-y divide-gray-200 border"> <thead> <tr> {{-- ... --}} </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"> @can('update-task', $task) @can('update', $task) <a href="{{ route('tasks.edit', $task) }}" class="underline">Edit</a> @endcan @can('delete-task', $task) @can('delete', $task) | <form action="{{ route('tasks.destroy', $task) }}" method="POST" class="inline-block" onsubmit="return confirm('Are you sure?')"> @method('DELETE') @csrf <button type="submit" class="underline text-red-600">Delete</button> </form> @endcan </td> </tr> @endforeach </tbody> </table></div>
Now, reloading the page, we should see that nothing has changed, but we have refactored our code to be more maintainable!
Note: There is no need to have the -task
suffix anymore, as we are always passing a Task Model. Laravel auto-resolves the correct policy!
Tests are an essential system stability thing, so let's write a few of them:
tests/Feature/TaskTest.php
use App\Models\User;use App\Models\Task;use function Pest\Laravel\actingAs; it('allows administrator to enter update page for any task', function () { $user = User::factory()->create(['is_admin' => true]); $task = Task::factory()->create(['user_id' => User::factory()->create()->id]); actingAs($user) ->get(route('tasks.edit', $task)) ->assertOk();}); it('allows administrator to update any task', function () { $user = User::factory()->create(['is_admin' => true]); $task = Task::factory()->create(['user_id' => User::factory()->create()->id]); actingAs($user) ->put(route('tasks.update', $task), [ 'name' => 'updated task name', ]) ->assertRedirect(); expect($task->refresh()->name)->toBe('updated task name');}); it('allows user to update his own task', function () { $user = User::factory()->create(); $task = Task::factory()->create(['user_id' => $user->id]); actingAs($user) ->put(route('tasks.update', $task), [ 'name' => 'updated task name', ]); expect($task->refresh()->name)->toBe('updated task name');}); it('does no allow user to update other users task', function () { $user = User::factory()->create(); $task = Task::factory()->create(['user_id' => User::factory()->create()->id]); actingAs($user) ->put(route('tasks.update', $task), [ 'name' => 'updated task name', ]) ->assertForbidden();}); it('allows administrator to delete task', function () { $user = User::factory()->create(['is_admin' => true]); $task = Task::factory()->create(['user_id' => User::factory()->create()->id]); actingAs($user) ->delete(route('tasks.destroy', $task)) ->assertRedirect(); expect(Task::count())->toBe(0);}); it('allows user to delete his own task', function () { $user = User::factory()->create(); $task = Task::factory()->create(['user_id' => $user->id]); actingAs($user) ->delete(route('tasks.destroy', $task)) ->assertRedirect(); expect(Task::count())->toBe(0);}); it('does not allow other users to delete tasks', function () { $user = User::factory()->create(); $task = Task::factory()->create(['user_id' => User::factory()->create()->id]); actingAs($user) ->delete(route('tasks.destroy', $task)) ->assertForbidden();});
Now, we can run the tests using:
php artisan test --filter=TaskTest
And see that all of them pass:
What if we limit the User task list to only his tasks? How can we do that?
There are a couple of options available here:
->where()
condition to the queryFor this example, we have chosen the global scope:
app/Models/Task.php
use Illuminate\Database\Eloquent\Builder; // ...protected static function booted(){ self::addGlobalScope(function (Builder $query) { if (auth()->check() && !auth()->user()->is_admin) { $query->where('user_id', auth()->id()); } });}
Now, if we reload the table, we should only see the User's tasks:
Of course, our Admin user can still see all the tasks:
That's it! It's this simple to add additional access limitations! With both Policy and this global scope in place, our Users are unable to edit Tasks assigned to others or even know that they exist!
Of course, this causes a little problem with our tests:
As you can see, we have a few failing tests with 404
instead of the expected 403
. This is because our global scope affects the query before checking our Policy.
Let's fix the tests to make them pass:
tests/Feature/TaskTest.php
// ... it('does no allow user to update other users task', function () { $user = User::factory()->create(); $task = Task::factory()->create(['user_id' => User::factory()->create()->id]); actingAs($user) ->put(route('tasks.update', $task), [ 'name' => 'updated task name', ]) ->assertForbidden(); ->assertNotFound();}); // ... it('does not allow other users to delete tasks', function () { $user = User::factory()->create(); $task = Task::factory()->create(['user_id' => User::factory()->create()->id]); actingAs($user) ->delete(route('tasks.destroy', $task)) ->assertForbidden(); ->assertNotFound();});
With this small change, we should see our tests pass with no issues:
But we can take it one step further and check to confirm that our User can't see other tasks:
tests/Feature/TaskTest.php
// ... it('user is unable to see other people tasks', function () { $user = User::factory()->create(); $task = Task::factory()->create(['user_id' => $user->id]); $user2 = User::factory()->create(); $otherTasks = Task::factory()->create(['user_id' => $user2->id]); actingAs($user) ->get(route('tasks.index')) ->assertSeeText($task->name) ->assertDontSeeText($otherTasks->name);});
Now, if we run this test, we can see that it passes:
But as soon as we remove the global scope from our Tasks model, we will see that it fails:
Note: Two other tests failed, but that's expected!
This allows us to be sure that our User has a limited view now and that no tasks are not his!
In this example, we have used one Controller, but its methods are protected with Policy. This prevents unauthorized users from taking actions we don't want them to.
Of course, this was another simplified example. However, it clearly shows how easy it is to implement Policies and Gates into your application.