From c39798089012c633f4ed4fe9c5a4e212d2bf09f1 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 17 Oct 2026 12:55:53 +0600 Subject: [PATCH 1/2] Clearer errors for frozen services --- .../ImmutableViolationException.php | 13 ++++- tests/Application/ContainerTest.php | 47 ++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/Phaseolies/DI/Exceptions/ImmutableViolationException.php b/src/Phaseolies/DI/Exceptions/ImmutableViolationException.php index 1a1376da..761ee6db 100644 --- a/src/Phaseolies/DI/Exceptions/ImmutableViolationException.php +++ b/src/Phaseolies/DI/Exceptions/ImmutableViolationException.php @@ -8,9 +8,20 @@ class ImmutableViolationException extends RuntimeException { public function __construct(string $class, string $property) { + if ($property === '__clone') { + parent::__construct( + "Cannot clone immutable service [{$class}]. " . + "Services marked #[Immutable] are frozen once the container resolves them, " . + "and a clone would be a way around that. Resolve the service from the container instead." + ); + + return; + } + parent::__construct( "Cannot mutate property \${$property} on immutable service [{$class}]. " . - "Services marked #[Immutable] are frozen after instantiation." + "Services marked #[Immutable] are frozen once the container resolves them. " . + "Configure the service in a launcher during boot, before it is first resolved, not at runtime." ); } } diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index a2563af4..9a1345d5 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -2474,11 +2474,12 @@ public function testManualFreezeWorks() public function testFreezeIsIdempotent() { $service = new MockPaymentService(); + $service->gateway = 'paypal'; $service->freeze(); - // $service->freeze(); // second call must not throw - // Phaseolies\DI\Exceptions\ImmutableViolationException: Cannot mutate property $gateway on immutable service [Tests\Application\Mock\Services\MockPaymentService]. Services marked #[Immutable] are frozen after instantiation. + $service->freeze(); // second call is a no-op and keeps the values $this->assertTrue($service->isFrozen()); + $this->assertSame('paypal', $service->gateway); } // ========================================================================= @@ -2748,6 +2749,48 @@ public function testCloningFrozenServiceThrows() $cloned = clone $service; } + public function testMutationMessageNamesClassPropertyAndTheFix() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->taxRate = 0.0; + $this->fail('Expected ImmutableViolationException'); + } catch (ImmutableViolationException $e) { + $this->assertStringContainsString('$taxRate', $e->getMessage()); + $this->assertStringContainsString(MockPaymentService::class, $e->getMessage()); + $this->assertStringContainsString('frozen once the container resolves them', $e->getMessage()); + $this->assertStringContainsString('launcher', $e->getMessage()); + $this->assertStringNotContainsString('ServiceProvider', $e->getMessage()); + } + } + + public function testCloneMessageSaysCloneNotProperty() + { + $service = $this->container->make(MockPaymentService::class); + + try { + clone $service; + $this->fail('Expected ImmutableViolationException'); + } catch (ImmutableViolationException $e) { + $this->assertStringContainsString('Cannot clone immutable service', $e->getMessage()); + $this->assertStringContainsString(MockPaymentService::class, $e->getMessage()); + $this->assertStringNotContainsString('$__clone', $e->getMessage()); + } + } + + public function testUnsetMessageNamesTheProperty() + { + $service = $this->container->make(MockPaymentService::class); + + try { + unset($service->gateway); + $this->fail('Expected ImmutableViolationException'); + } catch (ImmutableViolationException $e) { + $this->assertStringContainsString('$gateway', $e->getMessage()); + } + } + public function testCloningUnfrozenServiceIsAllowed() { $service = new MockPaymentService(); // not through container — not frozen yet From a0a21653a20126bbf63d31883a2e6a24c58d920d Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 17 Oct 2026 13:06:40 +0600 Subject: [PATCH 2/2] fix: Restore MockHook's own hooks after temporary test hooks --- tests/Model/EntityModelHookTest.php | 42 +++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/tests/Model/EntityModelHookTest.php b/tests/Model/EntityModelHookTest.php index 39d6bc1b..7d552dd0 100644 --- a/tests/Model/EntityModelHookTest.php +++ b/tests/Model/EntityModelHookTest.php @@ -21,8 +21,34 @@ protected function setUp(): void $this->setupDatabaseConnections(); } + /** + * Hook table of MockHook as it was before a test added temporary hooks. + * MockHook registers its own hooks once per process, so restoring must + * put this back instead of removing the entry. + */ + private ?array $savedHooks = null; + + private function tempHooks(array $hooks): void + { + new MockHook(); // make sure the class has booted and registered its own hooks + + $this->savedHooks ??= HookHandler::$hooks[MockHook::class] ?? []; + + HookHandler::register(MockHook::class, $hooks); + } + + private function restoreHooks(): void + { + if ($this->savedHooks !== null) { + HookHandler::$hooks[MockHook::class] = $this->savedHooks; + $this->savedHooks = null; + } + } + protected function tearDown(): void { + $this->restoreHooks(); + $this->pdo = null; $this->tearDownDatabaseConnections(); } @@ -272,7 +298,7 @@ public function testBuilderUpdateWithoutWithHooksSkipsHooks(): void public function testBuilderUpdateWithHooksFiresHooksPerModel(): void { $seen = []; - HookHandler::register(MockHook::class, [ + $this->tempHooks([ 'before_updated' => function ($model) use (&$seen) { $seen[] = $model->getKey(); }, @@ -285,7 +311,7 @@ public function testBuilderUpdateWithHooksFiresHooksPerModel(): void ->withHooks() ->update(['name' => 'Hooked']); } finally { - unset(HookHandler::$hooks[MockHook::class]); + $this->restoreHooks(); } $this->assertTrue($result); @@ -312,7 +338,7 @@ public function testBuilderUpdateWithHooksReachesRowsThatLeaveTheWhereClause(): public function testBuilderUpdateWithHooksStopsWhereAHookThrows(): void { - HookHandler::register(MockHook::class, [ + $this->tempHooks([ 'before_updated' => function ($model) { if ((int) $model->getKey() === 2) { throw new \RuntimeException('not this one'); @@ -326,7 +352,7 @@ public function testBuilderUpdateWithHooksStopsWhereAHookThrows(): void } catch (\RuntimeException $e) { $this->assertSame('not this one', $e->getMessage()); } finally { - unset(HookHandler::$hooks[MockHook::class]); + $this->restoreHooks(); } // Rows are written one by one and there is no implicit transaction: @@ -339,7 +365,7 @@ public function testBuilderUpdateWithHooksStopsWhereAHookThrows(): void public function testBuilderDeleteWithHooksFiresHooksPerModel(): void { $seen = []; - HookHandler::register(MockHook::class, [ + $this->tempHooks([ 'before_deleted' => function ($model) use (&$seen) { $seen[] = (int) $model->getKey(); }, @@ -348,7 +374,7 @@ public function testBuilderDeleteWithHooksFiresHooksPerModel(): void try { MockHook::query()->where('id', '<', 3)->withHooks()->delete(); } finally { - unset(HookHandler::$hooks[MockHook::class]); + $this->restoreHooks(); } $this->assertEqualsCanonicalizing([1, 2], $seen); @@ -358,7 +384,7 @@ public function testBuilderDeleteWithHooksFiresHooksPerModel(): void public function testBuilderDeleteWithoutWithHooksSkipsHooks(): void { $fired = false; - HookHandler::register(MockHook::class, [ + $this->tempHooks([ 'before_deleted' => function () use (&$fired) { $fired = true; }, @@ -367,7 +393,7 @@ public function testBuilderDeleteWithoutWithHooksSkipsHooks(): void try { MockHook::query()->where('id', 1)->delete(); } finally { - unset(HookHandler::$hooks[MockHook::class]); + $this->restoreHooks(); } $this->assertFalse($fired);