Currently, our project has a users.role_id
column. But what if a user can have many roles instead of just one?
In this lesson, let's change a one-to-many to a many-to-many relationship.
The obvious first step is to change the migration from users.role_id
to a pivot table called role_user
.
Migration
Schema::table('users', function (Blueprint $table) { $table->foreignId('role_id')->default(1)->constrained();}); Schema::create('role_user', static function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained(); $table->foreignId('role_id')->constrained();});
Notice: I added the id()
column as an auto-increment, but it's optional and just a personal preference.
We also changed the relationship in the Model, changing the method name from role()
to roles()
and removing the old fillable field.
use Illuminate\Database\Eloquent\Relations\BelongsTo;use Illuminate\Database\Eloquent\Relations\BelongsToMany; // ... protected $fillable = [ 'name', 'email', 'password', 'role_id', ]; // ... protected $with = [ 'roles']; public function role(): BelongsTo{ return $this->belongsTo(Role::class);}public function roles(): BelongsToMany{ return $this->belongsToMany(Role::class);}
Notice: I also added a $with
array, so user would be always loaded with their roles as a relationship. This is a bit "risky" and may cause unnecessary loading if you want to have just Users on other pages of your application. So, use it with caution.
Did you see that $with
from the above? It allows us to have a $user->roles
Collection in multiple Policy methods without running into the N+1 Query problem.
From there, we can use a Collection method contains()
to check for the roles we want.
Next, we have to modify our Policy conditions to use the belongsToMany
relationship logic:
public function create(User $user): bool{ return $user->role_id == Role::ROLE_ADMIN; return $user->roles->contains(Role::ROLE_ADMIN);} public function update(User $user, Task $task): bool{ return in_array($user->role_id, [Role::ROLE_ADMIN, Role::ROLE_MANAGER]) || return $user->roles->contains(Role::ROLE_ADMIN) || $user->roles->contains(Role::ROLE_MANAGER) || $task->user_id === $user->id;} public function delete(User $user, Task $task): bool{ return $user->role_id == Role::ROLE_ADMIN; return $user->roles->contains(Role::ROLE_ADMIN);}
This is the point where everything should work for you on the UI, but not tests:
In this case, we don't need to change the tests from the previous lesson, but we need to change how the users are created/defined for those tests in the Pest Datasets.
For convenience, we will create three Factory States, one for each role.
database/factories/UserFactory.php
// ... public function admin(): static{ return $this->state([]) ->afterCreating(function (User $user) { $user->roles()->attach(Role::ROLE_ADMIN); });} public function manager(): static{ return $this->state([]) ->afterCreating(function (User $user) { $user->roles()->attach(Role::ROLE_MANAGER); });} public function user(): static{ return $this->state([]) ->afterCreating(function (User $user) { $user->roles()->attach(Role::ROLE_USER); });}
These states will allow us to quickly create multiple users with a role:
User::factory()->admin()
- Will create adminsUser::factory()->manager()
- Will create managersUser::factory()->user()
- Will create usersThen, we can use those states in the tests and their Datasets.
tests/Feature/TaskTest.php
it('allows administrator to access create task page', function () { $user = User::factory() ->create(['role_id' => Role::ROLE_ADMIN]); ->admin() ->create(); 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]), fn() => User::factory()->user()->create(), fn() => User::factory()->manager()->create(),]); 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]), fn() => User::factory()->admin()->create(), fn() => User::factory()->manager()->create(),]); 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]), fn() => User::factory()->admin()->create(), fn() => User::factory()->manager()->create(),]); 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]); ->admin() ->create(); 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]), fn() => User::factory()->user()->create(), fn() => User::factory()->manager()->create(),]);
Now, if we run tests, everything will pass:
Great, so we cover a few basic options for roles/permissions. But one popular tool in the Laravel ecosystem would save us some time with our projects: spatie/laravel-permission.
In the next lesson, we will try to adapt that package to our scenario and see the difference.
Complete code in repository