Skip to content

Commit b5a6e21

Browse files
committed
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.
1 parent 063e067 commit b5a6e21

3 files changed

Lines changed: 148 additions & 1 deletion

File tree

framework/core/src/Database/AbstractModel.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ abstract class AbstractModel extends Eloquent
5454
*/
5555
public static array $defaults = [];
5656

57+
/**
58+
* Resolved casts, keyed by model class.
59+
*
60+
* Eloquent asks for the casts on every attribute access, so resolving the
61+
* class hierarchy each time is measurable: an index request resolved them
62+
* over 130,000 times. The result only changes when an extender registers
63+
* new casts, which is what flushCastsCache() is for.
64+
*
65+
* @var array<class-string, array<string, string>>
66+
*/
67+
protected static array $castsCache = [];
68+
5769
/**
5870
* An alias for the table name, used in queries.
5971
*
@@ -102,13 +114,27 @@ public function __construct(array $attributes = [])
102114

103115
public function getCasts(): array
104116
{
117+
if (isset(static::$castsCache[static::class])) {
118+
return static::$castsCache[static::class];
119+
}
120+
105121
$casts = parent::getCasts();
106122

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

111-
return $casts;
127+
return static::$castsCache[static::class] = $casts;
128+
}
129+
130+
/**
131+
* Discard the resolved casts, so that newly registered ones are picked up.
132+
*
133+
* @internal
134+
*/
135+
public static function flushCastsCache(): void
136+
{
137+
static::$castsCache = [];
112138
}
113139

114140
/**

framework/core/src/Extend/Model.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,5 +181,8 @@ public function extend(Container $container, ?Extension $extension = null): void
181181
$this->casts
182182
)
183183
);
184+
185+
// Models may already have resolved (and memoised) their casts by now.
186+
AbstractModel::flushCastsCache();
184187
}
185188
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Tests\unit\Database;
11+
12+
use Flarum\Database\AbstractModel;
13+
use Flarum\Testing\unit\TestCase;
14+
15+
class AbstractModelCastsTest extends TestCase
16+
{
17+
protected function setUp(): void
18+
{
19+
parent::setUp();
20+
21+
AbstractModel::$customCasts = [];
22+
AbstractModel::flushCastsCache();
23+
}
24+
25+
protected function tearDown(): void
26+
{
27+
AbstractModel::$customCasts = [];
28+
AbstractModel::flushCastsCache();
29+
30+
parent::tearDown();
31+
}
32+
33+
public function test_it_includes_casts_declared_on_the_model(): void
34+
{
35+
$model = new AbstractModelCastsTestModel;
36+
37+
$this->assertSame('bool', $model->getCasts()['is_native'] ?? null);
38+
}
39+
40+
public function test_it_includes_custom_casts_registered_for_the_model(): void
41+
{
42+
AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['extended' => 'datetime'];
43+
44+
$model = new AbstractModelCastsTestModel;
45+
46+
$this->assertSame('datetime', $model->getCasts()['extended'] ?? null);
47+
}
48+
49+
public function test_it_inherits_custom_casts_registered_against_a_parent_class(): void
50+
{
51+
AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['from_parent' => 'int'];
52+
53+
$model = new AbstractModelCastsTestChildModel;
54+
55+
$this->assertSame('int', $model->getCasts()['from_parent'] ?? null);
56+
}
57+
58+
public function test_casts_registered_on_a_child_override_the_parent(): void
59+
{
60+
AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['overridden' => 'int'];
61+
AbstractModel::$customCasts[AbstractModelCastsTestChildModel::class] = ['overridden' => 'bool'];
62+
63+
$model = new AbstractModelCastsTestChildModel;
64+
65+
$this->assertSame('bool', $model->getCasts()['overridden'] ?? null);
66+
}
67+
68+
public function test_two_instances_of_the_same_model_resolve_the_same_casts(): void
69+
{
70+
AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['shared' => 'bool'];
71+
72+
$this->assertEquals(
73+
(new AbstractModelCastsTestModel)->getCasts(),
74+
(new AbstractModelCastsTestModel)->getCasts()
75+
);
76+
}
77+
78+
public function test_sibling_models_do_not_share_each_others_casts(): void
79+
{
80+
// A per-class cache keyed carelessly (or not keyed at all) would leak
81+
// the first model's casts onto the second.
82+
AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['only_on_parent' => 'bool'];
83+
AbstractModel::$customCasts[AbstractModelCastsTestSiblingModel::class] = ['only_on_sibling' => 'int'];
84+
85+
(new AbstractModelCastsTestModel)->getCasts();
86+
87+
$sibling = (new AbstractModelCastsTestSiblingModel)->getCasts();
88+
89+
$this->assertArrayNotHasKey('only_on_parent', $sibling);
90+
$this->assertSame('int', $sibling['only_on_sibling'] ?? null);
91+
}
92+
93+
public function test_casts_registered_after_a_first_resolution_are_picked_up(): void
94+
{
95+
// Extenders mutate $customCasts during boot, which can happen after a
96+
// model has already resolved its casts once. A cache that never
97+
// invalidates would silently ignore the newly registered cast.
98+
$this->assertArrayNotHasKey('late', (new AbstractModelCastsTestModel)->getCasts());
99+
100+
AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['late' => 'bool'];
101+
AbstractModel::flushCastsCache();
102+
103+
$this->assertSame('bool', (new AbstractModelCastsTestModel)->getCasts()['late'] ?? null);
104+
}
105+
}
106+
107+
class AbstractModelCastsTestModel extends AbstractModel
108+
{
109+
protected $casts = ['is_native' => 'bool'];
110+
}
111+
112+
class AbstractModelCastsTestChildModel extends AbstractModelCastsTestModel
113+
{
114+
}
115+
116+
class AbstractModelCastsTestSiblingModel extends AbstractModel
117+
{
118+
}

0 commit comments

Comments
 (0)