feat: Cache Model::getCasts() merged result per instance#374
Merged
binaryfire merged 7 commits into0.4from Apr 23, 2026
Merged
Conversation
Adds a ?array $mergedCastsCache instance property and uses ??= in getCasts() to cache the merged [keyName => keyType] + $this->casts result for incrementing models. mergeCasts() and initializeHasAttributes() clear the cache so it never serves stale data after $this->casts mutates. Eloquent-heavy pages call getCasts() hundreds of times per model (hasCast, transformModelValue, originalIsEquivalent, addCastAttributesToArray, etc.), each previously allocating a throwaway merged array. Caching eliminates those allocations with no behavior change.
setKeyName(), setKeyType(), and setIncrementing() now clear $mergedCastsCache so subsequent getCasts() calls reflect the new primary key name, key type, or incrementing state. __sleep() clears the cache alongside the existing classCastCache and attributeCastCache clears, keeping serialized payloads minimal — the cache is derived state that will be rebuilt on first getCasts() call after unserialize.
initializeSoftDeletes() adds deleted_at to $this->casts during trait initialization. Clear $mergedCastsCache unconditionally at the end so a booting-event listener that populated the cache before trait initialization doesn't leave stale data — a narrow edge case, but one covered by the mutate-then-invalidate principle the rest of the cache already follows.
Nine new tests exercising every $mergedCastsCache invalidation point: - mergeCasts, setKeyName, setKeyType, setIncrementing - non-incrementing bypass (cache untouched when getIncrementing() is false) - per-instance isolation (two instances, different cast sets) - newInstance() isolation (source and clone hold independent state) - booting-event edge cases for initializeHasAttributes and initializeSoftDeletes The two booting-event tests use real Hypervel\Events\Dispatcher (aliased to avoid collision with the existing Dispatcher contract import), a try/finally cleanup of clearBootedModels / flushEventListeners / unsetEventDispatcher to prevent state leakage, and two new stub classes (GetCastsBootingStub, GetCastsSoftDeletingBootingStub) added at the bottom of the file.
testWithCastsDoesNotLeakAcrossQueries verifies that withCasts() overrides applied to one query don't contaminate a subsequent unscoped query against the same model class. Exercises the full builder → hydration → attribute-access path, guarding against class-level cache designs that would leak casts across queries.
assertEquals on full Model instances compares the entire property bag, which breaks once any per-instance memoization is introduced — in this case, $mergedCastsCache populated on one side but not the other after delete() + find() go through different code paths. $model->is() compares primary key, table, and connection — the correct semantic for "is this the same row?" — and is stable under any internal state divergence.
…tEquals Four assertEquals comparisons on full Model instances replaced with $model->is() — same reasoning as the soft-deletes test. Property-bag equality is fragile under per-instance memoization; identity by primary key / table / connection is what these assertions actually mean.
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.
Model::getCasts()callsarray_merge([$this->getKeyName() => $this->getKeyType()], $this->casts)on every invocation when the model is incrementing (which is the default). It gets called fromhasCast,castAttribute,originalIsEquivalent,addCastAttributesToArray,getCastType,isEnumCastable, and several other other hot paths.This PR adds a
?array $mergedCastsCacheinstance property on theHasAttributestrait to cache the merged result on first access. The cache is cleared everywhere$this->casts,$this->primaryKey,$this->keyType, or$this->incrementingcan change:mergeCasts(), which also coversBuilder::withCasts()since that callsmergeCastsinternallysetKeyName(),setKeyType(),setIncrementing()initializeHasAttributes()andinitializeSoftDeletes()- the trait initialisers that mutate$this->castsduring construction, to cover the case where abootingevent listener callsgetCasts()before those initialisers runModel::__sleep(), alongside the existing$classCastCacheand$attributeCastCacheclears, so serialised payloads don't carry derived stateThe cache lives on the instance because
withCasts(),mergeCasts(), andnewInstance()can all produce two instances of the same class with different casts. Anything class-level either serves stale data or has to re-check the cast array on every call, which defeats the point. The instance property also matches the existing$classCastCacheand$attributeCastCachepattern.This is a win in normal request lifecycles too: the same model instance can hit
getCasts()repeatedly during attribute reads, writes, dirty checks, and array / JSON serialisation.Test changes
The new tests cover the runtime invalidation paths:
mergeCasts(),setKeyName(),setKeyType(),setIncrementing(), and thebootingevent edge case againstinitializeHasAttributes/initializeSoftDeletes. The per-instance andnewInstance()tests guard against naive class-level leakage. AwithCasts()regression test inDatabaseEloquentWithCastsTestexercises the full builder-to-hydration-to-attribute-access path to catch the same type of leak through real query behaviour, not just the trait in isolation.Model::__sleep()is also updated to clear the new derived cache alongside the existing cast caches so serialised payloads don't carry memoised state.Several existing assertions changed from
assertEquals($modelA, $modelB)to$modelA->is($modelB).assertEqualson full model objects compares the internal property bag, which breaks as soon as any per-instance memoisation is added - the cache populates on one side of the comparison but not the other.