Almost every cache bug I’ve debugged wasn’t a TTL problem. It was a scoping problem — the cache didn’t know what it was allowed to know, so it forgot the wrong things, or remembered the wrong things, at exactly the wrong moment.
The most common version: a merchant updates their product, the cache key for “that product” gets busted correctly, and the listing page that shows “all products” keeps serving the old count for another ten minutes because nobody connected the single-record invalidation to the collection that contains it. The fix people reach for first is shortening the TTL until the staleness window feels acceptable. That’s not a fix. That’s negotiating with the actual bug instead of finding it.
The second most common version, and the more dangerous one: a cache key built from a query, without accounting for which tenant asked the question. In a single-database multi-tenant app, that’s not stale data — that’s tenant A seeing tenant B’s numbers, served confidently, from cache, with no error anywhere in the logs to flag it. This is the failure mode that doesn’t show up in a demo. It shows up in a support ticket from a customer who noticed something in their dashboard that couldn’t possibly be theirs.
Caching is not a performance feature bolted onto a repository. It’s a data-correctness contract, and it needs the same rigor as the query it’s caching.
core-foundation’s repository cache is tag-based, not key-based, on purpose. A write to a model flushes every tag associated with that model — the single-record cache and every listing that could contain it — instead of relying on a developer to remember every place a stale record might be hiding. It runs two tiers deliberately: Listing caches (collections, filtered queries) and Record caches (single models) invalidate differently, because a new record affects every listing that might now include it, while an update to one record only affects that record and the listings sorted or filtered by the field that changed.
class ProductRepository extends BaseRepository
{
protected function cacheScope(): ?CacheScope
{
return new PrefixCacheScope('tenant:'.tenant()->id);
}
}
// AppServiceProvider::boot() — tells the observer how to resolve scope for a model
RepositoryCacheObserver::resolveScopeUsing(function ($model) {
return $model->tenant_id
? new PrefixCacheScope("tenant:{$model->tenant_id}")
: null;
});
There is no code path where a read for tenant A’s scope can return a cache entry written under tenant B’s scope. The prefix isn’t a convention someone has to remember — it’s the key itself.
The tenant problem is solved the same way the Octane tenant problem is solved — by making it structural, not a discipline. Every cache tag is automatically prefixed with the current tenant’s scope before it ever reaches Redis. There is no code path where a cache read can return another tenant’s data, because the tag itself was never shared between them in the first place. You don’t have to remember to scope the cache key. The cache doesn’t have a way to not scope it.
The part that took the longest to get right wasn’t the invalidation logic — it was deciding when caching should get out of the way entirely. A repository under an active lock bypasses its own cache automatically, because serving a cached read while a write is mid-flight is how you reintroduce the exact race condition the lock exists to prevent. Caching that doesn’t know when to defer to correctness isn’t an optimization. It’s a liability wearing a performance metric as a disguise.
None of this makes caching hard to reason about. It makes caching something you don’t have to reason about at all, for the common cases — which is the entire point of putting it in a foundation instead of re-deriving the same tag strategy, the same tenant scoping, and the same lock-awareness in every project that needs it.
composer require rupeshstha/core-foundation — free, MIT, part of the base architecture. Full two-tier tagging strategy and the frontend cache bridge: Caching Strategy