Skip to content

feat: Cache Model::getCasts() merged result per instance#374

Merged
binaryfire merged 7 commits into0.4from
feat/getcasts-cache
Apr 23, 2026
Merged

feat: Cache Model::getCasts() merged result per instance#374
binaryfire merged 7 commits into0.4from
feat/getcasts-cache

Conversation

@binaryfire
Copy link
Copy Markdown
Collaborator

Model::getCasts() calls array_merge([$this->getKeyName() => $this->getKeyType()], $this->casts) on every invocation when the model is incrementing (which is the default). It gets called from hasCast, castAttribute, originalIsEquivalent, addCastAttributesToArray, getCastType, isEnumCastable, and several other other hot paths.

This PR adds a ?array $mergedCastsCache instance property on the HasAttributes trait to cache the merged result on first access. The cache is cleared everywhere $this->casts, $this->primaryKey, $this->keyType, or $this->incrementing can change:

  • mergeCasts(), which also covers Builder::withCasts() since that calls mergeCasts internally
  • setKeyName(), setKeyType(), setIncrementing()
  • initializeHasAttributes() and initializeSoftDeletes() - the trait initialisers that mutate $this->casts during construction, to cover the case where a booting event listener calls getCasts() before those initialisers run
  • Model::__sleep(), alongside the existing $classCastCache and $attributeCastCache clears, so serialised payloads don't carry derived state

The cache lives on the instance because withCasts(), mergeCasts(), and newInstance() 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 $classCastCache and $attributeCastCache pattern.

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 the booting event edge case against initializeHasAttributes / initializeSoftDeletes. The per-instance and newInstance() tests guard against naive class-level leakage. A withCasts() regression test in DatabaseEloquentWithCastsTest exercises 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). assertEquals on 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.

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.
@binaryfire binaryfire merged commit d80d079 into 0.4 Apr 23, 2026
34 checks passed
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