Tutorial

Laravel Role-Based Access Control: A Complete Guide with Repository Pattern

Laravel Role-Based Access Control: A Complete Guide with Repository Pattern

Learn how to implement Role-Based Access Control in Laravel using the Controller–Service–Repository pattern. Covers repositories, service layer, Gates, Policies, middleware protection, and activity logging — all production-ready.

Laravel Role-Based Access Control: A Complete Guide with Repository Pattern

Role-Based Access Control (RBAC) is one of those features every serious Laravel application eventually needs — and one that's easy to implement poorly. Bolting permissions directly onto controllers, scattering hasRole() checks throughout your codebase, or letting Eloquent queries bleed into business logic are all common traps.

In this guide, you'll implement a clean, scalable RBAC system using the Controller–Service–Repository pattern, so your authorization logic stays testable, maintainable, and easy to extend.


Why the Repository Pattern Matters for RBAC

Authorization logic often touches multiple models: User, Role, Permission, and pivot tables. Without a clear separation of concerns, your controllers become bloated, your queries get duplicated, and unit testing turns painful.

The pattern we'll follow:

  • Controller — handles HTTP request/response only
  • Service — contains business logic (e.g., assigning roles, checking permissions)
  • Repository — owns all database queries (Eloquent or DB facade)

Database Structure

Start with a clean migration setup:

// users, roles, permissions tables
Schema::create('roles', function (Blueprint $table) {
    $table->id();
    $table->string('name')->unique();     // e.g., 'admin', 'editor'
    $table->string('guard_name')->default('web');
    $table->timestamps();
});

Schema::create('permissions', function (Blueprint $table) {
    $table->id();
    $table->string('name')->unique();     // e.g., 'post.create', 'user.delete'
    $table->string('guard_name')->default('web');
    $table->timestamps();
});

// Pivot tables: role_user, permission_role, permission_user

Tip: If you're using Spatie Laravel Permission, these migrations are generated for you. The architecture below is package-agnostic, but pairs well with Spatie.


The Repository Layer

Create a RoleRepository that owns all database interaction:

<?php

declare(strict_types=1);

namespace App\Repositories;

use App\Models\Role;
use Illuminate\Database\Eloquent\Collection;

final class RoleRepository
{
    public function all(): Collection
    {
        return Role::with('permissions')->get();
    }

    public function findById(int $id): Role
    {
        return Role::with('permissions')->findOrFail($id);
    }

    public function findByName(string $name): ?Role
    {
        return Role::where('name', $name)->first();
    }

    public function create(array $data): Role
    {
        return Role::create($data);
    }

    public function syncPermissions(Role $role, array $permissionIds): void
    {
        $role->permissions()->sync($permissionIds);
    }
}

The key discipline here: no query logic leaks outside this class. Your service never calls Role::where(...) directly.


The Service Layer

The RoleService handles business rules — not HTTP, not raw queries:

<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Role;
use App\Models\User;
use App\Repositories\RoleRepository;
use App\Repositories\PermissionRepository;
use Illuminate\Support\Facades\DB;

final class RoleService
{
    public function __construct(
        private readonly RoleRepository $roleRepository,
        private readonly PermissionRepository $permissionRepository,
    ) {}

    public function assignRoleToUser(User $user, string $roleName): void
    {
        $role = $this->roleRepository->findByName($roleName)
            ?? throw new \DomainException("Role [{$roleName}] does not exist.");

        $user->roles()->syncWithoutDetaching([$role->id]);
    }

    public function createRoleWithPermissions(array $data, array $permissionIds): Role
    {
        return DB::transaction(function () use ($data, $permissionIds): Role {
            $role = $this->roleRepository->create($data);
            $this->roleRepository->syncPermissions($role, $permissionIds);

            return $role;
        });
    }
}

Notice that all Eloquent interaction goes through the repository. The service only orchestrates — it has no ::where() calls of its own.


The Controller Layer

Keep controllers lean. Their only job is to receive a request, call the service, and return a response:

<?php

declare(strict_types=1);

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreRoleRequest;
use App\Services\RoleService;
use Illuminate\Http\RedirectResponse;

final class RoleController extends Controller
{
    public function __construct(
        private readonly RoleService $roleService,
    ) {}

