Caching is the rare topic in Laravel where the tutorial example is genuinely most of what you need. Cache::remember('users.42', 60, fn () => User::find(42)) will save a database round trip and survive a fair amount of production traffic. The day it stops being enough is usually the day a marketing campaign drops, three thousand visitors hit the same page in a second, and your "cached" endpoint runs the underlying query three thousand times because nobody had warmed the key yet.
The difference between a cache that helps and a cache that hurts is not the call to remember(). It's invalidation, stampede protection, and stale-while-revalidate. Laravel ships primitives for all three; most teams only know the first.
Pick The Driver Before You Write The Code
The cache config in Laravel supports a respectable list of stores: redis, memcached, database, file, array, dynamodb, null, and on Laravel Octane an in-memory octane store that lives inside the worker.
The honest summary:
redis— what almost every production app should use. Atomic, supports tags, supports locks, fast, cheap to host. The default if you have any non-trivial traffic.memcached— fine, supports tags, but Redis has eaten its lunch in the Laravel ecosystem. Pick it only if it's already running.database— works without extra infrastructure, breaks the moment your traffic gets serious because every cache hit is a row read. Useful for low-volume admin tools.file— local-only, single-server, exactly the wrong choice once you scale horizontally. Each web node has its own cache and they never agree.array— request-scoped only. Useful in tests; useless in production.octane— in-memory, per-worker. Stupid fast for tiny config-shaped data; not shared across workers. Use it as an L1 cache in front of Redis, not as a replacement.
Set it once in .env and forget it:
CACHE_STORE=redis
REDIS_CLIENT=phpredis
The phpredis extension is faster than the pure-PHP Predis client, and on a busy app the difference shows up in your latency graphs.
Cache::remember And The Family Of Helpers
The four helpers that cover most reads:
// Pull from cache or compute, store for a TTL.
$users = Cache::remember('users.active', now()->addMinutes(10), function () {
return User::active()->orderBy('name')->get();
});
// Same, but never expires (use carefully — invalidation is now your problem).
$config = Cache::rememberForever('settings', fn () => Setting::all());
// Read once and delete — useful for one-shot tokens.
$token = Cache::pull('signup-token:' . $code);
// Explicit invalidation.
Cache::forget('users.active');
Two practical rules. First, prefer now()->addMinutes(10) over the integer 600 seconds — when the next person reads it, the unit is obvious. Second, version your keys when the shape of the data changes: users.active.v3. It's the cheapest invalidation strategy you'll ever ship — bumping v3 to v4 makes every old cache entry instantly irrelevant without you having to track them down.
Tags: Group Invalidation Without Tracking Every Key
The day you have ten cache keys all derived from the same Product table, you do not want to enumerate them one by one when an admin updates a price. Tags solve that — but only on Redis and Memcached. The database and file stores don't support tagging, and the call will throw.
return Cache::tags(['catalog', "brand:{$brandId}"])->remember(
"catalog.brand.{$brandId}.featured",
now()->addMinutes(15),
fn () => Product::featured()->where('brand_id', $brandId)->with('images')->get(),
);
Now invalidating an entire brand's worth of catalog cache is one line:
Cache::tags(["brand:{$brandId}"])->flush();
Or from a model observer:
final class ProductObserver
{
public function saved(Product $product): void
{
Cache::tags(['catalog', "brand:{$product->brand_id}"])->flush();
}
}
The catch is that tags()->flush() doesn't actually delete the entries — it bumps a tag-namespace version, so subsequent reads with the same tags miss and recompute. That's almost always what you want, but it means the old keys linger in Redis until they expire on their own. If you're memory-constrained, set realistic TTLs.
The Stampede Problem And Cache::lock
Here's the failure mode that the textbook example hides. A key expires. In the same second, fifty requests miss the cache. All fifty execute the closure. All fifty hit the database. All fifty store the result. You've turned one expensive query into fifty.
The fix is an atomic lock around the recompute:
public function featured(): Collection
{
return Cache::remember('catalog.featured', now()->addMinutes(15), function () {
return Cache::lock('catalog.featured.lock', 10)->block(5, function () {
// Re-check inside the lock — another worker may have just filled it.
return Cache::remember(
'catalog.featured',
now()->addMinutes(15),
fn () => Product::featured()->with('brand')->get(),
);
});
});
}
Cache::lock($name, $seconds)->block($wait, $callback) waits up to $wait seconds for the lock, holds it for at most $seconds, and runs the callback. The first request through computes the value; the rest wait, then read the freshly populated key on their next attempt.
For non-blocking semantics, use get() instead of block() and return whatever you'd have served as a fallback — last-known-good data, an empty array, a default response.
Cache::flexible — Stale-While-Revalidate, Built In
Laravel 11 added one of the most underrated cache primitives in any framework: Cache::flexible. It's stale-while-revalidate semantics, the same idea HTTP Cache-Control: stale-while-revalidate exposes at the edge.
$products = Cache::flexible(
'catalog.featured',
[now()->addMinutes(5), now()->addMinutes(15)],
fn () => Product::featured()->with('brand')->get(),
);
The two-element array is [fresh, stale]. For the first 5 minutes, the value is "fresh" and served immediately. Between 5 and 15 minutes, it's "stale" — Laravel still returns it instantly, but kicks off a background refresh (using defer() under the hood) so the next request gets a freshened value. After 15 minutes, the entry is gone and the next caller has to compute synchronously.
This is exactly the right shape for catalog pages, dashboards, leaderboards — anything where slightly stale is fine and a hard miss during peak traffic is not. The key word is "background": the user's request never waits for the recompute, which is why this beats a plain remember for high-traffic reads.
What To Cache And What Not To
The cache is not free. Every key consumes Redis memory, every recompute costs CPU, and every stale value is a potential bug. Cache when:
- The query is expensive (joins, aggregations, full-text search) and the answer is the same for many users.
- The data changes on a known signal you can hook (an Observer, an event, a webhook).
- The cost of being slightly stale is small compared to the cost of running the query every time.
Don't cache when:
- The data is small and the query is cheap.
User::find($id)on a primary key is already fast. - The result depends on fine-grained per-user state that's unlikely to repeat across requests.
- Correctness matters more than latency. Account balances, order status, anything financial — read fresh, or use a write-through cache with explicit invalidation, not a TTL.
A good rule of thumb: cache list endpoints and computed dashboards aggressively, cache individual records cautiously, never cache the row that powers an authorization check.
Mistakes That Show Up In Code Review
A few patterns I push back on every time:
- Caching inside
toArrayon a Resource. The Resource is a transformer — if it's hitting the cache to populate a field, the controller should have done that work and passed the value in. - TTL of
nullor "forever" without an Observer.rememberForeveris fine; "forever and we'll figure out invalidation later" is a six-month bug. - Tags on the
databasedriver. Throws at runtime, only in production, only when the key is missing. Read the driver docs once and pick a real cache backend. - Caching authenticated responses by URL alone. The cache key has to include the user identity (or the role, or the tenant) or you'll cross-contaminate sessions.
Testing The Cache Without Testing Redis
You don't need Redis running to write good cache tests — Laravel ships an array driver that's perfect for the job:
it('caches the featured catalog and invalidates on product save', function () {
config(['cache.default' => 'array']);
Cache::tags(['catalog'])->put('catalog.featured', collect(['stale']), 60);
expect(cache()->tags(['catalog'])->get('catalog.featured'))->toEqual(collect(['stale']));
Product::factory()->create(); // triggers ProductObserver::saved → tags()->flush()
expect(cache()->tags(['catalog'])->get('catalog.featured'))->toBeNull();
});
Switch the driver in the test, exercise the boundary, and you've pinned down the invariant: when a product changes, the catalog cache is gone.
A One-Sentence Mental Model
Treat the Laravel cache as four primitives — remember for the happy path, tags()->flush() for grouped invalidation, lock()->block() for stampedes, and flexible() for stale-while-revalidate — backed by Redis, versioned in the key, and invalidated on a real signal instead of a wish.




