Skip to main content

Caching — how much correctness you trade for speed

The last two chapters were about deferring slow processing. This chapter covers not doing the processing at all.

A product listing returns the same result for everyone. With 100 people opening it a second, the same query runs 100 times. Running it once and remembering the result is enough.

But remembering means accepting the possibility of returning a stale value. This chapter is about speed and, at the same time, about deciding how stale is acceptable.

What may be cached​

There is one question: is returning a slightly stale value acceptable?

TargetVerdict
Product and category listingsFine. Information from a few minutes ago does little harm
A product's stock quantityRisky. A stale value shows an unbuyable product as buyable
A user's own order historyGenerally no. Sharing it causes incidents (see below)
Permission and authorization decisionsNo. Someone whose access was revoked passes on a stale decision

Stock quantity sits in an awkward place. Showing "only a few left" in a listing is fine even if it drifts. The stock quantity used in order processing must never be cached. Chapter 14 added row locks and a conditional UPDATE precisely to handle that value accurately.

Do not nullify chapter 14's defense by adding a cache for speed.

Cache::remember​

The basic form is just this.

$value = Cache::remember('key', 300, function () {
return /* the expensive work */;
});

remember returns the value if the key exists, and otherwise runs the closure, stores the result, and returns it1. The second argument is how many seconds to keep it.

Chapter 1 said "a product listing becomes a caching target." But drape it straight over the listing API built in chapter 8 and it does not work.

The chapter 8 listing API cannot be cached as is​

Chapter 8's index accepts keyword, sort, direction, and per_page, plus a page number. Five things change the result. The cache key has to include all of them.

// A key for only "search coffee, price descending, 50 per page, page 3"
$key = "products.coffee.price.desc.50.3";

The number of key combinations grows without bound with the number of search terms. Most keys are read exactly once. You pay to store them and they expire unused. Far from getting faster, it gets slower by the cost of the write.

A cache only earns its keep when a few keys serve many requests.

Narrow it to where it works​

Real traffic is not uniform. It skews heavily toward page 1 with no search and no sorting, because that is the first screen someone from the top page sees.

Cache only that.

app/Http/Controllers/ProductController.php
use Illuminate\Support\Facades\Cache;

