Open
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 existingremembermethods. 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 treatsnullas a miss, so it re-runs the callback on the next call. That makes sense when null means “not ready yet.” It’s wasteful whennullis 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:
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')returnsnullCache::get('k', 'default')returns'default', matching theput('k', null)conventionCache::has('k')returnsfalse(Laravel's null-as-absence rule)Cache::pull('k')returnsnulland forgets the keyCache::forget('k')clears it; the nextrememberNullablecall runs the callback againAlready useful for 0.4
EloquentUserProvideralready hand-rolls this pattern: anauth-localNULL_SENTINELarray constant, a manual=== self::NULL_SENTINELcheck on reads, and a separateput()call that substitutes the sentinel when the user wasn't found. After this PR, it becomes much cleaner:Before (
retrieveById):After:
The
NULL_SENTINELclass 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 whencache.serializable_classesis configured. An object sentinel would silently become__PHP_Incomplete_Classunder restrictive configs and the identity check would fail; that isn't an issue with an array.getRaw()andmanyRaw()helpers have been added toRepository, documented@internal, and exposed via a newHypervel\Contracts\Cache\RawReadableinterface. The existingget,many,remember,rememberForever, andflexiblemethods 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 throughget.The two Redis tagged-cache subclasses (
AllTaggedCache,AnyTaggedCache) have an unwrap on theirrememberandrememberForeverreturn paths.AnyTaggedCachealso gotgetRaw/manyRawoverrides that throw, preserving the existing any-mode "tagged reads are rejected" logic.The two wrapper stores (
MemoizedStore,FailoverStore) implementRawReadable. Without that, a cached sentinel would get unwrapped by the inner Repository before the wrapper could see it, andrememberNullableon 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 firesCacheHit(because the key is present) with the sentinel as the event'svaluefield. Metrics or tracing listeners that inspect payloads can check=== NullSentinel::VALUEif 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, andflexiblebehave the same as before. TheStorecontract 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_classesconfigs, SwooleStore, StackStore). NullStore gets its own check to make surerememberNullablere-runs the callback every time, since NullStore never retains anything. Also added several mixed-usage scenarios (get/has/many/pull/ plainrememberon 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
flexibleNullablestale-hit deferred-refresh path.