From a9fdc7219240540744c51617d85397c50782116d Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sun, 6 Sep 2026 13:13:36 +0100 Subject: [PATCH] feat(container): add scoped bindings --- docs/1-essentials/05-container.md | 40 ++++++ .../src/Commands/ContainerShowCommand.php | 15 ++- packages/container/src/Container.php | 5 + packages/container/src/GenericContainer.php | 107 +++++++++++++--- packages/container/src/Scoped.php | 18 +++ packages/container/tests/ContainerTest.php | 115 ++++++++++++++++++ .../Fixtures/ClassWithScopedAttribute.php | 13 ++ .../TaggedDependencyScopedInitializer.php | 18 +++ packages/router/src/MatchRouteMiddleware.php | 5 +- packages/router/src/RouterReset.php | 18 --- .../Commands/ContainerShowCommandTest.php | 20 +++ .../Container/Fixtures/ScopedDependency.php | 10 ++ 12 files changed, 344 insertions(+), 40 deletions(-) create mode 100644 packages/container/src/Scoped.php create mode 100644 packages/container/tests/Fixtures/ClassWithScopedAttribute.php create mode 100644 packages/container/tests/Fixtures/TaggedDependencyScopedInitializer.php delete mode 100644 packages/router/src/RouterReset.php create mode 100644 tests/Integration/Container/Fixtures/ScopedDependency.php diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md index 71e12affae..9994091e1f 100644 --- a/docs/1-essentials/05-container.md +++ b/docs/1-essentials/05-container.md @@ -292,6 +292,46 @@ Some components implement the {`Tempest\Container\HasTag`} interface, which requ This is specifically useful to get multiple instances of the same configuration. This is how [multiple database connections support](../1-essentials/03-database.md#using-multiple-connections) is implemented. +## Scoped dependencies + +Some dependencies are meant to be shared, but only for the duration of a single request. A matched route, a tenant context or an authenticated user are safe to reuse while handling one request, and unsafe to carry over to the next one in a long-running process, such as a FrankenPHP worker. For these, you may use the `#[Scoped]` attribute instead of `#[Singleton]`: + +```php app/TenantContext.php +use Tempest\Container\Scoped; + +#[Scoped] +final class TenantContext +{ + // … +} +``` + +A scoped dependency behaves exactly like a singleton for as long as the current request lasts, and is discarded when the container is reset. Like `#[Singleton]`, the attribute may also be applied to an initializer method: + +```php app/TenantContextInitializer.php +use Tempest\Container\Initializer; +use Tempest\Container\Scoped; + +final readonly class TenantContextInitializer implements Initializer +{ + #[Scoped] + public function initialize(Container $container): TenantContext + { + // … + } +} +``` + +Dependencies may also be registered as scoped at runtime, which is how the router registers the {`Tempest\Router\MatchedRoute`} of the current request: + +```php +$container->scoped(TenantContext::class, $tenantContext); +``` + +:::info +When a scoped dependency is registered with a callable, the callable is kept and used to build a new instance in the next request. When it is registered with an instance, the registration is removed entirely, since that instance cannot be rebuilt. +::: + ## Built-in types dependencies Besides being able to depend on objects, sometimes you'd want to depend on built-in types like `string`, `int` or more often `array`. It is possible to depend on these built-in types, but these cannot be autowired and must be initialized through a [tagged singleton](#tagged-singletons). diff --git a/packages/container/src/Commands/ContainerShowCommand.php b/packages/container/src/Commands/ContainerShowCommand.php index 4e94eb2071..0e481f78ca 100644 --- a/packages/container/src/Commands/ContainerShowCommand.php +++ b/packages/container/src/Commands/ContainerShowCommand.php @@ -60,8 +60,21 @@ public function __invoke(): ExitCode }, ); + $scoped = $this->container->getScopedDefinitions(); + $singletons = sort_keys($this->container->getSingletons()); + $this->listBindings('Definitions', sort_keys($this->container->getDefinitions())); - $this->listBindings('Singletons', sort_keys($this->container->getSingletons())); + + $this->listBindings( + title: 'Singletons', + bindings: $singletons, + reject: static fn (string $class): bool => isset($scoped[$class]), + ); + + $this->listBindings( + title: 'Scoped', + bindings: array_intersect_key($singletons, $scoped), + ); return ExitCode::SUCCESS; } diff --git a/packages/container/src/Container.php b/packages/container/src/Container.php index dba11e198a..225b6205e8 100644 --- a/packages/container/src/Container.php +++ b/packages/container/src/Container.php @@ -18,6 +18,11 @@ public function unregister(string $className, bool $tagged = false): self; public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self; + /** + * Registers a singleton that only lives for the duration of the current lifecycle, and is discarded when {@see self::reset()} is called. + */ + public function scoped(string $className, mixed $definition, string|UnitEnum|null $tag = null): self; + public function config(object $config): self; /** diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 752fca8f24..a20967dd53 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -53,6 +53,9 @@ public function __construct( /** @var ArrayIterator> $resettables */ private(set) ArrayIterator $resettables = new ArrayIterator(), + /** @var ArrayIterator $scopedDefinitions */ + private(set) ArrayIterator $scopedDefinitions = new ArrayIterator(), + private(set) ?DependencyChain $chain = null, ) { $this->singleton(Container::class, $this); @@ -119,6 +122,11 @@ public function getSingletons(?string $interface = null): array ); } + public function getScopedDefinitions(): array + { + return $this->scopedDefinitions->getArrayCopy(); + } + public function getInitializers(): array { return $this->initializers->getArrayCopy(); @@ -146,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->scopedDefinitions[$className]); if ($tagged) { foreach ($this->singletonDefinitions as $key => $definition) { @@ -163,6 +172,14 @@ public function unregister(string $className, bool $tagged = false): self unset($this->resolvedSingletons[$key]); } + + foreach ($this->scopedDefinitions as $key => $dependencyName) { + if (! str_starts_with($key, "{$className}#")) { + continue; + } + + unset($this->scopedDefinitions[$key]); + } } return $this; @@ -174,6 +191,28 @@ public function has(string $className, string|UnitEnum|null $tag = null): bool } public function singleton(string $className, mixed $definition, string|UnitEnum|null $tag = null): self + { + $dependencyName = $this->registerSingletonDefinition($className, $definition, $tag); + + // Registering as a singleton overrides any previous scoped registration for this dependency + unset($this->scopedDefinitions[$dependencyName]); + + return $this; + } + + public function scoped(string $className, mixed $definition, string|UnitEnum|null $tag = null): self + { + $dependencyName = $this->registerSingletonDefinition($className, $definition, $tag); + + $this->scopedDefinitions[$dependencyName] = $dependencyName; + + return $this; + } + + /** + * Registers a definition that is only resolved once, and returns the name it was registered under. + */ + private function registerSingletonDefinition(string $className, mixed $definition, string|UnitEnum|null $tag): string { if ($definition instanceof HasTag) { $tag = $definition->tag; @@ -184,7 +223,7 @@ public function singleton(string $className, mixed $definition, string|UnitEnum| $this->singletonDefinitions[$dependencyName] = $definition; unset($this->resolvedSingletons[$dependencyName]); - return $this; + return $dependencyName; } public function config(object $config): self @@ -308,14 +347,14 @@ public function addInitializer(ClassReflector|string $initializerClass): Contain $initializeMethod = $initializerClass->getMethod('initialize'); // We resolve the optional Tag attribute from this initializer class - $singleton = $initializeMethod->getAttribute(Singleton::class); + $lifetime = $initializeMethod->getAttribute(Singleton::class) ?? $initializeMethod->getAttribute(Scoped::class); // For normal Initializers, we'll use the return type // to determine which dependency they resolve $returnType = $initializeMethod->getReturnType(); foreach ($returnType->split() as $type) { - $this->initializers[$this->resolveTaggedName($type->getName(), $singleton?->tag)] = $initializerClass->getName(); + $this->initializers[$this->resolveTaggedName($type->getName(), $lifetime?->tag)] = $initializerClass->getName(); } return $this; @@ -407,11 +446,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); - - if ($singleton !== null) { - $this->singleton($className, $object, $tag); - } + $this->registerResolvedInstance( + attribute: $this->lifetimeAttributeFor($initializerClass, $initializerClass->getMethod('initialize')), + className: $className, + instance: $object, + tag: $tag, + ); return $object; } @@ -485,12 +525,12 @@ private function autowire(string $className, mixed ...$params): object // Otherwise, use our autowireDependencies helper to automagically : $classReflector->newInstanceWithoutConstructor(); - if ( - ! $classReflector->getType()->matches(Initializer::class) - && ! $classReflector->getType()->matches(DynamicInitializer::class) - && $classReflector->hasAttribute(Singleton::class) - ) { - $this->singleton($className, $instance); + if (! $classReflector->getType()->matches(Initializer::class) && ! $classReflector->getType()->matches(DynamicInitializer::class)) { + $this->registerResolvedInstance( + attribute: $this->lifetimeAttributeFor($classReflector), + className: $className, + instance: $instance, + ); } foreach ($classReflector->getProperties() as $property) { @@ -606,11 +646,12 @@ private function autowireBuiltinDependency(ParameterReflector $parameter, mixed $object = $initializer->initialize($this->clone()); - $singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class); - - if ($singleton !== null) { - $this->singleton($typeName, $object, $tag->name); - } + $this->registerResolvedInstance( + attribute: $this->lifetimeAttributeFor($initializerClass, $initializerClass->getMethod('initialize')), + className: $typeName, + instance: $object, + tag: $tag->name, + ); return $object; } @@ -645,6 +686,23 @@ private function autowireBuiltinDependency(ParameterReflector $parameter, mixed throw new DependencyCouldNotBeAutowired($this->chain, new Dependency($parameter)); } + /** + * Resolves the attribute that determines how long a resolved instance is kept around. + */ + private function lifetimeAttributeFor(ClassReflector $class, ?MethodReflector $method = null): Singleton|Scoped|null + { + return $class->getAttribute(Singleton::class) ?? $class->getAttribute(Scoped::class) ?? $method?->getAttribute(Singleton::class) ?? $method?->getAttribute(Scoped::class); + } + + private function registerResolvedInstance(Singleton|Scoped|null $attribute, string $className, mixed $instance, string|UnitEnum|null $tag = null): void + { + match (true) { + $attribute instanceof Scoped => $this->scoped($className, $instance, $tag), + $attribute instanceof Singleton => $this->singleton($className, $instance, $tag), + default => null, + }; + } + private function clone(): self { return clone $this; @@ -729,6 +787,17 @@ public function reset(): self $this->resolvedSingletons = new ArrayIterator(); $this->resolvedDynamicInitializers = []; + foreach ($this->scopedDefinitions->getArrayCopy() as $dependencyName) { + // A callable definition can be invoked again, so it is kept for the next lifecycle. A concrete + // instance cannot be rebuilt, so the binding is dropped instead of leaking into the next one. + if (($this->singletonDefinitions[$dependencyName] ?? null) instanceof Closure) { + continue; + } + + unset($this->singletonDefinitions[$dependencyName]); + unset($this->scopedDefinitions[$dependencyName]); + } + foreach ($this->resettables as $resettableClass) { /** @var Resettable $resettable */ $resettable = $this->get($resettableClass); diff --git a/packages/container/src/Scoped.php b/packages/container/src/Scoped.php new file mode 100644 index 0000000000..a86168f84f --- /dev/null +++ b/packages/container/src/Scoped.php @@ -0,0 +1,18 @@ +assertSame(2, SingletonClass::$count); // constructed twice, once before and once after reset $this->assertTrue(ResettableDependency::$reset); } + + #[Test] + public function scoped_instance_is_shared_within_a_lifecycle(): void + { + $container = new GenericContainer(); + + $container->scoped(SingletonClass::class, $instance = new SingletonClass()); + + $this->assertSame($instance, $container->get(SingletonClass::class)); + $this->assertSame($instance, $container->get(SingletonClass::class)); + } + + #[Test] + public function scoped_instance_is_discarded_on_reset(): void + { + $container = new GenericContainer(); + + $container->scoped(SingletonClass::class, new SingletonClass()); + $this->assertTrue($container->has(SingletonClass::class)); + + $container->reset(); + + $this->assertFalse($container->has(SingletonClass::class)); + } + + #[Test] + public function scoped_tagged_instance_is_discarded_on_reset(): void + { + $container = new GenericContainer(); + + $container->scoped(SingletonClass::class, new SingletonClass(), tag: 'tag'); + $this->assertTrue($container->has(SingletonClass::class, tag: 'tag')); + + $container->reset(); + + $this->assertFalse($container->has(SingletonClass::class, tag: 'tag')); + } + + #[Test] + public function scoped_callable_definition_is_kept_and_resolved_again_after_reset(): void + { + SingletonClass::$count = 0; + + $container = new GenericContainer(); + + $container->scoped(SingletonClass::class, fn () => new SingletonClass()); + + $first = $container->get(SingletonClass::class); + $this->assertSame($first, $container->get(SingletonClass::class)); + + $container->reset(); + + // The factory survives the reset, but hands out a new instance for the next lifecycle. + $this->assertTrue($container->has(SingletonClass::class)); + $this->assertNotSame($first, $container->get(SingletonClass::class)); + $this->assertSame(2, SingletonClass::$count); + } + + #[Test] + public function singleton_is_not_discarded_on_reset(): void + { + $container = new GenericContainer(); + + $container->singleton(SingletonClass::class, new SingletonClass()); + + $container->reset(); + + $this->assertTrue($container->has(SingletonClass::class)); + } + + #[Test] + public function registering_a_scoped_dependency_as_a_singleton_makes_it_survive_a_reset(): void + { + $container = new GenericContainer(); + + $container->scoped(SingletonClass::class, new SingletonClass()); + $container->singleton(SingletonClass::class, new SingletonClass()); + + $container->reset(); + + $this->assertTrue($container->has(SingletonClass::class)); + } + + #[Test] + public function tagged_scoped_initializer(): void + { + $container = new GenericContainer(); + $container->addInitializer(TaggedDependencyScopedInitializer::class); + + $dependency = $container->get(TaggedDependency::class, tag: 'web'); + + $this->assertSame('web', $dependency->name); + $this->assertSame($dependency, $container->get(TaggedDependency::class, tag: 'web')); + + $container->reset(); + + $this->assertNotSame($dependency, $container->get(TaggedDependency::class, tag: 'web')); + } + + #[Test] + public function scoped_attribute(): void + { + $container = new GenericContainer(); + + $instance = $container->get(ClassWithScopedAttribute::class); + $instance->flag = true; + + $this->assertTrue($container->get(ClassWithScopedAttribute::class)->flag); + + $container->reset(); + + $this->assertFalse($container->get(ClassWithScopedAttribute::class)->flag); + } } diff --git a/packages/container/tests/Fixtures/ClassWithScopedAttribute.php b/packages/container/tests/Fixtures/ClassWithScopedAttribute.php new file mode 100644 index 0000000000..4ae2cd23e8 --- /dev/null +++ b/packages/container/tests/Fixtures/ClassWithScopedAttribute.php @@ -0,0 +1,13 @@ +container->singleton(MatchedRoute::class, fn () => $matchedRoute); + // We register the matched route in the container, some internal framework components will need it. + // It is scoped, so it does not survive into the next request in a long-running context. + $this->container->scoped(MatchedRoute::class, $matchedRoute); return $next($request); } diff --git a/packages/router/src/RouterReset.php b/packages/router/src/RouterReset.php deleted file mode 100644 index 1b40c4218c..0000000000 --- a/packages/router/src/RouterReset.php +++ /dev/null @@ -1,18 +0,0 @@ -container->unregister(MatchedRoute::class); - } -} diff --git a/tests/Integration/Container/Commands/ContainerShowCommandTest.php b/tests/Integration/Container/Commands/ContainerShowCommandTest.php index 6de904d3e4..a411250733 100644 --- a/tests/Integration/Container/Commands/ContainerShowCommandTest.php +++ b/tests/Integration/Container/Commands/ContainerShowCommandTest.php @@ -6,6 +6,7 @@ use Tempest\Container\Commands\ContainerShowCommand; use Tempest\Container\Container; use Tempest\Reflection\ClassReflector; +use Tests\Tempest\Integration\Container\Fixtures\ScopedDependency; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use UnitEnum; @@ -21,6 +22,18 @@ public function command(): void ->assertSuccess(); } + #[Test] + public function scoped_bindings_are_listed_separately(): void + { + $this->container->scoped(ScopedDependency::class, new ScopedDependency()); + + $this->console + ->call(ContainerShowCommand::class) + ->assertSee('SCOPED') + ->assertSee('ScopedDependency') + ->assertSuccess(); + } + #[Test] public function with_another_container(): void { @@ -52,6 +65,13 @@ public function singleton(string $className, mixed $definition, string|UnitEnum| return $this; } + public function scoped(string $className, mixed $definition, string|UnitEnum|null $tag = null): self + { + $this->container->scoped($className, $definition, $tag); + + return $this; + } + public function config(object $config): self { $this->container->config($config); diff --git a/tests/Integration/Container/Fixtures/ScopedDependency.php b/tests/Integration/Container/Fixtures/ScopedDependency.php new file mode 100644 index 0000000000..a651d99b3a --- /dev/null +++ b/tests/Integration/Container/Fixtures/ScopedDependency.php @@ -0,0 +1,10 @@ +