Skip to content

feat: Eloquent casts metadata cache#380

Merged
binaryfire merged 5 commits into0.4from
feat/casts-metadata-cache
May 2, 2026
Merged

feat: Eloquent casts metadata cache#380
binaryfire merged 5 commits into0.4from
feat/casts-metadata-cache

Conversation

@binaryfire
Copy link
Copy Markdown
Collaborator

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 like isJsonCastable(), isEncryptedCastable(), isClassCastable(), isEnumCastable(), and getCastType() are called repeatedly during attribute reads/writes, toArray(), dirty checks, and original-value comparisons.

This change adds a single $castMetadataCache bucket 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:

$this->castMetadataCache['jsonCastable'][$key]
$this->castMetadataCache['classCastable'][$key]
$this->castMetadataCache['castType'][$key]

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:

  • invalidation after each cast mutation/reset path
  • per-instance isolation
  • withCasts() leakage prevention
  • every cached predicate writing to its expected cache bucket
  • invalid class casts continuing to throw on repeated calls

binaryfire added 5 commits May 2, 2026 07:34
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.
@binaryfire binaryfire merged commit 6700ff6 into 0.4 May 2, 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