From 01a37660782cba2e3bf9f5adb91e6d3f2226c624 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:10:11 +0000 Subject: [PATCH 1/7] Cache getCasts() merged result in $mergedCastsCache Adds a ?array $mergedCastsCache instance property and uses ??= in getCasts() to cache the merged [keyName => keyType] + $this->casts result for incrementing models. mergeCasts() and initializeHasAttributes() clear the cache so it never serves stale data after $this->casts mutates. Eloquent-heavy pages call getCasts() hundreds of times per model (hasCast, transformModelValue, originalIsEquivalent, addCastAttributesToArray, etc.), each previously allocating a throwaway merged array. Caching eliminates those allocations with no behavior change. --- .../src/Eloquent/Concerns/HasAttributes.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index d681c4b22..9e3e4609e 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -98,6 +98,11 @@ trait HasAttributes */ protected array $attributeCastCache = []; + /** + * The cached merged casts array for this instance. + */ + protected ?array $mergedCastsCache = null; + /** * The built-in, primitive cast types supported by Eloquent. * @@ -193,6 +198,8 @@ protected function initializeHasAttributes(): void $this->casts = $this->ensureCastsAreStringValues( array_merge($this->casts, $this->casts()), ); + + $this->mergedCastsCache = null; } /** @@ -711,6 +718,8 @@ public function mergeCasts(array $casts): static $this->casts = array_merge($this->casts, $casts); + $this->mergedCastsCache = null; + return $this; } @@ -1500,7 +1509,10 @@ public function hasCast(string $key, array|string|null $types = null): bool public function getCasts(): array { if ($this->getIncrementing()) { - return array_merge([$this->getKeyName() => $this->getKeyType()], $this->casts); + return $this->mergedCastsCache ??= array_merge( + [$this->getKeyName() => $this->getKeyType()], + $this->casts, + ); } return $this->casts; From 334c5b75c88f700127a58b698780bc9c8f70559a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:10:17 +0000 Subject: [PATCH 2/7] Invalidate getCasts cache on setters and in __sleep() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setKeyName(), setKeyType(), and setIncrementing() now clear $mergedCastsCache so subsequent getCasts() calls reflect the new primary key name, key type, or incrementing state. __sleep() clears the cache alongside the existing classCastCache and attributeCastCache clears, keeping serialized payloads minimal — the cache is derived state that will be rebuilt on first getCasts() call after unserialize. --- src/database/src/Eloquent/Model.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index af74d35bf..61a12a47a 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1865,6 +1865,8 @@ public function setKeyName(string $key): static { $this->primaryKey = $key; + $this->mergedCastsCache = null; + return $this; } @@ -1891,6 +1893,8 @@ public function setKeyType(string $type): static { $this->keyType = $type; + $this->mergedCastsCache = null; + return $this; } @@ -1909,6 +1913,8 @@ public function setIncrementing(bool $value): static { $this->incrementing = $value; + $this->mergedCastsCache = null; + return $this; } @@ -2308,6 +2314,7 @@ public function __sleep(): array $this->classCastCache = []; $this->attributeCastCache = []; + $this->mergedCastsCache = null; $this->relationAutoloadCallback = null; $this->relationAutoloadContext = null; From 62be92b34b62b2b2f1310ae8990b68871dae3d77 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:10:22 +0000 Subject: [PATCH 3/7] Invalidate getCasts cache after initializeSoftDeletes mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initializeSoftDeletes() adds deleted_at to $this->casts during trait initialization. Clear $mergedCastsCache unconditionally at the end so a booting-event listener that populated the cache before trait initialization doesn't leave stale data — a narrow edge case, but one covered by the mutate-then-invalidate principle the rest of the cache already follows. --- src/database/src/Eloquent/SoftDeletes.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/database/src/Eloquent/SoftDeletes.php b/src/database/src/Eloquent/SoftDeletes.php index 2efbe5122..8e8cfdaf5 100644 --- a/src/database/src/Eloquent/SoftDeletes.php +++ b/src/database/src/Eloquent/SoftDeletes.php @@ -37,6 +37,8 @@ public function initializeSoftDeletes(): void if (! isset($this->casts[$this->getDeletedAtColumn()])) { $this->casts[$this->getDeletedAtColumn()] = 'datetime'; } + + $this->mergedCastsCache = null; } /** From 77a5cf55aa173b535e03644b86f9d5d1f8efa44a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:10:30 +0000 Subject: [PATCH 4/7] Cover getCasts cache invalidation and isolation Nine new tests exercising every $mergedCastsCache invalidation point: - mergeCasts, setKeyName, setKeyType, setIncrementing - non-incrementing bypass (cache untouched when getIncrementing() is false) - per-instance isolation (two instances, different cast sets) - newInstance() isolation (source and clone hold independent state) - booting-event edge cases for initializeHasAttributes and initializeSoftDeletes The two booting-event tests use real Hypervel\Events\Dispatcher (aliased to avoid collision with the existing Dispatcher contract import), a try/finally cleanup of clearBootedModels / flushEventListeners / unsetEventDispatcher to prevent state leakage, and two new stub classes (GetCastsBootingStub, GetCastsSoftDeletingBootingStub) added at the bottom of the file. --- tests/Database/DatabaseEloquentModelTest.php | 164 +++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 04a4ac59f..dd058e932 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -44,9 +44,11 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Query\Builder as BaseBuilder; use Hypervel\Database\Query\Grammars\Grammar; use Hypervel\Database\Query\Processors\Processor; +use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Support\Carbon; use Hypervel\Support\Collection as BaseCollection; use Hypervel\Support\Fluent; @@ -2904,6 +2906,151 @@ public function testMergeCastsMergesCasts() $this->assertArrayHasKey('foo', $model->getCasts()); } + public function testGetCastsCacheIsInvalidatedByMergeCasts() + { + $model = new ModelStub; + + $before = $model->getCasts(); + $this->assertArrayNotHasKey('foo', $before); + + $model->mergeCasts(['foo' => 'date']); + + $after = $model->getCasts(); + $this->assertArrayHasKey('foo', $after); + $this->assertSame('date', $after['foo']); + } + + public function testGetCastsCacheIsInvalidatedBySetKeyName() + { + $model = new ModelStub; + + $before = $model->getCasts(); + $this->assertArrayHasKey('id', $before); + + $model->setKeyName('uuid'); + + $after = $model->getCasts(); + $this->assertArrayHasKey('uuid', $after); + $this->assertArrayNotHasKey('id', $after); + } + + public function testGetCastsCacheIsInvalidatedBySetKeyType() + { + $model = new ModelStub; + + $before = $model->getCasts(); + $this->assertSame('int', $before[$model->getKeyName()]); + + $model->setKeyType('string'); + + $after = $model->getCasts(); + $this->assertSame('string', $after[$model->getKeyName()]); + } + + public function testGetCastsCacheIsInvalidatedBySetIncrementing() + { + $model = new ModelStub; + + $with = $model->getCasts(); + $this->assertArrayHasKey('id', $with); + + $model->setIncrementing(false); + + $without = $model->getCasts(); + $this->assertArrayNotHasKey('id', $without); + } + + public function testGetCastsForNonIncrementingModelReturnsCastsDirectly() + { + $model = new ModelStub; + $model->setIncrementing(false); + $model->mergeCasts(['foo' => 'int']); + + $casts = $model->getCasts(); + + $this->assertArrayNotHasKey($model->getKeyName(), $casts); + $this->assertArrayHasKey('foo', $casts); + $this->assertSame('int', $casts['foo']); + } + + public function testGetCastsIsIsolatedPerInstance() + { + $a = new ModelStub; + $b = new ModelStub; + + $b->mergeCasts(['extra' => 'int']); + + $this->assertArrayNotHasKey('extra', $a->getCasts()); + $this->assertArrayHasKey('extra', $b->getCasts()); + } + + public function testGetCastsOnNewInstanceIsNotSharedWithSource() + { + $source = new ModelStub; + $source->mergeCasts(['a' => 'int']); + $source->getCasts(); + + $clone = $source->newInstance(); + $clone->mergeCasts(['b' => 'string']); + + $this->assertArrayHasKey('b', $clone->getCasts()); + $this->assertArrayNotHasKey('b', $source->getCasts()); + } + + public function testGetCastsCacheIsInvalidatedDuringInitializeHasAttributes() + { + Model::clearBootedModels(); + + Model::setEventDispatcher($dispatcher = new EventDispatcher); + + try { + $dispatcher->listen( + 'eloquent.booting: ' . GetCastsBootingStub::class, + function (GetCastsBootingStub $model) { + $model->getCasts(); + } + ); + + $instance = new GetCastsBootingStub; + + $casts = $instance->getCasts(); + + $this->assertArrayHasKey('foo', $casts); + $this->assertSame('integer', $casts['foo']); + } finally { + GetCastsBootingStub::flushEventListeners(); + Model::clearBootedModels(); + Model::unsetEventDispatcher(); + } + } + + public function testGetCastsCacheIsInvalidatedDuringInitializeSoftDeletes() + { + Model::clearBootedModels(); + + Model::setEventDispatcher($dispatcher = new EventDispatcher); + + try { + $dispatcher->listen( + 'eloquent.booting: ' . GetCastsSoftDeletingBootingStub::class, + function (GetCastsSoftDeletingBootingStub $model) { + $model->getCasts(); + } + ); + + $instance = new GetCastsSoftDeletingBootingStub; + + $casts = $instance->getCasts(); + + $this->assertArrayHasKey('deleted_at', $casts); + $this->assertSame('datetime', $casts['deleted_at']); + } finally { + GetCastsSoftDeletingBootingStub::flushEventListeners(); + Model::clearBootedModels(); + Model::unsetEventDispatcher(); + } + } + public function testMergeCastsMergesCastsUsingArrays() { $model = new CastingStub; @@ -4470,6 +4617,23 @@ public function __toString() } } +class GetCastsBootingStub extends Model +{ + protected array $guarded = []; + + protected function casts(): array + { + return ['foo' => 'integer']; + } +} + +class GetCastsSoftDeletingBootingStub extends Model +{ + use SoftDeletes; + + protected array $guarded = []; +} + enum ConnectionName { case Foo; From e1d023fd161c3ca13b2cd1ee3f75d7f30b6370ed Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:10:35 +0000 Subject: [PATCH 5/7] Add withCasts leakage regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testWithCastsDoesNotLeakAcrossQueries verifies that withCasts() overrides applied to one query don't contaminate a subsequent unscoped query against the same model class. Exercises the full builder → hydration → attribute-access path, guarding against class-level cache designs that would leak casts across queries. --- tests/Database/DatabaseEloquentWithCastsTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Database/DatabaseEloquentWithCastsTest.php b/tests/Database/DatabaseEloquentWithCastsTest.php index 1cb461e71..bece5b3a3 100644 --- a/tests/Database/DatabaseEloquentWithCastsTest.php +++ b/tests/Database/DatabaseEloquentWithCastsTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database; +use Carbon\CarbonInterface; use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\Eloquent\MissingAttributeException; use Hypervel\Database\Eloquent\Model; @@ -80,6 +81,18 @@ public function testWithCreateOrFirst() $this->assertSame($time1->id, $time2->id); } + public function testWithCastsDoesNotLeakAcrossQueries() + { + Time::query()->insert(['time' => '07:30']); + + $scoped = Time::query()->withCasts(['time' => 'string'])->first(); + $this->assertIsString($scoped->time); + $this->assertSame('07:30', $scoped->time); + + $default = Time::query()->first(); + $this->assertInstanceOf(CarbonInterface::class, $default->time); + } + public function testThrowsExceptionIfCastableAttributeWasNotRetrievedAndPreventMissingAttributesIsEnabled() { Time::create(['time' => now()]); From 686281a22d8a0e1b86e9e94e2a5f715f64fa35d7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:10:44 +0000 Subject: [PATCH 6/7] Compare soft-deleted model identity with is() instead of assertEquals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertEquals on full Model instances compares the entire property bag, which breaks once any per-instance memoization is introduced — in this case, $mergedCastsCache populated on one side but not the other after delete() + find() go through different code paths. $model->is() compares primary key, table, and connection — the correct semantic for "is this the same row?" — and is stable under any internal state divergence. --- tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php index 4432698ea..fc2f829a2 100644 --- a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php +++ b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php @@ -407,7 +407,7 @@ public function testUpdateModelAfterSoftDeleting() $userModel->delete(); $this->assertEquals($now->toDateTimeString(), $userModel->getOriginal('deleted_at')); $this->assertNull(User::find(2)); - $this->assertEquals($userModel, User::withTrashed()->find(2)); + $this->assertTrue($userModel->is(User::withTrashed()->find(2))); } /** From 42673b43d9f43f7d69bb14c0ece46a22041fa589 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:10:50 +0000 Subject: [PATCH 7/7] Compare polymorphic-related model identity with is() instead of assertEquals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four assertEquals comparisons on full Model instances replaced with $model->is() — same reasoning as the soft-deletes test. Property-bag equality is fragile under per-instance memoization; identity by primary key / table / connection is what these assertions actually mean. --- .../DatabaseEloquentPolymorphicIntegrationTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Database/DatabaseEloquentPolymorphicIntegrationTest.php b/tests/Database/DatabaseEloquentPolymorphicIntegrationTest.php index d4e7655b4..62021d394 100644 --- a/tests/Database/DatabaseEloquentPolymorphicIntegrationTest.php +++ b/tests/Database/DatabaseEloquentPolymorphicIntegrationTest.php @@ -83,7 +83,7 @@ public function testItLoadsRelationshipsAutomatically() $like = LikeWithSingleWith::first(); $this->assertTrue($like->relationLoaded('likeable')); - $this->assertEquals(Comment::first(), $like->likeable); + $this->assertTrue($like->likeable->is(Comment::first())); } public function testItLoadsChainedRelationshipsAutomatically() @@ -93,7 +93,7 @@ public function testItLoadsChainedRelationshipsAutomatically() $like = LikeWithSingleWith::first(); $this->assertTrue($like->likeable->relationLoaded('commentable')); - $this->assertEquals(Post::first(), $like->likeable->commentable); + $this->assertTrue($like->likeable->commentable->is(Post::first())); } public function testItLoadsNestedRelationshipsAutomatically() @@ -105,7 +105,7 @@ public function testItLoadsNestedRelationshipsAutomatically() $this->assertTrue($like->relationLoaded('likeable')); $this->assertTrue($like->likeable->relationLoaded('owner')); - $this->assertEquals(User::first(), $like->likeable->owner); + $this->assertTrue($like->likeable->owner->is(User::first())); } public function testItLoadsNestedRelationshipsOnDemand() @@ -117,7 +117,7 @@ public function testItLoadsNestedRelationshipsOnDemand() $this->assertTrue($like->relationLoaded('likeable')); $this->assertTrue($like->likeable->relationLoaded('owner')); - $this->assertEquals(User::first(), $like->likeable->owner); + $this->assertTrue($like->likeable->owner->is(User::first())); } public function testItLoadsNestedMorphRelationshipsOnDemand()