TL;DR — In a typical authenticated Laravel app using
spatie/laravel-permission, every request that touches roles or permissions pays a fixed cost of *~4 database queries*, even with the permission cache enabled. That cost doesn't scale with the number ofhasPermissionTo()calls — it's the same whether you check 1 permission or 100. But it also never goes to zero. Below, I'll show you how to detect it in 5 minutes, why caching alone doesn't fix it, and three concrete paths out.
Eloquent's N+1 has a quiet cousin
Every Laravel developer has had the "wait, why is this page 200 queries?" moment. We learn with(), we install Telescope or Debugbar, and we move on. But there's a second N+1 hiding in plain sight inside almost every authenticated app: the permission check.
It's quieter than the classic N+1 for two reasons:
- It doesn't scale with the response payload — so it doesn't get worse as your data grows.
- The most popular permissions package (
spatie/laravel-permission) already caches "the permission registry," which gives developers the comforting feeling that authorization is solved.
It's not — at least not the way most of us assume. The cache helps. But there's still a fixed tax on every authenticated request, and on a high-traffic API it adds up to real money.
Let's look.
A 30-line reproduction
Spin up a fresh Laravel 11 app, install spatie/laravel-permission, and add this controller:
// app/Http/Controllers/DashboardController.php
public function index(Request $request)
{
$user = $request->user();
$can = [
'view' => $user->can('view dashboard'),
'edit' => $user->can('edit dashboard'),
'export' => $user->can('export dashboard'),
'archive' => $user->can('archive dashboard'),
'isAdmin' => $user->hasRole('admin'),
];
return response()->json($can);
}
Five checks. Nothing exotic. Now wrap it with a tiny query logger:
// app/Providers/AppServiceProvider.php — boot()
DB::listen(function ($query) {
Log::info($query->sql, ['bindings' => $query->bindings, 'time' => $query->time]);
});
Hit the endpoint authenticated, then check storage/logs/laravel.log. With the cache enabled, you'll see something like:
select * from "users" where "users"."id" = ? limit 1
select "roles".*, "model_has_roles"."model_id" as "pivot_model_id" ...
select "permissions".*, "model_has_permissions"."model_id" as "pivot_model_id" ...
select "permissions".* from "permissions" inner join "role_has_permissions" ...
Four queries. Every request. Even though you're only checking five things. Even with the cache. Now bump the controller to 50 checks: still 4 queries. Now drop it to 1 check: still 4 queries. Welcome to the fixed tax.
Why the cache doesn't make it zero
The Spatie cache is doing real work — but it's caching the wrong thing for this scenario. Specifically, it caches the global registry: the list of permissions, the list of roles, and which permissions belong to which role. That's why your hasPermissionTo('edit dashboard') call doesn't trigger a query to permissions every time — the lookup happens in memory.
What is not cached, by default, is the join between this user and their roles/permissions. The first time you ask "does this user have role X?" Eloquent lazy-loads:
-
users— the user itself. -
rolesviamodel_has_roles— what roles this user has. -
permissionsviamodel_has_permissions— direct permissions on this user. -
permissionsviaroles— permissions inherited via roles.
That's the 4-query floor. It's the same shape whether the user has 1 role or 50. And once those are hydrated, subsequent checks in the same request are free.
The trick is that "the same request" is the only place where caching helps. Two seconds later, on a different request, the cycle restarts. Four queries again. For every authenticated user. For every request.
If your app serves 50 req/s of authenticated traffic, that's 200 permission-related queries per second that you're paying as table stakes.
Finding your own number in 5 minutes
Don't trust me. Measure your app. The cheapest path:
// AppServiceProvider::boot()
if (app()->environment('local')) {
DB::listen(function ($q) {
if (str_contains($q->sql, 'role') || str_contains($q->sql, 'permission')) {
logger()->channel('single')->info('[AUTHZ]', [
'sql' => $q->sql, 'time_ms' => $q->time,
]);
}
});
}
Hit five different authenticated endpoints. Count entries per request. If the number is consistent and >0, that's your N+1. If it scales with response data, you have a different N+1 — and you should fix that first.
For a more graphical picture, install Laravel Telescope and group queries per request in the "Requests" tab. The repeating shape of 4 queries is unmistakable.
Three ways out (ordered by radicalness)
Option 1 — Eager load on auth
The cheapest fix: warm the relations once per request, in the auth middleware.
// app/Http/Middleware/EagerLoadAuthRelations.php
public function handle($request, Closure $next)
{
if ($user = $request->user()) {
$user->loadMissing(['roles.permissions', 'permissions']);
}
return $next($request);
}
Pros: Zero new infrastructure. Reduces 4 queries to 2 (because the joins now happen in one shot). Works today.
Cons: Still hits the database every request. Doesn't help if your user has many roles or permissions (you're now pulling a bigger payload every time). Stops working cleanly under Laravel Octane (the user model survives between requests, but auth context doesn't — and stale relations are a footgun).
If your traffic is moderate and you don't run Octane, this is enough. Stop reading.
Option 2 — Cache the user's permission set with Cache::remember()
Push the per-user resolved permissions into your cache driver, keyed by user ID:
$permissions = Cache::remember("user:{$user->id}:perms", 3600, function () use ($user) {
return $user->getAllPermissions()->pluck('name')->all();
});
$can = in_array('edit dashboard', $permissions, true);
Pros: Now zero queries on warm reads. Cache hit is fast.
Cons: Two big ones.
-
Invalidation is brutal. When you change a role's permissions, you have to forget the cache for every user that has that role. The standard answer is "forget the whole cache" (Spatie's
forgetCachedPermissions()does exactly this) — which means the next N concurrent requests all stampede the database simultaneously. On a busy app, this is a small outage waiting to happen. -
The lookup is O(n). You deserialize an array of permission names and
in_array()through it. Fast for 5 permissions, painful for 500. And every Blade@cancheck pays this cost.
This is what most teams reach for first. It's strictly better than Option 1, but the invalidation problem is real and bites in production.
Option 3 — Pre-resolve into Redis SETs, keyed per user
This is the path I ended up taking, and the one I built scabarcas/laravel-permissions-redis around. The core idea:
- For each user, store their resolved permissions and roles as Redis SETs:
user:42:permissions → {edit dashboard, view dashboard, ...}
user:42:roles → {admin, editor}
- A permission check becomes a single
SISMEMBER— O(1), no array deserialization, no scan. - Cache invalidation is surgical: when a role's permissions change, only the affected users get rewarmed. No drop-all, no thundering herd.
- An in-memory per-request layer sits in front of Redis, so repeated checks in the same request don't even hit the network.
// With the package installed and the trait on User:
$user->hasPermissionTo('edit dashboard'); // SISMEMBER under the hood
$user->hasRole('admin'); // SISMEMBER
The trade-off is honest: you need Redis. If Redis isn't already in your stack, this adds an infra dependency. If it is — for sessions, queues, cache, broadcasting — you're not adding a new system, you're using it more.
The numbers, side by side
I built a standalone benchmark that runs both packages under identical conditions: same database schema, same user fixtures, same checks, same hardware. Apple Silicon, PHP 8.4, predis, SQLite + Redis, 5 warm-up runs + 30 measurement runs per strategy.
Each iteration runs a realistic authorization-heavy request: 27 hasPermissionTo calls, 4 hasRole calls, 4 batch operations, and 2 collection calls — 37 checks total.
Database queries per request:
| Scenario | spatie | redis-package | Reduction |
|---|---|---|---|
| 1 iteration (37 checks) | 4 | 1 | 75% |
| 10 iterations | 40 | 10 | 75% |
| 50 iterations | 200 | 50 | 75% |
Wall-clock time per request (p50):
| Scenario | spatie | redis-package | Speedup |
|---|---|---|---|
| 1 iteration (37 checks) | 14.27 ms | 1.44 ms | 9.92x |
| 10 iterations | 144.38 ms | 14.39 ms | 10.03x |
| 50 iterations | 730.88 ms | 72.87 ms | 10.03x |
The 10x ratio is stable across iteration counts because both strategies scale linearly. What differs is the constant per request: 4 DB queries vs 1 Redis lookup. That ratio is what compounds when you go from 10 req/s to 10,000 req/s.
When not to make this change
I'd be a bad open-source author if I didn't say it plainly. Stick with spatie/laravel-permission (and just add Option 1 if needed) when:
- You don't run Redis. Adding infra for one feature is rarely worth it.
-
You distinguish direct vs role-inherited permissions at runtime. Spatie exposes
getDirectPermissions()andgetPermissionsViaRoles()natively; my package merges them. - You rely on Spatie's Teams feature. It's mature; mine has a different multi-tenancy model.
-
Authorization isn't your bottleneck. If a
php artisan tinkerDB::listentest shows 4 queries per request and your traffic is 5 req/s, your time is better spent elsewhere.
The honest target user is: high-traffic Laravel app, Redis already in the stack, ideally on Octane, where 4 queries × thousands of req/s starts to matter — both in latency and in database load.
Where to go next
-
Measure first. Drop in the
DB::listensnippet above. Find your number before you change anything. - If you want to try the Redis-first path:
scabarcas/laravel-permissions-redisships with a one-command migration from Spatie (php artisan permissions-redis:migrate-from-spatie). - The benchmark repo lets you reproduce the numbers above with
docker compose up. Don't take my word for it.
If you find an N+1 in your app this way, drop a comment with your numbers — I'm genuinely curious what the distribution looks like across real apps.
Sebastian Cabarcas — backend engineer working on high-performance Laravel infra. Github · Medium · X
Top comments (0)