public function index(Request $request)
{
$validated = $request->validate([
'keyword' => ['sometimes', 'string', 'max:100'],
'sort' => ['sometimes', 'in:id,name,price,created_at'],
'direction' => ['sometimes', 'in:asc,desc'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);

// Cache only page 1 with no filtering and no sorting
if ($validated === [] && $request->integer('page', 1) === 1) {
$products = Cache::remember($this->listCacheKey(), 300, function () {
return Product::with('stock')->orderBy('id')->paginate(20);
});

return ProductResource::collection($products);
}

// Everything below is unchanged from chapter 8
$query = Product::query()->with('stock');

if (isset($validated['keyword'])) {
$query->where('name', 'like', '%' . $validated['keyword'] . '%');
}

$query->orderBy(
$validated['sort'] ?? 'id',
$validated['direction'] ?? 'asc',
);

return ProductResource::collection(
$query->paginate($validated['per_page'] ?? 20)
);
}

Requests that do not match the condition go to the database as before. A cache is not a tool for speeding everything up; it is a tool aimed at where traffic skews.

orderBy('id') is written on the cached side too. The branch below defaults to ascending id, and without matching it the ordering differs between pages 1 and 2. The same product appears on both, or on neither. It is the same thing chapter 8 said about adding id to a sort with ties, showing up at the caching branch.

How much skew there is can be seen in your access logs. With no skew, there is no reason to add a cache.

This implementation caches the stock quantity too

The table at the top classified stock quantity as "risky," and yet with('stock') is included in the cache. That is deliberate. Here is why.

Store the object paginate() returns and it is serialized and restored. The Eloquent models inside are frozen at the values as of storage. They are not re-read the way chapter 16's SerializesModels does. Chapter 8's ProductResource returns stock_quantity as a number, so a product with one unit left keeps showing "1 left" for up to five minutes after it sells out.

That is acceptable because order processing does not use this value. The path built in chapter 14 locks and reads the stocks row directly on every order. A stale number in the listing never lets an unbuyable product be bought. It just returns a 409.

If that is not acceptable, there are three options: shorten the TTL, return only a band such as "in stock" or "only a few left" in the listing rather than a number, or drop with('stock') and read stock separately. The second is the easiest way to satisfy both the display requirement and accuracy.

Do not leave "the table says risky but the implementation includes it" standing without an explanation. When a reader notices, they cannot tell whether it was a decision or an oversight.

Choosing a driver​

It is set by CACHE_STORE in .env. Laravel 13's .env.example uses database.

DriverLocationCharacteristics
arrayThe PHP process's memoryFor tests. Gone when the process ends
databaseThe cache tableNo extra middleware needed. No tags
filestorage/framework/cacheFor a single server. No tags
redisRedisFast. Tags available. Shared across several servers

The cache table was created when you ran the migrations in chapter 3; that is the "we use it in chapter 18" note from then.

If you run the application on several servers, file is not an option. Each server looks at a different file, so a value stored on server A cannot be read on server B. You get "the same product listing changes content when you reload." It is the same problem as the locks in chapter 17.

Key design​

The most dangerous thing about caching is getting the key wrong and handing out someone else's data.

❌ Bad: per-user content under a shared key
$orders = Cache::remember('my.orders', 300, function () use ($request) {
return $request->user()->orders()->latest()->get();
});

The first person to hit it puts their order history into my.orders. The next user to arrive receives that history. However carefully you built authorization in chapter 12, this one line undoes it.

Include in the key everything that changes the result.

✅ Good
$key = "orders.user.{$request->user()->id}.page.{$request->integer('page', 1)}";

"Everything that changes the result" includes not only the user ID but the page number, the sort order, and the filter conditions. The listing API built in chapter 8 accepts all of those, and not reflecting them in the key returns results for different conditions.

Better still, do not cache per-user data

Build the key correctly and it works, but the number of keys grows with the number of users. Ten thousand people viewing 10 pages each is 100,000 keys. In most cases that does not pay for the speed you get.

Caching suits things where a few keys serve many requests. A product listing everyone sees works from one key for every request. An order history is one key per person, used only by that person.

Before considering a cache, suspect your indexes and queries first. Resolving the N+1 covered in chapter 8 is better on both effect and safety.

When to throw it away​

What you store eventually stops being correct. There are two ways to throw it away.

Throw it away by time​

That is remember's second argument. It is a declaration of "the value returned is at most this stale."

Cache a product listing for 300 seconds and a newly added product does not appear in the listing for up to five minutes. Deciding whether that is acceptable is the design. If it is not, do not shorten the number; use the next method.

Throw it away on change​

Chapter 1 said "a cache of the product listing, invalidated when a product is updated." We collect on that here.

The straightforward approach deletes the matching key when a product is updated.

Cache::forget('products.list'); // ← we do not take this route

It works, because the next request rebuilds it. While there is only one key being cached, this is enough.

The problem is that it collapses the moment you widen the scope. "Let us cache the popular page 2 too," "let us cache per category" — each addition needs another line on the deletion side. Forget a line and nothing errors. A stale value just keeps coming back.

Redis can delete a whole group with tags, but tags are unavailable on database and file1. This series defaults to database, so that route is closed too.

So we arrange not to delete anything. The next section is the method this series adopts, and it is the body of the listCacheKey() seen above.

Put a version in the key​

Without tags, changing the key itself means the stale value is never reached again.

Gather key construction into one method.

app/Http/Controllers/ProductController.php
private function listCacheKey(): string
{
$version = Cache::get('products.version', 0);

return "products.v{$version}.list";
}

When a product changes, set the version to a new value.

Cache::forever('products.version', (string) Str::uuid());

Do not use increment. Its behavior when the key does not exist yet varies by driver, producing the hard-to-see failure of "you thought it incremented and it did not."

Do not use the current time either. now()->timestamp is in seconds, so two updates within the same second leave the value unchanged. It rarely happens locally, but you hit it when registering products in bulk or updating repeatedly inside a test. It becomes the hardest kind of defect to chase: "occasionally a stale value sticks around."

Use something guaranteed to differ every time. A UUID differs from a missing key and differs on consecutive calls.

Because the whole key changes, widening the cached scope later needs no change to the invalidation code. Nobody reads the old keys, and they disappear on their own when the TTL expires.

Where invalidation goes​

There is not necessarily only one path that updates a product. Chapter 4 narrowed apiResource to index and show, so the current API has no product update endpoint. The next chapter adds one for image upload. In practice there are more paths still: an admin screen, batches, hand-written SQL.

To avoid missing one, catching it on the model is the reliable way.

app/Observers/ProductObserver.php
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

class ProductObserver
{
public function saved(Product $product): void
{
Cache::forever('products.version', (string) Str::uuid());
}

public function deleted(Product $product): void
{
Cache::forever('products.version', (string) Str::uuid());
}
}
app/Models/Product.php
use App\Observers\ProductObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy(ProductObserver::class)]
class Product extends Model
{
// ...
}

Bind it with #[ObservedBy]. It is the same attribute-declaration form as #[Fillable] in chapter 3 and #[Tries] in chapter 17.

Model events do not fire on bulk operations

As chapter 15 covered, mass updates and deletes do not dispatch model events. Rewrite prices with Product::where(...)->update([...]) and this observer is never called.

Wherever you write a bulk operation, update the version yourself. Part of it remains something you cannot say "the model handles it, so we are safe" about.

Chapter 15 said not to express business events with model events. We use a model event here because what we are handling is not a business event but the bare fact that a row changed. The condition under which a cache goes stale matches exactly that.

What happens when everyone arrives at once​

Consider the moment a cache expires.

At 100 requests a second, the product listing key expires. The next 100 requests all decide "there is no cache" and all 100 run the query. It is worse than before you added the cache.

This is called a stampede. It gets worse the more traffic you have, so something added for performance bares its teeth under load.

Return the stale value while rebuilding in the background​

Cache::flexible splits the expiry into two stages1.

$products = Cache::flexible($this->listCacheKey(), [60, 300], function () {
return Product::with('stock')->paginate(20);
});
  • Up to 60 seconds, return it as is
  • Between 60 and 300 seconds, return the stale value immediately and rebuild in the background
  • Past 300 seconds, wait for the rebuild before returning

It amounts to "once it is getting stale, refresh it off the back of somebody's request." You refresh without making the user wait.

The window during which a stale value is returned grows explicitly, so decide how stale is acceptable before using it.

Let only one rebuild​

Cache::lock grants the right to rebuild to just one request1.

$lock = Cache::lock('products.rebuild', 10);

if ($lock->get()) {
try {
// only one request gets here
} finally {
$lock->release();
}
}

A request that could not take the lock chooses to wait, return the stale value, or give up.

It has the same constraints as chapter 17's locks. You need a driver that supports atomic locks, and with several servers everyone has to look at the same place.

The database driver uses the cache_locks table. It was created alongside cache by chapter 3's migrations, so no extra work is needed.

Always release in finally. Leave a lock behind after an exception and nobody can rebuild until it expires.

Extending only the expiry​

Laravel 13 added Cache::touch()2. It extends the expiry without fetching the value and putting it back.

Cache::touch($this->listCacheKey(), 300);

It returns a bool, and false if the key does not exist. You cannot extend the expiry of something that was never created.

It earns its keep with large values. Previously you had to "read it and write it straight back," which transferred the whole value. "It is still in use, so keep it a little longer" becomes cheap to write.

Tests​

As promised in chapters 10 and 13, the cache survives between tests. RefreshDatabase restores only the database.

tests/Feature/ProductCacheTest.php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;

pest()->use(RefreshDatabase::class);

beforeEach(function () {
Cache::clear();
});

Clear it in beforeEach. We did the same in chapter 13's rate limit test. There it was "the counter survives"; the cause is the same.

The test environment defaults to array

phpunit.xml sets CACHE_STORE=array. It lives only in memory, so it disappears once the test run finishes.

Cache::clear() is still needed because several tests run in sequence within one test process. A value stored by the previous test is visible to the next. This is the classic "passes on its own but fails in the full run" promised in chapter 10.

Confirm the cache is working​

tests/Feature/ProductCacheTest.php
test('the second request comes from the cache', function () {
Product::factory()->create(['name' => 'The first product']);

$this->getJson('/api/v1/products')
->assertJsonCount(1, 'data');

// Add one row through a path that skips the observer
Product::query()->insert([
'name' => 'A product added afterwards',
'price' => 500,
'created_at' => now(),
'updated_at' => now(),
]);

$this->getJson('/api/v1/products')
->assertJsonCount(1, 'data');
});

We confirm the cache by "the added product not being visible." Counting queries is another approach, but on the database driver the cache itself issues queries, so a count cannot decide it. This keeps the test independent of .env settings.

Using insert() is the point. As chapter 15 covered, a bulk insert does not fire model events. It skips the observer's invalidation, so you see only whether the cache is working. Remove Cache::remember and two rows come back, going red.

Confirm invalidation works​

The common cache bug is "it does not go away."

tests/Feature/ProductCacheTest.php
test('updating a product is reflected in the listing', function () {
$product = Product::factory()->create(['name' => 'The old name']);

$this->getJson('/api/v1/products')
->assertJsonPath('data.0.name', 'The old name');

$product->update(['name' => 'The new name']);

$this->getJson('/api/v1/products')
->assertJsonPath('data.0.name', 'The new name');
});

Forget the invalidation and it goes red, because the first response is cached and the old name keeps coming back after the update.

This test is worth writing before you add the cache. Before, it is green as a matter of course; the moment it goes red on adding the cache, you know invalidation is required.

What matters in production​

What happens when the cache goes down​

When Redis is down, Cache::remember throws. What you added as a cache becomes a dependency for availability.

What should have been "the product listing gets a bit slower" can turn into the whole site returning 500. Whether to make it "slow but working" when the cache is down, or to accept "it goes down with it," is a decision to take in advance.

It is the same shape as chapter 8's decision to disable preventLazyLoading in production. The idea is not to promote a performance problem into an availability problem.

It is not the same as config:cache or route:cache​

The names are similar, but they have nothing to do with this chapter's cache.

config:cache and route:cache are the startup speedups covered in chapters 2 and 4, generated at deployment time and written to files. What the Cache:: facade handles is application data.

php artisan cache:clear does not clear the former, and vice versa. When "I cleared the cache and it is still wrong," check which cache you are talking about.

A missed invalidation breaks things silently​

A cache bug does not error. A stale value just keeps coming back. Tests pass too, if they clear the cache before running.

You find out from a "I updated it and it is not reflected" ticket. That is what the invalidation test is for.

Do not cache everything​

Every cache you add adds one more thing whose staleness you have to reason about.

Speed comes with a correctness question attached. Revisit your queries and indexes first. Then, when that is not enough, add a narrowly targeted cache.

Summary​

  • A cache is a trade: you buy speed with the possibility of returning a stale value. Decide what you give up first
  • Do not cache the stock quantity used in order processing. It nullifies chapter 14's defense
  • Cache::remember runs the closure and stores the result when the key is absent
  • Include everything that changes the result in the key. Forget the user ID and you hand out someone else's data
  • A listing that accepts filtering and sorting is not suited to caching as is. Keys pile up and are never reused
  • What works is where traffic skews. Do not drape it over everything; aim it at the one place it lands
  • Per-user data is not suited to caching in the first place
  • database and file have no tags. Put a version in the key and you can invalidate a whole group
  • Do not use increment or the current time to update the version. The first does not change when the key is missing; the second does not change on two updates within a second
  • If you cache only part of a listing, align the sort order on both paths. Diverge and products duplicate or disappear across pages
  • Catch invalidation with an observer, but it does not fire on bulk updates
  • Traffic piles up at the moment of expiry (a stampede). Guard with Cache::flexible or Cache::lock
  • Cache::touch() extends the expiry without reading or writing the value. It returns false when the key is missing
  • Call Cache::clear() in beforeEach in tests. Always write an invalidation test
  • Cache bugs do not error. A stale value just keeps coming back

We have deferred heavy processing and cut down repeated work. From the next chapter, File storage — product images and receipt PDFs, we take on a kind of data we have not touched: files that do not go into the database, such as product images and receipt PDFs. It covers receiving uploads, choosing where to store them, and distinguishing files that may be published from those that may not.

Exercises​

The following code has a serious problem from the user's point of view. What is it?
public function index(Request $request)
{
return Cache::remember('orders.list', 600, function () use ($request) {
return OrderResource::collection(
$request->user()->orders()->latest()->paginate(20)
);
});
}

Sample answer

Someone else's order history comes back.

The key is fixed at orders.list. The first user's orders get stored, and the next user to arrive receives exactly that content.

For ten minutes, everyone sees one person's order history. It contains names, email addresses, and purchases, so it is a leak of personal information.

Chapter 12 built authorization and made the listing fetch only your own orders through the relationship. That constraint is applied correctly inside the closure. The problem is that the closure runs only once. Authorization decides whose orders to fetch; the cache key does not look at that at all.

To fix it, first include the user ID in the key. The page number is needed too.

That said, also consider dropping the cache entirely. An order history is seen only by its owner, so caching it gains no reuse. Keys grow with the number of users and just consume memory. If speed is the problem, checking the user_id index comes first.

How do you respond to "we shortened the TTL to 10 seconds, so we do not need invalidation on product update"?

If you have decided "a stale value for 10 seconds is acceptable," it holds up as a decision. It is a trade: skip the invalidation code and accept up to 10 seconds of lag. As design it is coherent.

There are three things to check.

One: whose requirement produced the number 10? A number chosen because "shorter must be safer" has no basis. Consider someone fixing a product in the admin screen and reloading the listing to check: even 10 seconds reads as "it did not take effect."

Two: is there any caching benefit left? The shorter the TTL, the lower the hit rate. Rebuilding every 10 seconds runs that query 8,640 times a day. If the underlying query is fast enough, dropping the cache is simpler. Getting no speed while retaining the possibility of a stale value is the worst state to be in.

Three: stampedes. The shorter the TTL, the more often the moment of expiry arrives. With heavy traffic you get a pile-up every 10 seconds. Cache::flexible, returning the stale value while rebuilding behind it, suits this setup better.

"Not writing invalidation" is not blameworthy in itself. Whether it is a decision or just avoiding the effort shows in whether you can answer those three.


Footnotes​

  1. Source: Cache (Laravel documentation 13.x). On five points: Cache::remember running the closure and storing the result when the key is absent; Cache::flexible taking a two-stage "fresh" and "stale" period, returning the stale value while recalculating in the background; Cache::lock creating an atomic lock handled with get() / release(); Cache::forget and Cache::increment; and cache tags being unavailable on the file, dynamodb, database, and storage drivers (the warning in the "Cache Tags" section of the same page). ↩ ↩2 ↩3 ↩4

  2. Source: Release Notes (Laravel documentation 13.x), the "Cache TTL Extension" section. On Cache::touch(...) being added in Laravel 13, extending an existing cache item's TTL without retrieving and re-storing the value. The meaning of the return value comes from the Cache page. ↩