Back to Course |
Roles and Permissions in Laravel 11

Multiple Roles per User?

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.


Migration: From One Foreign Key to Pivot Table

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.


User Model Relationship: From BelongsTo to BelongsToMany

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.


Policy: Adding Collections

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:


Updating our 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 admins
  • User::factory()->manager() - Will create managers
  • User::factory()->user() - Will create users

Then, 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