Reproducible benchmark isolating three independent regressions on Eloquent model construction between Laravel v12.56 and v13.6.0, and the partial fix that landed on 13.x post-v13.8.0. Each regression and fix is attributed to a specific merged PR.
Five minimal skeletons, each pinning laravel/framework to a single point release (the last one pins to a 13.x-dev commit because the fix hasn't been tagged yet):
| Skeleton | Version | What it isolates |
|---|---|---|
before-59284-v13.1.1/ |
v13.1.1 | clean baseline — last release before any of the three PRs |
after-59284-v13.2.0/ |
v13.2.0 | adds #59284 only |
after-59404-v13.3.0/ |
v13.3.0 | adds #59404 on top |
after-59701-v13.6.0/ |
v13.6.0 | adds #59701 on top |
after-60008-13.x-dev/ |
13.x-dev @ fefc53a |
adds #60008 on top (sibling fix #60009 was closed without comment, so its target — the #59701 wall residue — is not addressed) |
Each skeleton has the same BenchModel.php (synthetic Eloquent model — 120 fillable, 13 casts, 50 relation stubs, 20 accessor stubs, traits HasFactory + SoftDeletes + Notifiable) and the same bench.php. Only the framework version pinned in composer.json differs.
With host PHP 8.4 + composer:
cd before-59284-v13.1.1 && composer install --no-dev && php bench.phpWith Docker (no host PHP needed):
./run.shrun.sh iterates the four skeletons under composer:2.8 (PHP 8.4.14). bench.php accepts overrides: php bench.php <count> <trials> <warmup>. Defaults: count=50000 trials=7 warmup=500.
bench.php constructs BenchModel N times across trials trials (after a warmup loop), and reports per trial:
- wall_ms: total wall time in ms for one trial of N constructions
- peak_mb:
memory_get_peak_usage(true)in MB after each trial - us_per_construct: average per-construction wall time in microseconds
memory_reset_peak_usage() and gc_collect_cycles() run between trials so each trial measures a fresh peak. Final medians are printed at the bottom.
before-59284-v13.1.1 median: wall=316 ms peak=148 MB us/construct=3.16
after-59284-v13.2.0 median: wall=444 ms peak=148 MB us/construct=4.43
after-59404-v13.3.0 median: wall=515 ms peak=400 MB us/construct=5.15
after-59701-v13.6.0 median: wall=540 ms peak=400 MB us/construct=5.40
after-60008-13.x-dev median: wall=399 ms peak=148 MB us/construct=3.99
| PR | Released | Wall delta | Peak delta | µs/construct |
|---|---|---|---|---|
| #59284 "Add symmetrical, expressive attributes" | v13.2.0 | +127 ms (+40%) | 0 MB | +1.27 µs |
| #59404 "Fix trait initializer collision with Attribute parsing" | v13.3.0 | +71 ms (+16%) | +252 MB (+170%) | +0.72 µs |
| #59701 "Allow Table Attribute on child to override parent" | v13.6.0 | +25 ms (+5%) | 0 MB | +0.25 µs |
#60008 "Skip allocation in merge* when input is empty" |
13.x-dev (post v13.8.0) | −141 ms (−26%) | −252 MB (−63%) | −1.41 µs |
| Total v13.1.1 → 13.x-dev (after #60008) | +83 ms (+26%) | 0 MB | +0.83 µs |
- #59284 added new
static::resolveClassAttribute()calls ininitializeModelAttributes(DateFormat,WithoutIncrementing) andinitializeHasAttributes. Each call is a hash lookup against a class-keyed cache, but at high construction rates the cumulative cost is non-trivial. Wall-only. - #59404 replaced
if (empty($this->fillable)) { $this->fillable = ... }(and the same forappends,hidden,visible) with$this->mergeFillable(...). For non-empty$fillable, that runsarray_values(array_unique([...$this->fillable, ...[]]))— three new arrays of size N per construct. Memory-only. Trivially fixed: short-circuit when the merged input is empty. - #59701 added uncached
new ReflectionClass(static::class)toinitializeModelAttributesto detect whether a child class overrides a parent's$tableproperty. Wall-only on this synthetic model; on larger fat models the cost grows because theReflectionClasspayload scales with class metadata size. Trivially fixed: cache per class via the existingstatic::$classAttributespattern. - #60008 adds four
if ($x === []) return $this;guards tomergeFillable/mergeAppends/mergeHidden/mergeVisible. Reclaims the full 252 MB peak from #59404 and removes the per-constructarray_merge/array_unique/array_valuesCPU cost — explaining the wall recovery from 540 ms → 399 ms.
- Memory regression is fully fixed. Peak returns to the v13.1.1 baseline (148 MB), erasing the entire +252 MB / +170% delta from #59404.
- Wall time is mostly recovered, from +71% over baseline at v13.6.0 down to +26% at 13.x-dev. The remaining gap is the unfixed
resolveClassAttributelookups from #59284 plus a small residue from #59701. - The sibling PR #60009 — which would have cached the #59701
ReflectionClassper class via the samestatic::$classAttributespattern — was closed by Taylor without comment, so the ~5% wall residue from #59701 remains.
This BenchModel is conservative. On larger fat models — more fillable fields, more relations, more traits, longer class file — the per-construct cost of #59701 in particular scales with class metadata size, because the ReflectionClass payload it instantiates per construct grows with the class. The synthetic delta of #59701 above (+0.25 µs) can be ~7× larger on application-side fat models. For applications that hydrate thousands of such models per request, the cumulative cost of these three regressions can push PHP's memory_limit.
BenchModel.php— shared synthetic model (copy in each skeleton).bench.php— shared bench harness (copy in each skeleton).<skeleton>/composer.json— only difference between skeletons: the pinned framework version.run.sh— runs the bench across all skeletons via Docker.