Sebastian CabarcasTL;DR — In a typical authenticated Laravel app using spatie/laravel-permission, every request that...
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.
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:
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.
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.
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.roles via model_has_roles — what roles this user has.permissions via model_has_permissions — direct permissions on this user.permissions via roles — 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.
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.
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.
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.
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.in_array() through it. Fast for 5 permissions, painful for 500. And every Blade @can check 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.
This is the path I ended up taking, and the one I built scabarcas/laravel-permissions-redis around. The core idea:
user:42:permissions → {edit dashboard, view dashboard, ...}
user:42:roles → {admin, editor}
SISMEMBER — O(1), no array deserialization, no scan.// 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.
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.
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:
getDirectPermissions() and getPermissionsViaRoles() natively; my package merges them.php artisan tinker DB::listen test 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.
DB::listen snippet above. Find your number before you change anything.scabarcas/laravel-permissions-redis ships with a one-command migration from Spatie (php artisan permissions-redis:migrate-from-spatie).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