skip to content

In Laravel, how does Cache::flexible() serve stale exchange rates while refreshing them, and how does it differ from Cache::remember() at expiry?

level: seniorimportance: should knowfreq 35%

answer

  1. two-number TTL: [fresh, stale]
  2. stores value plus a created timestamp
  3. stale: return old value, defer refresh
  4. refresh after response, under a lock
  5. past second number: synchronous recompute

basics

~20 s

Cache::flexible($key, [$fresh, $stale], $closure) returns the cached value untouched while it is younger than $fresh; between $fresh and $stale it returns the old value and refreshes it after the response; past $stale it recomputes inline, like a remember() miss.

solid answer

~40 s

`Cache::flexible('fx:rates:EUR', [300, 900], $fetch)` stores the value and a *created* timestamp, both with a lifetime of the second number. Younger than 300 seconds: returned as-is. Between 300 and 900: the stale value is returned immediately and a **deferred function** refreshes it after the response is sent, inside a non-blocking cache lock so only one process recomputes. Past 900 both keys have expired, so the closure runs inline and the user waits — the same as a `remember()` miss. With `remember()`, every expiry is that slow path, and concurrent misses all run the closure. Caveats: the refresh runs only after a successful response (status below 400) unless the fifth argument asks to always defer; refresh errors are reported, not thrown; and the store must support locks.

code

php · 12 lines
php
<?php

use Illuminate\Support\Facades\Cache;

// Fresh for 5 minutes, servable while stale up to 15 minutes.
$rates = Cache::flexible(
    "fx:rates:{$base}",
    [300, 900],
    fn () => $this->provider->latest($base),
    ['seconds' => 30], // refresh-lock options
    true,              // always defer: refresh even after a 4xx/5xx
);

go deeper

for a junior

Recall that flexible takes two numbers, a fresh period and a stale limit, and returns old data quickly while it is refreshed.

for a middle

Explain the three windows, the created-timestamp key, and why the refresh happens after the response rather than during it.

for a senior

Show how the lock and timestamp re-check prevent a stampede, and call out the conditional refresh, swallowed errors, cold keys and the lock-capable store requirement.

for a principal

Decide per dataset how much staleness the business tolerates, and when stale-while-revalidate beats pre-warming the cache from a scheduled job.

## The problem with `remember()` at expiry With `Cache::remember('fx:rates:EUR', 600, $fetch)`, the tenth minute is a cliff. The first request after expiry misses, calls the rate provider, and that visitor waits for it. If the page is busy, many requests miss in the same instant and all call the provider — a stampede. For data where being a few minutes old is acceptable, such as exchange rates shown on a converter, it is better to keep serving the previous value while one process fetches the new one. This is the **stale-while-revalidate** pattern, and `Cache::flexible()` implements it. ## The call ```php $rates = Cache::flexible('fx:rates:EUR', [300, 900], function () { return $this->provider->latest('EUR'); }); ``` The second argument is a pair: - **First number (300)** — how long the value is *fresh*. - **Second number (900)** — the total lifetime of the stored entry; between the two numbers the value is *stale but servable*. ## What happens on each request `Repository::flexible()` reads two keys in one `many()` call: the value and a companion key `illuminate:cache:flexible:created:{key}` holding the Unix timestamp of the last computation. 1. **Either key missing** (first request, or past 900 seconds): run the closure **inline**, store the value and a new timestamp with `putMany()` using the second number as TTL, and return the value. This is the slow path, identical in cost to a `remember()` miss. 2. **Age below 300 seconds**: return the value. Nothing else happens. 3. **Age between 300 and 900 seconds**: return the **stale value immediately** and register a refresh with Laravel's `defer()` helper, named after the key so repeated calls in one request register it once. The deferred refresh runs after the response has been sent: - It takes a cache lock named `illuminate:cache:flexible:lock:{key}` with a non-blocking `get()`. If another process holds it, this one simply skips the refresh. - Inside the lock it re-reads the created timestamp; if it changed, someone else already refreshed, so it stops. - Otherwise it runs the closure and writes value plus timestamp with a fresh 900-second lifetime. ## `flexible()` versus `remember()` | Aspect | `remember()` | `flexible()` | |---|---|---| | TTL argument | one lifetime | `[fresh, stale]` pair | | After the fresh window | miss, recompute inline | serve stale, refresh after response | | Concurrent recomputes | every missing request | one, guarded by a lock | | Keys written | one | two (value and created timestamp) | | Needs lock support | no | yes | | User-visible latency | at every expiry | only when the entry fully expires | ## Caveats interviewers look for - **The refresh is conditional.** HTTP deferred callbacks run only when the response status is below 400. A page that renders an error never refreshes the stale rates. The fifth parameter, `$alwaysDefer`, makes the deferred refresh run regardless. - **Errors are swallowed and reported.** Deferred callbacks run through `rescue()`, so a failing rate provider is reported to the exception handler and the stale value stays until the second number passes. - **Traffic keeps it warm.** Refresh only happens when a request arrives in the stale window. If no one asks for 900 seconds, the entry expires and the next visitor pays the full cost. - **Lock support is required.** `flexible()` calls the store's `lock()`, so it needs a lock-capable store such as database, redis, file, memcached, dynamodb or array. - **Lock options.** The fourth parameter accepts `['seconds' => …, 'owner' => …]` for the refresh lock; by default it is acquired with no expiry and released as soon as the refresh finishes. - **Outside HTTP**, deferred functions run when the Artisan command or queued job finishes successfully. ## When `flexible()` is the wrong tool - **Data that must never be stale**, such as a balance or a payment quote the user will be charged at. Serving a value up to the second number old is the whole point of `flexible()`; for such data, invalidate on write or skip the cache. - **Very slow recomputation.** The refresh still runs in a PHP process after the response, occupying that worker until it finishes. A computation measured in minutes belongs in a scheduled job or a queued job that writes the cache, with readers using plain `get()`. - **Rarely read keys.** With little traffic the stale window is rarely hit, so most reads fall through to the inline path anyway and `flexible()` adds a second key for no gain. For exchange rates that are read constantly and tolerate a few minutes of age, it is a good fit: visitors almost never wait for the provider, and at most one process calls it per refresh.

  • In Laravel, why might Cache::flexible() never refresh a stale value on a page that renders a 500 error?
    The refresh is a deferred function, and in HTTP requests deferred callbacks are invoked only when the response status is below 400 unless the callback is marked to always run. Passing `true` as the fifth argument (`$alwaysDefer`) makes the stale-value refresh run even after an error response.
  • In Laravel, what stops two servers from both recomputing a stale Cache::flexible() entry at the same moment?
    The deferred refresh acquires a cache lock named `illuminate:cache:flexible:lock:{key}` with a non-blocking `get()`. The process that fails to get the lock skips the refresh. The winner also re-checks the stored created timestamp, so a refresh that already happened is not repeated. This works across servers only when they share the cache store.

A cafe coffee urn: while the pot is fresh, staff just pour. Once it is getting old but still drinkable, they keep pouring and one barista brews a new pot on the side. Only if the pot is past drinkable does the next customer wait for the brew.

saying these in an interview costs you the question

  • Cache::flexible() refreshes the value on a queue worker in the background
  • In the stale window, flexible() makes the user wait for the new value
  • The two numbers are a minimum and a maximum random TTL for jitter
  • flexible() works on any store because it never takes a lock
  • After the second number passes, flexible() still returns the old value