"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-permissionv6 (database-backed, using Redis as Laravel cache driver) -
scabarcas/laravel-permissions-redisv3 (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
- Database queries -- the number of queries hitting MySQL per request
- Cache architecture -- how each package stores and retrieves permission data
- Invalidation cost -- what happens when permissions change
- Cold start -- first request after cache flush
- Warm state -- steady-state performance
Important note on fairness: Spatie is configured with
CACHE_DRIVER=redisto 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
On every hasPermissionTo() call:
- Fetch the full serialized blob from Redis (via Laravel Cache)
- Deserialize it (
unserialize()) - Filter permissions for the current user
- 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", ...}
On every hasPermissionTo() call:
- Check in-memory PHP array (if already resolved this request) -- zero I/O
- 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');
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
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:
- Query all permissions in the system
- Serialize and store in cache
- Deserialize and scan on each check
laravel-permissions-redis:
- Query this user's roles (1 query)
- Query permissions for those roles (1 query)
- Write Redis SETs:
SADD auth:user:42:permissions "posts.create" "posts.edit" ... - All subsequent checks:
SISMEMBERor 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
| 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
|
+---------------------------------------->
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
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:
-
O(1) vs O(n) lookups -- Redis
SISMEMBERvs array scanning - Surgical vs nuclear invalidation -- rewarm one user vs flush everything
- 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
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)