Back to Course |
Roles and Permissions in Laravel 11

Tasks CRUD: Permissions vs Global Scopes

Finally, we get to the actual point of this small application: Task management.

Compared to the Task Model in previous lessons of this course, we added a few more fields: assigned_to_user_id (clinic doctor/staff) and patient_id:

Tasks Migration:

$table->foreignId('assigned_to_user_id')->constrained('users');
$table->foreignId('patient_id')->constrained('users');

Then, I added them to the Model, too:

app/Models/Task.php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
 
class Task extends Model
{
use HasFactory;
 
protected $fillable = [
'name',
'due_date',
'assigned_to_user_id',
'patient_id',
'team_id',
];
 
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to_user_id');
}
 
public function patient(): BelongsTo
{
return $this->belongsTo(User::class, 'patient_id');
}
}

Then, we also changed the Factory with the new columns in mind.

database/factories/TaskFactory.php

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
 
class TaskFactory extends Factory
{
public function definition(): array
{
$randomAssignee = collect([
User::factory()->doctor(),
User::factory()->staff(),
])->random();
 
return [
'name' => fake()->text(30),
'due_date' => now()->addDays(rand(1, 100)),
'assigned_to_user_id' => $randomAssignee,
'patient_id' => User::factory()->patient(),
];
}
}

Now, who can manage tasks? Traditionally, let's start with Policy:

app/Policies/TaskPolicy.php

use App\Enums\Role;
use App\Models\Task;
use App\Models\User;
use App\Enums\Permission;
 
class TaskPolicy
{
public function viewAny(User $user): bool
{
return $user->hasPermissionTo(Permission::LIST_TASK);
}
 
public function create(User $user): bool
{
return $user->hasPermissionTo(Permission::CREATE_TASK);
}
 
public function update(User $user, Task $task): bool
{
return $user->hasPermissionTo(Permission::EDIT_TASK);
}
 
public function delete(User $user, Task $task): bool
{
return $user->hasPermissionTo(Permission::DELETE_TASK);
}
}

You don't see the filter by team here, right? The approach we took here is to filter them on the Eloquent level, with global scope.

In fact, it's a 2-in-1 scope:

  • Everyone sees the tasks only for their team by tasks.team_id
  • Patients see the tasks only related to them by tasks.patient_id

app/Models/Task.php

use App\Enums\Role;
use Illuminate\Database\Eloquent\Builder;
 
class Task extends Model
{
// ...
 
protected static function booted(): void
{
static::addGlobalScope('team-tasks', function (Builder $query) {
if (auth()->check()) {
$query->where('team_id', auth()->user()->current_team_id);
 
if (auth()->user()->hasRole(Role::Patient)) {
$query->where('patient_id', auth()->user()->id);
}
}
});
}
}

As you can see, we're checking for the Patient role and filtering the patient_id in that case.

Next, the Controller, which will look similar to the TeamController and UserController from before:

app/Http/Controllers/TaskController.php

use App\Enums\Role;
use App\Models\Task;
use App\Models\User;
use Illuminate\View\View;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Http\RedirectResponse;
 
class TaskController extends Controller
{
public function index(): View
{
Gate::authorize('viewAny', Task::class);
 
$tasks = Task::with('assignee', 'patient')->get();
 
return view('tasks.index', compact('tasks'));
}
 
public function create(): View
{
Gate::authorize('create', Task::class);
 
$assignees = User::whereRelation('roles', 'name', '=', Role::Doctor->value)
->orWhereRelation('roles', 'name', '=', Role::Staff->value)
->pluck('name', 'id');
 
$patients = User::whereRelation('roles', 'name', '=', Role::Patient->value)->pluck('name', 'id');
 
return view('tasks.create', compact('patients', 'assignees'));
}
 
public function store(Request $request): RedirectResponse
{
Gate::authorize('create', Task::class);
 
Task::create($request->only('name', 'due_date', 'assigned_to_user_id', 'patient_id'));
 
return redirect()->route('tasks.index');
}
 
public function edit(Task $task): View
{
Gate::authorize('update', $task);
 
$assignees = User::whereRelation('roles', 'name', '=', Role::Doctor->value)
->orWhereRelation('roles', 'name', '=', Role::Staff->value)
->pluck('name', 'id');
 
$patients = User::whereRelation('roles', 'name', '=', Role::Patient->value)->pluck('name', 'id');
 
return view('tasks.edit', compact('task', 'assignees', 'patients'));
}
 
public function update(Request $request, Task $task): RedirectResponse
{
Gate::authorize('update', $task);
 
$task->update($request->only('name', 'due_date', 'assigned_to_user_id', 'patient_id'));
 
return redirect()->route('tasks.index');
}
 
public function destroy(Task $task): RedirectResponse
{
Gate::authorize('delete', $task);
 
$task->delete();
 
return redirect()->route('tasks.index');
}
}

By now, you probably recognize the pattern of using Gate::authorize() in Controllers.

Finally, we write the tests for all of the scenarios. This will be one of the longer Test files, but it is pretty readable and self-explanatory.

tests/Feature/TaskTest.php:

use App\Models\User;
use App\Models\Team;
use App\Models\Task;
use Illuminate\Support\Collection;
use function Pest\Laravel\actingAs;
 
it('allows clinic admin and staff to access create task page', function (User $user) {
actingAs($user)
->get(route('tasks.create'))
->assertOk();
})->with([
fn () => User::factory()->clinicAdmin()->create(),
fn () => User::factory()->doctor()->create(),
fn () => User::factory()->staff()->create(),
]);
 
it('does not allow patient to access create task page', function () {
$user = User::factory()->patient()->create();
 
actingAs($user)
->get(route('tasks.create'))
->assertForbidden();
});
 
it('allows clinic admin and staff to enter update page for any task in their team', function (User $user) {
$team = Team::first();
 
$clinicAdmin = User::factory()->clinicAdmin()->create();
$clinicAdmin->update(['current_team_id' => $team->id]);
setPermissionsTeamId($team->id);
$clinicAdmin->unsetRelation('roles')->unsetRelation('permissions');
 
$task = Task::factory()->create([
'team_id' => $team->id,
]);
 
actingAs($user)
->get(route('tasks.edit', $task))
->assertOk();
})->with([
fn () => User::factory()->clinicAdmin()->create(),
fn () => User::factory()->doctor()->create(),
fn () => User::factory()->staff()->create(),
]);
 
it('does not allow administrator and manager to enter update page for other teams task', function (User $user) {
$team = Team::factory()->create();
 
$task = Task::factory()->create([
'team_id' => $team->id,
]);
 
actingAs($user)
->get(route('tasks.edit', $task))
->assertNotFound();
})->with([
fn () => User::factory()->clinicAdmin()->create(),
fn () => User::factory()->doctor()->create(),
fn () => User::factory()->staff()->create(),
]);
 
it('allows administrator and manager to update any task in their team', function (User $user) {
$team = Team::first();
 
$otherUser = User::factory()->clinicAdmin()->create();
$otherUser->update(['current_team_id' => $team->id]);
setPermissionsTeamId($team->id);
$otherUser->unsetRelation('roles')->unsetRelation('permissions');
 
$task = Task::factory()->create([
'team_id' => $team->id,
]);
 
actingAs($user)
->put(route('tasks.update', $task), [
'name' => 'updated task name',
])
->assertRedirect();
 
expect($task->refresh()->name)->toBe('updated task name');
})->with([
fn () => User::factory()->clinicAdmin()->create(),
fn () => User::factory()->doctor()->create(),
fn () => User::factory()->staff()->create(),
]);
 
it('allows clinic admin and staff to delete task for his team', function (User $user) {
User::factory()->create(['current_team_id' => $user->current_team_id]);
 
$task = Task::factory()->create([
'team_id' => $user->current_team_id,
]);
 
actingAs($user)
->delete(route('tasks.destroy', $task))
->assertRedirect();
 
expect(Task::count())->toBeInt()->toBe(0);
})->with([
fn () => User::factory()->clinicAdmin()->create(),
fn () => User::factory()->staff()->create(),
]);
 
