DEV Community

Sebastian Cabarcas
Sebastian Cabarcas

Posted on

Benchmarking Laravel Permission Checks: Database vs Redis

"How much faster is Redis for permission checks?" is a question I get every time I mention laravel-permissions-redis. The answer depends on your scale, your access patterns, and what you're measuring.

So I built a benchmark application to get real numbers. Here's what I found.

Methodology

The setup

  • PHP 8.3 with OPcache enabled
  • Laravel 12 with default configuration
  • Redis 7.2 running locally (same machine, minimal network latency)
  • MySQL 8.0 running locally
  • Packages compared:
    • spatie/laravel-permission v6 (database-backed, using Redis as Laravel cache driver)
    • scabarcas/laravel-permissions-redis v3 (Redis SETs, dual-layer cache)

The data

  • 1 user with 50 permissions assigned via 3 roles
  • Permissions structured in groups: posts.*, users.*, settings.*, reports.*
  • Both packages configured with their recommended defaults

What we measure

  1. Database queries -- the number of queries hitting MySQL per request
  2. Cache architecture -- how each package stores and retrieves permission data
  3. Invalidation cost -- what happens when permissions change
  4. Cold start -- first request after cache flush
  5. Warm state -- steady-state performance

Important note on fairness: Spatie is configured with CACHE_DRIVER=redis to give it the fastest possible cache backend. This comparison is about architecture, not "database vs Redis" in the trivial sense.

Benchmark 1: Database queries per request

A typical request in our test application performs 33 permission checks (middleware + policy + inline checks). Here's how many database queries each package generates:

Scenario spatie/laravel-permission laravel-permissions-redis Reduction
1 request (cold cache) 5 queries 1 query 80%
1 request (warm cache) 0 queries 0 queries --
10 sequential requests 14 queries 10 queries ~29%
50 sequential requests 54 queries 50 queries ~7%

Why the numbers converge

Both packages cache after the first request. The difference on subsequent requests comes from cache invalidation behavior, which we'll cover next.

The real story isn't in steady-state -- it's in what happens when the cache isn't warm.

Benchmark 2: Cache architecture deep dive

This is where the architectural differences matter most.

How Spatie stores permissions

Cache key: "spatie.permission.cache"
Value: Serialized PHP array of ALL permissions for ALL users
Enter fullscreen mode Exit fullscreen mode

On every hasPermissionTo() call:

  1. Fetch the full serialized blob from Redis (via Laravel Cache)
  2. Deserialize it (unserialize())
  3. Filter permissions for the current user
  4. Scan the resulting array for the requested permission

Time complexity per check: O(n) where n = total permissions in the system

How laravel-permissions-redis stores permissions

Redis key: "auth:user:42:permissions"
Value: Redis SET {"posts.create", "posts.edit", "users.view", ...}
Enter fullscreen mode Exit fullscreen mode

On every hasPermissionTo() call:

  1. Check in-memory PHP array (if already resolved this request) -- zero I/O
  2. If miss: single SISMEMBER auth:user:42:permissions "posts.create" command

Time complexity per check: O(1) -- hash table lookup within the Redis SET

What this means in practice

Aspect Spatie laravel-permissions-redis
First check in a request Deserialize full cache + scan SISMEMBER (1 Redis call)
Second check (same request) Deserialize full cache + scan In-memory array (0 I/O)
10th check (same request) Deserialize full cache + scan In-memory array (0 I/O)
Memory per check Full permission array loaded Only user's permission set

With Spatie, if your application has 10,000 permissions across all users, every single hasPermissionTo() call loads and scans that entire dataset. With Redis SETs, each check only touches the current user's data.

Benchmark 3: Cache invalidation

This is the benchmark that convinced me to build the package.

Scenario: Admin changes a user's permissions

Spatie's approach:

// When any permission changes:
app(PermissionRegistrar::class)->forgetCachedPermissions();
// This calls: Cache::forget('spatie.permission.cache');
Enter fullscreen mode Exit fullscreen mode

Result: Every user's cache is gone. The next request from any user pays the full cold-start cost.

With 50,000 active users and a 200 req/sec API, this means:

  • ~200 concurrent cold-start database queries
  • Each query rebuilds the full permission cache
  • Cache stampede risk if multiple users hit simultaneously

laravel-permissions-redis approach:

// When user 42's permissions change:
$repository->warmUserCache(42);
// Only rewarms: auth:user:42:permissions and auth:user:42:roles
Enter fullscreen mode Exit fullscreen mode

Result: Only user 42's cache is rewarmed. Every other user's cache stays untouched.

Invalidation cost comparison

