Caching an Eloquent collection can give you N+1 queries on cache hits

Tutorial

Took me far too long to spot this the first time, because a cache hit is supposed to mean an empty query log.

    $posts = Cache::remember('posts:index', now()->addMinutes(15), fn () =>
        Post::latest()->take(20)->get()
    );

    foreach ($posts as $post) {
        echo $post->author->name; // one query. per post. on every hit.
    }

The cached payload is the models, not the relations. So every hit deserialises 20 models with no author loaded and lazy-loads it in the loop. In one sense it's worse than not caching at all: you've hidden the expensive query, kept the N+1, and made it harder to find. Debugbar now shows twenty small identical queries instead of the one big one you were hunting for.

The fix is boring. Eager load inside the closure so the relations are part of what you store:

    $posts = Cache::remember('posts:index', now()->addMinutes(15), fn () =>
        Post::with(['author', 'tags'])->latest()->take(20)->get()
    );

The habit I'd actually push though is not caching models at all. Cache the shape you render, a lean array or DTO. Cheaper to serialise, cheaper to deserialise, and it physically cannot lazy-load anything. Rehydrating a graph of Eloquent models out of Redis on every request eats a real chunk of the win you were chasing.

Has anyone found a decent way to enforce this? I've considered turning on preventLazyLoading() beyond local but it gets noisy fast.

(This came out of a longer caching write-up I did on my blog with tags, atomic locks and invalidation strategy: https://richdynamix.com/articles/laravel-caching-strategies-complete-guide)

Posted Jul 23, 2026, 10:03 AM UTC

1418
Loading comments…