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 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 ships with a one-command migration from Spatie (php artisan permissions-redis:migrate-from-spatie).
SOCIAL SHARE CARD GENERATOR