Skip to content

feat: implement negative caching#372

Open
binaryfire wants to merge 25 commits into0.4from
feat/cache-null-sentinel
Open

feat: implement negative caching#372
binaryfire wants to merge 25 commits into0.4from
feat/cache-null-sentinel

Conversation

@binaryfire
Copy link
Copy Markdown
Collaborator

@binaryfire binaryfire commented Apr 21, 2026

This PR adds new methods that support caching null-returning callback results, so "we looked and there's nothing" becomes a legitimate cache entry instead of being mistaken for a miss. Four new methods - rememberNullable, rememberForeverNullable, searNullable, flexibleNullable - sit alongside the existing the existing remember methods. When these new methods are used, they substitute a sentinel when the callback returns null. Everything else passes through unchanged.

Why this matters

Laravel's default remember() intentionally treats null as a miss, so it re-runs the callback on the next call. That makes sense when null means “not ready yet.” It’s wasteful when null is the real answer. In those cases the callback keeps running on every request and the thing you were trying to protect (the database, an external API, an expensive query etc) keeps getting hit for no reason. This can be really useful for third-party API lookups that legitimately 404 (product not found, repo not found, user not found). Without negative caching, every retry is another paid request.

For background on the general pattern, GeeksforGeeks has a decent overview.

How it works

The new methods are drop-in replacements for their non-nullable siblings:

$user = Cache::rememberNullable("user:{$id}", 300, fn () => User::find($id));

// works with tags too
$report = Cache::tags(['reports'])->rememberNullable('weekly', 3600, fn () => $expensiveQuery());

If the callback returns a real value, it's stored and returned like any other cache entry. If the callback returns null, the cache stores a sentinel (an internal marker value) which is recognised as a hit on subsequent reads and unwrapped back to null before returning. Callers never see the sentinel.

Plain remember() still behaves the way Laravel documents: null callback returns aren't cached, so polling patterns keep polling. Only the new nullable wrappers opt into the substitution. Mixing both in the same codebase is safe.

