Skip to content

zenchef/laravel-eloquent-bench

Repository files navigation

laravel-eloquent-bench

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.

How to run

With host PHP 8.4 + composer:

cd before-59284-v13.1.1 && composer install --no-dev && php bench.php

With Docker (no host PHP needed):

./run.sh

run.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.

What the bench measures

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.

Measured results (PHP 8.4.14, count=100000, trials=7, warmup=1000)

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

Per-PR attribution

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

Mechanism per PR

  • #59284 added new static::resolveClassAttribute() calls in initializeModelAttributes (DateFormat, WithoutIncrementing) and initializeHasAttributes. 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 for appends, hidden, visible) with $this->mergeFillable(...). For non-empty $fillable, that runs array_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) to initializeModelAttributes to detect whether a child class overrides a parent's $table property. Wall-only on this synthetic model; on larger fat models the cost grows because the ReflectionClass payload scales with class metadata size. Trivially fixed: cache per class via the existing static::$classAttributes pattern.
  • #60008 adds four if ($x === []) return $this; guards to mergeFillable / mergeAppends / mergeHidden / mergeVisible. Reclaims the full 252 MB peak from #59404 and removes the per-construct array_merge / array_unique / array_values CPU cost — explaining the wall recovery from 540 ms → 399 ms.

After #60008: what's fixed, what isn't

  • 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 resolveClassAttribute lookups from #59284 plus a small residue from #59701.
  • The sibling PR #60009 — which would have cached the #59701 ReflectionClass per class via the same static::$classAttributes pattern — was closed by Taylor without comment, so the ~5% wall residue from #59701 remains.

Scaling beyond the synthetic model

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.

Files

  • 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.

About

Reproducible benchmark for SUDS-1121 — three independent Eloquent regressions in laravel/framework v13.2 / v13.3 / v13.6

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors