From b5a6e21523c69095a55b9a60cd2ae70293c0628d Mon Sep 17 00:00:00 2001 From: IanM Date: Sat, 1 Aug 2026 08:53:59 +0100 Subject: [PATCH] perf: cache resolved model casts per class 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. --- framework/core/src/Database/AbstractModel.php | 28 ++++- framework/core/src/Extend/Model.php | 3 + .../unit/Database/AbstractModelCastsTest.php | 118 ++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 framework/core/tests/unit/Database/AbstractModelCastsTest.php diff --git a/framework/core/src/Database/AbstractModel.php b/framework/core/src/Database/AbstractModel.php index f9ed974506..980bc0e0aa 100644 --- a/framework/core/src/Database/AbstractModel.php +++ b/framework/core/src/Database/AbstractModel.php @@ -54,6 +54,18 @@ abstract class AbstractModel extends Eloquent */ public static array $defaults = []; + /** + * Resolved casts, keyed by model class. + * + * Eloquent asks for the casts on every attribute access, so resolving the + * class hierarchy each time is measurable: an index request resolved them + * over 130,000 times. The result only changes when an extender registers + * new casts, which is what flushCastsCache() is for. + * + * @var array> + */ + protected static array $castsCache = []; + /** * An alias for the table name, used in queries. * @@ -102,13 +114,27 @@ public function __construct(array $attributes = []) public function getCasts(): array { + if (isset(static::$castsCache[static::class])) { + return static::$castsCache[static::class]; + } + $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; + return static::$castsCache[static::class] = $casts; + } + + /** + * Discard the resolved casts, so that newly registered ones are picked up. + * + * @internal + */ + public static function flushCastsCache(): void + { + static::$castsCache = []; } /** diff --git a/framework/core/src/Extend/Model.php b/framework/core/src/Extend/Model.php index 087872c85c..60e2564235 100644 --- a/framework/core/src/Extend/Model.php +++ b/framework/core/src/Extend/Model.php @@ -181,5 +181,8 @@ public function extend(Container $container, ?Extension $extension = null): void $this->casts ) ); + + // Models may already have resolved (and memoised) their casts by now. + AbstractModel::flushCastsCache(); } } diff --git a/framework/core/tests/unit/Database/AbstractModelCastsTest.php b/framework/core/tests/unit/Database/AbstractModelCastsTest.php new file mode 100644 index 0000000000..f139174db4 --- /dev/null +++ b/framework/core/tests/unit/Database/AbstractModelCastsTest.php @@ -0,0 +1,118 @@ +assertSame('bool', $model->getCasts()['is_native'] ?? null); + } + + public function test_it_includes_custom_casts_registered_for_the_model(): void + { + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['extended' => 'datetime']; + + $model = new AbstractModelCastsTestModel; + + $this->assertSame('datetime', $model->getCasts()['extended'] ?? null); + } + + public function test_it_inherits_custom_casts_registered_against_a_parent_class(): void + { + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['from_parent' => 'int']; + + $model = new AbstractModelCastsTestChildModel; + + $this->assertSame('int', $model->getCasts()['from_parent'] ?? null); + } + + public function test_casts_registered_on_a_child_override_the_parent(): void + { + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['overridden' => 'int']; + AbstractModel::$customCasts[AbstractModelCastsTestChildModel::class] = ['overridden' => 'bool']; + + $model = new AbstractModelCastsTestChildModel; + + $this->assertSame('bool', $model->getCasts()['overridden'] ?? null); + } + + public function test_two_instances_of_the_same_model_resolve_the_same_casts(): void + { + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['shared' => 'bool']; + + $this->assertEquals( + (new AbstractModelCastsTestModel)->getCasts(), + (new AbstractModelCastsTestModel)->getCasts() + ); + } + + public function test_sibling_models_do_not_share_each_others_casts(): void + { + // A per-class cache keyed carelessly (or not keyed at all) would leak + // the first model's casts onto the second. + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['only_on_parent' => 'bool']; + AbstractModel::$customCasts[AbstractModelCastsTestSiblingModel::class] = ['only_on_sibling' => 'int']; + + (new AbstractModelCastsTestModel)->getCasts(); + + $sibling = (new AbstractModelCastsTestSiblingModel)->getCasts(); + + $this->assertArrayNotHasKey('only_on_parent', $sibling); + $this->assertSame('int', $sibling['only_on_sibling'] ?? null); + } + + public function test_casts_registered_after_a_first_resolution_are_picked_up(): void + { + // Extenders mutate $customCasts during boot, which can happen after a + // model has already resolved its casts once. A cache that never + // invalidates would silently ignore the newly registered cast. + $this->assertArrayNotHasKey('late', (new AbstractModelCastsTestModel)->getCasts()); + + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['late' => 'bool']; + AbstractModel::flushCastsCache(); + + $this->assertSame('bool', (new AbstractModelCastsTestModel)->getCasts()['late'] ?? null); + } +} + +class AbstractModelCastsTestModel extends AbstractModel +{ + protected $casts = ['is_native' => 'bool']; +} + +class AbstractModelCastsTestChildModel extends AbstractModelCastsTestModel +{ +} + +class AbstractModelCastsTestSiblingModel extends AbstractModel +{ +}