DEV Community

Sebastian Cabarcas
Sebastian Cabarcas

Posted on

Why I Built a Redis-Backed Alternative to Spatie Permissions

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:

  1. 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.

  2. 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.

  3. 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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Configuration is two lines:

// config/permissions-redis.php
'tenancy' => [
    'enabled'  => true,
    'resolver' => 'stancl', // or any callable
],
Enter fullscreen mode Exit fullscreen mode

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,
],
Enter fullscreen mode Exit fullscreen mode

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() vs getPermissionsViaRoles(). 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
Enter fullscreen mode Exit fullscreen mode

Add the trait to your User model:

use Scabarcas\LaravelPermissionsRedis\Traits\HasRedisPermissions;

class User extends Authenticatable
{
    use HasRedisPermissions;
}
Enter fullscreen mode Exit fullscreen mode

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)