The most practical way to implement roles and permissions in Laravel is using Spatie Laravel Permissions — a battle-tested package that provides roles, permissions, middleware, and blade directives out of the box. For business applications with multiple user types (owner, manager, cashier, pharmacist), RBAC (Role-Based Access Control) is the architecture you need, and Spatie makes it clean and maintainable.

I've implemented RBAC in both a pharmacy POS system and a school ERP platform using Laravel and Spatie Permissions. This article shares the practical architecture — not just how to install the package, but how to design your roles, when to use Gates vs Policies vs Middleware, and the common mistakes that trip up developers building business applications.

Why RBAC Matters for Business Applications

In a business application, different users need different access levels. A pharmacy POS has four roles: owner (sees everything), manager (manages inventory and staff), pharmacist (processes prescriptions), and cashier (handles sales only). A school ERP has admin, teacher, student, and parent roles — each seeing completely different screens and data.

Without proper RBAC, you end up with one of two problems: either everyone has admin access (security nightmare) or you build custom if-else checks everywhere (maintenance nightmare). RBAC solves this by assigning permissions to roles, then assigning roles to users. Check the role, not the user.

Setting Up Spatie Laravel Permissions

Install the package and publish the migration:

composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate

Add the HasRoles trait to your User model:

use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasRoles;
    // ...
}

Define your roles and permissions. I recommend doing this in a seeder so it's version-controlled and reproducible:

use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;

// Pharmacy POS example
Role::create(['name' => 'owner']);
Role::create(['name' => 'manager']);
Role::create(['name' => 'pharmacist']);
Role::create(['name' => 'cashier']);

Permission::create(['name' => 'view-sales']);
Permission::create(['name' => 'manage-inventory']);
Permission::create(['name' => 'process-prescriptions']);
Permission::create(['name' => 'view-financials']);
Permission::create(['name' => 'manage-users']);
Permission::create(['name' => 'daily-cash-close']);

// Assign permissions to roles
Role::findByName('owner')->givePermissionTo([
    'view-sales', 'manage-inventory', 'process-prescriptions',
    'view-financials', 'manage-users', 'daily-cash-close'
]);

Role::findByName('cashier')->givePermissionTo(['view-sales']);

Role::findByName('pharmacist')->givePermissionTo([
    'view-sales', 'manage-inventory', 'process-prescriptions'
]);

Protecting Routes with Middleware

Spatie provides middleware that checks roles and permissions at the route level. This is the first layer of protection:

use Illuminate\Support\Facades\Route;

// Only owners can access user management
Route::middleware(['role:owner'])->group(function () {
    Route::get('/users', [UserController::class, 'index']);
    Route::post('/users', [UserController::class, 'store']);
    Route::delete('/users/{user}', [UserController::class, 'destroy']);
});

// Managers and owners can manage inventory
Route::middleware(['role:owner,manager'])->group(function () {
    Route::get('/inventory', [InventoryController::class, 'index']);
    Route::post('/inventory', [InventoryController::class, 'store']);
});

// Anyone with 'view-sales' permission can access sales
Route::middleware(['permission:view-sales'])->group(function () {
    Route::get('/sales', [SalesController::class, 'index']);
    Route::get('/sales/report', [SalesController::class, 'report']);
});

Gates vs Policies vs Middleware — When to Use Each

This is the decision that confuses most developers. Here's the clear framework:

Use Middleware When:

You want to block access to an entire route or group of routes based on role or permission. Middleware is the first line of defense — it runs before the controller and prevents unauthorized users from even reaching your logic.

// Middleware: "Can this user access this route at all?"
Route::middleware(['role:admin'])->group(function () {
    // All routes here require admin role
});

Use Gates When:

You need a simple boolean check that isn't tied to a specific model. Gates are for general authorization: "Can this user do X?" without considering a specific record.

// In AuthServiceProvider:
Gate::define('view-financials', function (User $user) {
    return $user->hasPermissionTo('view-financials');
});

// In a Blade view:
@can('view-financials')
    <a href="/reports/financial">Financial Reports</a>
@endcan

// In a controller:
if ($request->user()->can('view-financials')) {
    // show financial data
}

Use Policies When:

You need authorization tied to a specific Eloquent model. "Can this user edit this specific post?" "Can this user view this specific patient record?" Policies receive both the user and the model instance.

php artisan make:policy PrescriptionPolicy --model=Prescription
class PrescriptionPolicy
{
    public function view(User $user, Prescription $prescription)
    {
        // Owner and pharmacist can view any prescription
        return $user->hasAnyRole(['owner', 'pharmacist']);
    }

