Courses

Roles and Permissions in Laravel 12

Multiple Roles per User?

Summary of this lesson:
- Converting single role to many-to-many relationship
- Creating role_user pivot table
- Updating Policy to handle multiple roles
- Using Factory states for role assignment
- Modifying tests for many-to-many relationship

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

Previous: Roles in Separate DB Table
avatar

The Factory States don't work in the test, it tell me no role can be attach to the user.

avatar
Дмитрий Исаенко

Maybe you need to create the seeder for your table? As example:

class RoleSeeder extends Seeder { /** * Run the database seeds. */ public function run(): void { Role::create(['name' => 'User']); Role::create(['name' => 'Administrator']); Role::create(['name' => 'Manager']); } }

avatar
You can use Markdown
avatar
You can use Markdown