Introduction
Authorization usually starts simple.
Maybe you begin with an is_admin column, then add a couple of if statements, and everything
looks fine for a while. But as the application grows, that simplicity starts working against you.
Now you need a billing manager that can handle invoices but not delete projects. You need team admins that can invite members, but only inside their own team. You need auditors that can read everything but change nothing. And suddenly your "simple" authorization rules are spread across controllers, jobs, Livewire components, Blade templates, and random service classes.
That is where RBAC starts to make sense.
Role-Based Access Control gives authorization a clearer shape: users get roles, roles get permissions, and your application checks permissions instead of hard-coding job titles all over the codebase.
In this article, let's do a deep dive into RBAC and how to implement it in Laravel without using any external packages.
We will cover the theory first, because if you skip the design part, you will probably build a permission system that looks clean on day one and becomes painful six months later.
Then we will build a practical implementation with:
- team-scoped roles
- permission inheritance through roles
- separation-of-duty constraints
- policies and gates
- cached permission resolution
- tests for the most important authorization flows
It won't be enterprise-IAM-level complex, but it will be much closer to a real application than a
flat isAdmin() check.
What RBAC Actually Is
At its core, RBAC is a model where access is granted through roles, not directly through per-user permissions.
The idea is simple:
- a User can be assigned one or more Roles
- a Role groups a set of Permissions
- the system checks whether the user's active roles grant the required permission
This sounds obvious today, but there is an important design advantage here: roles tend to be more stable than users and individual access rules.
People join teams, change departments, get promoted, leave the company, or take temporary duties. If you attach permissions directly to each user, your authorization model becomes operationally expensive very quickly.
The classical RBAC model formalized by NIST is usually described in layers:
- Core RBAC: users, roles, and permissions
- Hierarchical RBAC: roles can inherit permissions from other roles
- Constrained RBAC: you add rules like separation of duties
That last part matters a lot in real applications.
A system is not secure just because it has roles. You often need extra constraints like:
- an auditor should not also be a billing manager in the same team
- the user inviting a member should not be able to assign a role higher than their own
- a suspended team should make every write permission fail, even if the user still has roles
So a useful mental model is this: RBAC gives you a strong base model, but real systems usually combine it with contextual rules.
RBAC Is Not the Same as "Users and Roles"
Many applications say they have RBAC, but what they actually have is a group flag system.
That distinction matters.
In a real RBAC model:
- permissions are attached to roles
- users receive permissions through their roles
- authorization checks talk in terms of abilities, not role names
That means your code should prefer asking:
$user->can('deploy', $project);
instead of:
$user->isAdmin() || $user->isOwner()
The second version leaks organizational structure into application logic.
The first version keeps your domain language focused on what the user is allowed to do.
This also makes the system easier to evolve. If tomorrow team-admin and release-manager can
both deploy projects, you update role-to-permission mappings, not every controller in the codebase.
Why RBAC Breaks Down in Real Applications
There are a few recurring problems that show up when teams implement RBAC too quickly.
Direct user permissions everywhere
This usually starts with convenience.
"Let's just store a JSON column with permissions on users."
That works until you need to audit who can do what, reason about consistency, or apply the same rule to hundreds of users.
Roles without permissions
Another common issue is checking role names directly everywhere:
if ($user->role === 'admin') {
// ...
}
This is brittle because your code becomes tightly coupled to your role taxonomy.
No scope
This is the big one.
A user might be an admin in one team and just a viewer in another. If your role assignment is global, you can't represent that cleanly.
No constraints
As soon as finance, compliance, or audit requirements enter the conversation, you need more than a list of roles. You need guardrails.
That is why a practical RBAC implementation usually needs scope, constraints, and a clear way to resolve the effective permissions for a user in a given context.
Designing a Team-Aware RBAC Model
Let's build an implementation for a SaaS-style application where users belong to teams and interact with projects inside those teams.
We will use these rules:
- users can have multiple roles inside the same team
- roles grant permissions
- effective permissions are the union of all role permissions for that user in that team
- some role combinations are forbidden in the same team
- a platform administrator bypasses normal team rules
This is the shape of the model:
flowchart LR
U[User] --> A[Team Role Assignment]
T[Team] --> A
A --> R[Role]
R --> RP[Role Permission Pivot]
RP --> P[Permission]
U -->|checks ability in| T
T --> PR[Project]
A team-scoped RBAC model where users receive permissions through roles assigned inside a team.
This is already more realistic than a single users.role column, because it captures something a
lot of applications need: the same person can mean different things in different boundaries.
Start with Permissions, Not Roles
One of the best ways to avoid a messy RBAC design is to define permissions first.
Roles are organizational labels. Permissions are actual application capabilities.
For our example, let's start with a small permission set:
enum Permission: string
{
case ViewTeam = 'team.view';
case UpdateTeam = 'team.update';
case InviteMembers = 'team.invite-members';
case UpdateMemberRoles = 'team.update-member-roles';
case RemoveMembers = 'team.remove-members';
case ViewProject = 'project.view';
case CreateProject = 'project.create';
case UpdateProject = 'project.update';
case DeleteProject = 'project.delete';
case DeployProject = 'project.deploy';
case ManageBilling = 'billing.manage';
}
With this, our code can stay focused on actions like project.deploy instead of role names like
owner, admin, or developer.
Now we can map roles to those permissions.
Defining Roles and Their Hierarchy
The simplest way to model hierarchy is by giving each role a numeric level and a permission set.
For our example, let's use these roles:
owneradminbilling-managerdeveloperviewerauditor
And here is a small RoleDefinition value object:
final readonly class RoleDefinition
{
/**
* @param list<Permission> $permissions
*/
public function __construct(
public string $name,
public int $level,
public array $permissions,
) {}
public function has(Permission $permission): bool
{
return in_array($permission, $this->permissions, true);
}
}
And a registry for the built-in roles:
final class RoleRegistry
{
/** @return array<string, RoleDefinition> */
public static function all(): array
{
return [
'owner' => new RoleDefinition(
name: 'owner',
level: 100,
permissions: Permission::cases(),
),
'admin' => new RoleDefinition(
name: 'admin',
level: 80,
permissions: [
Permission::ViewTeam,
Permission::UpdateTeam,
Permission::InviteMembers,
Permission::UpdateMemberRoles,
Permission::RemoveMembers,
Permission::ViewProject,
Permission::CreateProject,
Permission::UpdateProject,
Permission::DeleteProject,
Permission::DeployProject,
],
),
'billing-manager' => new RoleDefinition(
name: 'billing-manager',
level: 60,
permissions: [
Permission::ViewTeam,
Permission::ManageBilling,
],
),
'developer' => new RoleDefinition(
name: 'developer',
level: 40,
permissions: [
Permission::ViewTeam,
Permission::ViewProject,
Permission::CreateProject,
Permission::UpdateProject,
Permission::DeployProject,
],
),
'viewer' => new RoleDefinition(
name: 'viewer',
level: 10,
permissions: [
Permission::ViewTeam,
Permission::ViewProject,
],
),
'auditor' => new RoleDefinition(
name: 'auditor',
level: 20,
permissions: [
Permission::ViewTeam,
Permission::ViewProject,
],
),
];
}
public static function get(string $name): RoleDefinition
{
return self::all()[$name]
?? throw new InvalidArgumentException("Unknown role [{$name}].");
}
}
Even if you eventually store roles and permissions in the database, starting from a clear permission model like this will help you avoid fuzzy design decisions.
Separation of Duties Matters More Than People Think
One of the most useful things to bring from the formal RBAC model into your application is the idea of constraints.
For example, let's say your auditors should never be able to also manage billing in the same team. That is a separation-of-duty rule.
It is not a permission check during a request. It is a validity rule for role assignments.
That means the best place to enforce it is when roles are assigned, not when a user is trying to do something later.
final class TeamRoleConstraint
{
/**
* @return list<array{0: string, 1: string}>
*/
public function forbiddenPairs(): array
{
return [
['auditor', 'billing-manager'],
];
}
/**
* @param list<string> $roleNames
*/
public function assertValid(array $roleNames): void
{
foreach ($this->forbiddenPairs() as [$first, $second]) {
if (in_array($first, $roleNames, true) && in_array($second, $roleNames, true)) {
throw new DomainException("Roles [{$first}] and [{$second}] cannot be combined.");
}
}
}
}
This kind of rule keeps the authorization model honest.
Without it, you may technically have RBAC, but you still allow dangerous role combinations that the business never intended.
Database Schema
Now let's model this in the database.
We want:
- a
rolestable - a
permissionstable - a
permission_rolepivot - a
team_user_rolespivot that assigns roles to users inside teams
That last table is the key to the whole approach.
Schema::create('roles', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->unsignedInteger('level')->default(0);
$table->timestamps();
});
Schema::create('permissions', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
Schema::create('permission_role', function (Blueprint $table): void {
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->foreignId('permission_id')->constrained()->cascadeOnDelete();
$table->primary(['role_id', 'permission_id']);
});
Schema::create('team_user_roles', function (Blueprint $table): void {
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->foreignId('assigned_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['team_id', 'user_id', 'role_id']);
$table->index(['team_id', 'user_id']);
});
This design gives you a few important capabilities:
- one user may hold multiple roles in the same team
- a role can be reused across many teams
- effective permissions can be resolved with joins
- role assignments can be audited
If your application only allows a single role per user per team, you can simplify this into a
membership table with a role column. But keeping the many-to-many assignment gives you a more
faithful RBAC model.
The Eloquent Relationships
Let's keep the models straightforward.
final class Role extends Model
{
public function permissions(): BelongsToMany
{
return $this->belongsToMany(PermissionModel::class, 'permission_role');
}
}
final class Team extends Model
{
public function roleAssignments(): HasMany
{
return $this->hasMany(TeamUserRole::class);
}
}
final class User extends Authenticatable
{
public function teamRoleAssignments(): HasMany
{
return $this->hasMany(TeamUserRole::class);
}
public function rolesForTeam(Team $team): Collection
{
return $this->teamRoleAssignments()
->with('role.permissions')
->where('team_id', $team->id)
->get()
->pluck('role')
->filter();
}
}
final class TeamUserRole extends Model
{
protected $table = 'team_user_roles';
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
At this point, we have the structure. Now we need the part that makes the system pleasant to use: effective permission resolution.
Resolving Effective Permissions
You do not want your policies to manually join roles and permissions every time an authorization check runs.
That kind of logic tends to spread everywhere.
Instead, create one action or service that answers a simple question:
What permissions does this user effectively have in this team right now?
use Illuminate\Contracts\Cache\Repository as Cache;
final readonly class ResolveTeamPermissions
{
public function __construct(private Cache $cache) {}
/**
* @return array<string, true>
*/
public function handle(User $user, Team $team): array
{
$key = "rbac:team:{$team->id}:user:{$user->id}:permissions";
return $this->cache->remember($key, now()->addMinutes(10), function () use ($user, $team): array {
return $user->rolesForTeam($team)
->flatMap(fn (Role $role) => $role->permissions)
->pluck('name')
->unique()
->mapWithKeys(fn (string $permission) => [$permission => true])
->all();
});
}
}
This gives us a fast lookup map like:
[
'team.view' => true,
'project.deploy' => true,
'billing.manage' => true,
]
And then a dedicated authorizer can build on top of it:
use Illuminate\Auth\Access\Response;
final readonly class TeamAuthorizer
{
public function __construct(private ResolveTeamPermissions $resolver) {}
public function allows(User $user, Team $team, Permission $permission): Response
{
if ($user->is_platform_admin) {
return Response::allow();
}
if ($team->is_suspended) {
return $permission === Permission::ViewTeam || $permission === Permission::ViewProject
? Response::allow()
: Response::deny('This team is suspended.');
}
$permissions = $this->resolver->handle($user, $team);
return isset($permissions[$permission->value])
? Response::allow()
: Response::deny('You do not have the required permission in this team.');
}
}
This is a good place to add business-wide guardrails that sit above plain role membership.
A Better Role Assignment Flow
Now let's implement role assignment properly.
This is another place where people often cut corners and just call attach() somewhere inside a
controller.
A dedicated action keeps the rules centralized:
use Illuminate\Support\Facades\DB;
final readonly class AssignRolesToTeamMember
{
public function __construct(private TeamRoleConstraint $constraint) {}
/**
* @param list<int> $roleIds
*/
public function handle(User $actor, Team $team, User $member, array $roleIds): void
{
DB::transaction(function () use ($actor, $team, $member, $roleIds): void {
$roles = Role::query()
->whereKey($roleIds)
->get(['id', 'name', 'level']);
$roleNames = $roles->pluck('name')->all();
$this->constraint->assertValid($roleNames);
$highestRequestedLevel = (int) $roles->max('level');
$highestActorLevel = (int) $actor->rolesForTeam($team)->max('level');
if ($highestRequestedLevel >= $highestActorLevel && ! $actor->is_platform_admin) {
throw new DomainException('You cannot assign a role equal to or higher than your own.');
}
TeamUserRole::query()
->where('team_id', $team->id)
->where('user_id', $member->id)
->delete();
foreach ($roles as $role) {
TeamUserRole::create([
'team_id' => $team->id,
'user_id' => $member->id,
'role_id' => $role->id,
'assigned_by' => $actor->id,
]);
}
});
}
}
There are two nice things happening here:
- role constraints are enforced at write time
- role escalation is blocked before the assignment is persisted
This keeps your data cleaner and your authorization layer easier to trust.
Integrating RBAC with Gates and Policies
Now let's plug this into Laravel's authorization system.
The framework already gives us a clean vocabulary for abilities through gates and policies, so instead of inventing our own API, we should lean on that.
Let's start with a global bypass for platform admins.
use App\Models\User;
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::before(function (User $user, string $ability) {
return $user->is_platform_admin ? true : null;
});
}
That aligns nicely with Laravel's authorization model: if before returns a non-null value, that
result wins.
Now a policy for projects:
use Illuminate\Auth\Access\Response;
final readonly class ProjectPolicy
{
public function __construct(private TeamAuthorizer $authorizer) {}
public function view(User $user, Project $project): Response
{
return $this->authorizer->allows($user, $project->team, Permission::ViewProject);
}
public function update(User $user, Project $project): Response
{
return $this->authorizer->allows($user, $project->team, Permission::UpdateProject);
}
public function deploy(User $user, Project $project): Response
{
return $this->authorizer->allows($user, $project->team, Permission::DeployProject);
}
}
This gives you a clean usage surface:
Gate::authorize('deploy', $project);
And for a case that needs more context, like inviting a member with a requested role, Laravel's ability methods can receive an array of arguments.
final readonly class TeamPolicy
{
public function __construct(private TeamAuthorizer $authorizer) {}
public function inviteMember(User $user, Team $team, Role $requestedRole): Response
{
$permissionCheck = $this->authorizer->allows($user, $team, Permission::InviteMembers);
if ($permissionCheck->denied()) {
return $permissionCheck;
}
$highestActorLevel = (int) $user->rolesForTeam($team)->max('level');
return $requestedRole->level < $highestActorLevel
? Response::allow()
: Response::deny('You cannot invite a member with that role.');
}
}
And then from the controller:
public function store(StoreTeamInvitationRequest $request, Team $team): JsonResponse
{
$requestedRole = Role::query()->findOrFail($request->integer('role_id'));
Gate::authorize('inviteMember', [$team, $requestedRole]);
// Persist invitation...
}
This is one of those places where Laravel fits RBAC really well. The policy method gets the resource plus extra context, and your controller stays thin.
The Full Authorization Flow
Once everything is wired together, the authorization path looks like this:
sequenceDiagram
participant C as Controller
participant G as Gate / Policy
participant A as TeamAuthorizer
participant R as Permission Resolver
participant DB as Cache / Database
C->>G: authorize('deploy', project)
G->>A: deploy(user, project.team, project)
A->>A: platform admin or team state checks
A->>R: resolve effective permissions
R->>DB: load cached permission map
DB-->>R: team.user permission set
R-->>A: permission lookup map
A-->>G: allow or deny response
G-->>C: authorized or AuthorizationException
A typical request flow where a policy delegates the real RBAC decision to a dedicated authorizer.
That separation is worth keeping.
Policies are a great HTTP-facing authorization layer, but a dedicated authorizer keeps your domain rules reusable in jobs, actions, listeners, and anywhere else that is not a controller.
Caching and Invalidation
RBAC checks can become query-heavy if you resolve role and permission relationships on every request.
That is why caching the effective permission map is usually worth it.
But once you cache, you also need a clear invalidation strategy.
When any of these change, invalidate the cache for the affected team/user pair:
- a role assignment is added or removed
- a role's permissions change
- a team is suspended or reactivated
For example:
final readonly class ForgetTeamPermissionCache
{
public function __construct(private Cache $cache) {}
public function handle(int $teamId, int $userId): void
{
$this->cache->forget("rbac:team:{$teamId}:user:{$userId}:permissions");
}
}
This sounds small, but it is the difference between a fast RBAC layer and one that starts causing
N+1-style pain in every page that renders a lot of @can checks.
Testing RBAC Properly
Authorization code is exactly the kind of code that deserves tests.
It protects critical behavior, it tends to grow over time, and subtle regressions are easy to introduce.
For this kind of implementation, I like splitting tests into two groups:
- unit tests for permission resolution and role constraints
- feature tests for policy behavior through HTTP endpoints or
Gate
Let's check a few Pest examples.
First, a focused test for effective permissions:
it('unions permissions from multiple roles in the same team', function () {
$user = User::factory()->create();
$team = Team::factory()->create();
$developer = Role::factory()->create(['name' => 'developer']);
$billingManager = Role::factory()->create(['name' => 'billing-manager']);
$developer->permissions()->attach([
PermissionModel::fromName(Permission::DeployProject)->id,
]);
$billingManager->permissions()->attach([
PermissionModel::fromName(Permission::ManageBilling)->id,
]);
TeamUserRole::factory()->create([
'team_id' => $team->id,
'user_id' => $user->id,
'role_id' => $developer->id,
]);
TeamUserRole::factory()->create([
'team_id' => $team->id,
'user_id' => $user->id,
'role_id' => $billingManager->id,
]);
$permissions = app(ResolveTeamPermissions::class)->handle($user, $team);
expect($permissions)->toHaveKeys([
Permission::DeployProject->value,
Permission::ManageBilling->value,
]);
});
Now a constraint test:
it('rejects forbidden role combinations', function () {
$constraint = new TeamRoleConstraint();
expect(fn () => $constraint->assertValid(['auditor', 'billing-manager']))
->toThrow(DomainException::class, 'Roles [auditor] and [billing-manager] cannot be combined.');
});
And finally, a feature-style policy test:
it('allows a developer to deploy projects in their own team', function () {
$user = User::factory()->create();
$team = Team::factory()->create();
$project = Project::factory()->for($team)->create();
$developer = Role::factory()->create(['name' => 'developer', 'level' => 40]);
$deployPermission = PermissionModel::factory()->create([
'name' => Permission::DeployProject->value,
]);
$developer->permissions()->attach($deployPermission);
TeamUserRole::factory()->create([
'team_id' => $team->id,
'user_id' => $user->id,
'role_id' => $developer->id,
]);
expect(Gate::forUser($user)->allows('deploy', $project))->toBeTrue();
});
The important thing here is not just coverage. It is confidence.
When a new role or permission is added later, these tests help you change the system without guessing.
Practical Pitfalls to Avoid
There are a few traps that show up almost every time.
Role explosion
If every slight variation becomes a new role, your system gets harder to reason about.
Prefer a smaller set of roles plus clear permissions. If the model becomes too contextual, you may actually need some ABAC-style checks on top of RBAC.
Putting business rules inside controllers
Controllers should trigger authorization, not define it.
If one endpoint checks $user->isAdmin() and another checks $user->belongsToTeam($team), your
system is already drifting.
No audit trail for assignments
If roles change often, keep assigned_by, timestamps, and ideally a dedicated audit log. Access
control is one of the last places where "we think this changed last week" is acceptable.
Confusing ownership with authorization
Ownership may grant a lot of permissions, but it is still a business concept. Model it as a role or a special rule, not as scattered one-off checks.
Only testing happy paths
Authorization bugs often live in the negative cases:
- wrong team
- forbidden role escalation
- suspended tenant
- mixed role combinations
That is where your tests should spend real attention.
When RBAC Is Not Enough
RBAC is a great fit for many Laravel applications, but it is not a silver bullet.
Sometimes you also need rules based on:
- ownership
- time windows
- subscription plan
- geography
- document state
- user-to-resource relationships
That is not a failure of RBAC. It just means your authorization model is broader than roles alone.
In practice, a lot of mature systems end up with this layering:
- RBAC for the stable permission baseline
- policies for resource-aware checks
- extra constraints for contextual rules
That combination usually scales much better than trying to force every rule into a role name.
Conclusion
RBAC is much more than a role column on the users table.
When implemented well, it gives you a clear authorization model built on roles, permissions, scoping, and constraints. That means less duplicated logic, safer privilege management, and code that talks about abilities instead of job titles.
In Laravel, the sweet spot is usually to keep the RBAC model itself explicit in your domain, resolve effective permissions through a dedicated service, and expose those rules through gates and policies. That way, your controllers stay thin, your authorization stays centralized, and your tests have a stable target.
If you want to apply this in a real project, start small: define your permissions first, scope role assignments to the right boundary, add one or two separation-of-duty constraints, and test the negative cases as seriously as the happy path.
That will take you much further than bolting is_admin checks onto an application and hoping they
age well.
I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!