it('does not allow doctor to delete tasks', function () {
$doctor = User::factory()->doctor()->create();
User::factory()->create(['current_team_id' => $doctor->current_team_id]);
 
$task = Task::factory()->create([
'team_id' => $doctor->current_team_id,
]);
 
actingAs($doctor)
->delete(route('tasks.destroy', $task))
->assertForbidden();
 
expect(Task::count())->toBeInt()->toBe(1);
});
 
it('does not allow super admin and admin to delete task for other team', function (User $user) {
$team = Team::factory()->create();
 
$taskUser = User::factory()->clinicAdmin()->create();
$taskUser->update(['current_team_id' => $team->id]);
 
$task = Task::factory()->create([
'team_id' => $taskUser->current_team_id,
]);
 
actingAs($user)
->delete(route('tasks.destroy', $task))
->assertNotFound();
})->with([
fn () => User::factory()->clinicAdmin()->create(),
fn () => User::factory()->doctor()->create(),
fn () => User::factory()->staff()->create(),
]);
 
it('shows users with a role of doctor and staff as assignees', function () {
$doctor = User::factory()->doctor()->create();
$staff = User::factory()->staff()->create();
 
$clinicAdmin = User::factory()->clinicAdmin()->create();
$masterAdmin = User::factory()->masterAdmin()->create();
$patient = User::factory()->patient()->create();
 
actingAs($clinicAdmin)
->get(route('tasks.create'))
->assertViewHas('assignees', function (Collection $assignees) use ($doctor, $staff, $masterAdmin, $clinicAdmin, $patient): bool {
return $assignees->contains(fn (string $assignee) => $assignee === $doctor->name ||
$assignee === $staff->name
) && $assignees->doesntContain(fn (string $assignee) => $assignee === $masterAdmin->name
|| $assignee === $clinicAdmin->name
|| $assignee === $patient->name
);
});
});
 
it('shows users with a role of patient as patients', function () {
$doctor = User::factory()->doctor()->create();
$staff = User::factory()->staff()->create();
 
$clinicAdmin = User::factory()->clinicAdmin()->create();
$masterAdmin = User::factory()->masterAdmin()->create();
$patient = User::factory()->patient()->create();
 
actingAs($clinicAdmin)
->get(route('tasks.create'))
->assertViewHas('patients', function (Collection $patients) use ($patient, $doctor, $staff, $masterAdmin, $clinicAdmin): bool {
return $patients->contains(fn (string $value) => $value === $patient->name) &&
$patients->doesntContain(fn (string $value) =>
$value === $doctor->name
|| $value === $staff->name
|| $value === $masterAdmin->name
|| $value === $clinicAdmin->name
);
});
});
 
it('shows only teams tasks for doctor, staff, and clinic admin', function (User $user) {
$seeTask = Task::factory()->create(['team_id' => $user->current_team_id]);
$dontSeeTask = Task::factory()->create(['team_id' => Team::factory()->create()->id]);
 
actingAs($user)
->get(route('tasks.index'))
->assertOk()
->assertSeeText($seeTask->name)
->assertDontSeeText($dontSeeTask->name);
})->with([
fn() => User::factory()->clinicAdmin()->create(),
fn() => User::factory()->doctor()->create(),
fn() => User::factory()->staff()->create(),
]);
 
it('shows patient only his tasks', function () {
$patient = User::factory()->patient()->create();
 
$seeTask = Task::factory()->create([
'team_id' => $patient->current_team_id,
'patient_id' => $patient->id,
]);
$dontSeeTask = Task::factory()->create(['team_id' => Team::factory()->create()->id]);
 
actingAs($patient)
->get(route('tasks.index'))
->assertOk()
->assertSeeText($seeTask->name)
->assertDontSeeText($dontSeeTask->name);
});


Complete code in the GitHub repository.