DEV Community

Sebastian Cabarcas
Sebastian Cabarcas

Posted on

A Redis-first alternative to spatie/laravel-permission, benchmarked

TL;DR

I built a Redis-first roles & permissions package for Laravel called scabarcas/laravel-permissions-redis — Spatie-compatible API (hasRole, hasPermissionTo, Blade directives, middleware), but the user→roles→permissions mapping lives in Redis SETs instead of being hydrated from the DB on every request.

Benchmark vs spatie/laravel-permission ^7.2, with 5 warm-up + 30 measurement runs per scenario, GC reset between runs, predis client, SQLite + local Redis on Apple Silicon:

Workload Spatie p50 Redis p50 Speedup DB queries reduced
1 authorization-heavy request 13.76 ms 1.26 ms 10.94x 4 → 1 (75%)
10 iterations 138.87 ms 13.01 ms 10.68x 40 → 10 (75%)
50 iterations 696.73 ms 63.79 ms 10.92x 200 → 50 (75%)

The speedup is consistent — Redis lookups are near-constant time; Spatie's per-request relation hydration scales linearly. The whole bench repo is public if you want to reproduce: laravel-permissions-redis-benchmark.

Rest of the post is the why and the when not to use it.

The problem with Spatie isn't the cache — it's relation hydration

If you've used spatie/laravel-permission in a high-traffic app, you've probably noticed that authorization checks cost more than they look. Here's a query log from a single request that calls hasPermissionTo() 27 times, hasRole() 4 times, plus getAllPermissions() and getRoleNames():

-- 1. User lookup
select * from "users" where "users"."id" = 1
-- 2. Roles via pivot
select "roles".*, "model_has_roles"."model_id" as "pivot_model_id", ...
       from "roles" inner join "model_has_roles" on "roles"."id" = "model_has_roles"."role_id"
       where "model_has_roles"."model_id" = 1 and "model_has_roles"."model_type" = 'App\Models\User'
-- 3. Direct permissions via pivot
select "permissions".*, "model_has_permissions"."model_id" as "pivot_model_id", ...
       from "permissions" inner join "model_has_permissions" on "permissions"."id" = "model_has_permissions"."permission_id"
       where "model_has_permissions"."model_id" = 1 and "model_has_permissions"."model_type" = 'App\Models\User'
-- 4. Permissions via roles
select "permissions".*, "role_has_permissions"."role_id" as "pivot_role_id", ...
       from "permissions" inner join "role_has_permissions" on "permissions"."id" = "role_has_permissions"."permission_id"
       where "role_has_permissions"."role_id" in (1, 2)
Enter fullscreen mode Exit fullscreen mode

Four queries. Every request that authorizes anything. Doing 27 permission checks doesn't multiply that — it's still 4 queries, because Spatie loads the user's relations once per User::find() and then runs the membership checks in PHP memory.

But here's the part that surprised me when I first profiled it: Spatie's permission cache doesn't help with those 4 queries. That cache stores the global permission and role registry — "what permissions exist in the system" — using cache.permissions.cache via the Laravel cache facade. The per-user pivot relations (model_has_roles, model_has_permissions, role_has_permissions) are loaded by Eloquent every time you call User::find() followed by a permission check. The global cache only saves you from re-reading the permissions and roles tables themselves.

This is fine for low-traffic apps. It's a tax you stop noticing once you're past the database-side bottleneck.

It's annoying once you start having authorization in every middleware, every gate, every Blade directive on dashboards, every API endpoint.

Why I went Redis-first instead of "Spatie + better cache"

The natural next step is to cache the user's resolved permissions, not just the global registry. You can do that with Spatie by writing a custom decorator that caches getAllPermissions() per user — and people have done this. Two problems show up:

  1. Invalidation gets sharp. When you grant a user a permission, you need to clear that user's cache. When you change a role's permissions, you need to clear every user that has that role. When you delete a permission, you need a broader sweep. Spatie's API is built around forgetCachedPermissions() which nukes everything — fine, but every assignment change forces every active user back into the 4-query path on their next request.

  2. The data structure is wrong for the operation. Even if you cache per-user permissions as a JSON array, checking hasPermissionTo('posts.edit') is an in_array() scan over the deserialized array. Redis SETs do this in O(1) with SISMEMBER. Once you're already in Redis for the data, you might as well use the right operation.

So instead of a Spatie decorator I wrote a separate trait. The wire format in Redis is:

SET  permissions:user:1:permissions   {"posts.view", "posts.edit", "users.view", ...}
SET  permissions:user:1:roles         {"admin", "editor"}
HASH permissions:role:editor          {permissions: ["..."], ...}
Enter fullscreen mode Exit fullscreen mode

hasPermissionTo($perm) becomes SISMEMBER permissions:user:{id}:permissions {$perm}. hasRole($role) is the same against :roles. Wildcard checks (posts.*) use SMEMBERS + fnmatch() in PHP, which is still constant queries.

