A catalogue price is cached at revision 1. The database commits revision 2. Three seconds later, Laravel finds the key and returns revision 1 from inside its declared five-second fresh window.
The cache hit is real. So is the stale price.
In the LAR-06 fixture, the cached JSON is well formed and its
SHA-256 hash matches its body. Nothing has expired or become corrupt. The
failure is semantic: current-price requires the acknowledged source
revision, while the cache only knows that a key still contains bytes.
Freshness therefore belongs to the reader contract, not the cache backend. Name that contract before choosing a TTL, invalidation callback, versioned key, lock, or stale-while-revalidate window.
Start with the revision the reader is allowed to see
The fixture uses SQLite as the authoritative catalogue and Redis as a derived store. Every source update increments a revision. Every cached value carries the key family, schema, source revision, generation time, freshness windows, body, body hash, and writer.
That envelope makes two questions visible:
{
"key_family": "catalogue:books",
"schema": "catalogue-price-v1",
"source_revision": 1,
"generated_at": 100,
"fresh_until": 105,
"stale_until": 110,
"body_sha256": "c0827f...",
"writer": "ttl"
}Is this a valid cache value? Yes. Is revision 1 acceptable to this reader when the source has acknowledged revision 2? That depends on the reader.
The fixture declares three answers:
| Reader | Contract |
|---|---|
| Current price | Must match the acknowledged source revision |
| Catalogue browse | May serve a verified value for a bounded stale window |
| Best-effort recommendation | May serve an older verified value during an outage, with its age and degraded status |
These are synthetic contracts, not claims about Brian’s applications. Their purpose is to stop a framework feature from silently making a product decision. Serving an old thumbnail and serving an old price may use the same Redis command while requiring opposite failure behavior.
The no-cache control reads revision 1 directly from SQLite and returns it with one source call. That is not a naive baseline to dismiss. If the source can comfortably serve the workload, direct reads remove invalidation, key cleanup, stampede, and stale-data policy from the path. A cache has to earn the extra state it creates.
A TTL limits age, not disagreement with the source
Laravel’s cache repository can store a value for a duration and return it on subsequent reads. The cache documentation describes the retrieval and expiration mechanics. Neither mechanism observes a commit in an unrelated database.
The fixture makes that separation explicit:
100 cache writes source revision 1
102 SQLite commits source revision 2
103 cache hits revision 1 inside its fresh windowThe TTL bounds how long this entry remains eligible under the cache’s clock. It does not guarantee that the source stays unchanged during that interval. Calling the five-second interval “fresh” is safe only after the reader agrees that up to five seconds of source disagreement is acceptable.
A shorter TTL narrows the window but increases misses and origin work. A
longer TTL reduces that work while allowing disagreement to last longer.
Neither duration repairs a current-price contract which says an acknowledged
revision must be visible immediately.
TTL still has a useful role. It limits retention, supplies a repair path when invalidation is lost, and fits data whose acceptable age is already known. The order matters: choose the age from the reader’s tolerance, then configure the duration. Do not derive correctness from a convenient round number.
Invalidation moves the failure window; it does not erase it
The successful invalidation case commits revision 2, forgets the stable key, misses, and rebuilds revision 2. Ordering the forget after the transaction keeps uncommitted data out of the cache.
Moving the forget before the transaction creates a different problem. In the fixture, the source update rolls back after the key is removed. The next read misses and rebuilds revision 1. Correct data returns, but the miss was needless.
The harder case comes after a successful commit:
source.commit revision 2
cache.forget.failed cached revision 1The database is correct and the invalidation delivery failed. A synchronous after-commit callback can make this interval small. It cannot make delivery durable. If the reader cannot tolerate the old revision, the design needs an observable repair mechanism. Depending on the consequence, that may be an outbox, a reconciliation job, a revision check, or no cache on that path.
Versioned keys change the operation. Revision 1 lives at
catalogue:books:v1, revision 2 at catalogue:books:v2, and a small current
pointer selects one of them. Old values may remain addressable without being
selected.
That does not remove ordering concerns. The fixture advances the pointer to revision 2, then revision 3, and finally delivers an older revision 2 event. The handler compares revisions and leaves the pointer at 3. An event which blindly writes its own revision would move the reader backwards.
Stable keys trade cleanup simplicity for invalidation coordination. Versioned keys trade safer selection for a pointer protocol and old-version cleanup. Choose between those failures; “we use Redis” does not choose for you.
One cold key can become twelve source calls
Expiration solves retention and creates a concurrency problem. A popular key can become absent for many readers at once.
The fixture releases twelve real PHP processes against one missing key and a deterministic synthetic source delay. Without coordination, all twelve observe the miss and all twelve execute equivalent origin work. The response hashes match, which is precisely the waste: twelve source calls produce one logical answer.
A shared Laravel atomic lock reduces the observed origin count to one. One reader rebuilds while eleven obtain the same cached response after the lock. That protects the source, but it gives the waiters a new latency and timeout contract.
The lease must cover the work it protects. In the short-lease control, the first reader is still loading when the lock expires. A second reader acquires the same lock name, and two origin calls overlap. The cache eventually contains a valid response, yet the stampede control has already failed.
The Redis lock guidance emphasizes a unique owner value and a
bounded validity time. Laravel’s pinned RedisLock implementation
uses the owner token when releasing the lock. The fixture’s failure sequence
keeps that ownership visible: the first owner crashes before writing, an
immediate waiter times out, and a later reader rebuilds only after the lease
expires. Recovery finishes with revision 2 cached and no remaining owner.
An array cache or a process-local mutex cannot prove this behavior. Every PHP process must coordinate through the same lock store. Nor does one local Redis container prove a fault-tolerant distributed-lock topology. It proves the owner, lease, waiter, and recovery semantics exercised against this pinned store.
Stale-while-revalidate spends correctness to protect latency
Laravel’s flexible method divides a value’s life into fresh and stale
windows. A fresh request returns the value. A stale request also returns the
value, then registers a deferred refresh. An expired or absent value rebuilds
before returning.
The fixture controls the application clock instead of trying to infer these branches from sleep:
| Request time | Branch | Immediate revision | Deferred callbacks | Revision afterward |
|---|---|---|---|---|
| 100 | Cold | 1 | 0 | 1 |
| 103 | Fresh | 1 | 0 | 1 |
| 107 | Stale | 1 | 1 | 2 |
| 108 | Fresh after refresh | 2 | 0 | 2 |
| 118 | Expired | 3 | 0 | 3 |
At time 107, the fixture observes revision 1 before invoking the deferred callback. Only then does the cache move to revision 2. That order is the feature, not an implementation detail to edit out of the explanation.
Twelve concurrent stale readers all return revision 1 and register deferred callbacks. The pinned Laravel repository source uses a refresh lock and checks the stored creation time, so the callbacks produce one observed source refresh and revision 2 becomes current. The result belongs to Laravel 13.20 and the Redis store exercised here; it is not a promise about every framework version or cache driver.
The failed-refresh control returns stale revision 3, runs one deferred
callback, logs the declared exception, and leaves revision 3 cached. That can
be honest for catalogue browsing if the age remains acceptable. It violates
the current-price contract. Cache::flexible supplies lifecycle mechanics;
the reader still supplies permission to serve the stale response.
A cache miss is only one of the failure states
Code which catches every cache exception and calls the source has made several different events look like absence. The fallback might be appropriate, but the classification is gone.
The fixture isolates eight states, including:
- an absent key;
- an unreachable Redis endpoint;
- a Redis user which can read revision 1 but cannot write;
- a malformed envelope;
- a body whose stored hash does not match its content;
- an unavailable SQLite source;
- a lock timeout; and
- a failed deferred refresh.
None is silently reported as a miss. When Redis is unreachable but SQLite is
available, all three readers can use the current source response. When SQLite
is unavailable, current-price fails closed because it cannot establish the
current revision. The two stale-tolerant readers may serve verified revision 1
at age seven, marked degraded. A corrupt value is rejected rather than granted
the privileges of a previously verified stale response.
This distinction belongs in operations as well as code. A useful event names the strategy, reader contract, key family, source revision, cached revision, age, freshness state, and outcome. Those fields answer questions which a global hit ratio cannot:
- Are current-price readers receiving an old source revision?
- Is one cold key multiplying origin work?
- Did invalidation stop after a successful source commit?
- Are deferred callbacks refreshing values or only serving old ones?
- Is Redis empty, unavailable, corrupt, or slow?
Raw full keys may contain sensitive or unbounded identifiers. A bounded key
family such as catalogue:books is usually the safer signal dimension.
Choose the failure you can detect and repair
The strategies in this fixture do not form a maturity ladder.
| Strategy | Useful when | Failure which must be owned |
|---|---|---|
| No cache | The source can serve the workload | Source capacity and availability |
| TTL | Bounded source disagreement is acceptable | Stale hits until expiry and concurrent misses |
| After-commit invalidation | Source changes should remove a stable key | Lost delivery and the commit-to-forget window |
| Versioned keys | Readers can select an immutable revision | Pointer ordering and old-key cleanup |
| Shared lock | Cold rebuilds threaten the source | Lease sizing, waiter timeout, owner failure |
| Flexible windows | The reader explicitly tolerates stale responses | Deferred refresh and prolonged stale service |
There is no cache-readiness score. Correctness, source load, latency, availability, cleanup, and operating complexity are separate dimensions. A weighted total would conceal the very disagreement the design must resolve.
The reproduction retains seven authority and invalidation cases, 51
real-process reader outcomes, 18 reader-and-strategy decisions, eight failure
classes, 20 rejected counterfactuals, 13 hashed primary-source responses, and a
clean export of commit 925672495e377a133b21aee4322ce18176150b23. Its source
and traffic are synthetic. It does not supply Brian’s production workload,
topology, cost, service objective, or approved freshness windows.
Start with no cache. Add one only when the source pressure or latency justifies another state owner. Then write down the permitted revision, invalidation boundary, cold-miss behavior, failure policy, signals, repair, cleanup, and the condition which sends the path back to direct source reads.
A hit answers “did this key resolve?” Freshness begins with the next question: “is this the revision this reader is allowed to see?”