Courses

Roles and Permissions in Laravel 12

Tasks CRUD: Permissions vs Global Scopes

Summary of this lesson:
- Building task model with patient and assignee relationships
- Implementing team-specific task filtering
- Creating task-specific permissions and policies
- Managing task assignments between doctors and patients
- Writing comprehensive test suite for task operations

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.

Previous: Managing Users: Staff / Doctors / Patients
avatar

When I run the test command on ssh, I keep getting the same errors.

user18@server1:~/public_html$ php artisan test --filter=TaskTest

PASS Tests\Feature\TaskTest ✓ it allows clinic admin and staff to access create task page with (Closure Object ()) #1 0.46s
✓ it allows clinic admin and staff to access create task page with (Closure Object ()) #2 0.06s
✓ it allows clinic admin and staff to access create task page with (Closure Object ()) #3 0.07s
✓ it does not allow patient to access create task page 0.06s
✓ it allows clinic admin and staff to enter update page for any task in their team with (Closure Object ()) #1 0.09s
✓ it allows clinic admin and staff to enter update page for any task in their team with (Closure Object ()) #2 0.10s
✓ it allows clinic admin and staff to enter update page for any task in their team with (Closure Object ()) #3 0.10s
✓ it does not allow administrator and manager to enter update page for other teams task with (Closure Object ()) #1 0.06s
✓ it does not allow administrator and manager to enter update page for other teams task with (Closure Object ()) #2 0.06s
✓ it does not allow administrator and manager to enter update page for other teams task with (Closure Object ()) #3 0.06s
✓ it allows administrator and manager to update any task in their team with (Closure Object ()) #1 0.07s
✓ it allows administrator and manager to update any task in their team with (Closure Object ()) #2 0.07s
✓ it allows administrator and manager to update any task in their team with (Closure Object ()) #3 0.07s
✓ it allows clinic admin and staff to delete task for his team with (Closure Object ()) #1 0.06s
✓ it allows clinic admin and staff to delete task for his team with (Closure Object ()) #2 0.08s
✓ it does not allow doctor to delete tasks 0.06s
✓ it does not allow super admin and admin to delete task for other team with (Closure Object ()) #1 0.05s
✓ it does not allow super admin and admin to delete task for other team with (Closure Object ()) #2 0.05s
✓ it does not allow super admin and admin to delete task for other team with (Closure Object ()) #3 0.06s
✓ it shows users with a role of doctor and staff as assignees 0.07s
✓ it shows users with a role of patient as patients 0.09s
✓ it shows only teams tasks for doctor, staff, and clinic admin with (Closure Object ()) #1 0.08s
✓ it shows only teams tasks for doctor, staff, and clinic admin with (Closure Object ()) #2 0.06s
✓ it shows only teams tasks for doctor, staff, and clinic admin with (Closure Object ()) #3 0.06s
✓ it shows patient only his tasks 0.06s

FAIL Tests\Feature\UserTaskTest ⨯ it allows users to access tasks page 0.06s
⨯ it does not allow users to access admin task page 0.04s
⨯ it allows administrator to access tasks page 0.04s
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\UserTaskTest > it allows users to access tasks page RouteNotFoundException
Route [user.tasks.index] not defined.

at vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php:516 512▕ ! is_null($url = call_user_func($this->missingNamedRouteResolver, $name, $parameters, $absolute))) { 513▕ return $url; 514▕ } 515▕ ➜ 516▕ throw new RouteNotFoundException("Route [{$name}] not defined."); 517▕ } 518▕ 519▕ /** 520▕ * Get the URL for a given route instance.

  +2 vendor frames 

3 tests/Feature/UserTaskTest.php:11

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\UserTaskTest > it does not allow users to access admin task page RouteNotFoundException
Route [admin.tasks.index] not defined.

at vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php:516 512▕ ! is_null($url = call_user_func($this->missingNamedRouteResolver, $name, $parameters, $absolute))) { 513▕ return $url; 514▕ } 515▕ ➜ 516▕ throw new RouteNotFoundException("Route [{$name}] not defined."); 517▕ } 518▕ 519▕ /** 520▕ * Get the URL for a given route instance.

  +2 vendor frames 

3 tests/Feature/UserTaskTest.php:19

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\UserTaskTest > it allows administrator to access tasks page QueryException
SQLSTATE[HY000]: General error: 1 table users has no column named is_admin (Connection: sqlite, SQL: insert into "users" ("name", "email", "email_verified_at", "password", "remember_token", "is_admin", "updated_at", "created_at") values (Ms. Sheila Jones, tiara61@example.org, 2024-12-15 17:17:52, $2y$04$fy2DVEpJlH9s4sWT0qQXuOBt4/HnMBiO1viWnT/erPBTY/haI5N4i, WuD3JgyXhf, 1, 2024-12-15 17:17:52, 2024-12-15 17:17:52))

at vendor/laravel/framework/src/Illuminate/Database/Connection.php:565 561▕ if ($this->pretending()) { 562▕ return true; 563▕ } 564▕ ➜ 565▕ $statement = $this->getPdo()->prepare($query); 566▕ 567▕ $this->bindValues($statement, $this->prepareBindings($bindings)); 568▕ 569▕ $this->recordsHaveBeenModified();

  +16 vendor frames 

17 tests/Feature/UserTaskTest.php:24

Tests: 3 failed, 25 passed (42 assertions) Duration: 2.39s

I'm not using sqlite, I'm using mysql.

avatar

First - why are you running this on SSH? Live servers should never have tests running in them :)

Secondly, there seems to be some things missing. For example - the is_admin column. This could say that there's an issue with migrations (they either never ran or there's a missing one). As for the route not found - double check that the route was defined in your routes list by using php artisan route:list

avatar
You can use Markdown
avatar
You can use Markdown