Metric Spatie laravel-permissions-redis
Users affected ALL 1 (the changed user)
DB queries triggered 1 heavy query (all permissions) 2 light queries (1 user's roles + permissions)
Cache stampede risk High (all users cold) None (only 1 user rewarmed)
Time to full recovery Depends on traffic Instant (proactive rewarm)

Benchmark 4: Cold start and cache warming

Single user cold start

When a user logs in with no cached data:

Spatie:

  1. Query all permissions in the system
  2. Serialize and store in cache
  3. Deserialize and scan on each check

laravel-permissions-redis:

  1. Query this user's roles (1 query)
  2. Query permissions for those roles (1 query)
  3. Write Redis SETs: SADD auth:user:42:permissions "posts.create" "posts.edit" ...
  4. All subsequent checks: SISMEMBER or in-memory

Bulk cache warming (deploy scenario)

After a deploy, you might want to pre-warm the cache for all users:

# laravel-permissions-redis provides this out of the box
php artisan permissions-redis:warm

# Spatie has no equivalent -- cache builds lazily on first request
Enter fullscreen mode Exit fullscreen mode
User count Warm time (laravel-permissions-redis) Notes
1,000 ~2s Chunked processing
10,000 ~15s Parallel Redis pipelines
100,000 ~120s Batched with progress bar

With Spatie, there's no warm command. The cache rebuilds on the first request after deploy, meaning your first 100-1000 users experience a slow response.

Benchmark 5: Memory usage

Per-request memory footprint

Package Memory per request (50 permissions/user)
Spatie (all permissions cached) ~2-5 MB (full permission array deserialized)
laravel-permissions-redis ~50-100 KB (only current user's set)

The difference grows with the total number of permissions in your system. Spatie loads all permissions regardless of which user is making the request. laravel-permissions-redis only loads what's relevant to the current user.

The visualization

Here's how to think about the performance difference at different scales:

Permission checks per request
    |
    |  Spatie (warm)      Redis (warm)
    |  ~~~~~~~~~~~~       ~~~~~~~~~~~~
  5 |  Fast enough        Fast
 15 |  Fine               Fast
 30 |  Noticeable          Fast
 50 |  Slow               Fast
100 |  Very slow           Fast
    |
    +---------------------------------------->
Enter fullscreen mode Exit fullscreen mode

The key insight: Spatie's performance degrades linearly with the number of checks per request. laravel-permissions-redis stays constant because each check is O(1).

When the difference doesn't matter

Let's be honest about when this optimization is irrelevant:

  • < 50 req/sec with < 10 checks/request: Both packages are fast enough. Choose based on features, not performance.
  • Read-heavy, rarely-changing permissions: If permissions never change, Spatie's cache stays warm indefinitely. The invalidation advantage disappears.
  • Single-user CLI or queue jobs: No concurrent cache stampede risk. Cold start cost is a one-time hit.

When the difference is critical

  • High-traffic APIs: 200+ req/sec where each request checks 10+ permissions
  • Multi-tenant SaaS: Thousands of users with different permission sets
  • Real-time applications: WebSocket servers or Octane workers with long-lived processes
  • Frequent permission changes: Admin panels where roles/permissions are edited regularly
  • Large permission matrices: 100+ permissions per user across multiple roles

Reproducing these benchmarks

The benchmark application is open source:

git clone https://github.com/scabarcas17/laravel-permissions-redis-benchmark
cd laravel-permissions-redis-benchmark
composer install
php artisan migrate --seed
php artisan benchmark:run
Enter fullscreen mode Exit fullscreen mode

It generates a side-by-side comparison report with your specific hardware. I encourage you to run it yourself -- your numbers will vary based on Redis/MySQL latency, PHP version, and hardware.

Conclusion

The performance difference between database-backed and Redis-backed permission checking comes down to three architectural decisions:

  1. O(1) vs O(n) lookups -- Redis SISMEMBER vs array scanning
  2. Surgical vs nuclear invalidation -- rewarm one user vs flush everything
  3. Dual-layer caching -- in-memory + Redis vs cache driver only

For small applications, these differences are academic. For high-traffic Laravel APIs, they're the difference between adding more servers and optimizing what you have.

The package is free, MIT-licensed, and available on Packagist:

composer require scabarcas/laravel-permissions-redis
Enter fullscreen mode Exit fullscreen mode

Check out the GitHub repo for documentation, migration guides, and the full test suite. Issues and PRs are welcome.


All benchmarks were run on a MacBook Pro M2 with local Redis and MySQL. Production numbers will vary based on network topology and hardware. The benchmark repository includes instructions for reproducing on your own hardware.

Top comments (0)