Cache warm happens on login (or explicitly via AuthorizationCacheManager::warmUser($userId)). After that, every authorization check is a Redis round-trip — no DB queries except the user lookup itself.

The numbers, with methodology

Here's the bench harness in one screen (source):

class BenchmarkRunner
{
    public function execute(int $userId, int $iterations, int $warmUpRuns = 3, int $measurementRuns = 10): array
    {
        foreach ($this->strategies as $strategy) {
            // Flush Spatie cache once so both strategies start from the same baseline
            app(PermissionRegistrar::class)->forgetCachedPermissions();

            for ($w = 0; $w < $warmUpRuns; $w++) {
                $strategy->run($userId, self::PERMISSIONS, self::ROLES, $iterations);
            }

            $times = [];
            for ($m = 0; $m < $measurementRuns; $m++) {
                DB::flushQueryLog();
                gc_collect_cycles();
                $start = microtime(true);
                $strategy->run($userId, self::PERMISSIONS, self::ROLES, $iterations);
                $times[] = (microtime(true) - $start) * 1000;
            }

            // Aggregate into p50, p95, p99, mean, stddev
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Each "iteration" performs 27 hasPermissionTo() checks + 4 hasRole() checks + getAllPermissions() + getRoleNames(). The bench tests iterations=1, iterations=10, and iterations=50 to see how the cost scales when authorization is called more than once per request (typical for views that gate multiple sections).

Run yourself:

git clone https://github.com/scabarcas17/laravel-permissions-redis-benchmark
cd laravel-permissions-redis-benchmark
composer install
php artisan migrate:fresh --seed --seeder=BenchmarkSeeder
php artisan bench:markdown --warm=5 --runs=30
Enter fullscreen mode Exit fullscreen mode

The full percentile breakdown (also in the bench README):

### 10 Iterations (27 permission checks + 4 role checks + 2 collection calls)

| Metric            | Spatie         | Redis         | Delta            |
|-------------------|----------------|---------------|------------------|
| DB Queries        | 40             | 10            | 75% fewer        |
| Median (p50)      | 138.87 ms      | 13.01 ms      | 10.68x faster    |
| p95               | 140.13 ms      | 13.78 ms      | —                |
| p99               | 159.27 ms      | 13.87 ms      | —                |
| Mean ± StdDev     | 139.42 ± 3.86  | 13.11 ± 0.45  | —                |
Enter fullscreen mode Exit fullscreen mode

A few honest notes about these numbers:

  • Steady-state, not cold-start. Warm-up runs amortize app bootstrap, Spatie's global registry cache load, Redis connection setup. We're measuring what users see in production, not the first request after a deploy.
  • SQLite + local Redis. Absolute ms will be different on MySQL or Postgres or remote Redis. The ratio (~10x median) should hold; the constants won't. The bench is reproducible on your hardware in under 30 seconds.
  • Single user. No concurrent load. The bench doesn't simulate hundreds of simultaneous requests competing for Redis connections — that's a separate exercise (k6, wrk).
  • Doesn't cover v4-specific features yet. Wildcard permissions, group invalidation, the batch role-check API (userHasAnyRole, userHasAllRoles) aren't in the harness. They'd push the delta wider, not narrower, but the current numbers stop at the basic API.

The 5-line setup

composer require scabarcas/laravel-permissions-redis
php artisan vendor:publish --tag=permissions-redis-config
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Then on your User model:

use Scabarcas\LaravelPermissionsRedis\Traits\HasRedisPermissions;

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

Optional: in your AuthServiceProvider or a listener, warm on login:

Event::listen(Login::class, function (Login $event) {
    app(AuthorizationCacheManager::class)->warmUser($event->user->id);
});
Enter fullscreen mode Exit fullscreen mode

That's it. Same hasRole, hasPermissionTo, assignRole, givePermissionTo, @role, @can, role: middleware as Spatie. The migration adds the same permissions, roles, model_has_permissions, model_has_roles, role_has_permissions tables Spatie uses, so a migration from Spatie is essentially "swap the trait, run warmAll".

When you should NOT use this

The whole pitch falls apart in a couple of cases. Be honest with yourself:

  • You don't run Redis. Adding Redis as a hard dependency just for permissions is overkill if you're on a single-server SQLite setup with 10 daily users. Use Spatie.
  • Your authorization is shallow. If you do @can('admin') twice per page and ship 1000 requests/day, the absolute ms savings are tiny and the migration cost isn't worth it. Use Spatie.
  • You need a cache-driver-agnostic abstraction. This package binds you to Redis. If "we might swap Redis for Memcached / DynamoDB / database cache" is in your near-term plan, this isn't the right call — use Spatie + a custom decorator instead.
  • You're on Laravel <11. This package targets Laravel 11/12/13 and PHP 8.3+. Spatie supports older versions.

The package is honest about all of this in its README — the goal isn't to "replace Spatie" but to give you a clearly different choice when authorization throughput actually matters in your app.

Where to find it

Feedback welcome — especially from people who've already written custom Spatie cache decorators or who'd benchmark this against a different workload.

Top comments (0)