From 5a1a029cf8f0498ef9c0119d32034f090a6e0c36 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 07:34:26 +0000 Subject: [PATCH 1/5] perf(database): cache cast metadata predicates per instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single $castMetadataCache bucket property and a flushCastCaches() helper. Caches the result of getCastType, isDateCastable, isDateCastableWithCustomFormat, isJsonCastable, isEncryptedCastable, isClassCastable, isEnumCastable, isClassDeviable, isClassSerializable, and isClassComparable per instance, keyed by predicate name then attribute name. Each predicate is called per attribute per Eloquent operation (toArray, isDirty, originalIsEquivalent, attribute set/get) so the cumulative call count is high on attribute-heavy paths. The cache turns each subsequent call into a single hash lookup. One bucket property rather than ten saves ~180 bytes per Model instance — meaningful when collections of hundreds or thousands of models are hydrated per request under Swoole. isClassCastable's throw path is not cached; the exception re-throws on every call against an invalid cast. --- .../src/Eloquent/Concerns/HasAttributes.php | 71 ++++++++++++++----- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index 9e3e4609e..3ca36bb7f 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -103,6 +103,14 @@ trait HasAttributes */ protected ?array $mergedCastsCache = null; + /** + * The cached results of cast metadata predicate methods for this instance. + * + * Keyed by predicate name then attribute name. Cleared whenever cast state + * mutates via flushCastCaches(). + */ + protected array $castMetadataCache = []; + /** * The built-in, primitive cast types supported by Eloquent. * @@ -199,7 +207,7 @@ protected function initializeHasAttributes(): void array_merge($this->casts, $this->casts()), ); - $this->mergedCastsCache = null; + $this->flushCastCaches(); } /** @@ -718,11 +726,20 @@ public function mergeCasts(array $casts): static $this->casts = array_merge($this->casts, $casts); - $this->mergedCastsCache = null; + $this->flushCastCaches(); return $this; } + /** + * Flush the per-instance cast metadata caches. + */ + protected function flushCastCaches(): void + { + $this->mergedCastsCache = null; + $this->castMetadataCache = []; + } + /** * Ensure that the given casts are strings. */ @@ -873,10 +890,14 @@ protected function getEnumCastableAttributeValue(string $key, mixed $value): mix */ protected function getCastType(string $key): string { + if (isset($this->castMetadataCache['castType'][$key])) { + return $this->castMetadataCache['castType'][$key]; + } + $castType = $this->getCasts()[$key]; if (isset(static::$castTypeCache[$castType])) { - return static::$castTypeCache[$castType]; + return $this->castMetadataCache['castType'][$key] = static::$castTypeCache[$castType]; } if ($this->isCustomDateTimeCast($castType)) { @@ -891,7 +912,7 @@ protected function getCastType(string $key): string $convertedCastType = trim(strtolower($castType)); } - return static::$castTypeCache[$castType] = $convertedCastType; + return $this->castMetadataCache['castType'][$key] = static::$castTypeCache[$castType] = $convertedCastType; } /** @@ -1533,7 +1554,7 @@ protected function casts(): array */ protected function isDateCastable(string $key): bool { - return $this->hasCast($key, ['date', 'datetime', 'immutable_date', 'immutable_datetime']); + return $this->castMetadataCache['dateCastable'][$key] ??= $this->hasCast($key, ['date', 'datetime', 'immutable_date', 'immutable_datetime']); } /** @@ -1541,7 +1562,7 @@ protected function isDateCastable(string $key): bool */ protected function isDateCastableWithCustomFormat(string $key): bool { - return $this->hasCast($key, ['custom_datetime', 'immutable_custom_datetime']); + return $this->castMetadataCache['dateCastableWithCustomFormat'][$key] ??= $this->hasCast($key, ['custom_datetime', 'immutable_custom_datetime']); } /** @@ -1549,7 +1570,7 @@ protected function isDateCastableWithCustomFormat(string $key): bool */ protected function isJsonCastable(string $key): bool { - return $this->hasCast($key, ['array', 'json', 'json:unicode', 'object', 'collection', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + return $this->castMetadataCache['jsonCastable'][$key] ??= $this->hasCast($key, ['array', 'json', 'json:unicode', 'object', 'collection', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); } /** @@ -1557,7 +1578,7 @@ protected function isJsonCastable(string $key): bool */ protected function isEncryptedCastable(string $key): bool { - return $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + return $this->castMetadataCache['encryptedCastable'][$key] ??= $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); } /** @@ -1567,20 +1588,24 @@ protected function isEncryptedCastable(string $key): bool */ protected function isClassCastable(string $key): bool { + if (isset($this->castMetadataCache['classCastable'][$key])) { + return $this->castMetadataCache['classCastable'][$key]; + } + $casts = $this->getCasts(); if (! array_key_exists($key, $casts)) { - return false; + return $this->castMetadataCache['classCastable'][$key] = false; } $castType = $this->parseCasterClass($casts[$key]); if (in_array($castType, static::$primitiveCastTypes)) { - return false; + return $this->castMetadataCache['classCastable'][$key] = false; } if (class_exists($castType)) { - return true; + return $this->castMetadataCache['classCastable'][$key] = true; } throw new InvalidCastException($this, $key, $castType); @@ -1591,23 +1616,27 @@ protected function isClassCastable(string $key): bool */ protected function isEnumCastable(string $key): bool { + if (isset($this->castMetadataCache['enumCastable'][$key])) { + return $this->castMetadataCache['enumCastable'][$key]; + } + $casts = $this->getCasts(); if (! array_key_exists($key, $casts)) { - return false; + return $this->castMetadataCache['enumCastable'][$key] = false; } $castType = $casts[$key]; if (in_array($castType, static::$primitiveCastTypes)) { - return false; + return $this->castMetadataCache['enumCastable'][$key] = false; } if (is_subclass_of($castType, Castable::class)) { - return false; + return $this->castMetadataCache['enumCastable'][$key] = false; } - return enum_exists($castType); + return $this->castMetadataCache['enumCastable'][$key] = enum_exists($castType); } /** @@ -1617,13 +1646,17 @@ protected function isEnumCastable(string $key): bool */ protected function isClassDeviable(string $key): bool { + if (isset($this->castMetadataCache['classDeviable'][$key])) { + return $this->castMetadataCache['classDeviable'][$key]; + } + if (! $this->isClassCastable($key)) { - return false; + return $this->castMetadataCache['classDeviable'][$key] = false; } $castType = $this->resolveCasterClass($key); - return method_exists($castType::class, 'increment') && method_exists($castType::class, 'decrement'); + return $this->castMetadataCache['classDeviable'][$key] = method_exists($castType::class, 'increment') && method_exists($castType::class, 'decrement'); } /** @@ -1633,7 +1666,7 @@ protected function isClassDeviable(string $key): bool */ protected function isClassSerializable(string $key): bool { - return ! $this->isEnumCastable($key) + return $this->castMetadataCache['classSerializable'][$key] ??= ! $this->isEnumCastable($key) && $this->isClassCastable($key) && method_exists($this->resolveCasterClass($key), 'serialize'); } @@ -1643,7 +1676,7 @@ protected function isClassSerializable(string $key): bool */ protected function isClassComparable(string $key): bool { - return ! $this->isEnumCastable($key) + return $this->castMetadataCache['classComparable'][$key] ??= ! $this->isEnumCastable($key) && $this->isClassCastable($key) && method_exists($this->resolveCasterClass($key), 'compare'); } From 7a1f8191b8db63817df090d48f605babf760f156 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 07:34:30 +0000 Subject: [PATCH 2/5] refactor(database): route Model cache resets through flushCastCaches() setKeyName, setKeyType, setIncrementing, and __sleep now call the new flushCastCaches() helper instead of nulling $mergedCastsCache directly. This keeps the new $castMetadataCache invalidation in lockstep with the existing $mergedCastsCache invalidation. --- src/database/src/Eloquent/Model.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 61a12a47a..509d21808 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1865,7 +1865,7 @@ public function setKeyName(string $key): static { $this->primaryKey = $key; - $this->mergedCastsCache = null; + $this->flushCastCaches(); return $this; } @@ -1893,7 +1893,7 @@ public function setKeyType(string $type): static { $this->keyType = $type; - $this->mergedCastsCache = null; + $this->flushCastCaches(); return $this; } @@ -1913,7 +1913,7 @@ public function setIncrementing(bool $value): static { $this->incrementing = $value; - $this->mergedCastsCache = null; + $this->flushCastCaches(); return $this; } @@ -2314,7 +2314,7 @@ public function __sleep(): array $this->classCastCache = []; $this->attributeCastCache = []; - $this->mergedCastsCache = null; + $this->flushCastCaches(); $this->relationAutoloadCallback = null; $this->relationAutoloadContext = null; From 0c5f302fac57ac9d59416215df1b7ae1f41ab0af Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 07:34:34 +0000 Subject: [PATCH 3/5] refactor(database): route SoftDeletes cache reset through flushCastCaches() initializeSoftDeletes calls flushCastCaches() instead of nulling $mergedCastsCache directly. Same reasoning as the Model.php change. --- src/database/src/Eloquent/SoftDeletes.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/SoftDeletes.php b/src/database/src/Eloquent/SoftDeletes.php index 8e8cfdaf5..e94b87812 100644 --- a/src/database/src/Eloquent/SoftDeletes.php +++ b/src/database/src/Eloquent/SoftDeletes.php @@ -38,7 +38,7 @@ public function initializeSoftDeletes(): void $this->casts[$this->getDeletedAtColumn()] = 'datetime'; } - $this->mergedCastsCache = null; + $this->flushCastCaches(); } /** From 41e2ba1d304aa7fd5642842e42dea5b9854d2bb5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 07:34:41 +0000 Subject: [PATCH 4/5] test(database): cover predicate cache invalidation, isolation, and throw path Ten new tests cover the per-instance cast metadata cache: invalidation on each of the seven mutation sites (mergeCasts, the three setters, initializeHasAttributes via the booting-event edge case, initializeSoftDeletes via direct invocation, and __sleep), per-instance isolation, structural verification that each of the ten predicates writes to its expected bucket key, and the contract that isClassCastable continues to throw on invalid casts rather than caching the exception path as a false. Tests use ClassInvoker for protected-member access, matching the established pattern in the existing HasAttributesTest. --- tests/Database/DatabaseEloquentModelTest.php | 172 +++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index dd058e932..9f2a640e0 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -38,6 +38,7 @@ use Hypervel\Database\Eloquent\Concerns\HasUuids; use Hypervel\Database\Eloquent\Factories\Factory; use Hypervel\Database\Eloquent\Factories\HasFactory; +use Hypervel\Database\Eloquent\InvalidCastException; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\MassAssignmentException; use Hypervel\Database\Eloquent\MissingAttributeException; @@ -50,6 +51,7 @@ use Hypervel\Database\Query\Processors\Processor; use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Support\Carbon; +use Hypervel\Support\ClassInvoker; use Hypervel\Support\Collection as BaseCollection; use Hypervel\Support\Fluent; use Hypervel\Support\HtmlString; @@ -3051,6 +3053,169 @@ function (GetCastsSoftDeletingBootingStub $model) { } } + public function testCastCacheIsInvalidatedByMergeCasts() + { + $model = new ModelStub; + $model->mergeCasts(['foo' => 'array']); + + $this->assertTrue($model->hasCast('foo', 'array')); + + $model->mergeCasts(['foo' => 'integer']); + + $this->assertFalse($model->hasCast('foo', 'array')); + } + + public function testCastCacheIsInvalidatedBySetKeyType() + { + $model = new ModelStub; + + $this->assertTrue($model->hasCast($model->getKeyName(), 'int')); + + $model->setKeyType('string'); + + $this->assertFalse($model->hasCast($model->getKeyName(), 'int')); + $this->assertTrue($model->hasCast($model->getKeyName(), 'string')); + } + + public function testCastCacheIsInvalidatedBySetKeyName() + { + $model = new ModelStub; + $model->hasCast('id', 'int'); + + $invoker = new ClassInvoker($model); + $this->assertNotEmpty($invoker->castMetadataCache, 'cache should be populated before mutation'); + + $model->setKeyName('uuid'); + + $this->assertSame([], $invoker->castMetadataCache, 'cache should be cleared after setKeyName'); + } + + public function testCastCacheIsInvalidatedBySetIncrementing() + { + $model = new ModelStub; + $model->hasCast('id', 'int'); + + $invoker = new ClassInvoker($model); + $this->assertNotEmpty($invoker->castMetadataCache, 'cache should be populated before mutation'); + + $model->setIncrementing(false); + + $this->assertSame([], $invoker->castMetadataCache, 'cache should be cleared after setIncrementing'); + } + + public function testCastCacheIsInvalidatedDuringInitializeHasAttributes() + { + Model::clearBootedModels(); + + Model::setEventDispatcher($dispatcher = new EventDispatcher); + + try { + $dispatcher->listen( + 'eloquent.booting: ' . GetCastsBootingStub::class, + function (GetCastsBootingStub $model) { + $model->hasCast('id', 'int'); + } + ); + + $instance = new GetCastsBootingStub; + + $invoker = new ClassInvoker($instance); + $this->assertSame([], $invoker->castMetadataCache, 'cache should be cleared after initializeHasAttributes'); + } finally { + GetCastsBootingStub::flushEventListeners(); + Model::clearBootedModels(); + Model::unsetEventDispatcher(); + } + } + + public function testInitializeSoftDeletesClearsCastMetadataCache() + { + $model = new GetCastsSoftDeletingBootingStub; + $invoker = new ClassInvoker($model); + + $invoker->isDateCastable($model->getDeletedAtColumn()); + $this->assertNotEmpty($invoker->castMetadataCache, 'cache should be populated'); + + $invoker->initializeSoftDeletes(); + + $this->assertSame([], $invoker->castMetadataCache, 'initializeSoftDeletes should clear the cache'); + } + + public function testCastCacheIsClearedDuringSleep() + { + $model = new ModelStub; + $model->hasCast('id', 'int'); + + $invoker = new ClassInvoker($model); + $this->assertNotEmpty($invoker->castMetadataCache, 'cache should be populated'); + + $model->__sleep(); + + $this->assertSame([], $invoker->castMetadataCache, 'cache should be cleared by __sleep'); + } + + public function testIsClassCastableThrowsRepeatedlyForInvalidCast() + { + $invoker = new ClassInvoker(new CastCacheInvalidClassStub); + + try { + $invoker->isClassCastable('foo'); + $this->fail('Expected InvalidCastException on first call'); + } catch (InvalidCastException) { + // expected + } + + $this->expectException(InvalidCastException::class); + $invoker->isClassCastable('foo'); + } + + public function testCastCacheIsIsolatedPerInstance() + { + $a = new ModelStub; + $b = new ModelStub; + + $a->mergeCasts(['castedFloat' => 'integer']); + + // Populate $a's cache first to make any class-level leakage visible. + $this->assertTrue($a->hasCast('castedFloat', 'integer')); + + // $b retains the default 'float' cast for castedFloat. + $this->assertTrue($b->hasCast('castedFloat', 'float')); + $this->assertFalse($b->hasCast('castedFloat', 'integer')); + } + + public function testEachPredicatePopulatesItsExpectedBucket() + { + $cases = [ + ['castType', 'id', fn (ClassInvoker $i) => $i->getCastType('id')], + ['dateCastable', 'foo', fn (ClassInvoker $i) => $i->isDateCastable('foo')], + ['dateCastableWithCustomFormat', 'foo', fn (ClassInvoker $i) => $i->isDateCastableWithCustomFormat('foo')], + ['jsonCastable', 'foo', fn (ClassInvoker $i) => $i->isJsonCastable('foo')], + ['encryptedCastable', 'foo', fn (ClassInvoker $i) => $i->isEncryptedCastable('foo')], + ['classCastable', 'foo', fn (ClassInvoker $i) => $i->isClassCastable('foo')], + ['enumCastable', 'foo', fn (ClassInvoker $i) => $i->isEnumCastable('foo')], + ['classDeviable', 'foo', fn (ClassInvoker $i) => $i->isClassDeviable('foo')], + ['classSerializable', 'foo', fn (ClassInvoker $i) => $i->isClassSerializable('foo')], + ['classComparable', 'foo', fn (ClassInvoker $i) => $i->isClassComparable('foo')], + ]; + + foreach ($cases as [$bucket, $key, $call]) { + $invoker = new ClassInvoker(new ModelStub); + $call($invoker); + + $this->assertArrayHasKey( + $bucket, + $invoker->castMetadataCache, + "Predicate should populate '{$bucket}' bucket" + ); + $this->assertArrayHasKey( + $key, + $invoker->castMetadataCache[$bucket], + "Predicate should write to '{$bucket}'[{$key}], not under any other key" + ); + } + } + public function testMergeCastsMergesCastsUsingArrays() { $model = new CastingStub; @@ -4634,6 +4799,13 @@ class GetCastsSoftDeletingBootingStub extends Model protected array $guarded = []; } +class CastCacheInvalidClassStub extends Model +{ + protected array $guarded = []; + + protected array $casts = ['foo' => '\This\Class\Does\Not\Exist']; +} + enum ConnectionName { case Foo; From 97d0495afa7941e5b48f8b886c036aede0ab04ff Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 07:34:46 +0000 Subject: [PATCH 5/5] test(database): add predicate cache leakage regression for withCasts Verifies that hasCast results computed under a withCasts() override don't leak into a subsequent unscoped query against the same model class. --- tests/Database/DatabaseEloquentWithCastsTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Database/DatabaseEloquentWithCastsTest.php b/tests/Database/DatabaseEloquentWithCastsTest.php index bece5b3a3..9972adeda 100644 --- a/tests/Database/DatabaseEloquentWithCastsTest.php +++ b/tests/Database/DatabaseEloquentWithCastsTest.php @@ -93,6 +93,17 @@ public function testWithCastsDoesNotLeakAcrossQueries() $this->assertInstanceOf(CarbonInterface::class, $default->time); } + public function testWithCastsDoesNotLeakPredicateCacheAcrossQueries() + { + Time::query()->insert(['time' => '07:30']); + + $scoped = Time::query()->withCasts(['time' => 'string'])->first(); + $this->assertFalse($scoped->hasCast('time', 'datetime')); + + $default = Time::query()->first(); + $this->assertTrue($default->hasCast('time', 'datetime')); + } + public function testThrowsExceptionIfCastableAttributeWasNotRetrievedAndPreventMissingAttributesIsEnabled() { Time::create(['time' => now()]);