If you've worked with Laravel for more than a week, you've probably installed spatie/laravel-permission. It's the default answer to "how do I add roles and permissions to my app?" -- and for good reason. It's well-maintained, well-documented, and battle-tested.
So why did I build laravel-permissions-redis?
Because at scale, the database becomes the bottleneck.
The problem I kept hitting
I was building a multi-tenant SaaS with a Laravel API serving ~200 requests/second. Each request triggered between 5 and 30 permission checks -- middleware, policies, Blade directives, inline gates. With Spatie, every single one of those checks was hitting the cache driver, deserializing a full array of permissions, and scanning it linearly.
On cold cache? Full database reload. On cache invalidation? Forget everything, rebuild from scratch on the next request. With 50,000+ users, that "next request" was painfully slow.
The numbers looked something like this:
| Scenario | DB queries (Spatie) | DB queries (Redis approach) |
|---|---|---|
| 1 request, 33 checks | 5 | 1 |
| 10 requests | 14 | 10 |
| 50 requests | 54 | 50 |
After the initial cache warm, all permission checks with Redis resolve with zero additional database queries. Not "fewer queries" -- zero.
Why not just use Redis as a Laravel cache driver?
That was my first thought too. Set CACHE_DRIVER=redis and let Spatie's existing cache layer benefit from Redis speed.
It helps, but it doesn't solve the architectural problems:
O(n) lookups. Spatie serializes all permissions into a single cached array. Each
hasPermissionTo()call deserializes that array and scans it. With 200 permissions per user, that's 200 comparisons per check.Nuclear invalidation. When any permission changes, Spatie calls
forgetCachedPermissions()which drops the entire cache. Every user pays the cold-start penalty on their next request.No in-memory request cache. Even with Redis as the cache driver, Spatie hits Redis on every single check within the same request. If your middleware checks 5 permissions, that's 5 Redis round-trips.
The architecture of laravel-permissions-redis
I took a fundamentally different approach using Redis SET data structures:
auth:user:{userId}:permissions -> SET {"posts.create", "posts.edit", "users.view"}
auth:user:{userId}:roles -> SET {"admin", "editor"}
auth:role:{roleId}:permissions -> SET {"posts.create", "posts.edit"}
Each permission check is a single SISMEMBER command -- O(1) time complexity, regardless of how many permissions exist. No deserialization, no scanning.
The dual-layer cache
Request hits middleware
-> Check in-memory PHP array (zero I/O)
-> Miss? Check Redis SET (one SISMEMBER)
-> Miss? Query database, warm Redis, populate in-memory cache
Within a single request, the first check might hit Redis. Every subsequent check for the same user resolves from a PHP array in memory -- no I/O at all.
Surgical invalidation
When a user's permissions change, we don't flush everything. We rewarm only the affected user's Redis SETs. Everyone else's cache stays warm. No cold-start stampede.
// Spatie: nuke everything
Cache::forget('spatie.permission.cache');
// laravel-permissions-redis: rewarm only what changed
$repository->warmUserCache($userId);
Feature parity (and beyond)
I didn't want this to be a "fast but limited" alternative. The goal was full feature parity with Spatie plus the features I kept needing:
Drop-in API compatibility
// These work exactly the same as Spatie
$user->assignRole('admin');
$user->givePermissionTo('posts.create');
$user->hasPermissionTo('posts.edit');
$user->hasAnyRole('admin', 'editor');
Same method names, same Blade directives (@role, @hasanyrole), same middleware (permission:posts.create). Migrating is mostly swapping a trait and a config file.
Features Spatie doesn't have
| Feature | Description |
|---|---|
| Octane support | Automatic in-memory cache reset between requests in long-lived workers |
| Multi-tenancy | Redis key isolation per tenant, with built-in Stancl/Tenancy resolver |
| Wildcard permissions |
posts.* matches posts.create, posts.edit, etc. via fnmatch()
|
| Super admin role | One config value to make a role bypass all permission checks |
| Testing trait |
actingAsWithPermissions($user, ['posts.create']) -- one-liner test setup |
| Cache warming CLI |
php artisan permissions-redis:warm to pre-warm all users on deploy |
| Seed command |
php artisan permissions-redis:seed reads from config, supports --fresh
|
| Migrate from Spatie |
php artisan permissions-redis:migrate-from-spatie with --dry-run
|
| Fluent guard scoping | $user->forGuard('api')->hasPermissionTo('posts.create') |
Automated migration from Spatie
This was important to me. If you already have Spatie installed with production data, migration needs to be painless:
# See what would happen without changing anything
php artisan permissions-redis:migrate-from-spatie --dry-run
# Run the migration
php artisan permissions-redis:migrate-from-spatie
The command reads Spatie's tables, creates equivalent records in the new schema, and warms the Redis cache. There's a full migration guide with a method equivalence table and behavior differences.
Multi-tenancy without the headache
Spatie's "teams" feature works but adds a team_id column to every pivot table and requires careful query scoping.
With Redis, tenant isolation is simpler -- prefix the keys:
auth:user:t:{tenantId}:{userId}:permissions
Configuration is two lines:
// config/permissions-redis.php
'tenancy' => [
'enabled' => true,
'resolver' => 'stancl', // or any callable
],
Complete data isolation. No query scoping to forget. No cross-tenant leaks.
Octane: the problem nobody talks about
Laravel Octane keeps your application in memory across requests. Great for performance, terrible for anything that caches state in PHP properties.
Spatie caches permissions in static properties. In Octane, User A's permissions can leak into User B's request if they hit the same worker. The Spatie team acknowledges this and suggests workarounds, but there's no built-in solution.
laravel-permissions-redis listens for Octane's RequestReceived event and resets all in-memory state automatically:
// config/permissions-redis.php
'octane' => [
'reset_on_request' => true,
],
No leaked state. No workarounds. It just works.
The honest trade-offs
This package is not for everyone. Here's when you should not use it:
- You don't run Redis. This package requires Redis. If your stack is MySQL + file cache, Spatie is the right choice.
-
You need
getDirectPermissions()vsgetPermissionsViaRoles(). This package merges all permissions into a flat set. If you need to distinguish "was this permission assigned directly or via a role?", Spatie handles that better. - You need to support PHP 8.0 or Laravel 8. This package requires PHP 8.3+ and Laravel 11+.
- Authorization isn't your bottleneck. If you're doing 10 requests/second with 3 permission checks each, Spatie is fast enough. Don't add Redis complexity for no measurable gain.
Getting started
composer require scabarcas/laravel-permissions-redis
php artisan vendor:publish --provider="Scabarcas\LaravelPermissionsRedis\LaravelPermissionsRedisServiceProvider"
php artisan migrate
# Optional: warm cache for all existing users
php artisan permissions-redis:warm
Add the trait to your User model:
use Scabarcas\LaravelPermissionsRedis\Traits\HasRedisPermissions;
class User extends Authenticatable
{
use HasRedisPermissions;
}
That's it. The API is intentionally familiar. If you've used Spatie, you already know how to use this.
What's next
The package is at v3.0.0 with full support for PHP 8.3/8.4, Laravel 11/12/13, and Redis 6/7. The test suite covers UUID/ULID models, Octane workers, multi-tenant isolation, and integration tests against real Redis instances in CI.
I'd love feedback, issues, and PRs. The repo is at github.com/scabarcas17/laravel-permissions-redis.
If you're hitting performance walls with database-backed permissions, give it a try. Your Redis server is already sitting there -- might as well put it to work.
Top comments (0)