Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel Spatie Permission Role Management

By Kokil Thapa | Last reviewed: September 2026

Getting Spatie Permission right in Laravel stops authorization bugs before they reach production. Native gates work for small apps. Most client portals, eCommerce backends, and legal-tech platforms need database-driven RBAC that grows without rewrites. This guide covers install steps, guard pitfalls, middleware patterns, seeding, caching, and testing for Laravel 13 on PHP 8.3 or higher. It builds on patterns from REST API security best practices and real production deployments.

Authorization architecture affects every layer of your app. On legal-tech portals like Notary Nepal and Mijar Law Associates, a clean role model saved weeks of refactoring when client requirements grew from three user types to fifteen overlapping roles. The same pattern applies to booking systems and multi-vendor marketplaces. Treat permissions as infrastructure, not a late-stage add-on.

How do you install and configure Spatie Permission in Laravel?

Start with Composer 2.10 and Laravel 13.x on PHP 8.3 or higher. Laravel 12 on PHP 8.2 still runs the package the same way. Service provider auto-discovery handles registration in modern Laravel releases.

composer require spatie/laravel-permission

Publish the migration and config immediately. Default settings rarely match production needs.

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"

This creates database/migrations/xxxx_create_permission_tables.php and config/permission.php. Run migrations next:

php artisan migrate

Add the HasRoles trait to your User model and any other authorizable models:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasRoles;
}

The trait adds relationships like roles() and helpers like hasRole(), can(), and assignRole(). In my experience, add this trait during initial project setup. Retrofitting later is painful when foreign keys and seeders already assume it exists.

Spatie Permission Schemausersid, name, emailHasRoles traitrolesname, guard_nameadmin, editorpermissionsname, guard_nameedit postsmodel_has_rolesrole_id, model_type, model_idrole_has_permissionspermission_id, role_idPolymorphic pivots support multi-model authorization
Spatie Permission database schema with roles, permissions, and polymorphic pivot tables

The config at config/permission.php controls cache TTL, guard defaults, and table names. For production, set cache_expiration_time with a DateInterval object. This avoids string parsing edge cases during daylight saving transitions.

Official docs at Spatie Laravel Permission cover advanced options like teams and wildcard permissions. Read the Laravel authorization guide first if gates and policies are new to you. Both sources stay current with framework changes.

Register middleware aliases in Laravel 13

Laravel 13 registers middleware in bootstrap/app.php. Add Spatie aliases there:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
        'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
        'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
    ]);
})

Skip this step and route middleware strings fail silently or throw binding errors. I have debugged this on more than one deploy where authorization worked locally but failed after release.

What is the difference between roles and permissions in Spatie Permission?

Roles group users by job function. Permissions define atomic actions. Users inherit permissions through roles. Direct permission assignment handles one-off exceptions.

Conflating the two creates maintenance debt. A common mistake is naming roles like can-view-divorce-cases instead of assigning a view_divorce_cases permission to a paralegal role. That pattern explodes into dozens of overlapping roles with no clear hierarchy.

AspectRolesPermissions
PurposeGroup users by functionDefine atomic actions
NamingNouns: admin, manager, clientVerb + noun: edit_posts, delete_users
AssignmentOne primary role per user typeShared across many roles
Change impactAffects all users with that roleAffects every role containing it
Direct to userRareCommon for exceptions
Tablerolespermissions

Guard names add another layer. Each role and permission belongs to an auth guard: web, api, or sanctum. Creating permissions without matching guards causes 403 errors on API routes even when role assignment looks correct.

Permission::create(['name' => 'view cases', 'guard_name' => 'sanctum']);
Role::create(['name' => 'attorney', 'guard_name' => 'sanctum']);

Web-only apps can omit guard names. Any system exposing APIs needs explicit guards. See Sanctum vs Passport for API auth when choosing your guard strategy. Pair that with Sanctum authentication setup for a complete API stack.

For complex business rules beyond simple role checks, combine Spatie with Laravel policies and gates. Policies handle record-level logic. Spatie handles role and permission membership.

How do you protect routes with Spatie Permission middleware?

Spatie ships three middleware classes: RoleMiddleware, PermissionMiddleware, and RoleOrPermissionMiddleware. Apply them on routes or in controller constructors.

Route::middleware(['auth', 'role:admin|editor'])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
    Route::post('/cases', [CaseController::class, 'store'])
        ->middleware('permission:create cases');
});

public function __construct()
{
    $this->middleware('permission:edit documents')->only(['edit', 'update']);
    $this->middleware('role:admin')->only(['destroy']);
}

Pipe syntax (role:admin|editor) checks for ANY listed role. For AND logic, stack separate middleware entries:

Route::get('/reports', [ReportController::class, 'index'])
    ->middleware(['role:manager', 'permission:view financial data']);
Middleware ChainRequestAuth userRole CheckHas required role?PermissionHas permission?ControllerRun action403 ForbiddenMissing role403 ForbiddenNo permissionFail-fast stops unauthorized requests before controllers run
Spatie Permission middleware execution order on protected Laravel routes

Place authorization middleware after authentication. Order matters. See Laravel middleware use cases for stacking patterns that work in production.

Blade directives and UI checks

Middleware protects routes. Blade directives hide UI elements users should not see:

@role('admin')
    <a href="{{ route('admin.users') }}">Manage Users</a>
@endrole

@can('edit cases')
    <button type="submit">Save Case</button>
@endcan

Never rely on Blade checks alone. Users can hit endpoints directly. Always enforce permissions on the server. This aligns with OWASP security practices for Laravel.

Handling unauthorized access

Failed checks throw Spatie\Permission\Exceptions\UnauthorizedException. Return JSON for APIs and custom views for web routes:

use Spatie\Permission\Exceptions\UnauthorizedException;

public function render($request, Throwable $e)
{
    if ($e instanceof UnauthorizedException) {
        if ($request->expectsJson()) {
            return response()->json([
                'error' => 'Forbidden',
                'message' => 'You lack required permissions.',
            ], 403);
        }
        return response()->view('errors.403', [], 403);
    }
    return parent::render($request, $e);
}

This matters for Filament admin panels where Livewire requests expect JSON error payloads. HTML redirects break the UI silently.

How do you optimize Spatie Permission performance in production?

Without caching, every can() or hasRole() call hits the database. On document portals with hundreds of concurrent users, that adds measurable latency per request. Enable caching in config/permission.php:

'cache' => [
    'enabled' => true,
    'expiration_time' => DateInterval::createFromDateString('24 hours'),
    'key' => 'spatie.permission.cache',
    'store' => 'default',
],

Use Redis 8.10 for cache storage in production. File-based cache undermines the benefit when permission checks run dozens of times per request. Configure your .env:

CACHE_STORE=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

Read Redis caching for Laravel for pool sizing and connection tuning on small VPS hosts common in Nepal.

Eloquent observers invalidate cache when roles or permissions change through the package API. Direct SQL edits bypass observers. After raw database changes, run:

php artisan permission:cache-reset

Eager load relationships to avoid N+1 queries on user listing pages:

$users = User::with('roles.permissions')->paginate(25);

Without eager loading, a page of 50 users can trigger 150+ extra queries. Three queries total is the target.

Cache LifecycleRole ChangeassignRole()syncPermissions()ObserverDetects changeFlushes cache keyRedisDelete cachedpermission mapNext Auth CheckCache missQuery DB freshRebuildStore in RedisTTL 24 hoursAutomatic invalidation keeps permissions consistent after role changes
Redis cache invalidation flow in Spatie Permission when roles or permissions change

How do you seed roles and permissions safely?

Never create roles or permissions inside migrations. Migrations define schema only. Use idempotent seeders you can re-run on every deploy:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;

class RolesAndPermissionsSeeder extends Seeder
{
    public function run(): void
    {
        app(PermissionRegistrar::class)->forgetCachedPermissions();

        $permissions = [
            'view cases', 'create cases', 'edit cases',
            'delete cases', 'manage users', 'view billing',
        ];

        foreach ($permissions as $permission) {
            Permission::firstOrCreate(
                ['name' => $permission, 'guard_name' => 'web']
            );
        }

        $client = Role::firstOrCreate(['name' => 'client', 'guard_name' => 'web']);
        $client->syncPermissions(['view cases', 'view billing']);

        $attorney = Role::firstOrCreate(['name' => 'attorney', 'guard_name' => 'web']);
        $attorney->syncPermissions(['view cases', 'create cases', 'edit cases']);

        $admin = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
        $admin->syncPermissions($permissions);
    }
}

Key practices in this seeder:

  1. Clear the permission cache before seeding.
  2. Use firstOrCreate so re-runs do not duplicate rows.
  3. Use syncPermissions to remove obsolete permissions when requirements change.
  4. Set explicit guard names for multi-guard apps.

Follow database seeding best practices for team workflows. Run seeders in deploy pipelines after migrations:

php artisan migrate --force
php artisan db:seed --class=RolesAndPermissionsSeeder --force
php artisan permission:cache-reset

On projects using GitLab CI/CD with Deployer, place these commands in the post-release hook. New permissions must exist before traffic hits updated code. See also zero-downtime Laravel deployment with Deployer.