The other public accessors behave how you'd expect once a negative result has been cached:

  • Cache::get('k') returns null
  • Cache::get('k', 'default') returns 'default', matching the put('k', null) convention
  • Cache::has('k') returns false (Laravel's null-as-absence rule)
  • Cache::pull('k') returns null and forgets the key
  • Cache::forget('k') clears it; the next rememberNullable call runs the callback again
  • tag flush invalidates the entry like any other tagged value

Already useful for 0.4

EloquentUserProvider already hand-rolls this pattern: an auth-local NULL_SENTINEL array constant, a manual === self::NULL_SENTINEL check on reads, and a separate put() call that substitutes the sentinel when the user wasn't found. After this PR, it becomes much cleaner:

Before (retrieveById):

public function retrieveById(mixed $identifier): ?UserContract
{
    if (! $this->cache) {
        return $this->fetchUserById($identifier);
    }

    $key = $this->buildCacheKey($identifier);
    $cached = $this->cache->get($key);

    if ($cached === self::NULL_SENTINEL) {
        return null;
    }

    if ($cached !== null) {
        return $cached;
    }

    $user = $this->fetchUserById($identifier);

    $this->resolveWriteCache()->put($key, $user ?? self::NULL_SENTINEL, $this->cacheTtl);

    return $user;
}

After:

public function retrieveById(mixed $identifier): ?UserContract
{
    if (! $this->cache) {
        return $this->fetchUserById($identifier);
    }

    return $this->resolveWriteCache()->rememberNullable(
        $this->buildCacheKey($identifier),
        $this->cacheTtl,
        fn () => $this->fetchUserById($identifier),
    );
}

The NULL_SENTINEL class constant and the manual sentinel can be removed.

What changed internally

The sentinel is a plain array constant, not an object. This keeps it immune to the unserialize(..., ['allowed_classes' => ...]) restrictions some stores apply when cache.serializable_classes is configured. An object sentinel would silently become __PHP_Incomplete_Class under restrictive configs and the identity check would fail; that isn't an issue with an array.

getRaw() and manyRaw() helpers have been added to Repository, documented @internal, and exposed via a new Hypervel\Contracts\Cache\RawReadable interface. The existing get, many, remember, rememberForever, and flexible methods route through it so a cached sentinel is recognised as a hit at the store boundary and unwrapped at the public return boundary. has, missing, pull, and the typed getters (string, integer, etc.) inherit the right behaviour automatically because they all go through get.

The two Redis tagged-cache subclasses (AllTaggedCache, AnyTaggedCache) have an unwrap on their remember and rememberForever return paths. AnyTaggedCache also got getRaw / manyRaw overrides that throw, preserving the existing any-mode "tagged reads are rejected" logic.

The two wrapper stores (MemoizedStore, FailoverStore) implement RawReadable. Without that, a cached sentinel would get unwrapped by the inner Repository before the wrapper could see it, and rememberNullable on a memoized or failover stack would re-run the callback on every call.

Events (CacheHit, KeyWritten, etc.) fire exactly as before for all existing cases. The one difference is that a cached sentinel fires CacheHit (because the key is present) with the sentinel as the event's value field. Metrics or tracing listeners that inspect payloads can check === NullSentinel::VALUE if needed. The sentinel is never visible through any public cache method.

Store contract

Unchanged. Plain stores (Redis, File, Database, Array, Swoole, Null, Session, Stack) don't know about sentinels. They store and retrieve arrays like any other value. The sentinel-aware logic lives in Repository plus the two Redis tagged-cache subclasses plus the two wrapper stores.

Breaking changes

None. Existing remember, rememberForever, sear, and flexible behave the same as before. The Store contract is the same. Plain drivers are the same. The nullable methods are opt-in; code that doesn't use them will never see a sentinel anywhere.

Test coverage

Unit-level coverage across Repository, both Redis tagged-cache subclasses, the wrapper stores, and per-driver round-trips (ArrayStore under default and restrictive serializable_classes configs, SwooleStore, StackStore). NullStore gets its own check to make sure rememberNullable re-runs the callback every time, since NullStore never retains anything. Also added several mixed-usage scenarios (get / has / many / pull / plain remember on sentinel-stored keys), events-with-sentinel-payload, TTL expiry, put-overwrite, and forget interaction.

Added integration coverage against real Redis for both tag modes, Repository end-to-end, failover-backed stacks, memoized-backed stacks, and the flexibleNullable stale-hit deferred-refresh path.

Array-constant sentinel used by the nullable cache methods to distinguish
"key absent" from "key present with null value". Array (not object) for
immunity to unserialize allowed_classes restrictions. Includes a static
unwrap() helper for use at boundaries where the cache layer returns to
public API callers.
…ving reads

Capability interface for cache layers that need to expose raw reads (values
returned as stored, including NullSentinel::VALUE). Plain stores don't need
to implement it — Repository::getRaw()/manyRaw() fall back to get()/many().
Exists for wrapper stores (MemoizedStore, FailoverStore) whose internal
bounce-through-a-Repository path would otherwise unwrap sentinels prematurely.
rememberNullable / searNullable / rememberForeverNullable join the existing
remember family on the contract. flexibleNullable is concrete-only, matching
how flexible() is concrete-only (not on the contract).
… path

Repository implements RawReadable via public @internal getRaw()/manyRaw()
helpers. get()/many() route through them so sentinels are recognized as
hits internally and unwrapped to null at the public boundary. remember(),
rememberForever(), and flexible() use the raw path too, so a cached sentinel
from a prior rememberNullable call is a hit (no callback re-run) while plain
callers writing null still get Laravel's polling behavior. The four nullable
wrappers (rememberNullable, searNullable, rememberForeverNullable,
flexibleNullable) substitute null with NullSentinel::VALUE at the callback
boundary and delegate to their non-nullable counterparts.
…orever

The Redis tagged-cache subclass returns raw values from allTagOps(), so
tags()->rememberNullable() would leak the sentinel through tags()->remember()
(plain) on a subsequent call. Single-line unwrap on return keeps the sentinel
fully internal, matching Repository's boundary behavior.
…edCache

Same remember/rememberForever unwrap as AllTaggedCache. Additionally, getRaw()
and manyRaw() are overridden to throw BadMethodCallException — preserving the
existing any-mode invariant that tagged reads are rejected, which must now
cover the new raw-read path too (otherwise tags()->flexible() and
tags()->flexibleNullable() would accidentally work on any-mode stores).
…nels across memo

MemoizedStore wraps an inner Repository; its pre-refactor get()/many() bounced
through the inner Repository::get/many which would unwrap sentinels before the
memo layer saw them, breaking sentinel-aware hit detection on memoized stacks.
Now memoizes raw values; getRaw()/manyRaw() delegate to the inner repository's
raw path; get()/many() keep their Store-contract unwrap behavior for direct
callers by layering on top of the raw helpers.
…nels across failover

Same motivation as MemoizedStore: FailoverStore's attemptOnAllStores() bounced
through per-layer Repositories, so sentinels were unwrapped before the outer
Repository could see them. New getRaw()/manyRaw() reuse the existing
attemptOnAllStores() machinery by dispatching to the raw methods on each
layer's Repository (which now implements RawReadable). get()/many() keep the
pre-refactor Store-contract unwrap behavior for direct callers.
…he facade

Facade method annotations for rememberNullable, searNullable,
rememberForeverNullable, flexibleNullable, and getTagMode — so IDE
auto-complete and phpstan both see the new public surface.
…or fallback

With treatPhpDocTypesAsCertain disabled, the Traversable-but-not-Iterator
fallback branch in LazyCollection::makeIterator is no longer dead code in
phpstan's view. Phpstan can't infer generic parameters on a bare Traversable
to fill in IteratorIterator<TKey, TValue>, so supply them explicitly via @var
using the method's existing @template types. Also drops two now-obsolete
@phpstan-ignore comments that were suppressing the old dead-code verdicts.
PHPDocs aren't enforceable at runtime — callers can pass anything matching
the native type. Flagging defensive runtime checks against phpdoc-declared
types as "always false" pushes developers to delete those checks, leaving
library code brittle to real-world misuse. Setting this to false matches
Laravel's default stance and keeps legitimate defensive code green. Surfaced
one latent issue in LazyCollection (fixed separately).
Covers plain-Repository nullable hit/miss with mocked Store, mixed-usage
semantics via real ArrayStore (get/has/many/pull/plain-remember/plain-flexible
all resolve cached-null correctly), events-with-sentinel-payload, store-level
round-trip under default and restrictive serializable_classes configs, edge
cases (TTL expiry, put-overwrite, forget), plus the many() regression test
asserting CacheHit (not CacheMissed) fires for a cached-null entry.
Nullable methods on all-mode tags: rememberNullable value/sentinel storage,
sentinel-hit callback suppression, rememberForeverNullable, searNullable
delegation, plus plain-remember and plain-rememberForever unwrap-on-hit
regressions. Also covers flexibleNullable fresh-sentinel-hit via the
batched mget read path.
…e throw

Nullable methods on any-mode tags: rememberNullable value/sentinel via
evalWithShaCache, sentinel-hit suppression, rememberForeverNullable,
searNullable, plain-remember/rememberForever unwrap regressions, plus
flexibleNullable BadMethodCallException assertion (tags+flexible rejected
on any-mode via the new getRaw/manyRaw throwing overrides).
Verifies that after rememberNullable stores a sentinel under a tag, flushing
the tag removes the entry and the next rememberNullable call invokes the
callback again.
Three tests proving the RawReadable seam works: sentinel survives the memo
and raw-store inspection shows it intact; plain remember on a sentinel-stored
key returns null without re-running its callback; plain flexible likewise
treats the sentinel as a fresh hit. Plus a many() event-classification
regression — CacheHit (not CacheMissed) fires on a cached-null entry read
through the memoized stack.
NullStore never caches anything, so rememberNullable callbacks must run on
every invocation with no error.
SwooleStore uses its own Swoole Table storage with serialize/unserialize —
covered explicitly to prove the array sentinel survives this driver's unique
storage path.
StackStore wraps values in a record envelope at each layer; assert the
sentinel survives that envelope on write, and is served as a hit on read
without re-running the callback.
All-mode and any-mode integration tests against real Redis: rememberNullable
store-and-return-null, tag flush invalidation, plain remember on a
sentinel-stored key, plus a sanity check that tagged get() returns null
for a cached sentinel.
End-to-end: rememberNullable round-trip through the default driver,
has-returns-false convention, put-overwrite, TTL expiry triggering callback
re-run, and a flexibleNullable stale-hit test that pins the deferred-refresh
behavior end-to-end (invokes the deferred callback, asserts the sentinel
stays stored).
Four new regression tests: sentinel round-trip through primary, plain remember
and plain flexible both treating a sentinel as a hit through the failover
stack (RawReadable seam regressions), and a many() event-classification
check (CacheHit fires, not CacheMissed). Also updates the existing
shared-failure test to mock getRaw() instead of get() — FailoverStore::get()
now delegates to getRaw() internally for sentinel-aware reads.
End-to-end through Cache::memo(): sentinel round-trips through the memo +
inner store, plain remember and plain flexible both treat a sentinel as a
hit via the RawReadable seam, callbacks don't re-run on the second call.
…nel docblock

The auth provider will migrate to rememberNullable in a follow-up and its
NULL_SENTINEL constant goes away with it, so pointing at it as precedent
would read oddly once that lands. The collision-risk explanation is
self-contained without the cross-reference.
@binaryfire
Copy link
Copy Markdown
Collaborator Author

@albertcht Could you review this when you have a second? Let me know what you think.

No reason to shout the word for emphasis.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant