laravel/framework: Fix: Redis tagged cache entries now stay registered when touched
Contributed by Ashot1995
Part of the framework v13.36.0 release
Category: Fix
Pull request #61574 fixes a subtle but impactful bug in Laravel's Redis tagged cache: calling touch() extended a cached key's lifetime without updating the tag's internal reference to that entry's expiration. Once the original TTL passed, flushStale() dropped the reference even though the key was still alive, so a later tags(...)->flush() missed the entry and get() kept returning stale data. The fix gives RedisTaggedCache its own touch() override that re-registers the entry with the new TTL, mirroring how put() already works. If you are upgrading from v12.70.0, this lands on the 13.x branch as a transparent bugfix — no API, configuration, or code changes are required.
Why tagged caching keeps a second ledger
Tagged caching on Redis tracks two things in parallel. The value itself lives under its own Redis key with its own TTL, and every tagged write adds that key to each tag's reference set — a Redis sorted set whose score is the entry's expiration timestamp. Write methods like put(), add(), increment(), and decrement() all maintain this ledger. It is what allows flush() to delete everything belonging to a tag, and what allows flushStale() to cheaply discard references whose expiration score has already passed, instead of scanning the whole dataset.
The bug: touch() extended the key but not the reference
touch() was the one write method that bypassed the ledger. It pushed the key's own TTL further into the future, but the sorted-set score still pointed at the original expiration. The failure unfolded in four steps:
tags(['people'])->put('person-1', 'Sally', 60)— the reference score says the entry expires at "now + 60".tags(['people'])->touch('person-1', 300)— the key now lives for another 300 seconds, but the reference still says 60.- At second 61, a
flushStale()run sees an "expired" score and drops the reference, even though the key is alive for another four minutes. - A later
tags(['people'])->flush()no longer knows the key exists, misses it, andget('person-1')keeps returning the old value until the key's extended TTL finally lapses.
The cached data itself was never wrong — the tag's ledger simply claimed the entry was gone while Redis was still serving it.
The fix: touch() now re-registers the entry
The PR adds a touch() override in RedisTaggedCache:
public function touch($key, $ttl)
{
$key = enum_value($key);
$seconds = $this->getSeconds($ttl);
$result = parent::touch($key, $ttl);
if ($result && $seconds > 0) {
$this->tags->addEntry(
$this->itemKey($key),
$seconds
);
}
return $result;
}
The method resolves enum-backed keys, converts the TTL into seconds, and delegates the actual expiration update to the parent implementation. If the touch succeeded and the new TTL is positive, it re-registers the entry in the tag's sorted set with the new expiration — exactly what put() does after a successful write. A failed touch (for example, the key never existed) registers nothing, and the boolean return value is unchanged, so callers keep the exact semantics they had before.
What this looks like in practice
The integration test added with this PR captures the scenario end to end:
Cache::store('redis')->tags(['people'])->put('person-1', 'Sally', 1);
Cache::store('redis')->tags(['people'])->touch('person-1', 5);
sleep(2);
Cache::store('redis')->tags(['people'])->flushStale();
Cache::store('redis')->tags(['people'])->get('person-1'); // 'Sally'
On v12.70.0, the flushStale() call above dropped the entry's reference because its score said it had expired at second 1, orphaning the still-alive key. get() still returned 'Sally', but cleanup was silently broken: the flush() that followed could no longer find the entry and left it in place. With the fix, touch() re-registered the entry with the five-second expiration, so flushStale() leaves it alone and the final flush() removes it. The test even asserts the Redis keyspace is empty afterward, proving no orphaned keys linger.
Upgrade impact
There is nothing to migrate. touch() keeps its signature and return value, and the new logic lives inside RedisTaggedCache, so every call path — Cache::store('redis')->tags(['people'])->touch(...) or the Cache::tags(['people'])->touch(...) proxy — simply starts behaving correctly. If you worked around the bug by re-put()-ing values instead of touching them, or by scheduling extra flush() calls, you can simplify those workarounds, but nothing breaks if you leave them in place. The PR also hardens the test suite: the unit data providers for successful and failed tagged writes now cover touch alongside put, add, increment, and decrement, and the new testTouchedTagEntriesAreNotConsideredStale integration test locks the corrected flush behavior in.
Takeaways
touch()on a Redis tagged cache now re-registers the entry with its new TTL, keeping the tag's reference set in sync with the key.- Entries extended past their original expiration are no longer dropped by
flushStale()or missed by a laterflush(), eliminating the stale-read window. - Enum-backed keys, positive-TTL handling, and failed-touch semantics all match
put(), and the boolean return value is unchanged. - Upgrading from v12.70.0 requires no action: the fix is transparent, with no API, configuration, or code changes.