    public function store(StoreRoleRequest $request): RedirectResponse
    {
        $this->roleService->createRoleWithPermissions(
            $request->only('name', 'guard_name'),
            $request->input('permissions', []),
        );

        return redirect()->route('admin.roles.index')
            ->with('success', 'Role created successfully.');
    }
}

No business logic. No Eloquent. Just HTTP orchestration.


Gate & Policy Integration

For authorization checks across your app, register abilities in AuthServiceProvider:

// app/Providers/AuthServiceProvider.php

Gate::before(function (User $user, string $ability): ?bool {
    if ($user->hasRole('super-admin')) {
        return true; // Bypass all checks for super-admin
    }

    return null; // Fall through to individual policies
});

Then use policies for resource-level control:

// app/Policies/PostPolicy.php

public function update(User $user, Post $post): bool
{
    return $user->hasPermissionTo('post.update');
}

In your Blade templates:

@can('post.update', $post)
    <a href="{{ route('posts.edit', $post) }}">Edit</a>
@endcan

Protecting Routes with Middleware

Group your admin routes and apply role-based middleware:

// routes/web.php

Route::prefix('admin')
    ->middleware(['auth', 'role:admin'])
    ->name('admin.')
    ->group(function () {
        Route::resource('roles', RoleController::class);
        Route::resource('users', UserController::class);
    });

If you're using Spatie, the role: and permission: middleware are registered automatically. For a custom implementation, create the middleware class and bind it in bootstrap/app.php (Laravel 11+):

// app/Http/Middleware/CheckRole.php

<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final class CheckRole
{
    public function handle(Request $request, Closure $next, string ...$roles): Response
    {
        if (! $request->user()?->hasAnyRole($roles)) {
            abort(403, 'Unauthorized. Insufficient role.');
        }

        return $next($request);
    }
}
// app/Http/Middleware/CheckPermission.php

<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final class CheckPermission
{
    public function handle(Request $request, Closure $next, string $permission): Response
    {
        if (! $request->user()?->hasPermissionTo($permission)) {
            abort(403, 'Unauthorized. Missing permission: ' . $permission);
        }

        return $next($request);
    }
}

Register both in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'role'       => \App\Http\Middleware\CheckRole::class,
        'permission' => \App\Http\Middleware\CheckPermission::class,
    ]);
})

Usage: ->middleware(['role:admin,editor']) accepts multiple roles. The user passes if they have any of the listed roles.


Caching Permissions for Production Performance

Every hasPermissionTo() call hits the database by default. In a high-traffic app, this adds up quickly. The solution is to cache each user's resolved permissions and invalidate the cache whenever roles or permissions change.

Add a PermissionCache helper to your service:

<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\User;
use Illuminate\Support\Facades\Cache;

final class PermissionCacheService
{
    private const TTL = 3600; // 1 hour

    public function getPermissions(User $user): array
    {
        return Cache::remember(
            key: "user.{$user->id}.permissions",
            ttl: self::TTL,
            callback: fn (): array => $user->getAllPermissions()->pluck('name')->all(),
        );
    }

    public function invalidate(User $user): void
    {
        Cache::forget("user.{$user->id}.permissions");
    }
}

Then call invalidate() inside RoleService whenever a role or permission changes:

public function assignRoleToUser(User $user, string $roleName): void
{
    $role = $this->roleRepository->findByName($roleName)
        ?? throw new \DomainException("Role [{$roleName}] does not exist.");

    $user->roles()->syncWithoutDetaching([$role->id]);

    // Invalidate stale permission cache immediately
    $this->permissionCacheService->invalidate($user);
}

Note: If you're using Spatie Laravel Permission, it ships with built-in cache support via setPermissionsTeamId() and forgetCachedPermissions(). Configure the TTL in config/permission.php under cache.expiration_time.


Activity Logging

A production RBAC system should log who changed what. A simple approach using a dedicated service:

public function assignRoleToUser(User $user, string $roleName): void
{
    // ... assign role logic

    activity()
        ->causedBy(auth()->user())
        ->performedOn($user)
        ->withProperties(['role' => $roleName])
        ->log('role_assigned');
}

This pairs well with the Spatie Activity Log package and gives you a full audit trail out of the box.


Testing the RBAC System

The main advantage of the Controller–Service–Repository pattern is testability. Here's how to write focused unit tests for each layer.

Testing the Repository

Use an in-memory SQLite database (or RefreshDatabase) to keep repository tests fast and isolated:

<?php

declare(strict_types=1);

namespace Tests\Unit\Repositories;

use App\Models\Role;
use App\Repositories\RoleRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class RoleRepositoryTest extends TestCase
{
    use RefreshDatabase;

    private RoleRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();
        $this->repository = new RoleRepository();
    }

    public function test_find_by_name_returns_null_for_missing_role(): void
    {
        $result = $this->repository->findByName('ghost');

        $this->assertNull($result);
    }

    public function test_find_by_name_returns_role_when_exists(): void
    {
        Role::factory()->create(['name' => 'editor']);

        $result = $this->repository->findByName('editor');

        $this->assertInstanceOf(Role::class, $result);
        $this->assertSame('editor', $result->name);
    }

    public function test_sync_permissions_replaces_existing_permissions(): void
    {
        $role        = Role::factory()->create();
        $permissions = Permission::factory()->count(3)->create();

        $this->repository->syncPermissions($role, $permissions->pluck('id')->all());

        $this->assertCount(3, $role->fresh()->permissions);
    }
}

Testing the Service

Mock the repository to keep service tests pure — no database involvement:

<?php

declare(strict_types=1);

namespace Tests\Unit\Services;

use App\Models\Role;
use App\Models\User;
use App\Repositories\RoleRepository;
use App\Services\RoleService;
use DomainException;
use Mockery;
use Tests\TestCase;

final class RoleServiceTest extends TestCase
{
    public function test_assign_role_throws_when_role_does_not_exist(): void
    {
        $repository = Mockery::mock(RoleRepository::class);
        $repository->shouldReceive('findByName')
            ->with('ghost')
            ->andReturn(null);

        $service = new RoleService($repository, Mockery::mock(PermissionRepository::class));
        $user    = User::factory()->make();

        $this->expectException(DomainException::class);
        $this->expectExceptionMessage('Role [ghost] does not exist.');

        $service->assignRoleToUser($user, 'ghost');
    }

    public function test_assign_role_syncs_without_detaching(): void
    {
        $role = Role::factory()->make(['id' => 1, 'name' => 'editor']);

        $repository = Mockery::mock(RoleRepository::class);
        $repository->shouldReceive('findByName')->with('editor')->andReturn($role);

        $user = Mockery::mock(User::class)->makePartial();
        $user->shouldReceive('roles->syncWithoutDetaching')->with([1])->once();

        $service = new RoleService($repository, Mockery::mock(PermissionRepository::class));
        $service->assignRoleToUser($user, 'editor');
    }
}

Testing Middleware Authorization

<?php

declare(strict_types=1);

namespace Tests\Feature\Middleware;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class CheckRoleMiddlewareTest extends TestCase
{
    use RefreshDatabase;

    public function test_unauthenticated_user_is_redirected(): void
    {
        $this->get(route('admin.roles.index'))
            ->assertRedirect(route('login'));
    }

    public function test_user_without_admin_role_receives_403(): void
    {
        $user = User::factory()->create();

        $this->actingAs($user)
            ->get(route('admin.roles.index'))
            ->assertForbidden();
    }

    public function test_admin_user_can_access_protected_route(): void
    {
        $user = User::factory()->create();
        $user->assignRole('admin');

        $this->actingAs($user)
            ->get(route('admin.roles.index'))
            ->assertOk();
    }
}

Tip: If you're using Pest PHP, these tests translate cleanly into it() blocks with the same assertions — the architecture is identical.


Summary

A clean RBAC implementation in Laravel boils down to four principles:

  1. Repositories own queries — no Eloquent scattered across controllers or services
  2. Services own business logic — role assignment, permission syncing, domain validation
  3. Controllers stay thin — validate input, call service, return response
  4. Cache permission lookups — don't hit the database on every authorization check in production

This separation makes your authorization system easy to test, easy to extend (adding new roles or permissions doesn't touch your controllers), and straightforward to hand off to a team.


Want to Skip the Boilerplate?

If you'd rather not wire all of this up from scratch, I've built a Laravel Starter Kit that ships with a fully implemented RBAC system — role & permission management, Spatie integration, an admin dashboard built on Sneat UI, CRUD generator, activity log, and multi-language support, all following the Controller–Service–Repository pattern described in this article.

It's available for $39 on Lemon Squeezy. One purchase, one clean foundation — so you can focus on your actual product instead of re-implementing authorization for the fifth time.

👉 Get the Laravel Starter Kit →

Share this article: