In the first lessons, we relied on the users.is_admin
column to define permissions. But what if there are more than two roles? Then you probably need to create a separate DB table, roles
, and add a users.role_id
column with a foreign key. This is precisely what we will cover in this lesson.
Imagine we have three roles:
As usual, by the end of the lesson we will have a repository with automated tests, including the ones from default Laravel Breeze:
We will continue on the example of the last lesson, where the main logic is inside of TaskPolicy
. By the end of this lesson, we will change the conditions here. Our starting point is this:
app/Policies/TaskPolicy.php
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; }}
Let's change that $user->is_admin
to something more flexible.
Migration
Schema::create('roles', function (Blueprint $table) { $table->id(); $table->string('name'); $table->timestamps();});
app/Models/Role.php
class Role extends Model{ use HasFactory; protected $fillable = ['name'];}
database/seeders/RoleSeeder.php
use App\Models\Role; // ... class RoleSeeder extends Seeder{ /** * Run the database seeds. */ public function run(): void { Role::create(['name' => 'User']); Role::create(['name' => 'Administrator']); Role::create(['name' => 'Manager']); }}
And, of course, call the seeder:
database/seeders/DatabaseSeeder.php
class DatabaseSeeder extends Seeder{ /** * Seed the application's database. */ public function run(): void { $this->call(RoleSeeder::class); }}
Then, we add a foreign key with a belongsTo
relationship. In the next lesson, we will also cover a many-to-many
option for this.
Our first step is to create a new migration:
Migration
Schema::table('users', function (Blueprint $table) { $table->foreignId('role_id')->default(1)->constrained();});
As you can see, we immediately assigned a new role with ID 1.
app/Models/User.php
class User extends Authenticatable{ // ... protected $fillable = [ 'name', 'email', 'password', 'role_id', ]; // ... public function role(): BelongsTo { return $this->belongsTo(Role::class); }}
And, of course, create the Role
Model:
app/Models/Role.php
class Role extends Model{ use HasFactory; protected $fillable = ['name'];}
Once these are done, we can make sure that we seed our Roles:
database/seeders/DatabaseSeeder.php
Role::create(['name' => 'User']);Role::create(['name' => 'Administrator']);Role::create(['name' => 'Manager']);
That allows us to assume the following:
Now we get back to the initial Policy and change the condition:
app/Policies/TaskPolicy.php
class TaskPolicy{ public function create(User $user): bool { return true; return $user->role_id == 2; } public function update(User $user, Task $task): bool { return $user->is_admin || $task->user_id === $user->id; return in_array($user->role_id, [2, 3]) || $task->user_id === $user->id; } public function delete(User $user, Task $task): bool { return $user->is_admin || $task->user_id === $user->id; return $user->role_id == 2; }}
So, we changed the is_admin
to role_id
and implemented the logic from above; I will repeat it here:
role_id == 1
): can only view the list of tasksrole_id == 2
): can do everything with tasks - view/create/update/deleterole_id == 3
): can only view and update tasks created by the administratorNow, it's not that convenient to deal with those numbers 1/2/3 and remember which role is which. Let's improve the readability.
We can add human-readable constants somewhere to represent the name of each role. Probably, the most logical place is the Role Model itself:
app/Models/Role.php
class Role extends Model{ use HasFactory; protected $fillable = ['name']; public const ROLE_USER = 1; public const ROLE_ADMIN = 2; public const ROLE_MANAGER = 3;}
And then, in the Policy, we may do this:
We should avoid using Magic numbers (1, 2, 3) in our code. Instead, let's use Constants:
use App\Models\Role; // ... class TaskPolicy{ public function create(User $user): bool { return $user->role_id == 2; return $user->role_id == Role::ROLE_ADMIN; } public function update(User $user, Task $task): bool { return in_array($user->role_id, [2, 3]) return in_array($user->role_id, [Role::ROLE_ADMIN, Role::ROLE_MANAGER]) || $task->user_id === $user->id; } public function delete(User $user, Task $task): bool { return $user->role_id == 2; return $user->role_id == Role::ROLE_ADMIN; }}
Much more readable.
Another benefit of such constants is that we can use them elsewhere in our project, like tests, see below.
That's it. We now have a new system with multiple roles. We could test this manually at this point, but we think it's better to have tests.
Finally, we need to check if all roles have the correct permissions.
tests/Feature/TaskTest.php
use App\Models\Role;use App\Models\Task;use App\Models\User;use function Pest\Laravel\actingAs; it('allows administrator to access create task page', function () { $user = User::factory() ->create(['role_id' => Role::ROLE_ADMIN]); actingAs($user) ->get(route('tasks.create')) ->assertOk();}); it('does not allow other users to access create task page', function (User $user) { actingAs($user) ->get(route('tasks.create')) ->assertForbidden();})->with([ fn() => User::factory()->create(['role_id' => Role::ROLE_USER]), fn() => User::factory()->create(['role_id' => Role::ROLE_MANAGER]),]); it('allows administrator and manager to enter update page for any task', function (User $user) { $task = Task::factory()->create(['user_id' => User::factory()->create()->id]); actingAs($user) ->get(route('tasks.edit', $task)) ->assertOk();})->with([ fn() => User::factory()->create(['role_id' => Role::ROLE_ADMIN]), fn() => User::factory()->create(['role_id' => Role::ROLE_MANAGER]),]); it('allows administrator and manager to update any task', function (User $user) { $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');})->with([ fn() => User::factory()->create(['role_id' => Role::ROLE_ADMIN]), fn() => User::factory()->create(['role_id' => Role::ROLE_MANAGER]),]); 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 () { $task = Task::factory()->create(['user_id' => User::factory()->create()->id]); $user = User::factory() ->create(['role_id' => Role::ROLE_ADMIN]); 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) { $task = Task::factory()->create(['user_id' => User::factory()->create()->id]); actingAs($user) ->delete(route('tasks.destroy', $task)) ->assertForbidden();})->with([ fn() => User::factory()->create(['role_id' => Role::ROLE_USER]), fn() => User::factory()->create(['role_id' => Role::ROLE_MANAGER]),]);
The code for pest tests above is self-explanatory, except probably one thing: with()
.
These are Datasets. They allow you to repeat the same test with multiple implementations of the same object.
In our case, we need to test that the task cannot be deleted by user and manager roles. So, instead of writing the same code twice, we write one function with a parameter of User $user
and use it with([fn() => return user 1, fn() -> return user 2, ...])
.
Running our test suite will show that all of them pass:
Okay, so now we have three roles in our project: admin, manager, and simple user. But what if one user may have multiple roles? Let's look at that in the next lesson.
Complete code in repository