Caching is usually taught as "call Cache::remember() and move on," which is the right advice for production code and tells you nothing about what's actually happening underneath when a cached value expires. Building a minimal TTL (time-to-live) cache from an empty array up — including the stale-data bug the first version has and the specific fix for it — makes the whole idea concrete in a way that using a mature caching library never quite does, because the library hides exactly the part that's worth understanding.
Version 1: storing a value with an expiry time
The core idea of a TTL cache is simple: store the value alongside the time it should stop being considered valid.
class SimpleTtlCache
{
private array $store = [];
public function set(string $key, mixed $value, int $ttlSeconds): void
{
$this->store[$key] = [
'value' => $value,
'expiresAt' => time() + $ttlSeconds,
];
}
public function get(string $key): mixed
{
return $this->store[$key]['value'] ?? null;
}
}
This compiles, runs, and looks reasonable — and it has a real bug: get() never checks expiresAt at all. It stores an expiry time and then completely ignores it when reading the value back, which means every cached value lives forever regardless of the TTL passed to set(). This is worth building on purpose, because it's a genuinely easy mistake to make even when you know caches need expiry logic — the expiry timestamp existing in the data structure feels like it should mean something is checking it, and nothing is.
Version 2: checking expiry on read (lazy expiration)
class SimpleTtlCache
{
private array $store = [];
public function set(string $key, mixed $value, int $ttlSeconds): void
{
$this->store[$key] = [
'value' => $value,
'expiresAt' => time() + $ttlSeconds,
];
}
public function get(string $key): mixed
{
if (! isset($this->store[$key])) {
return null;
}
if (time() >= $this->store[$key]['expiresAt']) {
unset($this->store[$key]);
return null;
}
return $this->store[$key]['value'];
}
}
This is called lazy expiration: an expired entry isn't actively removed the moment it expires, it's removed the next time someone tries to read it and the check notices the expiry has passed. Between the actual expiry moment and the next read, the stale entry technically still sits in memory, unused — that's a genuinely fine trade-off for most application-level caching, and it's exactly how Redis's own passive expiration works by default.
Proving the fix actually works
$cache = new SimpleTtlCache();
$cache->set('greeting', 'hello', ttlSeconds: 2);
echo $cache->get('greeting'), "\n"; // "hello" — well within TTL
sleep(3);
var_dump($cache->get('greeting')); // NULL — expired, correctly evicted
Running this against Version 1 prints "hello" both times, because nothing ever checks the expiry. Running it against Version 2 correctly prints "hello" then null, demonstrating the exact bug and fix side by side rather than just asserting the fix works.
The memory-growth problem lazy expiration doesn't solve on its own
Lazy expiration has a real limitation: an entry that's set once and never read again stays in memory forever, because nothing ever triggers the expiry check for it — the check only runs inside get(). For a cache with a small, frequently-accessed key set this rarely matters in practice. For a cache accumulating many one-off keys that are set once and then abandoned (a common pattern for per-request memoization keyed by a unique request ID, for instance), memory grows unbounded over the life of the process unless something else cleans it up.
public function cleanExpired(): int
{
$now = time();
$removed = 0;
foreach ($this->store as $key => $entry) {
if ($now >= $entry['expiresAt']) {
unset($this->store[$key]);
$removed++;
}
}
return $removed;
}
Calling cleanExpired() periodically — on a schedule, or every N requests in a long-running worker process — is active expiration, and combining it with the lazy check in get() is exactly what production caching systems actually do: lazy expiration handles the common case cheaply on every read, and a periodic sweep catches the entries that were never read again to trigger that check.
- Lazy expiration alone: correct results on every read, but abandoned keys leak memory indefinitely.
- Active expiration alone: bounds memory growth, but between sweeps a stale-but-not-yet-swept entry could theoretically be read if the check in
get()were removed — so in practice you want both, not one instead of the other. - Both combined: what Redis, Memcached, and most real caching layers actually implement.
A real use case: caching an expensive lookup
Here's the cache applied to something concrete — avoiding a repeated expensive lookup, the same category of problem Cache::remember() solves in Laravel, but visible in full:
class GeoLookupService
{
public function __construct(private readonly SimpleTtlCache $cache) {}
public function lookup(string $ipAddress): array
{
$cached = $this->cache->get("geo:{$ipAddress}");
if ($cached !== null) {
return $cached;
}
$result = $this->callExternalGeoApi($ipAddress); // slow, rate-limited, costs money per call
$this->cache->set("geo:{$ipAddress}", $result, ttlSeconds: 3600);
return $result;
}
}
This is close to the actual shape of what powers a feature like an IP lookup tool under the hood — the same IP address looked up repeatedly within an hour hits the cache instead of re-querying an external provider every single time, which matters both for speed and because most geolocation APIs charge per request or rate-limit aggressively.
A subtler bug: caching a value that shouldn't be cached at all
Adding a cache in front of the geo-lookup service above exposed a second bug that had nothing to do with expiry logic. The external API occasionally returned a rate-limit error response instead of real data, and the first version of lookup() cached that error response with the same TTL as a successful one — which meant a single rate-limit hiccup got treated as "the correct answer" for the next hour, silently returning bad data for every request to that IP during that window instead of retrying.
public function lookup(string $ipAddress): array
{
$cached = $this->cache->get("geo:{$ipAddress}");
if ($cached !== null) {
return $cached;
}
$result = $this->callExternalGeoApi($ipAddress);
// Only cache genuinely successful responses — an error or
// rate-limit response should be retried next time, not frozen
// into the cache for a full hour
if (($result['status'] ?? null) === 'ok') {
$this->cache->set("geo:{$ipAddress}", $result, ttlSeconds: 3600);
}
return $result;
}
The general lesson generalizes past this one example: a cache doesn't know or care whether the value it's storing is actually good, it just stores whatever it's handed and returns it faithfully until the TTL runs out. Deciding what's actually cacheable is the caller's responsibility, not something the cache layer can infer on its own — and skipping that decision is how a transient upstream failure turns into an hour of consistently wrong answers instead of a few seconds of one.
Choosing a TTL isn't just picking a round number
It's tempting to reach for an arbitrary round number — an hour, a day — without thinking about what's actually driving the choice. Two questions are worth asking for any specific cache: how often does the underlying data actually change, and what's the real cost of serving a stale value for slightly too long? Geo-lookup data for a given IP changes rarely (an IP's general location is fairly stable), so an hour-long TTL trades a small amount of staleness for a large reduction in external API calls. A cached price or inventory count, by contrast, might need a TTL measured in seconds or none at all, because serving a stale price is a much more expensive mistake than serving a stale city name.
- Rarely-changing, low-stakes-if-stale data (geo lookups, category lists): longer TTLs, sometimes hours.
- Frequently-changing or high-stakes data (prices, inventory, account balances): short TTLs measured in seconds, or no caching at all for the specific fields that matter most.
- Anything where staleness could mislead a user into a bad decision should default toward a shorter TTL than feels convenient, not a longer one — the cost of an extra API call is almost always smaller than the cost of someone acting on wrong information.
It's worth writing the reasoning for a chosen TTL down as a code comment next to the set() call, not just picking a number and moving on — six months later, nobody reviewing that line will know whether 3600 was a careful decision about geo-data staleness or an arbitrary placeholder nobody revisited, and that ambiguity is exactly what leads to someone bumping it up "for performance" without realizing what they're trading away. A single sentence explaining why that number was chosen costs almost nothing to write and saves the next person from having to reverse-engineer the reasoning from scratch, or worse, guessing wrong and quietly shipping a regression that nobody notices until a customer does.
What this toy version is missing versus a real cache
This implementation is deliberately minimal and leaves out several things a production cache needs: it's single-process only (each PHP-FPM worker would have its own separate, inconsistent cache, since there's no shared storage like Redis behind it), it has no size limit or eviction policy for when memory itself runs low, and it has no thread-safety consideration for concurrent access. None of that invalidates what building it taught, though — Cache::remember(), Redis's EXPIRE, and every other production TTL implementation are solving exactly the expiry-checking problem worked through here, just with shared storage, eviction policies, and concurrency handling layered on top of the same core idea: store a value, store when it stops being valid, and check that timestamp before trusting the value on read.
If you're building toward something that needs this pattern in a real request lifecycle rather than a toy script, pairing it with the URL Shortener build in building a URL shortener from scratch is a natural next step — caching the long-URL lookup for a popular short code is close to the geo-lookup example above, and it's the kind of addition that turns a working tutorial project into something that would actually hold up under real, sustained traffic instead of just a demo.
No comments yet.
Be the first visitor to add a thoughtful comment on this article.