Skip to content

[2.x] perf: cache resolved model casts per class - #4875

Merged
imorland merged 1 commit into
2.xfrom
im/cache-model-casts
Aug 1, 2026
Merged

[2.x] perf: cache resolved model casts per class#4875
imorland merged 1 commit into
2.xfrom
im/cache-model-casts

Conversation

@imorland

@imorland imorland commented Aug 1, 2026

Copy link
Copy Markdown
Member

The problem

Eloquent asks a model for its casts on every attribute access. In Laravel that's fine, because getCasts() is a property read:

// Illuminate\Database\Eloquent\Concerns\HasAttributes
public function getCasts()
{
    if ($this->getIncrementing()) {
        return array_merge([$this->getKeyName() => $this->getKeyType()], $this->casts);
    }

    return $this->casts;
}

Laravel calls it unguarded in hot paths (getCastType(), hasCast(), transformModelValue()) precisely because it is O(1).

Flarum's override breaks that assumption:

public function getCasts(): array
{
    $casts = parent::getCasts();

    foreach (array_merge(array_reverse(class_parents($this)), [static::class]) as $class) {
        $casts = array_merge($casts, Arr::get(static::$customCasts, $class, []));
    }

    return $casts;
}

The hierarchy walk is a real requirement — extensions register casts into a static $customCasts map 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, so class_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:

calls in one request
AbstractModel->getCasts 131,619
array_merge 704,030
class_parents 138,295
Arr::get 431,815

getCasts() was the single hottest function in the profile at 14.3% of total self cost, with Arr::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. HasAttributes keeps five protected static per-class caches ($mutatorCache, $attributeMutatorCache, $getAttributeMutatorCache, $setAttributeMutatorCache, $castTypeCache) for exactly this reason. getCastType() is the closest precedent: it calls getCasts() and then guards the expensive part behind an isset() on a static cache, the same shape as this change.

The one hazard is invalidation: Extend\Model mutates AbstractModel::$customCasts during 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%). getCasts and Arr::get both drop out of the top ten — Arr::get falls 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):

route patched baseline
/ index 216 ms 272 ms
/d/709 (54 posts, mention-heavy) 317 ms 345 ms
/api/posts?filter[discussion]=709 135 ms 157 ms

This 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).

  • 396 core unit tests pass
  • 36 model integration tests pass (these exercise the Extend\Model cast path)
  • PHPStan clean from the monorepo root
  • Live JSON responses byte-identical patched vs baseline on /api, /api/discussions/709, and /api/posts?filter[discussion]=709

Scope

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 (/api with 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 where mentionedBy targets are materialised without their discussion relation so every visibility check lazy-loads it.

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.
@imorland
imorland requested a review from a team as a code owner August 1, 2026 07:54
@imorland imorland changed the title perf: cache resolved model casts per class [2.x] perf: cache resolved model casts per class Aug 1, 2026
@imorland imorland added this to the 2.0.0-rc.6 milestone Aug 1, 2026
@imorland
imorland merged commit d858f9f into 2.x Aug 1, 2026
25 checks passed
@imorland
imorland deleted the im/cache-model-casts branch August 1, 2026 08:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant