
August 12, 2026
11 min read
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.
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.
| Aspect | Roles | Permissions |
|---|---|---|
| Purpose | Group users by function | Define atomic actions |
| Naming | Nouns: admin, manager, client | Verb + noun: edit_posts, delete_users |
| Assignment | One primary role per user type | Shared across many roles |
| Change impact | Affects all users with that role | Affects every role containing it |
| Direct to user | Rare | Common for exceptions |
| Table | roles | permissions |
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']); 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.
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:
- Clear the permission cache before seeding.
- Use
firstOrCreateso re-runs do not duplicate rows. - Use
syncPermissionsto remove obsolete permissions when requirements change. - 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.
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
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.