    public function update(User $user, Prescription $prescription)
    {
        // Only the pharmacist assigned to this prescription can update it
        return $user->hasRole('pharmacist')
            && $prescription->assigned_pharmacist_id === $user->id;
    }
}
// In controller:
public function update(Request $request, Prescription $prescription)
{
    $this->authorize('update', $prescription);

    // If we reach here, the user is authorized
    // ...
}
Tool Use When Example
Middleware Block entire routes by role/permission middleware('role:admin')
Gates General boolean checks, not tied to a model $user->can('view-financials')
Policies Authorization tied to a specific model instance $this->authorize('update', $post)

Real Role Architecture: Pharmacy POS

Here's the actual role architecture I implemented for a pharmacy POS system. This is production code, not a tutorial example:

// Roles
$roles = ['owner', 'manager', 'pharmacist', 'cashier'];

// Permissions matrix
$permissions = [
    'owner'    => ['*'],  // All permissions
    'manager'  => [
        'view-sales', 'manage-inventory', 'view-reports',
        'daily-cash-close', 'manage-prescriptions'
    ],
    'pharmacist' => [
        'view-sales', 'manage-inventory', 'process-prescriptions',
        'view-stock'
    ],
    'cashier'  => ['view-sales', 'process-sales'],
];

// In the POS controller:
public function dailyClose(Request $request)
{
    $this->authorize('daily-cash-close'); // Policy or Gate check

    $todaySales = Sale::whereDate('created_at', today())->get();
    $cashReceived = $todaySales->where('payment_method', 'cash')->sum('amount');
    // ... generate report
}

Common Mistakes to Avoid

Checking roles instead of permissions. Don't write if ($user->role === 'admin'). Write if ($user->can('manage-users')). Permission-based checks are more flexible — when you add a new role, you don't need to update every if-else in your code.

Forgetting the middleware layer. Even if you check permissions in your controller, always add middleware as the first line of defense. It prevents unauthorized requests from reaching your business logic at all.

Not using super-admin carefully. The * wildcard grants every permission by definition, so treat it as an explicit grant-all for the owner/super-admin role — never assign it to a role that might be given to employees. For a fully privileged super-admin, a simpler and more predictable pattern is a single Gate::before check that bypasses authorization entirely, keeping the wildcard out of your database roles.

Hardcoding role names in views. Don't write @if(auth()->user()->role === 'admin') in Blade. Use @can('permission-name') or @role('admin') (Spatie blade directive). This keeps your views decoupled from your role definitions.

Forgetting to revoke permissions when changing roles. When you remove a role from a user, the user immediately loses that role's permissions — but the role and its permission assignments still exist and can be reused. If you're managing permissions manually, always revoke old ones before assigning new ones.

Testing Your RBAC Setup

Write tests for your authorization logic. This is non-negotiable for business applications:

use Tests\TestCase;
use Spatie\Permission\Models\Role;

class RoleTest extends TestCase
{
    public function test_cashier_cannot_access_inventory()
    {
        $cashier = User::factory()->create();
        $cashier->assignRole('cashier');

        $response = $this->actingAs($cashier)
            ->get('/inventory');

        $response->assertForbidden();
    }

    public function test_pharmacist_can_process_prescriptions()
    {
        $pharmacist = User::factory()->create();
        $pharmacist->assignRole('pharmacist');

        $this->assertTrue(
            $pharmacist->can('process-prescriptions')
        );
    }

    public function test_owner_can_access_everything()
    {
        $owner = User::factory()->create();
        $owner->assignRole('owner');

        $permissions = ['view-sales', 'manage-inventory',
            'view-financials', 'manage-users'];

        foreach ($permissions as $permission) {
            $this->assertTrue($owner->can($permission));
        }
    }
}

Frequently Asked Questions

What is the best way to handle roles and permissions in Laravel?

The best approach for most Laravel applications is using Spatie Laravel Permissions — a battle-tested package that provides roles, permissions, and middleware. For simple apps, Laravel's built-in Gates and Policies work well without extra packages.

When should you use Gates vs Policies in Laravel?

Use Policies when authorization is tied to a specific model (e.g., can this user edit this post?). Use Gates for general authorization checks that aren't tied to a model (e.g., can this user access the admin dashboard?).

What is RBAC in Laravel?

RBAC (Role-Based Access Control) assigns permissions to roles, then assigns roles to users. Instead of checking individual permissions for each user, you check the user's role. This simplifies authorization in business applications with multiple user types.

How do you protect Laravel routes with roles?

Use the 'role' middleware from Spatie Permissions: Route::middleware('role:admin')->group(function() { ... }). For more granular control, use the 'permission' middleware or check permissions in controllers using $user->can('permission-name').

Faysal Mahmud Prem

Faysal Mahmud Prem

Software Engineer · IT Consultant

Building software that helps businesses work better — from scalable back-end systems to responsive front-end interfaces.