From 0439fa4641956e82bcbe563f75a4eb8fd79d8805 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 9 Sep 2026 11:40:51 +0200 Subject: [PATCH 01/15] wip --- packages/container/src/GenericContainer.php | 29 +- packages/container/src/Lifetime.php | 14 + packages/container/src/Singleton.php | 1 + .../container/tests/ContainerLifetimeTest.php | 248 ++++++++++++++++++ packages/container/tests/ContainerTest.php | 2 +- .../Fixtures/ProcessLifetimeSingleton.php | 10 + .../RequestLifetimeClassInitializer.php | 19 ++ .../Fixtures/RequestLifetimeDependency.php | 12 + .../RequestLifetimeDynamicInitializer.php | 26 ++ .../Fixtures/RequestLifetimeInitializer.php | 19 ++ .../Fixtures/RequestLifetimeInterface.php | 7 + .../Fixtures/RequestLifetimeSingleton.php | 13 + 12 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 packages/container/src/Lifetime.php create mode 100644 packages/container/tests/ContainerLifetimeTest.php create mode 100644 packages/container/tests/Fixtures/ProcessLifetimeSingleton.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeClassInitializer.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeDependency.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeDynamicInitializer.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeInitializer.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeInterface.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeSingleton.php diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 583ed878e9..3cc224752e 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -21,6 +21,7 @@ use Tempest\Reflection\TypeReflector; use Throwable; use UnitEnum; +use WeakMap; use const ARRAY_FILTER_USE_BOTH; @@ -31,6 +32,9 @@ final class GenericContainer implements Container /** @var array, DynamicInitializer> */ private array $resolvedDynamicInitializers = []; + /** @var WeakMap */ + private WeakMap $singletonLifetimes; + public function __construct( /** @var ArrayIterator $definitions */ private(set) ArrayIterator $definitions = new ArrayIterator(), @@ -55,6 +59,7 @@ public function __construct( private(set) ?DependencyChain $chain = null, ) { + $this->singletonLifetimes = new WeakMap(); $this->singleton(Container::class, $this); $this->singleton(ContainerInterface::class, $this); $this->singleton(GenericContainer::class, $this); @@ -407,9 +412,12 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()), }; - $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class); + $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass + ->getMethod('initialize') + ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); if ($singleton !== null) { + $this->singletonLifetimes[$object] = $singleton->lifetime; $this->singleton($className, $object, $tag); } @@ -506,8 +514,9 @@ private function autowire(string $className, string|UnitEnum|null $tag, mixed .. if ( ! $classReflector->getType()->matches(Initializer::class) && ! $classReflector->getType()->matches(DynamicInitializer::class) - && $classReflector->hasAttribute(Singleton::class) + && ($singleton = $classReflector->getAttribute(Singleton::class)) !== null ) { + $this->singletonLifetimes[$instance] = $singleton->lifetime; $this->singleton($className, $instance, $tag); } @@ -744,7 +753,6 @@ public function addResettable(string|ClassReflector $resettableClass): Container public function reset(): self { - $this->resolvedSingletons = new ArrayIterator(); $this->resolvedDynamicInitializers = []; foreach ($this->resettables as $resettableClass) { @@ -754,6 +762,21 @@ public function reset(): self $resettable->reset(); } + foreach ([$this->singletonDefinitions, $this->resolvedSingletons] as $singletons) { + foreach ($singletons->getArrayCopy() as $dependencyName => $instance) { + // Factories remain registered so the next request can create a fresh instance. + if (! is_object($instance) || $instance instanceof Closure) { + continue; + } + + $this->singletonLifetimes[$instance] ??= new ClassReflector($instance)->getAttribute(Singleton::class)->lifetime ?? Lifetime::PROCESS; + + if ($this->singletonLifetimes[$instance] === Lifetime::REQUEST) { + unset($singletons[$dependencyName]); + } + } + } + return $this; } diff --git a/packages/container/src/Lifetime.php b/packages/container/src/Lifetime.php new file mode 100644 index 0000000000..d07dbc34bd --- /dev/null +++ b/packages/container/src/Lifetime.php @@ -0,0 +1,14 @@ +get(ProcessLifetimeSingleton::class); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeSingleton::class); + $tagged = $container->get(RequestLifetimeSingleton::class, 'tag'); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($tagged, $container->get(RequestLifetimeSingleton::class, 'tag')); + $this->assertNotSame($instance, $tagged); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertNotSame($tagged, $container->get(RequestLifetimeSingleton::class, 'tag')); + $this->assertSame($process, $container->get(ProcessLifetimeSingleton::class)); + } + } + + #[Test] + public function lifetimes_are_tracked_when_resolving_nested_dependencies(): void + { + $container = new GenericContainer(); + $instance = $container->get(RequestLifetimeDependency::class)->singleton; + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeDependency::class)->singleton); + } + + #[Test] + public function initializer_singletons_are_shared_until_reset(): void + { + foreach ([RequestLifetimeInitializer::class, RequestLifetimeClassInitializer::class, RequestLifetimeDynamicInitializer::class] as $initializer) { + $container = new GenericContainer(); + $discovery = new InitializerDiscovery($container); + $discovery->setItems(new DiscoveryItems()); + $discovery->discover(new DiscoveryLocation(__NAMESPACE__, __DIR__), new ClassReflector($initializer)); + $discovery->apply(); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class)); + } + } + } + + #[Test] + public function tagged_initializer_singletons_are_cleared(): void + { + $container = new GenericContainer(); + $container->addInitializer(RequestLifetimeInitializer::class); + $instance = $container->get(RequestLifetimeInterface::class, 'tag'); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class, 'tag')); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class, 'tag')); + } + + #[Test] + public function discovered_interface_bindings_follow_the_class_lifetime(): void + { + $container = new GenericContainer(); + $discovery = new AutowireDiscovery($container); + $discovery->setItems(new DiscoveryItems()); + $discovery->discover(new DiscoveryLocation(__NAMESPACE__, __DIR__), new ClassReflector(RequestLifetimeSingleton::class)); + $discovery->apply(); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + + $container->reset(); + + $next = $container->get(RequestLifetimeInterface::class); + $this->assertNotSame($instance, $next); + $this->assertSame($next, $container->get(RequestLifetimeSingleton::class)); + } + } + + #[Test] + public function registered_factories_follow_their_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, fn () => new SingletonClass()); + $container->singleton(RequestLifetimeInterface::class, fn () => new RequestLifetimeSingleton(), 'request'); + $process = $container->get(SingletonClass::class); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class, 'request'); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class, 'request')); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class, 'request')); + $this->assertSame($process, $container->get(SingletonClass::class)); + } + } + + #[Test] + public function process_initializer_singletons_survive_reset(): void + { + $container = new GenericContainer(); + $container->addInitializer(SingletonInitializer::class); + $instance = $container->get(ContainerObjectE::class); + + $container->reset(); + + $this->assertSame($instance, $container->get(ContainerObjectE::class)); + } + + #[Test] + public function initialized_classes_can_declare_their_own_lifetime(): void + { + $initializer = new class implements Initializer { + public function initialize(Container $container): RequestLifetimeSingleton + { + return new RequestLifetimeSingleton(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + $instance = $container->get(RequestLifetimeSingleton::class); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + } + + #[Test] + public function registered_request_instances_are_removed(): void + { + $container = new GenericContainer(); + $container->singleton(RequestLifetimeSingleton::class, $instance = new RequestLifetimeSingleton()); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertFalse($container->has(RequestLifetimeSingleton::class)); + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + } + + #[Test] + public function initializer_lifetime_is_shared_by_all_bindings_of_the_instance(): void + { + $initializer = new class implements Initializer { + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(Container $container): SingletonClass + { + return new SingletonClass(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + $container->singleton(SingletonClass::class, fn (Container $container) => $container->get(SingletonClass::class), 'alias'); + + for ($request = 0; $request < 3; $request++) { + // Parameter resolution uses an internal container clone. + $instance = $container->invoke(fn (SingletonClass $instance) => $instance); + $container->singleton(SingletonClass::class, $instance, 'instance'); + $this->assertSame($instance, $container->get(SingletonClass::class, 'alias')); + $this->assertSame($instance, $container->get(SingletonClass::class, 'instance')); + + $container->reset(); + + $this->assertFalse($container->has(SingletonClass::class, 'instance')); + $next = $container->get(SingletonClass::class, 'alias'); + $this->assertNotSame($instance, $next); + $this->assertSame($next, $container->get(SingletonClass::class)); + } + } + + #[Test] + public function initializer_lifetime_takes_precedence_over_the_class_lifetime(): void + { + $initializer = new class implements Initializer { + #[Singleton(lifetime: Lifetime::PROCESS)] + public function initialize(Container $container): RequestLifetimeSingleton + { + return new RequestLifetimeSingleton(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + $instance = $container->get(RequestLifetimeSingleton::class); + $container->singleton(RequestLifetimeInterface::class, $instance); + + $container->reset(); + + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + } + + #[Test] + public function replacing_a_request_singleton_updates_its_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, new RequestLifetimeSingleton()); + $container->get(SingletonClass::class); + $container->singleton(SingletonClass::class, $process = new SingletonClass()); + + $container->reset(); + + $this->assertSame($process, $container->get(SingletonClass::class)); + } +} diff --git a/packages/container/tests/ContainerTest.php b/packages/container/tests/ContainerTest.php index b37f7e8e6b..7b833f7b34 100644 --- a/packages/container/tests/ContainerTest.php +++ b/packages/container/tests/ContainerTest.php @@ -860,7 +860,7 @@ public function reset(): void $container->get(SingletonClass::class); $container->get(SingletonClass::class); - $this->assertSame(2, SingletonClass::$count); // constructed twice, once before and once after reset + $this->assertSame(1, SingletonClass::$count); $this->assertTrue(ResettableDependency::$reset); } } diff --git a/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php b/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php new file mode 100644 index 0000000000..a447a0c3c7 --- /dev/null +++ b/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php @@ -0,0 +1,10 @@ +getName() === RequestLifetimeInterface::class; + } + + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Container $container): object + { + return new RequestLifetimeSingleton(); + } +} diff --git a/packages/container/tests/Fixtures/RequestLifetimeInitializer.php b/packages/container/tests/Fixtures/RequestLifetimeInitializer.php new file mode 100644 index 0000000000..2265cceedf --- /dev/null +++ b/packages/container/tests/Fixtures/RequestLifetimeInitializer.php @@ -0,0 +1,19 @@ + Date: Wed, 9 Sep 2026 11:44:35 +0200 Subject: [PATCH 02/15] wip --- docs/1-essentials/05-container.md | 39 ++++++++++++++++++++- packages/container/src/GenericContainer.php | 9 ++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md index 9de0854cc5..855d2a7123 100644 --- a/docs/1-essentials/05-container.md +++ b/docs/1-essentials/05-container.md @@ -224,7 +224,7 @@ final readonly class Client } ``` -Furthermore, an initializer method can be annotated as a `#[Singleton]`, meaning its return object will only ever be resolved once: +Furthermore, an initializer method can be annotated as a `#[Singleton]`, meaning its return object will be reused for the singleton’s lifetime: ```php app/MarkdownInitializer.php use Tempest\Console\ConsoleCommand; @@ -241,6 +241,43 @@ final readonly class MarkdownInitializer implements Initializer } ``` +### Singleton lifetimes + +By default, singletons use {`Tempest\Container\Lifetime::PROCESS`} as their lifetime: the container keeps the same instance for the lifetime of the process, including across requests in long-running applications. + +For objects that hold request-specific state, use `Lifetime::REQUEST`: + +```php +use Tempest\Container\Lifetime; +use Tempest\Container\Singleton; + +#[Singleton(lifetime: Lifetime::REQUEST)] +final class RequestContext +{ + public ?string $tenantId = null; +} +``` + +The container reuses this instance within a request. When the container is reset, it clears the instance so that the next request receives a fresh one, this happens after every request in long-running applications. + +Note that you can also declare the lifetime via initializers: + +```php app/RequestContextInitializer.php +use Tempest\Container\Container; +use Tempest\Container\Initializer; +use Tempest\Container\Lifetime; +use Tempest\Container\Singleton; + +final class RequestContextInitializer implements Initializer +{ + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(Container $container): RequestContext + { + return new RequestContext(); + } +} +``` + ### Tagged singletons In some cases, you want more control over singleton definitions. diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 3cc224752e..1736dffde7 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -58,7 +58,8 @@ public function __construct( private(set) ArrayIterator $resettables = new ArrayIterator(), private(set) ?DependencyChain $chain = null, - ) { + ) + { $this->singletonLifetimes = new WeakMap(); $this->singleton(Container::class, $this); $this->singleton(ContainerInterface::class, $this); @@ -412,9 +413,9 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()), }; - $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass - ->getMethod('initialize') - ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); + $singleton = $initializerClass->getAttribute(Singleton::class) + ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class) + ?? $class->getAttribute(Singleton::class); if ($singleton !== null) { $this->singletonLifetimes[$object] = $singleton->lifetime; From 1056a47c3c342ce255d147861954aa7d0e36e015 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 9 Sep 2026 11:45:35 +0200 Subject: [PATCH 03/15] wip --- docs/1-essentials/05-container.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md index 855d2a7123..a83f9836ae 100644 --- a/docs/1-essentials/05-container.md +++ b/docs/1-essentials/05-container.md @@ -245,7 +245,7 @@ final readonly class MarkdownInitializer implements Initializer By default, singletons use {`Tempest\Container\Lifetime::PROCESS`} as their lifetime: the container keeps the same instance for the lifetime of the process, including across requests in long-running applications. -For objects that hold request-specific state, use `Lifetime::REQUEST`: +For objects that hold request-specific state, you can specify `Lifetime::REQUEST` instead: ```php use Tempest\Container\Lifetime; @@ -262,7 +262,7 @@ The container reuses this instance within a request. When the container is reset Note that you can also declare the lifetime via initializers: -```php app/RequestContextInitializer.php +```php use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Lifetime; From d0da26ad3f1f98f3dcd3e1960da992eb31869165 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 9 Sep 2026 11:48:45 +0200 Subject: [PATCH 04/15] wip --- packages/container/src/GenericContainer.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 1736dffde7..dccd7d0495 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -58,8 +58,7 @@ public function __construct( private(set) ArrayIterator $resettables = new ArrayIterator(), private(set) ?DependencyChain $chain = null, - ) - { + ) { $this->singletonLifetimes = new WeakMap(); $this->singleton(Container::class, $this); $this->singleton(ContainerInterface::class, $this); @@ -413,9 +412,10 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()), }; - $singleton = $initializerClass->getAttribute(Singleton::class) - ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class) - ?? $class->getAttribute(Singleton::class); + $singleton = + $initializerClass->getAttribute(Singleton::class) ?? $initializerClass + ->getMethod('initialize') + ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); if ($singleton !== null) { $this->singletonLifetimes[$object] = $singleton->lifetime; @@ -766,10 +766,12 @@ public function reset(): self foreach ([$this->singletonDefinitions, $this->resolvedSingletons] as $singletons) { foreach ($singletons->getArrayCopy() as $dependencyName => $instance) { // Factories remain registered so the next request can create a fresh instance. - if (! is_object($instance) || $instance instanceof Closure) { + if (! is_object($instance)) { + continue; + } + if ($instance instanceof Closure) { continue; } - $this->singletonLifetimes[$instance] ??= new ClassReflector($instance)->getAttribute(Singleton::class)->lifetime ?? Lifetime::PROCESS; if ($this->singletonLifetimes[$instance] === Lifetime::REQUEST) { From d8f31329257e6431511b0a3a528e8f477a2df1f2 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 9 Sep 2026 11:50:33 +0200 Subject: [PATCH 05/15] wip --- packages/container/tests/ContainerLifetimeTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/container/tests/ContainerLifetimeTest.php b/packages/container/tests/ContainerLifetimeTest.php index 11ba5e20c9..bbe790edd5 100644 --- a/packages/container/tests/ContainerLifetimeTest.php +++ b/packages/container/tests/ContainerLifetimeTest.php @@ -89,6 +89,7 @@ public function tagged_initializer_singletons_are_cleared(): void { $container = new GenericContainer(); $container->addInitializer(RequestLifetimeInitializer::class); + $instance = $container->get(RequestLifetimeInterface::class, 'tag'); $this->assertSame($instance, $container->get(RequestLifetimeInterface::class, 'tag')); @@ -125,6 +126,7 @@ public function registered_factories_follow_their_lifetime(): void $container = new GenericContainer(); $container->singleton(SingletonClass::class, fn () => new SingletonClass()); $container->singleton(RequestLifetimeInterface::class, fn () => new RequestLifetimeSingleton(), 'request'); + $process = $container->get(SingletonClass::class); for ($request = 0; $request < 3; $request++) { @@ -143,6 +145,7 @@ public function process_initializer_singletons_survive_reset(): void { $container = new GenericContainer(); $container->addInitializer(SingletonInitializer::class); + $instance = $container->get(ContainerObjectE::class); $container->reset(); @@ -161,6 +164,7 @@ public function initialize(Container $container): RequestLifetimeSingleton }; $container = new GenericContainer(); $container->addInitializer($initializer::class); + $instance = $container->get(RequestLifetimeSingleton::class); $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); @@ -224,6 +228,7 @@ public function initialize(Container $container): RequestLifetimeSingleton }; $container = new GenericContainer(); $container->addInitializer($initializer::class); + $instance = $container->get(RequestLifetimeSingleton::class); $container->singleton(RequestLifetimeInterface::class, $instance); From 94917bc01f779e93d42566b7c60f9db08c5e36de Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 9 Sep 2026 12:09:53 +0200 Subject: [PATCH 06/15] wip --- packages/console/tests/TerminalTest.php | 1 + packages/container/src/GenericContainer.php | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/console/tests/TerminalTest.php b/packages/console/tests/TerminalTest.php index e99cd5b090..ff249f96c2 100644 --- a/packages/console/tests/TerminalTest.php +++ b/packages/console/tests/TerminalTest.php @@ -61,6 +61,7 @@ public function cursor_is_hidden_while_redrawing_content(): void $terminal = new Terminal($console); $terminal->disableTty(); $terminal->cursor = $cursor; + $events = []; iterator_to_array($terminal->render(new TextInputComponent(label: 'Name'))); diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index dccd7d0495..01508ec9f9 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -412,10 +412,9 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()), }; - $singleton = - $initializerClass->getAttribute(Singleton::class) ?? $initializerClass - ->getMethod('initialize') - ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); + $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass + ->getMethod('initialize') + ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); if ($singleton !== null) { $this->singletonLifetimes[$object] = $singleton->lifetime; @@ -769,9 +768,11 @@ public function reset(): self if (! is_object($instance)) { continue; } + if ($instance instanceof Closure) { continue; } + $this->singletonLifetimes[$instance] ??= new ClassReflector($instance)->getAttribute(Singleton::class)->lifetime ?? Lifetime::PROCESS; if ($this->singletonLifetimes[$instance] === Lifetime::REQUEST) { From e634d4093b74fba6296b0e826e73a9a725bf85b4 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 9 Sep 2026 11:40:51 +0200 Subject: [PATCH 07/15] wip --- packages/container/src/GenericContainer.php | 29 +- packages/container/src/Lifetime.php | 14 + packages/container/src/Singleton.php | 1 + .../container/tests/ContainerLifetimeTest.php | 248 ++++++++++++++++++ packages/container/tests/ContainerTest.php | 2 +- .../Fixtures/ProcessLifetimeSingleton.php | 10 + .../RequestLifetimeClassInitializer.php | 19 ++ .../Fixtures/RequestLifetimeDependency.php | 12 + .../RequestLifetimeDynamicInitializer.php | 26 ++ .../Fixtures/RequestLifetimeInitializer.php | 19 ++ .../Fixtures/RequestLifetimeInterface.php | 7 + .../Fixtures/RequestLifetimeSingleton.php | 13 + 12 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 packages/container/src/Lifetime.php create mode 100644 packages/container/tests/ContainerLifetimeTest.php create mode 100644 packages/container/tests/Fixtures/ProcessLifetimeSingleton.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeClassInitializer.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeDependency.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeDynamicInitializer.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeInitializer.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeInterface.php create mode 100644 packages/container/tests/Fixtures/RequestLifetimeSingleton.php diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 583ed878e9..3cc224752e 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -21,6 +21,7 @@ use Tempest\Reflection\TypeReflector; use Throwable; use UnitEnum; +use WeakMap; use const ARRAY_FILTER_USE_BOTH; @@ -31,6 +32,9 @@ final class GenericContainer implements Container /** @var array, DynamicInitializer> */ private array $resolvedDynamicInitializers = []; + /** @var WeakMap */ + private WeakMap $singletonLifetimes; + public function __construct( /** @var ArrayIterator $definitions */ private(set) ArrayIterator $definitions = new ArrayIterator(), @@ -55,6 +59,7 @@ public function __construct( private(set) ?DependencyChain $chain = null, ) { + $this->singletonLifetimes = new WeakMap(); $this->singleton(Container::class, $this); $this->singleton(ContainerInterface::class, $this); $this->singleton(GenericContainer::class, $this); @@ -407,9 +412,12 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()), }; - $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class); + $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass + ->getMethod('initialize') + ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); if ($singleton !== null) { + $this->singletonLifetimes[$object] = $singleton->lifetime; $this->singleton($className, $object, $tag); } @@ -506,8 +514,9 @@ private function autowire(string $className, string|UnitEnum|null $tag, mixed .. if ( ! $classReflector->getType()->matches(Initializer::class) && ! $classReflector->getType()->matches(DynamicInitializer::class) - && $classReflector->hasAttribute(Singleton::class) + && ($singleton = $classReflector->getAttribute(Singleton::class)) !== null ) { + $this->singletonLifetimes[$instance] = $singleton->lifetime; $this->singleton($className, $instance, $tag); } @@ -744,7 +753,6 @@ public function addResettable(string|ClassReflector $resettableClass): Container public function reset(): self { - $this->resolvedSingletons = new ArrayIterator(); $this->resolvedDynamicInitializers = []; foreach ($this->resettables as $resettableClass) { @@ -754,6 +762,21 @@ public function reset(): self $resettable->reset(); } + foreach ([$this->singletonDefinitions, $this->resolvedSingletons] as $singletons) { + foreach ($singletons->getArrayCopy() as $dependencyName => $instance) { + // Factories remain registered so the next request can create a fresh instance. + if (! is_object($instance) || $instance instanceof Closure) { + continue; + } + + $this->singletonLifetimes[$instance] ??= new ClassReflector($instance)->getAttribute(Singleton::class)->lifetime ?? Lifetime::PROCESS; + + if ($this->singletonLifetimes[$instance] === Lifetime::REQUEST) { + unset($singletons[$dependencyName]); + } + } + } + return $this; } diff --git a/packages/container/src/Lifetime.php b/packages/container/src/Lifetime.php new file mode 100644 index 0000000000..d07dbc34bd --- /dev/null +++ b/packages/container/src/Lifetime.php @@ -0,0 +1,14 @@ +get(ProcessLifetimeSingleton::class); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeSingleton::class); + $tagged = $container->get(RequestLifetimeSingleton::class, 'tag'); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($tagged, $container->get(RequestLifetimeSingleton::class, 'tag')); + $this->assertNotSame($instance, $tagged); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertNotSame($tagged, $container->get(RequestLifetimeSingleton::class, 'tag')); + $this->assertSame($process, $container->get(ProcessLifetimeSingleton::class)); + } + } + + #[Test] + public function lifetimes_are_tracked_when_resolving_nested_dependencies(): void + { + $container = new GenericContainer(); + $instance = $container->get(RequestLifetimeDependency::class)->singleton; + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeDependency::class)->singleton); + } + + #[Test] + public function initializer_singletons_are_shared_until_reset(): void + { + foreach ([RequestLifetimeInitializer::class, RequestLifetimeClassInitializer::class, RequestLifetimeDynamicInitializer::class] as $initializer) { + $container = new GenericContainer(); + $discovery = new InitializerDiscovery($container); + $discovery->setItems(new DiscoveryItems()); + $discovery->discover(new DiscoveryLocation(__NAMESPACE__, __DIR__), new ClassReflector($initializer)); + $discovery->apply(); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class)); + } + } + } + + #[Test] + public function tagged_initializer_singletons_are_cleared(): void + { + $container = new GenericContainer(); + $container->addInitializer(RequestLifetimeInitializer::class); + $instance = $container->get(RequestLifetimeInterface::class, 'tag'); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class, 'tag')); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class, 'tag')); + } + + #[Test] + public function discovered_interface_bindings_follow_the_class_lifetime(): void + { + $container = new GenericContainer(); + $discovery = new AutowireDiscovery($container); + $discovery->setItems(new DiscoveryItems()); + $discovery->discover(new DiscoveryLocation(__NAMESPACE__, __DIR__), new ClassReflector(RequestLifetimeSingleton::class)); + $discovery->apply(); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + + $container->reset(); + + $next = $container->get(RequestLifetimeInterface::class); + $this->assertNotSame($instance, $next); + $this->assertSame($next, $container->get(RequestLifetimeSingleton::class)); + } + } + + #[Test] + public function registered_factories_follow_their_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, fn () => new SingletonClass()); + $container->singleton(RequestLifetimeInterface::class, fn () => new RequestLifetimeSingleton(), 'request'); + $process = $container->get(SingletonClass::class); + + for ($request = 0; $request < 3; $request++) { + $instance = $container->get(RequestLifetimeInterface::class, 'request'); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class, 'request')); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeInterface::class, 'request')); + $this->assertSame($process, $container->get(SingletonClass::class)); + } + } + + #[Test] + public function process_initializer_singletons_survive_reset(): void + { + $container = new GenericContainer(); + $container->addInitializer(SingletonInitializer::class); + $instance = $container->get(ContainerObjectE::class); + + $container->reset(); + + $this->assertSame($instance, $container->get(ContainerObjectE::class)); + } + + #[Test] + public function initialized_classes_can_declare_their_own_lifetime(): void + { + $initializer = new class implements Initializer { + public function initialize(Container $container): RequestLifetimeSingleton + { + return new RequestLifetimeSingleton(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + $instance = $container->get(RequestLifetimeSingleton::class); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + } + + #[Test] + public function registered_request_instances_are_removed(): void + { + $container = new GenericContainer(); + $container->singleton(RequestLifetimeSingleton::class, $instance = new RequestLifetimeSingleton()); + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + + $container->reset(); + + $this->assertFalse($container->has(RequestLifetimeSingleton::class)); + $this->assertNotSame($instance, $container->get(RequestLifetimeSingleton::class)); + } + + #[Test] + public function initializer_lifetime_is_shared_by_all_bindings_of_the_instance(): void + { + $initializer = new class implements Initializer { + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(Container $container): SingletonClass + { + return new SingletonClass(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + $container->singleton(SingletonClass::class, fn (Container $container) => $container->get(SingletonClass::class), 'alias'); + + for ($request = 0; $request < 3; $request++) { + // Parameter resolution uses an internal container clone. + $instance = $container->invoke(fn (SingletonClass $instance) => $instance); + $container->singleton(SingletonClass::class, $instance, 'instance'); + $this->assertSame($instance, $container->get(SingletonClass::class, 'alias')); + $this->assertSame($instance, $container->get(SingletonClass::class, 'instance')); + + $container->reset(); + + $this->assertFalse($container->has(SingletonClass::class, 'instance')); + $next = $container->get(SingletonClass::class, 'alias'); + $this->assertNotSame($instance, $next); + $this->assertSame($next, $container->get(SingletonClass::class)); + } + } + + #[Test] + public function initializer_lifetime_takes_precedence_over_the_class_lifetime(): void + { + $initializer = new class implements Initializer { + #[Singleton(lifetime: Lifetime::PROCESS)] + public function initialize(Container $container): RequestLifetimeSingleton + { + return new RequestLifetimeSingleton(); + } + }; + $container = new GenericContainer(); + $container->addInitializer($initializer::class); + $instance = $container->get(RequestLifetimeSingleton::class); + $container->singleton(RequestLifetimeInterface::class, $instance); + + $container->reset(); + + $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + } + + #[Test] + public function replacing_a_request_singleton_updates_its_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, new RequestLifetimeSingleton()); + $container->get(SingletonClass::class); + $container->singleton(SingletonClass::class, $process = new SingletonClass()); + + $container->reset(); + + $this->assertSame($process, $container->get(SingletonClass::class)); + } +} diff --git a/packages/container/tests/ContainerTest.php b/packages/container/tests/ContainerTest.php index b37f7e8e6b..7b833f7b34 100644 --- a/packages/container/tests/ContainerTest.php +++ b/packages/container/tests/ContainerTest.php @@ -860,7 +860,7 @@ public function reset(): void $container->get(SingletonClass::class); $container->get(SingletonClass::class); - $this->assertSame(2, SingletonClass::$count); // constructed twice, once before and once after reset + $this->assertSame(1, SingletonClass::$count); $this->assertTrue(ResettableDependency::$reset); } } diff --git a/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php b/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php new file mode 100644 index 0000000000..a447a0c3c7 --- /dev/null +++ b/packages/container/tests/Fixtures/ProcessLifetimeSingleton.php @@ -0,0 +1,10 @@ +getName() === RequestLifetimeInterface::class; + } + + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(ClassReflector $class, string|UnitEnum|null $tag, Container $container): object + { + return new RequestLifetimeSingleton(); + } +} diff --git a/packages/container/tests/Fixtures/RequestLifetimeInitializer.php b/packages/container/tests/Fixtures/RequestLifetimeInitializer.php new file mode 100644 index 0000000000..2265cceedf --- /dev/null +++ b/packages/container/tests/Fixtures/RequestLifetimeInitializer.php @@ -0,0 +1,19 @@ + Date: Wed, 9 Sep 2026 11:44:35 +0200 Subject: [PATCH 08/15] wip --- docs/1-essentials/05-container.md | 39 ++++++++++++++++++++- packages/container/src/GenericContainer.php | 9 ++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md index 9de0854cc5..855d2a7123 100644 --- a/docs/1-essentials/05-container.md +++ b/docs/1-essentials/05-container.md @@ -224,7 +224,7 @@ final readonly class Client } ``` -Furthermore, an initializer method can be annotated as a `#[Singleton]`, meaning its return object will only ever be resolved once: +Furthermore, an initializer method can be annotated as a `#[Singleton]`, meaning its return object will be reused for the singleton’s lifetime: ```php app/MarkdownInitializer.php use Tempest\Console\ConsoleCommand; @@ -241,6 +241,43 @@ final readonly class MarkdownInitializer implements Initializer } ``` +### Singleton lifetimes + +By default, singletons use {`Tempest\Container\Lifetime::PROCESS`} as their lifetime: the container keeps the same instance for the lifetime of the process, including across requests in long-running applications. + +For objects that hold request-specific state, use `Lifetime::REQUEST`: + +```php +use Tempest\Container\Lifetime; +use Tempest\Container\Singleton; + +#[Singleton(lifetime: Lifetime::REQUEST)] +final class RequestContext +{ + public ?string $tenantId = null; +} +``` + +The container reuses this instance within a request. When the container is reset, it clears the instance so that the next request receives a fresh one, this happens after every request in long-running applications. + +Note that you can also declare the lifetime via initializers: + +```php app/RequestContextInitializer.php +use Tempest\Container\Container; +use Tempest\Container\Initializer; +use Tempest\Container\Lifetime; +use Tempest\Container\Singleton; + +final class RequestContextInitializer implements Initializer +{ + #[Singleton(lifetime: Lifetime::REQUEST)] + public function initialize(Container $container): RequestContext + { + return new RequestContext(); + } +} +``` + ### Tagged singletons In some cases, you want more control over singleton definitions. diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 3cc224752e..1736dffde7 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -58,7 +58,8 @@ public function __construct( private(set) ArrayIterator $resettables = new ArrayIterator(), private(set) ?DependencyChain $chain = null, - ) { + ) + { $this->singletonLifetimes = new WeakMap(); $this->singleton(Container::class, $this); $this->singleton(ContainerInterface::class, $this); @@ -412,9 +413,9 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()), }; - $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass - ->getMethod('initialize') - ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); + $singleton = $initializerClass->getAttribute(Singleton::class) + ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class) + ?? $class->getAttribute(Singleton::class); if ($singleton !== null) { $this->singletonLifetimes[$object] = $singleton->lifetime; From 12aa71fce06a8ae3281835aef837c9178c5b711e Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 9 Sep 2026 11:45:35 +0200 Subject: [PATCH 09/15] wip --- docs/1-essentials/05-container.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md index 855d2a7123..a83f9836ae 100644 --- a/docs/1-essentials/05-container.md +++ b/docs/1-essentials/05-container.md @@ -245,7 +245,7 @@ final readonly class MarkdownInitializer implements Initializer By default, singletons use {`Tempest\Container\Lifetime::PROCESS`} as their lifetime: the container keeps the same instance for the lifetime of the process, including across requests in long-running applications. -For objects that hold request-specific state, use `Lifetime::REQUEST`: +For objects that hold request-specific state, you can specify `Lifetime::REQUEST` instead: ```php use Tempest\Container\Lifetime; @@ -262,7 +262,7 @@ The container reuses this instance within a request. When the container is reset Note that you can also declare the lifetime via initializers: -```php app/RequestContextInitializer.php +```php use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Lifetime; From 1c8b57cca7528774f46b448e816b1297ed25f42a Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 9 Sep 2026 11:48:45 +0200 Subject: [PATCH 10/15] wip --- packages/container/src/GenericContainer.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 1736dffde7..dccd7d0495 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -58,8 +58,7 @@ public function __construct( private(set) ArrayIterator $resettables = new ArrayIterator(), private(set) ?DependencyChain $chain = null, - ) - { + ) { $this->singletonLifetimes = new WeakMap(); $this->singleton(Container::class, $this); $this->singleton(ContainerInterface::class, $this); @@ -413,9 +412,10 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()), }; - $singleton = $initializerClass->getAttribute(Singleton::class) - ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class) - ?? $class->getAttribute(Singleton::class); + $singleton = + $initializerClass->getAttribute(Singleton::class) ?? $initializerClass + ->getMethod('initialize') + ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); if ($singleton !== null) { $this->singletonLifetimes[$object] = $singleton->lifetime; @@ -766,10 +766,12 @@ public function reset(): self foreach ([$this->singletonDefinitions, $this->resolvedSingletons] as $singletons) { foreach ($singletons->getArrayCopy() as $dependencyName => $instance) { // Factories remain registered so the next request can create a fresh instance. - if (! is_object($instance) || $instance instanceof Closure) { + if (! is_object($instance)) { + continue; + } + if ($instance instanceof Closure) { continue; } - $this->singletonLifetimes[$instance] ??= new ClassReflector($instance)->getAttribute(Singleton::class)->lifetime ?? Lifetime::PROCESS; if ($this->singletonLifetimes[$instance] === Lifetime::REQUEST) { From f8ef1da83c1b736bed37f73a76dabc38b9b61a46 Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 9 Sep 2026 11:50:33 +0200 Subject: [PATCH 11/15] wip --- packages/container/tests/ContainerLifetimeTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/container/tests/ContainerLifetimeTest.php b/packages/container/tests/ContainerLifetimeTest.php index 11ba5e20c9..bbe790edd5 100644 --- a/packages/container/tests/ContainerLifetimeTest.php +++ b/packages/container/tests/ContainerLifetimeTest.php @@ -89,6 +89,7 @@ public function tagged_initializer_singletons_are_cleared(): void { $container = new GenericContainer(); $container->addInitializer(RequestLifetimeInitializer::class); + $instance = $container->get(RequestLifetimeInterface::class, 'tag'); $this->assertSame($instance, $container->get(RequestLifetimeInterface::class, 'tag')); @@ -125,6 +126,7 @@ public function registered_factories_follow_their_lifetime(): void $container = new GenericContainer(); $container->singleton(SingletonClass::class, fn () => new SingletonClass()); $container->singleton(RequestLifetimeInterface::class, fn () => new RequestLifetimeSingleton(), 'request'); + $process = $container->get(SingletonClass::class); for ($request = 0; $request < 3; $request++) { @@ -143,6 +145,7 @@ public function process_initializer_singletons_survive_reset(): void { $container = new GenericContainer(); $container->addInitializer(SingletonInitializer::class); + $instance = $container->get(ContainerObjectE::class); $container->reset(); @@ -161,6 +164,7 @@ public function initialize(Container $container): RequestLifetimeSingleton }; $container = new GenericContainer(); $container->addInitializer($initializer::class); + $instance = $container->get(RequestLifetimeSingleton::class); $this->assertSame($instance, $container->get(RequestLifetimeSingleton::class)); @@ -224,6 +228,7 @@ public function initialize(Container $container): RequestLifetimeSingleton }; $container = new GenericContainer(); $container->addInitializer($initializer::class); + $instance = $container->get(RequestLifetimeSingleton::class); $container->singleton(RequestLifetimeInterface::class, $instance); From 0cf89ab6e45f4cd78f7c5c1d224b7da6cf602ace Mon Sep 17 00:00:00 2001 From: brendt Date: Wed, 9 Sep 2026 12:09:53 +0200 Subject: [PATCH 12/15] wip --- packages/container/src/GenericContainer.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index dccd7d0495..01508ec9f9 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -412,10 +412,9 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()), }; - $singleton = - $initializerClass->getAttribute(Singleton::class) ?? $initializerClass - ->getMethod('initialize') - ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); + $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass + ->getMethod('initialize') + ->getAttribute(Singleton::class) ?? $class->getAttribute(Singleton::class); if ($singleton !== null) { $this->singletonLifetimes[$object] = $singleton->lifetime; @@ -769,9 +768,11 @@ public function reset(): self if (! is_object($instance)) { continue; } + if ($instance instanceof Closure) { continue; } + $this->singletonLifetimes[$instance] ??= new ClassReflector($instance)->getAttribute(Singleton::class)->lifetime ?? Lifetime::PROCESS; if ($this->singletonLifetimes[$instance] === Lifetime::REQUEST) { From 6e88e3bdb9832e353b5924d4352b31acb9301fe2 Mon Sep 17 00:00:00 2001 From: brendt Date: Fri, 23 Oct 2026 08:51:38 +0200 Subject: [PATCH 13/15] wip --- packages/container/src/Container.php | 2 +- packages/container/src/GenericContainer.php | 21 ++++++++++- .../container/tests/ContainerLifetimeTest.php | 37 +++++++++++++++++++ .../Commands/ContainerShowCommandTest.php | 5 ++- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/container/src/Container.php b/packages/container/src/Container.php index dba11e198a..7331cc9408 100644 --- a/packages/container/src/Container.php +++ b/packages/container/src/Container.php @@ -16,7 +16,7 @@ public function register(string $className, callable $definition): self; public function unregister(string $className, bool $tagged = false): self; - public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self; + public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self; public function config(object $config): self; diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 01508ec9f9..9d7a0e6472 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -35,6 +35,9 @@ final class GenericContainer implements Container /** @var WeakMap */ private WeakMap $singletonLifetimes; + /** @var array */ + private array $registeredSingletonLifetimes = []; + public function __construct( /** @var ArrayIterator $definitions */ private(set) ArrayIterator $definitions = new ArrayIterator(), @@ -151,6 +154,7 @@ public function unregister(string $className, bool $tagged = false): self unset($this->definitions[$className]); unset($this->singletonDefinitions[$className]); unset($this->resolvedSingletons[$className]); + unset($this->registeredSingletonLifetimes[$className]); if ($tagged) { foreach ($this->singletonDefinitions as $key => $definition) { @@ -159,6 +163,7 @@ public function unregister(string $className, bool $tagged = false): self } unset($this->singletonDefinitions[$key]); + unset($this->registeredSingletonLifetimes[$key]); } foreach ($this->resolvedSingletons as $key => $definition) { @@ -178,7 +183,7 @@ public function has(string $className, string|UnitEnum|null $tag = null): bool return isset($this->definitions[$className]) || isset($this->singletonDefinitions[$this->resolveTaggedName($className, $tag)]); } - public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self + public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self { if ($definition instanceof HasTag) { $tag = $definition->tag; @@ -186,6 +191,16 @@ public function singleton(string $className, mixed $definition, string|UnitEnum| $dependencyName = $this->resolveTaggedName($className, $tag); + if ($lifetime !== null) { + $this->registeredSingletonLifetimes[$dependencyName] = $lifetime; + + if (is_object($definition) && ! $definition instanceof Closure) { + $this->singletonLifetimes[$definition] = $lifetime; + } + } else { + unset($this->registeredSingletonLifetimes[$dependencyName]); + } + $this->singletonDefinitions[$dependencyName] = $definition; unset($this->resolvedSingletons[$dependencyName]); @@ -387,6 +402,10 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag if ($singletonDefinition = $this->singletonDefinitions[$dependencyName] ?? null) { $instance = $singletonDefinition instanceof Closure ? $singletonDefinition($this) : $singletonDefinition; + if (isset($this->registeredSingletonLifetimes[$dependencyName])) { + $this->singletonLifetimes[$instance] = $this->registeredSingletonLifetimes[$dependencyName]; + } + $this->resolvedSingletons[$dependencyName] = $instance; $this->resolveChain()->add($class); diff --git a/packages/container/tests/ContainerLifetimeTest.php b/packages/container/tests/ContainerLifetimeTest.php index bbe790edd5..284945b18e 100644 --- a/packages/container/tests/ContainerLifetimeTest.php +++ b/packages/container/tests/ContainerLifetimeTest.php @@ -140,6 +140,43 @@ public function registered_factories_follow_their_lifetime(): void } } + #[Test] + public function registered_factory_can_specify_a_request_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, fn () => new SingletonClass(), lifetime: Lifetime::REQUEST); + + $instance = $container->get(SingletonClass::class); + $this->assertSame($instance, $container->get(SingletonClass::class)); + + $container->reset(); + + $this->assertNotSame($instance, $container->get(SingletonClass::class)); + } + + #[Test] + public function registered_instance_can_override_its_attribute_lifetime(): void + { + $container = new GenericContainer(); + $instance = new RequestLifetimeSingleton(); + $container->singleton(RequestLifetimeInterface::class, $instance, lifetime: Lifetime::PROCESS); + + $container->reset(); + + $this->assertSame($instance, $container->get(RequestLifetimeInterface::class)); + } + + #[Test] + public function registered_instance_can_specify_a_request_lifetime(): void + { + $container = new GenericContainer(); + $container->singleton(SingletonClass::class, new SingletonClass(), lifetime: Lifetime::REQUEST); + + $container->reset(); + + $this->assertFalse($container->has(SingletonClass::class)); + } + #[Test] public function process_initializer_singletons_survive_reset(): void { diff --git a/tests/Integration/Container/Commands/ContainerShowCommandTest.php b/tests/Integration/Container/Commands/ContainerShowCommandTest.php index 6de904d3e4..2e89df6628 100644 --- a/tests/Integration/Container/Commands/ContainerShowCommandTest.php +++ b/tests/Integration/Container/Commands/ContainerShowCommandTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\Test; use Tempest\Container\Commands\ContainerShowCommand; use Tempest\Container\Container; +use Tempest\Container\Lifetime; use Tempest\Reflection\ClassReflector; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use UnitEnum; @@ -45,9 +46,9 @@ public function unregister(string $className, bool $tagged = false): self return $this; } - public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self + public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self { - $this->container->singleton($className, $definition, $tag); + $this->container->singleton($className, $definition, $tag, $lifetime); return $this; } From 9b60678144c2d944a19f8734627e8ad9c18a57ad Mon Sep 17 00:00:00 2001 From: brendt Date: Fri, 23 Oct 2026 08:54:25 +0200 Subject: [PATCH 14/15] wip --- .../config/sets/level/up-to-tempest-321.php | 18 ++++++ packages/upgrade/config/sets/tempest321.php | 13 ++++ .../UpdateContainerImplementationsRector.php | 60 +++++++++++++++++++ .../ContainerImplementation.input.php | 14 +++++ .../Tempest321/Fixtures/Unchanged.input.php | 23 +++++++ .../tests/Tempest321/Tempest321RectorTest.php | 29 +++++++++ .../tests/Tempest321/tempest321_rector.php | 11 ++++ 7 files changed, 168 insertions(+) create mode 100644 packages/upgrade/config/sets/level/up-to-tempest-321.php create mode 100644 packages/upgrade/config/sets/tempest321.php create mode 100644 packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php create mode 100644 packages/upgrade/tests/Tempest321/Fixtures/ContainerImplementation.input.php create mode 100644 packages/upgrade/tests/Tempest321/Fixtures/Unchanged.input.php create mode 100644 packages/upgrade/tests/Tempest321/Tempest321RectorTest.php create mode 100644 packages/upgrade/tests/Tempest321/tempest321_rector.php diff --git a/packages/upgrade/config/sets/level/up-to-tempest-321.php b/packages/upgrade/config/sets/level/up-to-tempest-321.php new file mode 100644 index 0000000000..158f0d316e --- /dev/null +++ b/packages/upgrade/config/sets/level/up-to-tempest-321.php @@ -0,0 +1,18 @@ +sets([ + TempestSetList::TEMPEST_20, + TempestSetList::TEMPEST_28, + TempestSetList::TEMPEST_30, + TempestSetList::TEMPEST_34, + TempestSetList::TEMPEST_310, + TempestSetList::TEMPEST_314, + TempestSetList::TEMPEST_321, + ]); +}; diff --git a/packages/upgrade/config/sets/tempest321.php b/packages/upgrade/config/sets/tempest321.php new file mode 100644 index 0000000000..e4f70a37fd --- /dev/null +++ b/packages/upgrade/config/sets/tempest321.php @@ -0,0 +1,13 @@ +rule(UpdateContainerImplementationsRector::class); +}; diff --git a/packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php b/packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php new file mode 100644 index 0000000000..273841520c --- /dev/null +++ b/packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php @@ -0,0 +1,60 @@ +implementsContainer($node)) { + return null; + } + + $singleton = $node->getMethod('singleton'); + + if (! $singleton instanceof ClassMethod || count($singleton->params) !== 3) { + return null; + } + + $singleton->params[] = new Param( + var: new Node\Expr\Variable('lifetime'), + default: new ConstFetch(new Name('null')), + type: new NullableType(new Name\FullyQualified(Lifetime::class)), + ); + + return $node; + } + + private function implementsContainer(Class_ $class): bool + { + foreach ($class->implements as $interface) { + $resolvedName = $interface->getAttribute('resolvedName'); + + if ($resolvedName instanceof Name && $resolvedName->toString() === Container::class) { + return true; + } + + if ($interface->toString() === Container::class) { + return true; + } + } + + return false; + } +} diff --git a/packages/upgrade/tests/Tempest321/Fixtures/ContainerImplementation.input.php b/packages/upgrade/tests/Tempest321/Fixtures/ContainerImplementation.input.php new file mode 100644 index 0000000000..210fc7cb71 --- /dev/null +++ b/packages/upgrade/tests/Tempest321/Fixtures/ContainerImplementation.input.php @@ -0,0 +1,14 @@ + new RectorTester(__DIR__ . '/tempest321_rector.php'); + } + + #[Test] + public function container_implementations_receive_the_optional_lifetime(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/ContainerImplementation.input.php') + ->assertContains('use Tempest\\Container\\Lifetime;') + ->assertContains('public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null, ?Lifetime $lifetime = null): self'); + } + + #[Test] + public function unrelated_or_already_updated_methods_are_unchanged(): void + { + $this->assertSame('', $this->rector->runFixture(__DIR__ . '/Fixtures/Unchanged.input.php')->actual); + } +} diff --git a/packages/upgrade/tests/Tempest321/tempest321_rector.php b/packages/upgrade/tests/Tempest321/tempest321_rector.php new file mode 100644 index 0000000000..1db881515c --- /dev/null +++ b/packages/upgrade/tests/Tempest321/tempest321_rector.php @@ -0,0 +1,11 @@ +withSets([TempestSetList::TEMPEST_321]) + ->withCache(cacheClass: MemoryCacheStorage::class); From ac7a42242619d98251acd0e1733ad8ddf74afdc1 Mon Sep 17 00:00:00 2001 From: brendt Date: Fri, 23 Oct 2026 08:59:10 +0200 Subject: [PATCH 15/15] wip --- docs/1-essentials/05-container.md | 12 ++++++++++++ packages/container/src/GenericContainer.php | 2 +- packages/upgrade/src/Set/TempestLevelSetList.php | 2 ++ packages/upgrade/src/Set/TempestSetList.php | 2 ++ .../UpdateContainerImplementationsRector.php | 6 ++++-- 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md index a83f9836ae..ea231fba98 100644 --- a/docs/1-essentials/05-container.md +++ b/docs/1-essentials/05-container.md @@ -260,6 +260,18 @@ final class RequestContext The container reuses this instance within a request. When the container is reset, it clears the instance so that the next request receives a fresh one, this happens after every request in long-running applications. +You can also set the lifetime when registering a singleton with the container. Use a factory for request-lifetime singletons so the container can create a fresh instance after each reset: + +```php +use Tempest\Container\Lifetime; + +$container->singleton( + RequestContext::class, + fn () => new RequestContext(), + lifetime: Lifetime::REQUEST, +); +``` + Note that you can also declare the lifetime via initializers: ```php diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 9d7a0e6472..bb24e5706b 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -191,7 +191,7 @@ public function singleton(string $className, mixed $definition, string|UnitEnum| $dependencyName = $this->resolveTaggedName($className, $tag); - if ($lifetime !== null) { + if ($lifetime instanceof Lifetime) { $this->registeredSingletonLifetimes[$dependencyName] = $lifetime; if (is_object($definition) && ! $definition instanceof Closure) { diff --git a/packages/upgrade/src/Set/TempestLevelSetList.php b/packages/upgrade/src/Set/TempestLevelSetList.php index 458d7f992e..a9836c64f8 100644 --- a/packages/upgrade/src/Set/TempestLevelSetList.php +++ b/packages/upgrade/src/Set/TempestLevelSetList.php @@ -17,4 +17,6 @@ final class TempestLevelSetList public const string UP_TO_TEMPEST_310 = __DIR__ . '/../../config/sets/level/up-to-tempest-310.php'; public const string UP_TO_TEMPEST_314 = __DIR__ . '/../../config/sets/level/up-to-tempest-314.php'; + + public const string UP_TO_TEMPEST_321 = __DIR__ . '/../../config/sets/level/up-to-tempest-321.php'; } diff --git a/packages/upgrade/src/Set/TempestSetList.php b/packages/upgrade/src/Set/TempestSetList.php index e786902548..cf475b8265 100644 --- a/packages/upgrade/src/Set/TempestSetList.php +++ b/packages/upgrade/src/Set/TempestSetList.php @@ -17,4 +17,6 @@ final class TempestSetList public const string TEMPEST_310 = __DIR__ . '/../../config/sets/tempest310.php'; public const string TEMPEST_314 = __DIR__ . '/../../config/sets/tempest314.php'; + + public const string TEMPEST_321 = __DIR__ . '/../../config/sets/tempest321.php'; } diff --git a/packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php b/packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php index 273841520c..e8743d44bf 100644 --- a/packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php +++ b/packages/upgrade/src/Tempest321/UpdateContainerImplementationsRector.php @@ -2,6 +2,8 @@ namespace Tempest\Upgrade\Tempest321; +use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Name\FullyQualified; use PhpParser\Node; use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Name; @@ -33,9 +35,9 @@ public function refactor(Node $node): ?Node } $singleton->params[] = new Param( - var: new Node\Expr\Variable('lifetime'), + var: new Variable('lifetime'), default: new ConstFetch(new Name('null')), - type: new NullableType(new Name\FullyQualified(Lifetime::class)), + type: new NullableType(new FullyQualified(Lifetime::class)), ); return $node;