DEV Community

Sebastian Cabarcas
Sebastian Cabarcas

Posted on

The Hidden N+1 in Laravel Authorization (And Why Caching Alone Doesn’t Fix It)

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 of hasPermissionTo() 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:

  1. It doesn't scale with the response payload — so it doesn't get worse as your data grows.
  2. 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);
}
Enter fullscreen mode Exit fullscreen mode

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]);
});
Enter fullscreen mode Exit fullscreen mode

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" ...
Enter fullscreen mode Exit fullscreen mode

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:

  1. users — the user itself.
  2. roles via model_has_roles — what roles this user has.
  3. permissions via model_has_permissions — direct permissions on this user.
  4. 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.


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,
            ]);
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 @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.

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}
Enter fullscreen mode Exit fullscreen mode
  • A permission check becomes a single SISMEMBERO(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
Enter fullscreen mode Exit fullscreen mode

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() and getPermissionsViaRoles() 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 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.


Where to go next

  • Measure first. Drop in the DB::listen snippet above. Find your number before you change anything.
  • If you want to try the Redis-first path: scabarcas/laravel-permissions-redis ships 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)