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:

ENV
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:

PHP
// 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.

PHP
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:

PHP
Cache::tags(["brand:{$brandId}"])->flush();

Or from a model observer:

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

Diagram of a Laravel cache layer in front of a database. Requests arrive at a controller, pass through Cache::remember which checks Redis first; on a hit the response goes back immediately, on a miss the closure runs against the DB and the result is stored. A separate panel shows Cache::lock wrapping the closure to prevent stampedes when many simultaneous requests miss the same key, and Cache::flexible serving stale data while a background refresh runs.
Hit, miss, lock, refresh — the four states a real cache strategy has to handle.

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:

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

PHP
$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:

  1. Caching inside toArray on 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.
  2. TTL of null or "forever" without an Observer. rememberForever is fine; "forever and we'll figure out invalidation later" is a six-month bug.
  3. Tags on the database driver. Throws at runtime, only in production, only when the key is missing. Read the driver docs once and pick a real cache backend.
  4. 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.

A five-card production cache checklist arranged as a Redis dashboard. Card 01 "Pick the right driver" — Redis or Memcached, never database in production. Card 02 "Lock the stampede" — Cache::lock() around expensive recomputes so 200 workers don't all rebuild the same key. Card 03 "Tag for grouped invalidation" — Cache::tags(['products','tenant:42']) so a single product update flushes only that tenant's slice. Card 04 "Bound the TTL" — never rememberForever without an Observer that invalidates. Card 05 "Key by identity" — keys include user/tenant/role to stop cross-session leaks. Each card is annotated with the failure mode it prevents.
Five rules taped above the Redis dashboard — each one is a bug you only meet once if you write it down.

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:

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