Skip to content

Tag versioning: replace Laravel cache tags (v2) - #30

Merged
Moritz Mazetti (Radiergummi) merged 12 commits into
masterfrom
feature/tag-versioning
Oct 26, 2026
Merged

Moritz Mazetti (Radiergummi) merged 12 commits into
masterfrom
feature/tag-versioning

Conversation

@Radiergummi

@Radiergummi Moritz Mazetti (Radiergummi) commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Summary

Replaces Laravel cache tags with tag versioning. Laravel tags are part of the cache key (an entry stored with [a, b] can only be read with exactly [a, b]), which made several operations silently do nothing or clear far too much.

How it works now

  • Entries are stored under a key derived only from the request, together with the versions of their tags.
  • Every tag has a version, a random token in the cache. Flushing a tag writes a new token, so every entry carrying it becomes a miss.
  • Versions are read before the response is built, so a flush that happens while a response is being built invalidates it too.
  • Flushes wait until every open database transaction, on every connection, has ended, so no other request can cache uncommitted data under the new version. They run after rollbacks too, because the data may have been written outside that transaction or on another connection. A failed deferred flush is reported, not thrown, so the commit and other after-commit callbacks are unaffected.
  • Works on every cache store (file, database, DynamoDB, Redis, …). The failover store is refused.

Bugs fixed on master

  • response-cache:clear / ResponseCache::flush() without tags did nothing on taggable stores. With default tags configured, delete($uri) and the command also did nothing.
  • Any flush wiped the whole store on non-taggable stores (the old default file).
  • flush(['x']) merged in the default tags, so flushing one tag cleared everything.
  • Strategy tags that depended on the response were written but never found.
  • RESPONSE_CACHE_TTL and RESPONSE_CACHE_ENABLED from .env threw a TypeError under strict_types.
  • A tag binding to a model with an integer key (e.g. user:{user}) threw a TypeError under strict_types.
  • On Laravel 13, where new apps set cache.serializable_classes to false, cached responses could not be read back from Redis, database or file stores, so nothing was ever served from the cache. Entries are now stored as plain data (status, headers, content).

Breaking changes (v2)

The README has a full Upgrading to v2 section. The main changes:

  • CacheStrategy::tags(Request $request): the response parameter is gone. There is a new CacheStrategy::isCacheable(Request $request).
  • flush() flushes everything, flush([]) flushes nothing, and default tags are no longer merged in.
  • ResponseCache::has() is removed. put() now takes a Snapshot (from lookup() or snapshot()) instead of tags.
  • store defaults to the app's default cache store, and there is a new tag_ttl option (30 days).
  • Requests rejected by isCacheable (e.g. POST) no longer emit Miss.
  • Custom serialization through a Repository subclass is removed.

The README also has a new Limitations section (data read before the snapshot, read replicas, store failover, DynamoDB, local stores, hit rate).

Test plan

  • composer test: 73 passing. New unit tests for Repository and new feature tests in InvalidationTest cover:
    • every confirmed bug
    • flushing while a response is built
    • commit, rollback, nested transactions, transactions on a second connection, flushes from rollback callbacks, failed commits, and
      application tests wrapped in RefreshDatabase/DatabaseTransactions
    • a failed deferred flush being reported without breaking the commit
    • every audience and query variant
    • an evicted version, and an entry outliving tag_ttl
    • get() not writing to the store
    • a non-taggable store, and the refused failover store
  • composer analyze (PHPStan) is clean.
  • vendor/bin/pint --test is clean.
  • End-to-end, in Docker: a fresh Laravel 13.38 app on PHP 8.4 with the package installed from this branch, run against Redis 7, Memcached 1.6, Postgres 17 (database store) and the file store. A scripted run over real HTTP passed 65/65 checks:
    • hits, observer flushes inside transactions, and flushes across audiences and query variants
    • response-cache:clear with and without tags (from the CLI), delete(), and foreign keys surviving a flush-all
    • a flush while a response is being built, a 2-second held transaction, a rollback, and a rollback on an unrelated connection
    • an evicted version key, non-cacheable POST requests, and 30 concurrent cold requests
    • tag_ttl expiry
    • Memcached stopped mid-run: reads degrade to misses, an immediate flush fails, and a deferred flush is reported while the commit completes

🤖 Generated with Claude Code

https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
ResponseCache and the middleware still call the removed Repository API, so
the CacheTest, TagBindingTest and ResponseCacheTest suites fail until the
following commits rewire them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
ResponseCache and the middleware still call the removed Repository API until
the next commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
The CacheResponse middleware still calls the removed has() until the next
commit, so feature tests and PHPStan fail on it until then.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
…ocument Miss change

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
- Validate the store and tag_ttl in the Repository constructor, so the
  checks hold however it is built
- Write all versions of a flush in one setMultiple() call
- De-duplicate tags only in ResponseCache::resolveTags()
- BaseStrategy::shouldCache() defers to isCacheable()
- Merge near-identical command tests into one with a dataset

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
- getDefaultTtl() casts RESPONSE_CACHE_TTL, which arrives as a string
- Entries expire after tag_ttl at the latest, since their versions do; entries
  without a TTL no longer stay in the store forever
- Document serializable_classes and null snapshots

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
…enabled

- Flushes wait until all open transactions on every connection have ended,
  and run after rollbacks too: the data may have been written outside the
  transaction or on another connection, so dropping them could serve stale
  responses
- A deferred flush that fails is reported instead of thrown, so it no longer
  aborts DB::commit() and the application's other after-commit callbacks
- ResponseCache::get() no longer creates tag versions
- enabled() accepts boolean strings from the environment

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
- Ignore transactions Laravel still lists as pending although their
  connection has left them: during a full rollback, and after a failed
  commit. Flushes registered from rollback callbacks were lost, and every
  flush after a failed commit stayed deferred until the next commit
- Skip transactions opened by testing traits such as RefreshDatabase, which
  deferred all flushes in application tests until the test ended
- enabled() keeps the default for unset or unrecognized values

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
Found by running a fresh Laravel 13 app against Redis, Memcached, Postgres
and the file store:

- New Laravel 13 apps set cache.serializable_classes to false, so cached
  Response objects came back as __PHP_Incomplete_Class and every lookup on
  Redis, database and file stores missed. Entries now hold the status,
  headers and content, and hits are restored as Illuminate\Http\Response.
  Responses without content (streamed, file downloads) are not cached.
- A tag binding to a model with an integer route key, like user:{user},
  passed an int to str_replace() under strict_types and failed the request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGNGyJLoFTM5C474gZPW5q
@Radiergummi
Moritz Mazetti (Radiergummi) merged commit 0436ce4 into master Oct 26, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant