Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/1-essentials/05-container.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
15 changes: 14 additions & 1 deletion packages/container/src/Commands/ContainerShowCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/container/src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
107 changes: 88 additions & 19 deletions packages/container/src/GenericContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public function __construct(
/** @var ArrayIterator<array-key, class-string<\Tempest\Container\Resettable>> $resettables */
private(set) ArrayIterator $resettables = new ArrayIterator(),

/** @var ArrayIterator<array-key, string> $scopedDefinitions */
private(set) ArrayIterator $scopedDefinitions = new ArrayIterator(),

private(set) ?DependencyChain $chain = null,
) {
$this->singleton(Container::class, $this);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions packages/container/src/Scoped.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Tempest\Container;

use Attribute;

/**
* Registers the class as a singleton that only lives for the duration of a single lifecycle, such as a request in a long-running worker.
*/
#[Attribute]
final readonly class Scoped
{
public function __construct(
public ?string $tag = null,
) {}
}
Loading
Loading