[2.x] perf: cache resolved model casts per class - #4875
Merged
Conversation
Eloquent asks a model for its casts on every attribute access, and Flarum's override rebuilt the answer each time: class_parents(), array_reverse(), then an Arr::get() and array_merge() for every ancestor. The result is immutable for a given class, so all of that work was repeated for no gain. Profiling the forum index showed getCasts() called 131,619 times in a single request, driving 704,030 array_merge() and 138,295 class_parents() calls. It was the hottest function in the profile at 14.3% of total self cost, with Arr::get() accounting for another 13.6% largely on its behalf. Memoising per class removes both from the top of the profile and cuts total self cost by 40%. Measured against an install with 74 extensions: the index goes from 272ms to 216ms, a discussion from 345ms to 317ms, and the post listing from 157ms to 135ms. Extenders mutate $customCasts during boot, possibly after a model has already resolved its casts, so Extend\Model flushes the cache when it registers new ones.
This was referenced Aug 1, 2026
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 problem
Eloquent asks a model for its casts on every attribute access. In Laravel that's fine, because
getCasts()is a property read:Laravel calls it unguarded in hot paths (
getCastType(),hasCast(),transformModelValue()) precisely because it is O(1).Flarum's override breaks that assumption:
The hierarchy walk is a real requirement — extensions register casts into a static
$customCastsmap keyed by class, and a model has to pick up casts registered against its ancestors, which Laravel has no equivalent of. But the result is immutable for a given class, soclass_parents(),array_reverse()and a merge per ancestor were being repeated on every single attribute read.Profiling the forum index (Xdebug cachegrind, debug off, assets warm) on an install with 74 extensions:
AbstractModel->getCastsarray_mergeclass_parentsArr::getgetCasts()was the single hottest function in the profile at 14.3% of total self cost, withArr::get()adding another 13.6% largely on its behalf.The fix
Memoise the resolved casts per class — which is what Laravel already does for everything else in this hot path.
HasAttributeskeeps fiveprotected staticper-class caches ($mutatorCache,$attributeMutatorCache,$getAttributeMutatorCache,$setAttributeMutatorCache,$castTypeCache) for exactly this reason.getCastType()is the closest precedent: it callsgetCasts()and then guards the expensive part behind anisset()on a static cache, the same shape as this change.The one hazard is invalidation:
Extend\ModelmutatesAbstractModel::$customCastsduring boot, which can happen after a model has already resolved its casts. A cache that never invalidates would silently ignore newly registered casts, so the extender flushes it. There's a test covering exactly that ordering.Results
Total profiled self cost 958.8M → 570.4M (−40%).
getCastsandArr::getboth drop out of the top ten —Arr::getfalls from 13.61% to 1.93%.Wall time, A/B by stashing the patch and re-running the same in-process dispatch (median of 7):
/index/d/709(54 posts, mention-heavy)/api/posts?filter[discussion]=709This helps any request that serializes models, so the benefit is broad rather than route-specific.
Testing
7 new unit tests, written RED first — covering native casts, custom casts, inheritance from a parent class, child-overrides-parent precedence, that sibling models don't leak casts to each other, and that casts registered after a first resolution are still picked up (the invalidation case).
Extend\Modelcast path)/api,/api/discussions/709, and/api/posts?filter[discussion]=709Scope
Found while profiling a report that discussion views feel slow. It's a real, broad win, but it is not the whole story for that report — worth saying plainly. Booting Flarum turns out to be cheap (
/apiwith all 74 extensions is 10ms / 7 queries); the cost is in serialization, which scales with how many models a response touches. Remaining threads I'm still chasing: HTML document rendering, and an N+1 wherementionedBytargets are materialised without theirdiscussionrelation so every visibility check lazy-loads it.