From 1885870e726942e0ceb8d672e31f1ba0221304f1 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 15:31:04 +0600 Subject: [PATCH 01/12] Boot-time Frozen Services via #[Immutable] --- src/Phaseolies/DI/Attributes/Immutable.php | 22 +++ .../DI/Concerns/EnforcesImmutability.php | 128 ++++++++++++++++++ src/Phaseolies/DI/Container.php | 41 ++++-- .../ImmutableViolationException.php | 16 +++ 4 files changed, 197 insertions(+), 10 deletions(-) create mode 100644 src/Phaseolies/DI/Attributes/Immutable.php create mode 100644 src/Phaseolies/DI/Concerns/EnforcesImmutability.php create mode 100644 src/Phaseolies/DI/Exceptions/ImmutableViolationException.php diff --git a/src/Phaseolies/DI/Attributes/Immutable.php b/src/Phaseolies/DI/Attributes/Immutable.php new file mode 100644 index 0000000..ef96e89 --- /dev/null +++ b/src/Phaseolies/DI/Attributes/Immutable.php @@ -0,0 +1,22 @@ +getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isStatic()) { + continue; + } + + $name = $property->getName(); + + $this->__immutableProperties[$name] = $property->isInitialized($this) + ? $property->getValue($this) + : null; + + unset($this->$name); + } + + $this->__frozen = true; + } + + /** + * Check if the instance is frozen + * + * @return bool + */ + public function isFrozen(): bool + { + return $this->__frozen; + } + + /** + * Serve reads from the frozen snapshot + * + * @throws ImmutableViolationException on write attempts after freeze() + * @throws \RuntimeException on access to undefined properties + * @return mixed + */ + public function __get(string $name): mixed + { + if (array_key_exists($name, $this->__immutableProperties)) { + return $this->__immutableProperties[$name]; + } + + throw new \RuntimeException( + "Property \${$name} does not exist on " . static::class + ); + } + + /** + * Block all writes once frozen + * + * @throws ImmutableViolationException + * @return void + */ + public function __set(string $name, mixed $value): void + { + if ($this->__frozen) { + throw new ImmutableViolationException(static::class, $name); + } + + $this->$name = $value; + } + + /** + * Forward isset() checks to the frozen map + * + * @return bool + */ + public function __isset(string $name): bool + { + return isset($this->__immutableProperties[$name]); + } + + /** + * Block unset() — it is a mutation + * + * @throws ImmutableViolationException + * @return void + */ + public function __unset(string $name): void + { + throw new ImmutableViolationException(static::class, $name); + } + + /** + * Prevent cloning of frozen services + * + * @throws ImmutableViolationException + * @return void + */ + public function __clone(): void + { + if ($this->__frozen) { + throw new ImmutableViolationException(static::class, '__clone'); + } + } +} diff --git a/src/Phaseolies/DI/Container.php b/src/Phaseolies/DI/Container.php index cb593c8..c4e2d5d 100644 --- a/src/Phaseolies/DI/Container.php +++ b/src/Phaseolies/DI/Container.php @@ -3,6 +3,7 @@ namespace Phaseolies\DI; use ArrayAccess; +use Phaseolies\DI\Attributes\Immutable; class Container implements ArrayAccess { @@ -154,7 +155,7 @@ public function instance(string $abstract, mixed $instance): void * @param class-string $abstract * @param array $parameters * @return T - * @throws RuntimeException + * @throws \RuntimeException */ public function get(string $abstract, array $parameters = []): mixed { @@ -240,7 +241,7 @@ public function make(string $abstract, array $parameters = []): mixed * @param class-string $concrete * @param array $parameters * @return T - * @throws RuntimeException + * @throws \RuntimeException */ public function build(string $concrete, array $parameters = []): object { @@ -257,16 +258,36 @@ public function build(string $concrete, array $parameters = []): object $constructor = $reflector->getConstructor(); if (is_null($constructor)) { - return new $concrete(...$parameters); + $instance = new $concrete(...$parameters); + } else { + $dependencies = $this->resolveDependencies( + $constructor->getParameters(), + $parameters, + $concrete + ); + $instance = $reflector->newInstanceArgs($dependencies); } - $dependencies = $this->resolveDependencies( - $constructor->getParameters(), - $parameters, - $concrete - ); + $this->freezeIfImmutable($reflector, $instance); - return $reflector->newInstanceArgs($dependencies); + return $instance; + } + + /** + * Freeze the instance if the class has the #[Immutable] attribute and uses the EnforcesImmutability trait. + * + * @param \ReflectionClass $reflector + * @param object $instance + * @return void + */ + private function freezeIfImmutable(\ReflectionClass $reflector, object $instance): void + { + $hasAttribute = !empty($reflector->getAttributes(Immutable::class)); + $usesTrait = method_exists($instance, 'freeze') && method_exists($instance, 'isFrozen'); + + if ($hasAttribute && $usesTrait) { + $instance->freeze(); + } } /** @@ -292,7 +313,7 @@ protected function resolveDependencies(array $parameters, array $primitives = [] /** * Resolve a single dependency * - * @param ReflectionParameter $parameter + * @param \ReflectionParameter $parameter * @param array $primitives * @param string $className * @return mixed diff --git a/src/Phaseolies/DI/Exceptions/ImmutableViolationException.php b/src/Phaseolies/DI/Exceptions/ImmutableViolationException.php new file mode 100644 index 0000000..1a1376d --- /dev/null +++ b/src/Phaseolies/DI/Exceptions/ImmutableViolationException.php @@ -0,0 +1,16 @@ + Date: Sun, 1 Mar 2026 16:22:26 +0600 Subject: [PATCH 02/12] Unit test for immutable: freeze --- src/Phaseolies/DI/Attributes/Immutable.php | 11 - tests/Application/ContainerTest.php | 215 +++++++++++------- .../Mock/Services/MockMailerService.php | 15 ++ .../Mock/Services/MockMutableService.php | 14 ++ .../Mock/Services/MockPaymentService.php | 44 ++++ .../Services/MockServiceWithConstructor.php | 21 ++ 6 files changed, 223 insertions(+), 97 deletions(-) create mode 100644 tests/Application/Mock/Services/MockMailerService.php create mode 100644 tests/Application/Mock/Services/MockMutableService.php create mode 100644 tests/Application/Mock/Services/MockPaymentService.php create mode 100644 tests/Application/Mock/Services/MockServiceWithConstructor.php diff --git a/src/Phaseolies/DI/Attributes/Immutable.php b/src/Phaseolies/DI/Attributes/Immutable.php index ef96e89..1dba941 100644 --- a/src/Phaseolies/DI/Attributes/Immutable.php +++ b/src/Phaseolies/DI/Attributes/Immutable.php @@ -4,17 +4,6 @@ use Attribute; -/** - * #[Immutable] — Freeze a Service After Boot - * - * When applied to a class, the container will wrap the resolved instance - * in an ImmutableProxy. Any attempt to mutate a property at runtime will - * throw an ImmutableViolationException. - * - * Usage: - * #[Immutable] - * class PaymentService { ... } - */ #[Attribute(Attribute::TARGET_CLASS)] final class Immutable { diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index 0f7195a..b5447e9 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -2,72 +2,74 @@ namespace Tests\Application; -use TypeError; -use Tests\Application\Mock\StaticCallableClass; -use Tests\Application\Mock\SimpleClass; -use Tests\Application\Mock\Services\ConcreteServiceLayer; -use Tests\Application\Mock\Services\ConcreteService; -use Tests\Application\Mock\Services\AlternateDependency; -use Tests\Application\Mock\Repository\ConcreteRepository; -use Tests\Application\Mock\Providers\TestServiceProvider; -use Tests\Application\Mock\Providers\ProviderWithDependencies; -use Tests\Application\Mock\Providers\BootableServiceProvider; -use Tests\Application\Mock\Providers\BootableProviderWithDependencies; -use Tests\Application\Mock\Providers\AnotherServiceProvider; -use Tests\Application\Mock\MixedOptionalClass; -use Tests\Application\Mock\InvokableClass; -use Tests\Application\Mock\Interfaces\UnboundInterface; -use Tests\Application\Mock\Interfaces\TestInterface; -use Tests\Application\Mock\Interfaces\ServiceLayerInterface; -use Tests\Application\Mock\Interfaces\ServiceInterface; -use Tests\Application\Mock\Interfaces\RepositoryInterface; -use Tests\Application\Mock\Interfaces\DependencyInterface; -use Tests\Application\Mock\Interfaces\ConnectionInterface; -use Tests\Application\Mock\ExtendedSimpleClass; -use Tests\Application\Mock\DeepNestedClass; -use Tests\Application\Mock\DatabaseConnection; -use Tests\Application\Mock\Counter; -use Tests\Application\Mock\Controllers\ControllerClass; -use Tests\Application\Mock\ConcreteImplementation; -use Tests\Application\Mock\ConcreteDependency; -use Tests\Application\Mock\ComplexDependencyGraph; -use Tests\Application\Mock\ComplexConstructorClass; -use Tests\Application\Mock\ClassWithoutConstructor; -use Tests\Application\Mock\ClassWithVariadic; -use Tests\Application\Mock\ClassWithUnresolvablePrimitive; -use Tests\Application\Mock\ClassWithUnboundDependency; -use Tests\Application\Mock\ClassWithTypedVariadic; -use Tests\Application\Mock\ClassWithString; -use Tests\Application\Mock\ClassWithOptionalDependency; -use Tests\Application\Mock\ClassWithOnlyOptionals; -use Tests\Application\Mock\ClassWithNullableDefault; -use Tests\Application\Mock\ClassWithNullableClass; -use Tests\Application\Mock\ClassWithNullable; -use Tests\Application\Mock\ClassWithNestedDependency; -use Tests\Application\Mock\ClassWithMultiplePrimitives; -use Tests\Application\Mock\ClassWithMultipleDependencies; -use Tests\Application\Mock\ClassWithMixedRequiredOptional; -use Tests\Application\Mock\ClassWithMixedParams; -use Tests\Application\Mock\ClassWithManyParams; -use Tests\Application\Mock\ClassWithInt; -use Tests\Application\Mock\ClassWithFloat; -use Tests\Application\Mock\ClassWithEmptyConstructor; -use Tests\Application\Mock\ClassWithDependencyChain; -use Tests\Application\Mock\ClassWithDependencyAndVariadic; -use Tests\Application\Mock\ClassWithDependency; -use Tests\Application\Mock\ClassWithDefaults; -use Tests\Application\Mock\ClassWithBool; -use Tests\Application\Mock\ClassWithArray; -use Tests\Application\Mock\ClassWithAllDefaults; -use Tests\Application\Mock\CircularC; -use Tests\Application\Mock\CircularB; -use Tests\Application\Mock\CircularA; -use Tests\Application\Mock\CallableClass; -use Tests\Application\Mock\ApplicationClass; -use Tests\Application\Mock\Abstracts\AbstractClass; -use RuntimeException; use Phaseolies\DI\Container; use PHPUnit\Framework\TestCase; +use RuntimeException; +use Tests\Application\Mock\Abstracts\AbstractClass; +use Tests\Application\Mock\ApplicationClass; +use Tests\Application\Mock\CallableClass; +use Tests\Application\Mock\CircularA; +use Tests\Application\Mock\CircularB; +use Tests\Application\Mock\CircularC; +use Tests\Application\Mock\ClassWithAllDefaults; +use Tests\Application\Mock\ClassWithArray; +use Tests\Application\Mock\ClassWithBool; +use Tests\Application\Mock\ClassWithDefaults; +use Tests\Application\Mock\ClassWithDependency; +use Tests\Application\Mock\ClassWithDependencyAndVariadic; +use Tests\Application\Mock\ClassWithDependencyChain; +use Tests\Application\Mock\ClassWithEmptyConstructor; +use Tests\Application\Mock\ClassWithFloat; +use Tests\Application\Mock\ClassWithInt; +use Tests\Application\Mock\ClassWithManyParams; +use Tests\Application\Mock\ClassWithMixedParams; +use Tests\Application\Mock\ClassWithMixedRequiredOptional; +use Tests\Application\Mock\ClassWithMultipleDependencies; +use Tests\Application\Mock\ClassWithMultiplePrimitives; +use Tests\Application\Mock\ClassWithNestedDependency; +use Tests\Application\Mock\ClassWithNullable; +use Tests\Application\Mock\ClassWithNullableClass; +use Tests\Application\Mock\ClassWithNullableDefault; +use Tests\Application\Mock\ClassWithOnlyOptionals; +use Tests\Application\Mock\ClassWithOptionalDependency; +use Tests\Application\Mock\ClassWithoutConstructor; +use Tests\Application\Mock\ClassWithString; +use Tests\Application\Mock\ClassWithTypedVariadic; +use Tests\Application\Mock\ClassWithUnboundDependency; +use Tests\Application\Mock\ClassWithUnresolvablePrimitive; +use Tests\Application\Mock\ClassWithVariadic; +use Tests\Application\Mock\ComplexConstructorClass; +use Tests\Application\Mock\ComplexDependencyGraph; +use Tests\Application\Mock\ConcreteDependency; +use Tests\Application\Mock\ConcreteImplementation; +use Tests\Application\Mock\Controllers\ControllerClass; +use Tests\Application\Mock\Counter; +use Tests\Application\Mock\DatabaseConnection; +use Tests\Application\Mock\DeepNestedClass; +use Tests\Application\Mock\ExtendedSimpleClass; +use Tests\Application\Mock\Interfaces\ConnectionInterface; +use Tests\Application\Mock\Interfaces\DependencyInterface; +use Tests\Application\Mock\Interfaces\RepositoryInterface; +use Tests\Application\Mock\Interfaces\ServiceInterface; +use Tests\Application\Mock\Interfaces\ServiceLayerInterface; +use Tests\Application\Mock\Interfaces\TestInterface; +use Tests\Application\Mock\Interfaces\UnboundInterface; +use Tests\Application\Mock\InvokableClass; +use Tests\Application\Mock\MixedOptionalClass; +use Tests\Application\Mock\Providers\AnotherServiceProvider; +use Tests\Application\Mock\Providers\BootableProviderWithDependencies; +use Tests\Application\Mock\Providers\BootableServiceProvider; +use Tests\Application\Mock\Providers\ProviderWithDependencies; +use Tests\Application\Mock\Providers\TestServiceProvider; +use Tests\Application\Mock\Repository\ConcreteRepository; +use Tests\Application\Mock\Services\AlternateDependency; +use Tests\Application\Mock\Services\ConcreteService; +use Tests\Application\Mock\Services\ConcreteServiceLayer; +use Tests\Application\Mock\Services\MockPaymentService; +use Tests\Application\Mock\SimpleClass; +use Tests\Application\Mock\StaticCallableClass; +use TypeError; + class ContainerTest extends TestCase { protected Container $container; @@ -970,7 +972,7 @@ public function testMakeClassWithDependencies() $this->assertInstanceOf(ConcreteDependency::class, $instance->dependency); } - public function testMakeWithParameters() + public function testMakeWithParameters() { $instance = $this->container->make(ClassWithString::class, ['name' => 'Test']); $this->assertEquals('Test', $instance->name); @@ -1130,7 +1132,7 @@ public function testResolveMethodDependenciesWithParams() public function testIsResolvingDuringResolution() { - $this->container->bind('service', function() { + $this->container->bind('service', function () { return $this->container->isResolving('service') ? 'resolving' : 'not'; }); @@ -1325,7 +1327,7 @@ public function testDifferentImplementationsForDifferentClasses() public function testCallableWithContainerParameter() { - $this->container->bind('test', function(Container $container) { + $this->container->bind('test', function (Container $container) { return $container->has('dependency') ? 'has' : 'not'; }); @@ -1335,7 +1337,7 @@ public function testCallableWithContainerParameter() public function testCallableWithParameters() { - $this->container->bind('test', function(Container $c, array $params) { + $this->container->bind('test', function (Container $c, array $params) { return $params['value'] ?? 'default'; }); @@ -1346,7 +1348,7 @@ public function testCallableWithParameters() public function testNestedCallableResolution() { $this->container->bind('inner', fn() => 'inner_value'); - $this->container->bind('outer', function(Container $c) { + $this->container->bind('outer', function (Container $c) { return $c->get('inner') . '_outer'; }); @@ -1439,11 +1441,11 @@ public function testMultipleServicesResolvedConcurrently() public function testNestedContainerCalls() { - $this->container->bind('level1', function(Container $c) { + $this->container->bind('level1', function (Container $c) { return $c->get('level2') . ':1'; }); - $this->container->bind('level2', function(Container $c) { + $this->container->bind('level2', function (Container $c) { return $c->get('level3') . ':2'; }); @@ -1570,7 +1572,7 @@ public function testCallStaticMethodWithDependencies() // COMPLEX SCENARIOS //==================================== - public function testDependencyGraphWithSingletons() + public function testDependencyGraphWithSingletons() { $this->container->singleton(DependencyInterface::class, ConcreteDependency::class); $this->container->bind(ServiceInterface::class, ConcreteService::class); @@ -1585,7 +1587,7 @@ public function testDependencyGraphWithSingletons() public function testComplexDependencyWithExtend() { $this->container->bind(DependencyInterface::class, ConcreteDependency::class); - $this->container->extend(DependencyInterface::class, function($dep, $c) { + $this->container->extend(DependencyInterface::class, function ($dep, $c) { $dep->extended = true; return $dep; }); @@ -1612,11 +1614,11 @@ public function testEmptyArrayParameter() $this->assertEquals([], $instance->items); } - //======================================== - // EDGE CASES - //======================================== + //======================================== + // EDGE CASES + //======================================== - public function testBindingWithSpecialCharacters() + public function testBindingWithSpecialCharacters() { $this->container->bind('service.name', fn() => 'value'); $this->assertEquals('value', $this->container->get('service.name')); @@ -1732,7 +1734,7 @@ public function testSetAndGetInstance() public function testDependencyWithFactory() { - $this->container->bind(DependencyInterface::class, function() { + $this->container->bind(DependencyInterface::class, function () { static $counter = 0; $dep = new ConcreteDependency(); $dep->id = ++$counter; @@ -1747,7 +1749,7 @@ public function testDependencyWithFactory() public function testSingletonWithFactory() { - $this->container->singleton(DependencyInterface::class, function() { + $this->container->singleton(DependencyInterface::class, function () { static $counter = 0; $dep = new ConcreteDependency(); $dep->id = ++$counter; @@ -1764,7 +1766,7 @@ public function testSingletonWithFactory() public function testExtendWithComplexLogic() { $this->container->bind('service', fn() => ['base' => true]); - $this->container->extend('service', function($original, $c) { + $this->container->extend('service', function ($original, $c) { $original['extended'] = true; $original['dependency'] = $c->has('dep') ? 'yes' : 'no'; return $original; @@ -1835,7 +1837,7 @@ public function testCallbackWithOnlyDependencies() { $this->container->bind(DependencyInterface::class, ConcreteDependency::class); - $result = $this->container->call(function(DependencyInterface $dep) { + $result = $this->container->call(function (DependencyInterface $dep) { return get_class($dep); }); @@ -1986,7 +1988,7 @@ public function testServiceProviderWithDependencies() $this->assertTrue($this->container->has('provider_service')); } - public function testServiceProviderBootReceivesDependencies() + public function testServiceProviderBootReceivesDependencies() { $this->container->bind(DependencyInterface::class, ConcreteDependency::class); $provider = new BootableProviderWithDependencies(); @@ -2027,7 +2029,7 @@ public function testMultipleAliasesResolveSame() public function testExtendPreservesSingletonBehavior() { $this->container->singleton('service', fn() => new \stdClass()); - $this->container->extend('service', function($obj) { + $this->container->extend('service', function ($obj) { $obj->extended = true; return $obj; }); @@ -2042,7 +2044,7 @@ public function testExtendPreservesSingletonBehavior() public function testExtendPreservesTransientBehavior() { $this->container->bind('service', fn() => new \stdClass()); - $this->container->extend('service', function($obj) { + $this->container->extend('service', function ($obj) { $obj->extended = true; return $obj; }); @@ -2197,7 +2199,7 @@ public function testCallWithInvokableClass() public function testCallWithClosureBindTo() { - $closure = function() { + $closure = function () { return $this->container->has('test') ? 'yes' : 'no'; }; @@ -2370,7 +2372,7 @@ public function testResolutionWithAllFeatures() // Setup complex scenario $this->container->singleton(DependencyInterface::class, ConcreteDependency::class); $this->container->bind(ServiceInterface::class, ConcreteService::class); - $this->container->extend(DependencyInterface::class, function($dep) { + $this->container->extend(DependencyInterface::class, function ($dep) { $dep->extended = true; return $dep; }); @@ -2386,4 +2388,45 @@ public function testResolutionWithAllFeatures() $aliased = $this->container->get('service'); $this->assertInstanceOf(ConcreteService::class, $aliased); } + + // ========================================================================= + // FREEZE STATE TESTS + // ========================================================================= + + public function testServiceIsNotFrozenBeforeContainerResolves() + { + $service = new MockPaymentService(); + $this->assertFalse($service->isFrozen()); + } + + public function testServiceIsFrozenAfterMake() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertTrue($service->isFrozen()); + } + + public function testServiceIsFrozenAfterSingletonResolves() + { + $this->container->singleton(MockPaymentService::class); + $service = $this->container->get(MockPaymentService::class); + $this->assertTrue($service->isFrozen()); + } + + public function testManualFreezeWorks() + { + $service = new MockPaymentService(); + $this->assertFalse($service->isFrozen()); + $service->freeze(); + $this->assertTrue($service->isFrozen()); + } + + public function testFreezeIsIdempotent() + { + $service = new MockPaymentService(); + $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. + + $this->assertTrue($service->isFrozen()); + } } diff --git a/tests/Application/Mock/Services/MockMailerService.php b/tests/Application/Mock/Services/MockMailerService.php new file mode 100644 index 0000000..4da79ff --- /dev/null +++ b/tests/Application/Mock/Services/MockMailerService.php @@ -0,0 +1,15 @@ +count++; + } +} diff --git a/tests/Application/Mock/Services/MockPaymentService.php b/tests/Application/Mock/Services/MockPaymentService.php new file mode 100644 index 0000000..dbd52a4 --- /dev/null +++ b/tests/Application/Mock/Services/MockPaymentService.php @@ -0,0 +1,44 @@ +taxRate, 2); + $total = round($amount + $tax, 2); + return [ + 'gateway' => $this->gateway, + 'amount' => $amount, + 'tax' => $tax, + 'total' => $total, + 'status' => 'charged', + ]; + } + + public function getGateway(): string + { + return $this->gateway; + } + public function isLive(): bool + { + return $this->liveMode; + } + public function getRetries(): int + { + return $this->retries; + } +} diff --git a/tests/Application/Mock/Services/MockServiceWithConstructor.php b/tests/Application/Mock/Services/MockServiceWithConstructor.php new file mode 100644 index 0000000..1a8f2b1 --- /dev/null +++ b/tests/Application/Mock/Services/MockServiceWithConstructor.php @@ -0,0 +1,21 @@ +name = $name; + $this->value = $value; + } +} From 959689fea514ec2604a27daec75f215f6df44842 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 16:23:20 +0600 Subject: [PATCH 03/12] Unit test for immutable: property read test: --- tests/Application/ContainerTest.php | 57 +++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index b5447e9..f498d07 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -2429,4 +2429,61 @@ public function testFreezeIsIdempotent() $this->assertTrue($service->isFrozen()); } + + // ========================================================================= + // PROPERTY READ TESTS + // ========================================================================= + + public function testReadStringPropertyAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals('stripe', $service->gateway); + } + + public function testReadFloatPropertyAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals(0.08, $service->taxRate); + } + + public function testReadBoolPropertyAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertFalse($service->liveMode); + } + + public function testReadIntPropertyAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals(3, $service->retries); + } + + public function testReadArrayPropertyAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals(['card', 'bank'], $service->methods); + } + + public function testAllPropertiesRetainCorrectValuesAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + + $this->assertEquals('stripe', $service->gateway); + $this->assertEquals(0.08, $service->taxRate); + $this->assertFalse($service->liveMode); + $this->assertEquals(3, $service->retries); + $this->assertEquals(['card', 'bank'], $service->methods); + } + + public function testIssetOnFrozenPropertyReturnsTrue() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertTrue(isset($service->gateway)); + } + + public function testIssetOnNonExistentPropertyReturnsFalse() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertFalse(isset($service->nonExistent)); + } } From f95b4b8a02d2eb8e3a62619d2c839bbc8e982d26 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 16:24:43 +0600 Subject: [PATCH 04/12] Unit test for immutable: method call test: --- tests/Application/ContainerTest.php | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index f498d07..7c169e4 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -2486,4 +2486,56 @@ public function testIssetOnNonExistentPropertyReturnsFalse() $service = $this->container->make(MockPaymentService::class); $this->assertFalse(isset($service->nonExistent)); } + + // ========================================================================= + // METHOD CALL TESTS + // ========================================================================= + + public function testMethodCallAfterFreezeWorks() + { + $service = $this->container->make(MockPaymentService::class); + $result = $service->charge(100.00); + + $this->assertEquals('stripe', $result['gateway']); + $this->assertEquals(100.00, $result['amount']); + $this->assertEquals(8.00, $result['tax']); + $this->assertEquals(108.00, $result['total']); + $this->assertEquals('charged', $result['status']); + } + + public function testMethodThatReadsStringPropertyInternallyWorks() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals('stripe', $service->getGateway()); + } + + public function testMethodThatReadsBoolPropertyInternallyWorks() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertFalse($service->isLive()); + } + + public function testMethodThatReadsIntPropertyInternallyWorks() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals(3, $service->getRetries()); + } + + public function testChargeComputesTaxCorrectly() + { + $service = $this->container->make(MockPaymentService::class); + $result = $service->charge(200.00); + + $this->assertEquals(16.00, $result['tax']); + $this->assertEquals(216.00, $result['total']); + } + + public function testChargeWithZeroAmount() + { + $service = $this->container->make(MockPaymentService::class); + $result = $service->charge(0.00); + + $this->assertEquals(0.00, $result['tax']); + $this->assertEquals(0.00, $result['total']); + } } From 0e5c9ba03bbe1f06602f9cd85d1522317f17bf79 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 16:29:51 +0600 Subject: [PATCH 05/12] Unit test for immutable: mutation blocking test --- tests/Application/ContainerTest.php | 149 ++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index 7c169e4..fd11c25 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -3,6 +3,7 @@ namespace Tests\Application; use Phaseolies\DI\Container; +use Phaseolies\DI\Exceptions\ImmutableViolationException; use PHPUnit\Framework\TestCase; use RuntimeException; use Tests\Application\Mock\Abstracts\AbstractClass; @@ -2538,4 +2539,152 @@ public function testChargeWithZeroAmount() $this->assertEquals(0.00, $result['tax']); $this->assertEquals(0.00, $result['total']); } + + // ========================================================================= + // MUTATION BLOCKING TESTS — core immutability contract + // ========================================================================= + + public function testWriteStringPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->gateway = 'paypal'; + } + + public function testWriteFloatPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->taxRate = 0.0; + } + + public function testWriteBoolPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->liveMode = true; + } + + public function testWriteIntPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->retries = 99; + } + + public function testWriteArrayPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->methods = ['crypto']; + } + + public function testWriteNewDynamicPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->brandNewProp = 'sneaky'; + } + + public function testUnsetPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + unset($service->gateway); + } + + public function testExceptionMessageContainsClassName() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->taxRate = 0.0; + $this->fail('Expected ImmutableViolationException'); + } catch (ImmutableViolationException $e) { + $this->assertStringContainsString('MockPaymentService', $e->getMessage()); + } + } + + public function testExceptionMessageContainsPropertyName() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->taxRate = 0.0; + $this->fail('Expected ImmutableViolationException'); + } catch (ImmutableViolationException $e) { + $this->assertStringContainsString('taxRate', $e->getMessage()); + } + } + + public function testExceptionMessageContainsBothClassAndProperty() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->gateway = 'paypal'; + $this->fail('Expected ImmutableViolationException'); + } catch (ImmutableViolationException $e) { + $this->assertStringContainsString('MockPaymentService', $e->getMessage()); + $this->assertStringContainsString('gateway', $e->getMessage()); + } + } + + public function testPropertyValueUnchangedAfterBlockedWrite() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->taxRate = 0.0; + } catch (ImmutableViolationException $e) { + // expected — swallow + } + + $this->assertEquals(0.08, $service->taxRate); + } + + public function testGatewayUnchangedAfterBlockedWrite() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->gateway = 'paypal'; + } catch (ImmutableViolationException $e) { + // expected — swallow + } + + $this->assertEquals('stripe', $service->gateway); + } + + public function testAllWriteAttemptsThrow() + { + $service = $this->container->make(MockPaymentService::class); + $caught = 0; + + $attempts = [ + fn() => ($service->gateway = 'paypal'), + fn() => ($service->taxRate = 0.0), + fn() => ($service->liveMode = true), + fn() => ($service->retries = 99), + ]; + + foreach ($attempts as $attempt) { + try { + $attempt(); + } catch (ImmutableViolationException $e) { + $caught++; + } + } + + $this->assertEquals(4, $caught); + } + + } From 8f6407108c88f36e78dfc1406a576e8425dd701c Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 16:30:28 +0600 Subject: [PATCH 06/12] Unit test for immutable: clone protection test: --- tests/Application/ContainerTest.php | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index fd11c25..34fe0ea 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -2686,5 +2686,23 @@ public function testAllWriteAttemptsThrow() $this->assertEquals(4, $caught); } - + // ========================================================================= + // CLONE PROTECTION TESTS + // ========================================================================= + + public function testCloningFrozenServiceThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $cloned = clone $service; + } + + public function testCloningUnfrozenServiceIsAllowed() + { + $service = new MockPaymentService(); // not through container — not frozen yet + $cloned = clone $service; + + $this->assertNotSame($service, $cloned); + } } From c5c1846f52efdb855386c33da953e4094c765a42 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 16:36:10 +0600 Subject: [PATCH 07/12] Unit test for immutable: non-immutable services must be unaffected --- tests/Application/ContainerTest.php | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index 34fe0ea..91ce17e 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -66,6 +66,7 @@ use Tests\Application\Mock\Services\AlternateDependency; use Tests\Application\Mock\Services\ConcreteService; use Tests\Application\Mock\Services\ConcreteServiceLayer; +use Tests\Application\Mock\Services\MockMutableService; use Tests\Application\Mock\Services\MockPaymentService; use Tests\Application\Mock\SimpleClass; use Tests\Application\Mock\StaticCallableClass; @@ -2705,4 +2706,104 @@ public function testCloningUnfrozenServiceIsAllowed() $this->assertNotSame($service, $cloned); } + + + // ========================================================================= + // PRE-FREEZE (BOOT WINDOW) TESTS + // ========================================================================= + + public function testWriteBeforeFreezeSucceeds() + { + $service = new MockPaymentService(); + $service->gateway = 'paypal'; // not frozen — must succeed + + $this->assertEquals('paypal', $service->gateway); + } + + public function testMultipleWritesBeforeFreezeAllSucceed() + { + $service = new MockPaymentService(); + $service->gateway = 'paypal'; + $service->taxRate = 0.15; + $service->liveMode = true; + + $this->assertEquals('paypal', $service->gateway); + $this->assertEquals(0.15, $service->taxRate); + $this->assertTrue($service->liveMode); + } + + public function testFreezeSnapshotsValuesSetBeforeFreeze() + { + $service = new MockPaymentService(); + $service->gateway = 'braintree'; + $service->taxRate = 0.10; + $service->liveMode = true; + $service->freeze(); + + $this->assertEquals('braintree', $service->gateway); + $this->assertEquals(0.10, $service->taxRate); + $this->assertTrue($service->liveMode); + } + + public function testWriteAfterManualFreezeThrows() + { + $service = new MockPaymentService(); + $service->gateway = 'braintree'; // ok — pre-freeze + $service->freeze(); + + $this->expectException(ImmutableViolationException::class); + $service->gateway = 'paypal'; // must throw — post-freeze + } + + public function testServiceProviderBootWindowPattern() + { + // Simulates a ServiceProvider configuring the service before binding + $payment = new MockPaymentService(); + $payment->gateway = config_mock('payment.gateway', 'braintree'); + $payment->taxRate = config_mock('payment.tax_rate', 0.15); + + $this->container->singleton(MockPaymentService::class, fn() => $payment); + + $resolved = $this->container->get(MockPaymentService::class); + + $this->assertEquals('braintree', $resolved->gateway); + $this->assertEquals(0.15, $resolved->taxRate); + } + + // ========================================================================= + // MUTABLE SERVICE CONTROL — non-immutable services must be unaffected + // ========================================================================= + + public function testMutableServiceCanBeModified() + { + $service = $this->container->make(MockMutableService::class); + $service->state = 'modified'; + + $this->assertEquals('modified', $service->state); + } + + public function testMutableServiceMethodMutatesState() + { + $service = $this->container->make(MockMutableService::class); + $service->increment(); + $service->increment(); + + $this->assertEquals(2, $service->count); + } + + public function testMutableSingletonStateIsShared() + { + $this->container->singleton(MockMutableService::class); + + $a = $this->container->get(MockMutableService::class); + $a->increment(); + + $b = $this->container->get(MockMutableService::class); + $this->assertEquals(1, $b->count); + } } + +function config_mock(string $key, mixed $default = null): mixed +{ + return $default; +} \ No newline at end of file From 5646fb6ff3756568c1d7f67e5bbf1055e6619587 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 16:36:49 +0600 Subject: [PATCH 08/12] Unit test for immutable: singleton test --- tests/Application/ContainerTest.php | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index 91ce17e..69ace60 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -2801,6 +2801,51 @@ public function testMutableSingletonStateIsShared() $b = $this->container->get(MockMutableService::class); $this->assertEquals(1, $b->count); } + + // ========================================================================= + // SINGLETON BEHAVIOUR WITH IMMUTABLE + // ========================================================================= + + public function testImmutableSingletonReturnsSameInstance() + { + $this->container->singleton(MockPaymentService::class); + + $first = $this->container->get(MockPaymentService::class); + $second = $this->container->get(MockPaymentService::class); + + $this->assertSame($first, $second); + } + + public function testImmutableSingletonIsFrozenOnFirstResolve() + { + $this->container->singleton(MockPaymentService::class); + $first = $this->container->get(MockPaymentService::class); + + $this->assertTrue($first->isFrozen()); + } + + public function testImmutableSingletonBlocksMutationOnSecondResolve() + { + $this->container->singleton(MockPaymentService::class); + $this->container->get(MockPaymentService::class); + + $second = $this->container->get(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $second->taxRate = 0.0; + } + + public function testImmutableSingletonBothReferencesAreFrozen() + { + $this->container->singleton(MockPaymentService::class); + + $first = $this->container->get(MockPaymentService::class); + $second = $this->container->get(MockPaymentService::class); + + $this->assertTrue($first->isFrozen()); + $this->assertTrue($second->isFrozen()); + $this->assertSame($first, $second); + } } function config_mock(string $key, mixed $default = null): mixed From 757374702cee2e8712f96ce916225727e4f93cfa Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 16:37:14 +0600 Subject: [PATCH 09/12] Unit test for immutable: transient behavior test --- tests/Application/ContainerTest.php | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index 69ace60..65597b7 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -2846,6 +2846,50 @@ public function testImmutableSingletonBothReferencesAreFrozen() $this->assertTrue($second->isFrozen()); $this->assertSame($first, $second); } + + // ========================================================================= + // TRANSIENT BEHAVIOUR WITH IMMUTABLE + // ========================================================================= + + public function testImmutableTransientReturnsDifferentInstances() + { + $first = $this->container->make(MockPaymentService::class); + $second = $this->container->make(MockPaymentService::class); + + $this->assertNotSame($first, $second); + } + + public function testImmutableTransientEachInstanceIsFrozen() + { + $first = $this->container->make(MockPaymentService::class); + $second = $this->container->make(MockPaymentService::class); + + $this->assertTrue($first->isFrozen()); + $this->assertTrue($second->isFrozen()); + } + + public function testImmutableTransientMutationBlockedOnBothInstances() + { + $first = $this->container->make(MockPaymentService::class); + $second = $this->container->make(MockPaymentService::class); + + $firstThrew = false; + $secondThrew = false; + + try { + $first->gateway = 'paypal'; + } catch (ImmutableViolationException $e) { + $firstThrew = true; + } + try { + $second->gateway = 'paypal'; + } catch (ImmutableViolationException $e) { + $secondThrew = true; + } + + $this->assertTrue($firstThrew); + $this->assertTrue($secondThrew); + } } function config_mock(string $key, mixed $default = null): mixed From 0cc677af7dddf5da490882d375d1a2ffd048f70a Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 16:37:54 +0600 Subject: [PATCH 10/12] Unit test for immutable: constructor injection with immutable --- tests/Application/ContainerTest.php | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index 65597b7..7d6d289 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -68,6 +68,7 @@ use Tests\Application\Mock\Services\ConcreteServiceLayer; use Tests\Application\Mock\Services\MockMutableService; use Tests\Application\Mock\Services\MockPaymentService; +use Tests\Application\Mock\Services\MockServiceWithConstructor; use Tests\Application\Mock\SimpleClass; use Tests\Application\Mock\StaticCallableClass; use TypeError; @@ -2890,6 +2891,51 @@ public function testImmutableTransientMutationBlockedOnBothInstances() $this->assertTrue($firstThrew); $this->assertTrue($secondThrew); } + + // ========================================================================= + // CONSTRUCTOR INJECTION WITH IMMUTABLE + // ========================================================================= + + public function testImmutableServiceWithConstructorArgsIsFrozen() + { + $service = $this->container->make(MockServiceWithConstructor::class, [ + 'name' => 'doppar', + 'value' => 42, + ]); + + $this->assertTrue($service->isFrozen()); + } + + public function testImmutableServiceWithConstructorArgsPreservesValues() + { + $service = $this->container->make(MockServiceWithConstructor::class, [ + 'name' => 'doppar', + 'value' => 42, + ]); + + $this->assertEquals('doppar', $service->name); + $this->assertEquals(42, $service->value); + } + + public function testImmutableServiceWithConstructorArgsMutationThrows() + { + $service = $this->container->make(MockServiceWithConstructor::class, [ + 'name' => 'doppar', + 'value' => 42, + ]); + + $this->expectException(ImmutableViolationException::class); + $service->name = 'hacked'; + } + + public function testImmutableServiceConstructorDefaultsPreserved() + { + $service = $this->container->make(MockServiceWithConstructor::class); + + $this->assertEquals('default', $service->name); + $this->assertEquals(0, $service->value); + $this->assertTrue($service->isFrozen()); + } } function config_mock(string $key, mixed $default = null): mixed From 305a770e956078e355d2bfde14e7ccf1f5f5badb Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 16:38:32 +0600 Subject: [PATCH 11/12] Unit test for immutable: multiple immutable --- tests/Application/ContainerTest.php | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index 7d6d289..5c9a42f 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -66,6 +66,7 @@ use Tests\Application\Mock\Services\AlternateDependency; use Tests\Application\Mock\Services\ConcreteService; use Tests\Application\Mock\Services\ConcreteServiceLayer; +use Tests\Application\Mock\Services\MockMailerService; use Tests\Application\Mock\Services\MockMutableService; use Tests\Application\Mock\Services\MockPaymentService; use Tests\Application\Mock\Services\MockServiceWithConstructor; @@ -2936,6 +2937,50 @@ public function testImmutableServiceConstructorDefaultsPreserved() $this->assertEquals(0, $service->value); $this->assertTrue($service->isFrozen()); } + + // ========================================================================= + // MULTIPLE IMMUTABLE SERVICES + // ========================================================================= + + public function testMultipleImmutableServicesAreIndependentlyFrozen() + { + $payment = $this->container->make(MockPaymentService::class); + $mailer = $this->container->make(MockMailerService::class); + + $this->assertTrue($payment->isFrozen()); + $this->assertTrue($mailer->isFrozen()); + } + + public function testMultipleImmutableServicesGuardedIndependently() + { + $payment = $this->container->make(MockPaymentService::class); + $mailer = $this->container->make(MockMailerService::class); + + $paymentThrew = false; + $mailerThrew = false; + + try { + $payment->gateway = 'paypal'; + } catch (ImmutableViolationException $e) { + $paymentThrew = true; + } + try { + $mailer->host = 'smtp.hacked.com'; + } catch (ImmutableViolationException $e) { + $mailerThrew = true; + } + + $this->assertTrue($paymentThrew); + $this->assertTrue($mailerThrew); + } + + public function testMailerServiceReadsWorkAfterFreeze() + { + $mailer = $this->container->make(MockMailerService::class); + + $this->assertEquals('smtp.example.com', $mailer->host); + $this->assertEquals(587, $mailer->port); + } } function config_mock(string $key, mixed $default = null): mixed From 880fb700bb67f762be042326a23dc48cae366d5f Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 1 Mar 2026 16:41:05 +0600 Subject: [PATCH 12/12] Unit test for immutable: done --- .../Mock/Services/MockImmutableWithoutTrait.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/Application/Mock/Services/MockImmutableWithoutTrait.php diff --git a/tests/Application/Mock/Services/MockImmutableWithoutTrait.php b/tests/Application/Mock/Services/MockImmutableWithoutTrait.php new file mode 100644 index 0000000..296a07b --- /dev/null +++ b/tests/Application/Mock/Services/MockImmutableWithoutTrait.php @@ -0,0 +1,12 @@ +