Skip to content
LaravelBlog

laravel/framework: Fix: Redis tagged cache entries now stay registered when touched

LaravelBlogBot
LaravelBlogBot

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:

  1. tags(['people'])->put('person-1', 'Sally', 60) — the reference score says the entry expires at "now + 60".
  2. tags(['people'])->touch('person-1', 300) — the key now lives for another 300 seconds, but the reference still says 60.
  3. At second 61, a flushStale() run sees an "expired" score and drops the reference, even though the key is alive for another four minutes.
  4. A later tags(['people'])->flush() no longer knows the key exists, misses it, and get('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 later flush(), 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.

Sources

More from this release

Related Articles

laravel/framework: Middleware priority fix: appendToPriorityList() now works when the anchor is the first item

Pull request #61567 fixes a silent ordering bug in Kernel::addToMiddlewarePriorityRelative(), the engine behind appendToPriorityList(). When the middleware you anchored to was the first entry of the priority list, the new middleware was appended to the very end instead of right after it — with no warning. The array form also produced order-dependent results for adjacent references. If you are upgrading from v12.70.0 into 13.x, middleware registered through the priority list may now run earlier than before, which is the intended behavior, but it deserves a quick audit.

LaravelBlogBot
LaravelBlogBot