Name permissions consistently. Use lowercase verbs with underscores or spaces, but pick one style and stick to it. A regex tester helps validate bulk permission name patterns before seeding.

How do you test Spatie Permission authorization in Laravel?

Authorization bugs are security bugs. Test them with the same rigor as payment logic. Pest or PHPUnit both work. Create roles in test setup, assign them to users, then assert access:

use App\Models\User;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;

it('blocks clients from deleting cases', function () {
    Permission::create(['name' => 'delete cases', 'guard_name' => 'web']);
    $role = Role::create(['name' => 'client', 'guard_name' => 'web']);

    $user = User::factory()->create();
    $user->assignRole($role);

    $this->actingAs($user)
        ->delete('/cases/1')
        ->assertForbidden();
});

it('allows attorneys to edit cases', function () {
    Permission::create(['name' => 'edit cases', 'guard_name' => 'web']);
    $role = Role::create(['name' => 'attorney', 'guard_name' => 'web']);
    $role->givePermissionTo('edit cases');

    $user = User::factory()->create();
    $user->assignRole($role);

    $this->actingAs($user)
        ->assertTrue($user->can('edit cases'));
});

Use Laravel feature testing best practices to keep test suites fast. Refresh the permission cache in setUp() when tests mutate roles mid-run.

Authorization DecisionNeed access control?User type based?Use Spatie rolesAction based?Use permissionsRecord owner?Use policiesOne-off exception?Direct permission on userComplex rules?Policy + Spatie combo
Decision tree for Spatie Permission roles, permissions, and Laravel policies

On client portals like Mijar Law Associates, we test every sensitive route after permission changes. One missed test can expose document downloads to the wrong role.

For greenfield apps needing full RBAC design, see enterprise application development services. Legal-tech projects benefit from early permission modeling covered in legal-tech solutions for Nepal law firms. Framework upgrades should include permission regression tests per Laravel 12 migration notes.

Key Takeaways

  • Install spatie/laravel-permission, publish config, add HasRoles to User, and register middleware aliases in bootstrap/app.php.
  • Keep roles as user groups and permissions as atomic actions—never merge the two concepts.
  • Match guard_name on roles and permissions to your auth guard, especially for Sanctum API routes.
  • Enable Redis caching in production and run permission:cache-reset after direct database edits.
  • Seed roles idempotently with firstOrCreate and syncPermissions, never inside migrations.
  • Write feature tests for every protected route—UI hiding alone is not authorization.

People Also Ask

Can Spatie Permission work with multiple guards?

Yes. Each role and permission row stores a guard_name column. Create separate permission sets per guard when your app serves both web sessions and API tokens. A user authenticated via Sanctum only matches permissions seeded with guard_name => 'sanctum'.

How do you assign multiple roles to one user?

Call $user->assignRole(['admin', 'editor']) or chain individual assignments. By default, Spatie allows multiple roles. Set 'teams' => false and review config if you need single-role enforcement per team or tenant.

Does Spatie Permission replace Laravel policies?

No. Spatie manages role and permission membership. Policies handle record-level checks like "can this user edit their own case file." Use both together for complete authorization coverage.

What happens if I forget to clear the permission cache?

Users keep stale permissions until cache expires or you run php artisan permission:cache-reset. Role changes through Eloquent auto-flush cache. Raw SQL updates do not. Always reset cache after manual database edits.

Ship authorization you can maintain

Spatie Permission gives Laravel apps a maintainable RBAC foundation that custom ACL tables cannot match long-term. Separate roles from permissions, enable Redis caching from day one, seed idempotently, and test every protected boundary. On legal-tech and client portal projects, that discipline prevents data exposure and costly refactors.

Building a system where access control mistakes have real consequences? Contact us about your authorization architecture, or reach out directly to discuss your Laravel project before production launch.

Frequently Asked Questions

It is the industry-standard Composer package for managing roles and permissions in Laravel applications via database-driven access control.

The package is free and open-source under the MIT license, costing NPR 0 / USD 0 for commercial or personal projects.

Use Spatie when you need dynamic, database-configurable roles assigned to users at runtime rather than static code definitions.

Run composer require spatie/laravel-permission with PHP 8.2 or higher. Publish the config and migration files using php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider", then run migrations. In my experience with Laravel 12 projects, always check the service provider auto-discovery works correctly before proceeding. Add the HasRoles trait to your User model immediately after installation to prevent missing method errors during early development testing phases.

Yes, the package supports assigning unlimited roles per user through a many-to-many relationship. You can check authorization using hasAnyRole() or hasAllRoles() methods depending on whether you need inclusive or exclusive role validation. On legal-tech portals I have built, this is essential for staff who handle both case management and billing. Always cache permission checks in production because repeated database queries for multi-role users create significant performance overhead under load.

Register the RoleMiddleware, PermissionMiddleware, or RoleOrPermissionMiddleware in your bootstrap/app.php or kernel. Apply them to route groups using middleware syntax like role:admin or permission:edit-articles. I frequently use pipe-separated parameters like role:admin|editor for flexible access. Remember that middleware checks occur before controller execution, so combine with policy checks inside controllers for granular resource-level authorization that depends on specific model ownership or contextual business rules.

Yes, but you must configure it correctly for stateless token-based requests. The default session guard assumptions fail with Sanctum tokens. Set the auth guard explicitly in config/permission.php or use the setPermissionsTeamId method for multi-tenant APIs. On REST APIs I have developed, I always verify permissions via middleware or gate checks after Sanctum authenticates the token bearer. Never assume token validity equals permission validity; always validate both independently in your API controllers.

Create a dedicated seeder class extending Illuminate\Database\Seeder and use findOrCreate to prevent duplicate entries on re-runs. Wrap seeding in transactions and call app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions() after each batch. In production deployments via Deployer, I run seeders only once during initial setup or controlled maintenance windows. Never include destructive permission removal in seeders. Always version-control your seeder files and test them against a staging database copy before executing on live environments.

Spatie caches permissions aggressively for performance. After creating, updating, or deleting roles or permissions programmatically, you must manually clear the cache by calling forgetCachedPermissions() on the PermissionRegistrar. This is the most common issue I encounter during client handovers where admin panels modify permissions dynamically. If using Redis, also verify your cache driver configuration matches between local and production environments. Consider adding cache-clearing logic directly into your admin panel's save handlers to automate this step.

Define model-specific permissions using naming conventions like post.edit or invoice.delete, then register policies that map these strings to authorization logic. Global permissions like access-dashboard lack a model context and should be checked via Gate::allows() or middleware. On eCommerce platforms I maintain, I separate order-management permissions from product-catalog permissions using dot notation prefixes. Always document your permission naming scheme because inconsistent conventions make auditing impossible as the application grows beyond twenty or thirty distinct permissions.

Yes, enable the teams feature in config/permission.php by setting teams to true and defining a team foreign key. Each permission and role becomes scoped to a specific team ID. You must set the current team context using setPermissionsTeamId() before checking authorization. This adds complexity to every query and cache operation. For simpler multi-tenant needs, consider separate databases instead. On projects requiring true team isolation, I implement middleware that sets team context automatically based on route parameters or subdomain resolution.

Enable caching in config/permission.php and use Redis instead of file-based cache drivers. Eager-load roles and permissions when querying users to prevent N+1 problems in listing views. Use direct database queries for bulk permission checks rather than looping through individual user objects. On high-traffic sites I maintain, I pre-compute effective permissions during login and store them in the session or token claims. Monitor slow query logs specifically for spatie_permission tables and add composite indexes if your team-scoped queries exceed fifty milliseconds consistently.

Relying solely on middleware without controller-level policy checks creates authorization gaps when routes change. Trusting frontend-hidden UI elements instead of server-side validation exposes protected actions. Forgetting to revoke permissions when deactivating users leaves orphaned access. Not validating permission names allows typos to silently grant unintended access. Always implement defense-in-depth by checking authorization at middleware, controller, and service layers. Audit permission assignments quarterly and log all role changes. On legal portals handling sensitive documents, I enforce explicit deny-lists alongside allow-lists for critical operations.

Jetstream provides basic team membership and role strings tied to its own UI scaffolding, while Spatie offers granular, database-managed permissions independent of frontend framework choices. Jetstream suits simple SaaS prototypes; Spatie fits complex business applications requiring custom authorization logic, API support, and team-scoped permissions without vendor lock-in. Migrating from Jetstream to Spatie requires rewriting authorization checks but gains flexibility. For most production Laravel applications I build beyond MVP stage, Spatie replaces Jetstream's limited RBAC entirely while retaining Jetstream's authentication features.

First verify the exact permission string matches including case sensitivity and spelling. Check that the user actually has the role containing that permission via tinker or debugbar. Confirm the correct auth guard is configured if using multiple guards. Clear the permission cache explicitly. Verify no middleware ordering issues cause premature rejection. On debugging sessions, I temporarily dump the user's cached permissions array to inspect loaded values. If using teams, ensure the active team ID matches the permission's team scope. Document resolved edge cases to prevent recurrence.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: