feat: Eloquent casts metadata cache#380
Merged
binaryfire merged 5 commits into0.4from May 2, 2026
Merged
Conversation
Adds a single $castMetadataCache bucket property and a flushCastCaches() helper. Caches the result of getCastType, isDateCastable, isDateCastableWithCustomFormat, isJsonCastable, isEncryptedCastable, isClassCastable, isEnumCastable, isClassDeviable, isClassSerializable, and isClassComparable per instance, keyed by predicate name then attribute name. Each predicate is called per attribute per Eloquent operation (toArray, isDirty, originalIsEquivalent, attribute set/get) so the cumulative call count is high on attribute-heavy paths. The cache turns each subsequent call into a single hash lookup. One bucket property rather than ten saves ~180 bytes per Model instance — meaningful when collections of hundreds or thousands of models are hydrated per request under Swoole. isClassCastable's throw path is not cached; the exception re-throws on every call against an invalid cast.
setKeyName, setKeyType, setIncrementing, and __sleep now call the new flushCastCaches() helper instead of nulling $mergedCastsCache directly. This keeps the new $castMetadataCache invalidation in lockstep with the existing $mergedCastsCache invalidation.
…ches() initializeSoftDeletes calls flushCastCaches() instead of nulling $mergedCastsCache directly. Same reasoning as the Model.php change.
…row path Ten new tests cover the per-instance cast metadata cache: invalidation on each of the seven mutation sites (mergeCasts, the three setters, initializeHasAttributes via the booting-event edge case, initializeSoftDeletes via direct invocation, and __sleep), per-instance isolation, structural verification that each of the ten predicates writes to its expected bucket key, and the contract that isClassCastable continues to throw on invalid casts rather than caching the exception path as a false. Tests use ClassInvoker for protected-member access, matching the established pattern in the existing HasAttributesTest.
Verifies that hasCast results computed under a withCasts() override don't leak into a subsequent unscoped query against the same model class.
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.
The existing per-instance
getCasts()cache already avoids rebuilding the merged casts array on repeated calls. This PR builds on that by caching the derived cast metadata predicates that still re-resolved the same answers over and over for the same instance. Methods likeisJsonCastable(),isEncryptedCastable(),isClassCastable(),isEnumCastable(), andgetCastType()are called repeatedly during attribute reads/writes,toArray(), dirty checks, and original-value comparisons.This change adds a single
$castMetadataCachebucket on each model instance and memoizes those derived answers by predicate name and attribute key.The cache uses one bucket property instead of one property per predicate. That keeps the per-model memory cost lower while still making each cached result explicit:
Cached Paths
This PR caches:
getCastType()isDateCastable()isDateCastableWithCustomFormat()isJsonCastable()isEncryptedCastable()isClassCastable()isEnumCastable()isClassDeviable()isClassSerializable()isClassComparable()The invalid class-cast exception path isn't cached. Invalid casts still throw every time.
Invalidation
The new cache is cleared anywhere the existing merged-casts cache was already cleared:
initializeHasAttributes()mergeCasts()setKeyName()setKeyType()setIncrementing()initializeSoftDeletes()__sleep()Those call a shared
flushCastCaches()helper so future cast metadata caches only need to be added in one place.Background
This is in the same general problem discussed in Laravel's Eloquent casting performance thread:
laravel/framework#57101. That discussion shows how much time can be spent repeatedly resolving cast metadata on model-heavy workloads.
Tests
Added coverage for:
withCasts()leakage prevention