Back to Course |
Roles and Permissions in Laravel 11

Roles in Separate DB Table

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:

  • Simple user: can only view the list of tasks
  • Administrator: can do everything with tasks - view/create/update/delete
  • Manager: can only view and update tasks created by the administrator

As usual, by the end of the lesson we will have a repository with automated tests, including the ones from default Laravel Breeze:


The Key Point: Policy

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.


Migrations and Models for Separate Roles Table

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:

  • ID 1 is User
  • ID 2 is Administrator
  • ID 3 is Manager

Policy Changes

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:

  • Simple user (role_id == 1): can only view the list of tasks
  • Administrator (role_id == 2): can do everything with tasks - view/create/update/delete
  • Manager (role_id == 3): can only view and update tasks created by the administrator

Now, it's not that convenient to deal with those numbers 1/2/3 and remember which role is which. Let's improve the readability.


Using Constants Instead of "Magic Numbers"

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.


Writing 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