From 28f0739c41c03620778a4f0e9d04ed7479a73107 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Wed, 21 Oct 2026 13:04:00 +0200
Subject: [PATCH 1/5] Add TagRegistry and InvalidTagNameException for service
tagging functionality
---
src/Container.php | 265 +++++++++++++++++++++-
src/Exception/InvalidTagNameException.php | 32 +++
src/Internal/TagRegistry.php | 106 +++++++++
src/Resolver.php | 110 ++++++++-
src/Scope.php | 173 +++++++++++++-
5 files changed, 678 insertions(+), 8 deletions(-)
create mode 100644 src/Exception/InvalidTagNameException.php
create mode 100644 src/Internal/TagRegistry.php
diff --git a/src/Container.php b/src/Container.php
index 0f227c5..2ad969e 100644
--- a/src/Container.php
+++ b/src/Container.php
@@ -21,9 +21,12 @@
Alias, Cls, Factory, Instance
};
use FireHub\Container\Internal\ {
- ContextualBindingRegistry, InstanceStore, ParameterBindingRegistry
+ ContextualBindingRegistry, InstanceStore, ParameterBindingRegistry, ResolutionContext, TagRegistry
};
-use FireHub\Container\Exception\ContainerFrozenException;
+use FireHub\Container\Exception\ {
+ ContainerFrozenException, InvalidTagNameException, ServiceNotFoundException
+};
+use FireHub\Runtime;
use Closure;
/**
@@ -68,6 +71,12 @@ final class Container implements ContainerBoundary, Registrar {
*/
private readonly ContextualBindingRegistry $contextual;
+ /**
+ * ### Service tag registry
+ * @since 1.0.0
+ */
+ private readonly TagRegistry $tags;
+
/**
* ### Container freeze state
* @var bool
@@ -85,12 +94,14 @@ public function __construct () {
$this->registry = new Registry;
$this->parameters = new ParameterBindingRegistry;
$this->contextual = new ContextualBindingRegistry;
+ $this->tags = new TagRegistry;
$this->resolver = new Resolver(
$this->registry,
new InstanceStore,
$this->parameters,
- $this->contextual
+ $this->contextual,
+ $this->tags
);
}
@@ -166,6 +177,21 @@ public function register (string $id, Definition $definition):self {
* Contextual bindings apply only to the exact consumer class and do not affect global service registrations.
*
* The target service retains its configured lifetime.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->bindContextual(
+ * FrontendService::class,
+ * LoggerInterface::class,
+ * FileLogger::class
+ * );
+ *
+ * $frontend = $container->get(FrontendService::class);
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Container::assertMutable()
@@ -535,6 +561,239 @@ public function bindParameters (string $class, array $parameters):self {
}
+ /**
+ * ### Associates registered services with a tag
+ *
+ * Groups explicitly registered service identifiers under the specified tag.
+ *
+ * Only services registered in the container can be tagged. Implicitly constructible classes are not considered
+ * registered services.
+ *
+ * Service identifiers are stored in registration order, and duplicate identifiers within the same tag are
+ * ignored.
+ *
+ * Registration is atomic: all service identifiers are validated before any tag association is created.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(AuthenticationMiddleware::class);
+ * $container->singleton(AuthorizationMiddleware::class);
+ * $container->singleton(LoggingMiddleware::class);
+ *
+ * $container->tag(
+ * 'http.middleware',
+ * AuthenticationMiddleware::class,
+ * AuthorizationMiddleware::class,
+ * LoggingMiddleware::class
+ * );
+ *
+ *
+ * You can also tag services with multiple calls to the `tag()` method:
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(AuthenticationMiddleware::class);
+ * $container->singleton(AuthorizationMiddleware::class);
+ * $container->singleton(LoggingMiddleware::class);
+ *
+ * $container->tag(
+ * 'http.middleware',
+ * AuthenticationMiddleware::class
+ * );
+ *
+ * $container->tag(
+ * 'http.middleware',
+ * AuthorizationMiddleware::class,
+ * LoggingMiddleware::class
+ * );
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Container::assertMutable() To ensure the container is not frozen.
+ * @uses \FireHub\Container\Container::has() To verify explicit service registrations.
+ * @uses \FireHub\Container\Internal\TagRegistry::register() To register service tag associations.
+ * @uses \FireHub\Runtime\Str\SB\Transform::trim() To validate tag names.
+ *
+ * @param non-empty-string $tag
+ * Tag name.
+ *
+ * @param class-string ...$services
+ * Explicitly registered service identifiers.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a service is not explicitly registered.
+ * @throws \FireHub\Container\Exception\InvalidTagNameException If the tag name is empty.
+ *
+ * @return $this Container instance.
+ */
+ public function tag (string $tag, string ...$services):self {
+
+ $this->assertMutable();
+
+ if (Runtime\Str\SB\Transform::trim($tag) === '')
+ throw new InvalidTagNameException;
+
+ foreach ($services as $service)
+ if (!$this->has($service))
+ throw new ServiceNotFoundException;
+
+ $this->tags->register($tag, ...$services);
+
+ return $this;
+
+ }
+
+ /**
+ * ### Checks whether a service tag exists
+ *
+ * Determines whether the specified tag contains at least one registered service identifier.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(AuthenticationMiddleware::class);
+ * $container->singleton(AuthorizationMiddleware::class);
+ * $container->singleton(LoggingMiddleware::class);
+ *
+ * $container->tag(
+ * 'http.middleware',
+ * AuthenticationMiddleware::class,
+ * AuthorizationMiddleware::class,
+ * LoggingMiddleware::class
+ * );
+ *
+ * $container->hasTag('http.middleware');
+ *
+ * // true
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\TagRegistry::has() To check tag registration.
+ *
+ * @param non-empty-string $tag
+ * Tag name.
+ *
+ *
+ * @return bool True if the tag contains registered services, otherwise false.
+ */
+ public function hasTag (string $tag):bool {
+
+ return $this->tags->has($tag);
+
+ }
+
+ /**
+ * ### Retrieves service identifiers associated with a tag
+ *
+ * Returns explicitly registered service identifiers in their original registration order without resolving or
+ * instantiating services.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(AuthenticationMiddleware::class);
+ * $container->singleton(AuthorizationMiddleware::class);
+ * $container->singleton(LoggingMiddleware::class);
+ *
+ * $container->tag(
+ * 'http.middleware',
+ * AuthenticationMiddleware::class,
+ * AuthorizationMiddleware::class,
+ * LoggingMiddleware::class
+ * );
+ *
+ * $container->taggedIds('http.middleware');
+ *
+ * // [
+ * // AuthenticationMiddleware::class,
+ * // AuthorizationMiddleware::class,
+ * // LoggingMiddleware::class
+ * // ]
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers.
+ *
+ * @param non-empty-string $tag
+ * Tag name.
+ *
+ *
+ * @return list Tagged service identifiers.
+ */
+ public function taggedIds (string $tag):array {
+
+ return $this->tags->get($tag);
+
+ }
+
+ /**
+ * ### Resolves all services associated with a tag
+ *
+ * Returns tagged service instances in their registration order.
+ *
+ * Each service retains its configured lifetime. Scoped services require an active scope.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(AuthenticationMiddleware::class);
+ * $container->singleton(AuthorizationMiddleware::class);
+ * $container->singleton(LoggingMiddleware::class);
+ *
+ * $container->tag(
+ * 'http.middleware',
+ * AuthenticationMiddleware::class,
+ * AuthorizationMiddleware::class,
+ * LoggingMiddleware::class
+ * );
+ *
+ * $container->tagged('http.middleware');
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Resolver::tagged() To resolve tagged services.
+ *
+ * @param non-empty-string $tag
+ * Service tag name.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ *
+ * @return list Resolved service instances.
+ */
+ public function tagged (string $tag):array {
+
+ return $this->resolver->tagged(
+ $tag,
+ new ResolutionContext
+ );
+
+ }
+
/**
* ### Resolves a registered service
*
diff --git a/src/Exception/InvalidTagNameException.php b/src/Exception/InvalidTagNameException.php
new file mode 100644
index 0000000..e1431bd
--- /dev/null
+++ b/src/Exception/InvalidTagNameException.php
@@ -0,0 +1,32 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.1
+ * @package Container
+ */
+
+namespace FireHub\Container\Exception;
+
+use FireHub\Core\Exception\Runtime\System\InvariantException;
+
+/**
+ * ### Thrown when a service tag name is invalid
+ *
+ * Indicates that a service tag name is empty or contains only whitespace characters.
+ * @since 1.0.0
+ */
+final class InvalidTagNameException extends InvariantException {
+
+ /**
+ * ### Default exception message
+ * @since 1.0.0
+ */
+ protected const string DEFAULT_MESSAGE = 'Service tag name must not be empty or contain only whitespace';
+
+}
\ No newline at end of file
diff --git a/src/Internal/TagRegistry.php b/src/Internal/TagRegistry.php
new file mode 100644
index 0000000..6519712
--- /dev/null
+++ b/src/Internal/TagRegistry.php
@@ -0,0 +1,106 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.4
+ * @package Container
+ */
+
+namespace FireHub\Container\Internal;
+
+use FireHub\Runtime;
+
+/**
+ * ### Stores service tag registrations
+ *
+ * Maintains ordered associations between tag names and explicitly registered service identifiers.
+ *
+ * Each service identifier can belong to multiple tags, while duplicate registrations within the same tag are
+ * ignored. Registration order is preserved for deterministic service collection resolution.
+ *
+ * This registry stores only service identifiers and does not instantiate services, manage lifetimes, or resolve
+ * dependencies.
+ * @since 1.0.0
+ *
+ * @internal
+ */
+final class TagRegistry {
+
+ /**
+ * ### Registered service tags
+ *
+ * @var array>
+ */
+ private array $tags = [];
+
+ /**
+ * ### Registers service identifiers under a tag
+ *
+ * Associates the specified service identifiers with the given tag while preserving registration order.
+ * Duplicate identifiers within the same tag are ignored.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Runtime\Arr\Inspection::inArray() To check if the service identifier is already registered.
+ *
+ * @param non-empty-string $tag
+ * Tag name.
+ *
+ * @param class-string ...$services
+ * Registered service identifiers.
+ *
+ *
+ * @return void
+ */
+ public function register (string $tag, string ...$services):void {
+
+ foreach ($services as $service) {
+
+ if (Runtime\Arr\Inspection::inArray($this->tags[$tag] ?? [], $service))
+ continue;
+
+ $this->tags[$tag][] = $service;
+
+ }
+
+ }
+
+ /**
+ * ### Checks whether a tag contains registered services
+ * @since 1.0.0
+ *
+ * @param non-empty-string $tag
+ * Tag name.
+ *
+ *
+ * @return bool True if the tag contains at least one service, otherwise false.
+ */
+ public function has (string $tag):bool {
+
+ return isset($this->tags[$tag]);
+
+ }
+
+ /**
+ * ### Retrieves service identifiers associated with a tag
+ *
+ * Returns registered service identifiers in their original registration order.
+ * @since 1.0.0
+ *
+ * @param non-empty-string $tag
+ * Tag name.
+ *
+ *
+ * @return list Registered service identifiers.
+ */
+ public function get (string $tag):array {
+
+ return $this->tags[$tag] ?? [];
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/Resolver.php b/src/Resolver.php
index 27e8a05..27c4140 100644
--- a/src/Resolver.php
+++ b/src/Resolver.php
@@ -21,7 +21,7 @@
use FireHub\Container\Internal\ {
Autowirer, CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector,
ContextualResolver, DependencyPlanCache, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeState,
- TypeMatcher
+ TagRegistry, TypeMatcher
};
use FireHub\Container\Exception\ {
ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException, UnsupportedDefinitionException
@@ -78,6 +78,9 @@
* @param \FireHub\Container\Internal\ContextualBindingRegistry $contextual
* Contextual binding registry.
*
+ * @param \FireHub\Container\Internal\TagRegistry $tags
+ * Service tag registry.
+ *
*
* @return void
*/
@@ -85,7 +88,8 @@ public function __construct (
private Registry $registry,
private InstanceStore $singletons,
ParameterBindingRegistry $parameters,
- ContextualBindingRegistry $contextual
+ ContextualBindingRegistry $contextual,
+ private TagRegistry $tags
) {
$this->metadata = new ConstructorMetadataCache;
@@ -212,6 +216,108 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope)
}
+ /**
+ * ### Resolves services associated with a tag
+ *
+ * Resolves all service identifiers registered under the specified tag in their original registration order.
+ *
+ * Each service is resolved through the active resolution context, preserving singleton, scoped, and transient
+ * lifetime semantics.
+ *
+ * An unknown tag produces an empty collection.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers.
+ * @uses \FireHub\Container\Resolver::resolve() To resolve each tagged service.
+ *
+ * @param non-empty-string $tag
+ * Service tag name.
+ *
+ * @param \FireHub\Container\Internal\ResolutionContext $context
+ * Active dependency resolution context.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ *
+ * @return list Resolved service instances.
+ */
+ public function tagged (string $tag, ResolutionContext $context):array {
+
+ $services = [];
+
+ foreach ($this->tags->get($tag) as $id)
+ $services[] = $this->resolve($id, $context);
+
+ return $services;
+
+ }
+
+ /**
+ * ### Resolves tagged services within a scope
+ *
+ * Resolves all services associated with a tag using the provided scoped instance store and lifecycle state.
+ *
+ * All tagged services share the same resolution context, preserving scoped instance caching and circular
+ * dependency detection.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\ScopeState::enter() To enter the scope.
+ * @uses \FireHub\Container\Internal\ScopeState::leave() To leave the scope.
+ * @uses \FireHub\Container\Resolver::tagged() To resolve tagged services.
+ *
+ * @param non-empty-string $tag
+ * Service tag name.
+ *
+ * @param \FireHub\Container\Internal\InstanceStore $scoped
+ * Scoped instance storage.
+ *
+ * @param \FireHub\Container\Internal\ScopeState $scope
+ * Scope lifecycle state.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ *
+ * @return list Resolved service instances.
+ */
+ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $scope):array {
+
+ $scope->enter();
+
+ try {
+
+ return $this->tagged(
+ $tag,
+ new ResolutionContext(
+ scoped: $scoped,
+ scope: $scope
+ )
+ );
+
+ } finally {
+
+ $scope->leave();
+
+ }
+
+ }
+
/**
* ### Checks whether a service is registered
*
diff --git a/src/Scope.php b/src/Scope.php
index 6ddf816..de5ad84 100644
--- a/src/Scope.php
+++ b/src/Scope.php
@@ -70,6 +70,78 @@ public function __construct (
}
+ /**
+ * ### Resolves tagged services within the current scope
+ *
+ * Resolves all services associated with the specified tag using the current scope.
+ *
+ * Scoped services are reused within this scope, singleton services are shared with the parent container, and
+ * transient services are constructed on each resolution.
+ *
+ * Resolution is prohibited after the scope has been closed.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(AuthenticationMiddleware::class);
+ * $container->scoped(AuthorizationMiddleware::class);
+ * $container->transient(LoggingMiddleware::class);
+ *
+ * $container->tag(
+ * 'http.middleware',
+ * AuthenticationMiddleware::class,
+ * AuthorizationMiddleware::class,
+ * LoggingMiddleware::class
+ * );
+ *
+ * $scope = $container->createScope();
+ *
+ * $first = $scope->tagged('http.middleware');
+ * $second = $scope->tagged('http.middleware');
+ *
+ * // Singleton and scoped instances are reused.
+ * $first[0] === $second[0]; // true
+ * $first[1] === $second[1]; // true
+ *
+ * // Transient instances are created on each resolution.
+ * $first[2] !== $second[2]; // true
+ *
+ * $scope->close();
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Resolver::taggedScoped() To resolve tagged services within the scope.
+ *
+ * @param non-empty-string $tag
+ * Service tag name.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ *
+ * @return list Resolved service instances.
+ */
+ public function tagged (string $tag):array {
+
+ return $this->resolver->taggedScoped(
+ $tag,
+ $this->instances,
+ $this->state
+ );
+
+ }
+
/**
* ### Resolves a registered service
*
@@ -79,6 +151,25 @@ public function __construct (
* transient services are created on each resolution.
*
* Resolution is prohibited after the scope has been closed.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->scoped(DatabaseConnection::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * $first = $scope->get(DatabaseConnection::class);
+ * $second = $scope->get(DatabaseConnection::class);
+ *
+ * // Scoped services are reused within the same scope.
+ * $first === $second; // true
+ *
+ * $scope->close();
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Resolver::getScoped() To resolve a scoped service.
@@ -89,12 +180,13 @@ public function __construct (
* Service identifier.
*
*
- * @throws \FireHub\Container\Exception\ScopeClosedException If the scope has been closed.
* @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
- * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
- * with its registered identifier.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible with its registered identifier.
* @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
* @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
*
* @return TService Resolved service.
*/
@@ -116,6 +208,22 @@ public function get (string $id):object {
* The operation does not instantiate the service.
*
* Registration checks are prohibited after the scope has been closed.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(Logger::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * $scope->has(Logger::class); // true
+ * $scope->has(Cache::class); // false
+ *
+ * $scope->close();
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Internal\ScopeState::assertOpen() To check if the scope is open.
@@ -145,6 +253,31 @@ public function has (string $id):bool {
* The scope is automatically closed after callback execution, including when the callback throws an exception.
*
* Nested execution of the same scope is not permitted.
+ *
+ *
+ * use FireHub\Container\Container;
+ * use FireHub\Core\Boundary\Resolver;
+ *
+ * $container = new Container;
+ *
+ * $container->scoped(DatabaseConnection::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * $scope->run(
+ * function (Resolver $resolver):void {
+ *
+ * $connection = $resolver->get(DatabaseConnection::class);
+ *
+ * $connection->connect();
+ *
+ * }
+ * );
+ *
+ * // The scope is automatically closed after execution.
+ * $scope->isClosed(); // true
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Internal\ScopeState::beginRun() To begin scope execution.
@@ -188,6 +321,21 @@ public function run (Closure $callback):mixed {
/**
* ### Checks whether the scope is closed
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $scope = $container->createScope();
+ *
+ * $scope->isClosed(); // false
+ *
+ * $scope->close();
+ *
+ * $scope->isClosed(); // true
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Internal\ScopeState::isClosed() To check the scope lifecycle state.
@@ -209,6 +357,25 @@ public function isClosed ():bool {
*
* Closing a scope does not affect singleton instances maintained by the parent container or instances stored in
* other scopes.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->scoped(DatabaseConnection::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * $connection = $scope->get(DatabaseConnection::class);
+ *
+ * // Release cached scoped instances.
+ * $scope->close();
+ *
+ * // The scope can no longer resolve services.
+ * $scope->isClosed(); // true
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Internal\ScopeState::close() To close the scope.
From eb6a428ca68613be4a964142bebaf4fe931847b8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Wed, 21 Oct 2026 14:37:29 +0200
Subject: [PATCH 2/5] Add new stub classes for container testing and enhance
existing ones
---
src/Attribute/Tagged.php | 69 ++
src/Internal/Autowirer.php | 7 +
src/Internal/DependencyPlanCache.php | 146 ++-
src/Internal/ParameterPlan.php | 8 +-
src/Resolver.php | 1 +
tests/Stubs/AbstractLogger.php | 19 +
tests/Stubs/AnotherTestLogger.php | 19 +
tests/Stubs/BoundConnection.php | 28 +
tests/Stubs/BoundDefaultService.php | 25 +
tests/Stubs/BoundMixedService.php | 27 +
tests/Stubs/BoundNullableService.php | 25 +
tests/Stubs/BoundObjectService.php | 25 +
tests/Stubs/BoundOtherConnection.php | 25 +
tests/Stubs/CircularA.php | 25 +
tests/Stubs/CircularB.php | 25 +
tests/Stubs/ContextualCache.php | 19 +
tests/Stubs/ContextualCacheService.php | 19 +
tests/Stubs/ContextualCircularA.php | 25 +
tests/Stubs/ContextualCircularB.php | 25 +
tests/Stubs/ContextualConsoleLogger.php | 19 +
tests/Stubs/ContextualDatabaseLogger.php | 19 +
tests/Stubs/ContextualDefaultConsumer.php | 25 +
tests/Stubs/ContextualDnfConsumer.php | 25 +
tests/Stubs/ContextualFileLogger.php | 19 +
.../Stubs/ContextualIntersectionConsumer.php | 25 +
tests/Stubs/ContextualLogger.php | 19 +
tests/Stubs/ContextualNamedConsumer.php | 25 +
tests/Stubs/ContextualNestedConsumer.php | 25 +
tests/Stubs/ContextualOtherConsumer.php | 25 +
tests/Stubs/ContextualResettable.php | 19 +
tests/Stubs/ContextualScopedConsumer.php | 25 +
tests/Stubs/ContextualSerializable.php | 19 +
tests/Stubs/ContextualSerializableCache.php | 19 +
tests/Stubs/ContextualSingletonConsumer.php | 25 +
.../ContextualTwoIntersectionsConsumer.php | 25 +
tests/Stubs/ContextualUnionConsumer.php | 25 +
tests/Stubs/ContextualUniversalService.php | 23 +
tests/Stubs/DnfCache.php | 19 +
tests/Stubs/DnfCacheService.php | 19 +
tests/Stubs/DnfConsoleLogger.php | 19 +
tests/Stubs/DnfDefaultConsumer.php | 25 +
tests/Stubs/DnfFactoryConsumer.php | 25 +
tests/Stubs/DnfFileLogger.php | 19 +
tests/Stubs/DnfFirstBranchConsumer.php | 25 +
tests/Stubs/DnfLogger.php | 19 +
tests/Stubs/DnfLoggerCache.php | 19 +
tests/Stubs/DnfMultipleNamedConsumer.php | 25 +
tests/Stubs/DnfNestedConsumer.php | 25 +
tests/Stubs/DnfNoCandidateConsumer.php | 25 +
tests/Stubs/DnfRequiredNullableConsumer.php | 25 +
tests/Stubs/DnfResettable.php | 19 +
tests/Stubs/DnfScopedConsumer.php | 25 +
tests/Stubs/DnfSecondBranchConsumer.php | 25 +
tests/Stubs/DnfSerializable.php | 19 +
tests/Stubs/DnfSerializableCache.php | 19 +
tests/Stubs/DnfSingletonConsumer.php | 25 +
tests/Stubs/DnfTwoIntersectionsConsumer.php | 25 +
tests/Stubs/DnfUniversalService.php | 19 +
tests/Stubs/IntersectionConsumer.php | 25 +
tests/Stubs/Logger.php | 19 +
tests/Stubs/LoggerInterface.php | 19 +
tests/Stubs/MixedDefault.php | 25 +
tests/Stubs/MixedRequired.php | 25 +
tests/Stubs/NullableConcrete.php | 25 +
tests/Stubs/NullableDefault.php | 25 +
tests/Stubs/NullableRequired.php | 25 +
tests/Stubs/Repository.php | 25 +
tests/Stubs/ScalarDefault.php | 26 +
tests/Stubs/ScalarRequired.php | 25 +
tests/Stubs/ScopedConsumer.php | 25 +
tests/Stubs/ScopedDependency.php | 19 +
tests/Stubs/SingletonConsumer.php | 25 +
tests/Stubs/TaggedConsoleLogger.php | 19 +
tests/Stubs/TaggedDatabaseLogger.php | 19 +
tests/Stubs/TaggedFactoryService.php | 19 +
tests/Stubs/TaggedFileLogger.php | 19 +
tests/Stubs/TaggedInjectionCircularA.php | 28 +
tests/Stubs/TaggedInjectionCircularB.php | 25 +
tests/Stubs/TaggedInjectionConsumer.php | 31 +
.../Stubs/TaggedInjectionDefaultConsumer.php | 28 +
.../Stubs/TaggedInjectionEmptyTagConsumer.php | 26 +
tests/Stubs/TaggedInjectionNestedConsumer.php | 25 +
.../Stubs/TaggedInjectionNullableConsumer.php | 26 +
tests/Stubs/TaggedInjectionScalarConsumer.php | 26 +
.../Stubs/TaggedInjectionUnknownConsumer.php | 28 +
.../Stubs/TaggedInjectionUntypedConsumer.php | 26 +
.../TaggedInjectionWhitespaceTagConsumer.php | 26 +
tests/Stubs/TaggedLogger.php | 19 +
tests/Stubs/TaggedScopedService.php | 19 +
tests/Stubs/TaggedSingletonService.php | 19 +
tests/Stubs/TaggedTransientService.php | 19 +
tests/Stubs/TaggedUnregisteredService.php | 19 +
tests/Stubs/TestConsoleLogger.php | 19 +
tests/Stubs/TestFileLogger.php | 19 +
tests/Stubs/TestLogger.php | 19 +
tests/Stubs/TestResettable.php | 19 +
tests/Stubs/UnionA.php | 19 +
tests/Stubs/UnionAmbiguous.php | 25 +
tests/Stubs/UnionB.php | 19 +
tests/Stubs/UnionInterface.php | 19 +
tests/Stubs/UnionNullable.php | 25 +
tests/Stubs/UnionOnlyA.php | 25 +
tests/Stubs/UserService.php | 25 +
tests/Stubs/VariadicService.php | 30 +
tests/Support/ExceptionAssertions.php | 53 +
tests/Unit/AutowiringTest.php | 580 ++++++++++
tests/Unit/ContextualBindingTest.php | 1018 +++++++++++++++++
tests/Unit/DnfResolutionTest.php | 777 +++++++++++++
tests/Unit/ParameterBindingTest.php | 272 +++++
tests/Unit/TaggedInjectionTest.php | 524 +++++++++
tests/Unit/TaggedServicesTest.php | 831 ++++++++++++++
111 files changed, 6525 insertions(+), 25 deletions(-)
create mode 100644 src/Attribute/Tagged.php
create mode 100644 tests/Stubs/AbstractLogger.php
create mode 100644 tests/Stubs/AnotherTestLogger.php
create mode 100644 tests/Stubs/BoundConnection.php
create mode 100644 tests/Stubs/BoundDefaultService.php
create mode 100644 tests/Stubs/BoundMixedService.php
create mode 100644 tests/Stubs/BoundNullableService.php
create mode 100644 tests/Stubs/BoundObjectService.php
create mode 100644 tests/Stubs/BoundOtherConnection.php
create mode 100644 tests/Stubs/CircularA.php
create mode 100644 tests/Stubs/CircularB.php
create mode 100644 tests/Stubs/ContextualCache.php
create mode 100644 tests/Stubs/ContextualCacheService.php
create mode 100644 tests/Stubs/ContextualCircularA.php
create mode 100644 tests/Stubs/ContextualCircularB.php
create mode 100644 tests/Stubs/ContextualConsoleLogger.php
create mode 100644 tests/Stubs/ContextualDatabaseLogger.php
create mode 100644 tests/Stubs/ContextualDefaultConsumer.php
create mode 100644 tests/Stubs/ContextualDnfConsumer.php
create mode 100644 tests/Stubs/ContextualFileLogger.php
create mode 100644 tests/Stubs/ContextualIntersectionConsumer.php
create mode 100644 tests/Stubs/ContextualLogger.php
create mode 100644 tests/Stubs/ContextualNamedConsumer.php
create mode 100644 tests/Stubs/ContextualNestedConsumer.php
create mode 100644 tests/Stubs/ContextualOtherConsumer.php
create mode 100644 tests/Stubs/ContextualResettable.php
create mode 100644 tests/Stubs/ContextualScopedConsumer.php
create mode 100644 tests/Stubs/ContextualSerializable.php
create mode 100644 tests/Stubs/ContextualSerializableCache.php
create mode 100644 tests/Stubs/ContextualSingletonConsumer.php
create mode 100644 tests/Stubs/ContextualTwoIntersectionsConsumer.php
create mode 100644 tests/Stubs/ContextualUnionConsumer.php
create mode 100644 tests/Stubs/ContextualUniversalService.php
create mode 100644 tests/Stubs/DnfCache.php
create mode 100644 tests/Stubs/DnfCacheService.php
create mode 100644 tests/Stubs/DnfConsoleLogger.php
create mode 100644 tests/Stubs/DnfDefaultConsumer.php
create mode 100644 tests/Stubs/DnfFactoryConsumer.php
create mode 100644 tests/Stubs/DnfFileLogger.php
create mode 100644 tests/Stubs/DnfFirstBranchConsumer.php
create mode 100644 tests/Stubs/DnfLogger.php
create mode 100644 tests/Stubs/DnfLoggerCache.php
create mode 100644 tests/Stubs/DnfMultipleNamedConsumer.php
create mode 100644 tests/Stubs/DnfNestedConsumer.php
create mode 100644 tests/Stubs/DnfNoCandidateConsumer.php
create mode 100644 tests/Stubs/DnfRequiredNullableConsumer.php
create mode 100644 tests/Stubs/DnfResettable.php
create mode 100644 tests/Stubs/DnfScopedConsumer.php
create mode 100644 tests/Stubs/DnfSecondBranchConsumer.php
create mode 100644 tests/Stubs/DnfSerializable.php
create mode 100644 tests/Stubs/DnfSerializableCache.php
create mode 100644 tests/Stubs/DnfSingletonConsumer.php
create mode 100644 tests/Stubs/DnfTwoIntersectionsConsumer.php
create mode 100644 tests/Stubs/DnfUniversalService.php
create mode 100644 tests/Stubs/IntersectionConsumer.php
create mode 100644 tests/Stubs/Logger.php
create mode 100644 tests/Stubs/LoggerInterface.php
create mode 100644 tests/Stubs/MixedDefault.php
create mode 100644 tests/Stubs/MixedRequired.php
create mode 100644 tests/Stubs/NullableConcrete.php
create mode 100644 tests/Stubs/NullableDefault.php
create mode 100644 tests/Stubs/NullableRequired.php
create mode 100644 tests/Stubs/Repository.php
create mode 100644 tests/Stubs/ScalarDefault.php
create mode 100644 tests/Stubs/ScalarRequired.php
create mode 100644 tests/Stubs/ScopedConsumer.php
create mode 100644 tests/Stubs/ScopedDependency.php
create mode 100644 tests/Stubs/SingletonConsumer.php
create mode 100644 tests/Stubs/TaggedConsoleLogger.php
create mode 100644 tests/Stubs/TaggedDatabaseLogger.php
create mode 100644 tests/Stubs/TaggedFactoryService.php
create mode 100644 tests/Stubs/TaggedFileLogger.php
create mode 100644 tests/Stubs/TaggedInjectionCircularA.php
create mode 100644 tests/Stubs/TaggedInjectionCircularB.php
create mode 100644 tests/Stubs/TaggedInjectionConsumer.php
create mode 100644 tests/Stubs/TaggedInjectionDefaultConsumer.php
create mode 100644 tests/Stubs/TaggedInjectionEmptyTagConsumer.php
create mode 100644 tests/Stubs/TaggedInjectionNestedConsumer.php
create mode 100644 tests/Stubs/TaggedInjectionNullableConsumer.php
create mode 100644 tests/Stubs/TaggedInjectionScalarConsumer.php
create mode 100644 tests/Stubs/TaggedInjectionUnknownConsumer.php
create mode 100644 tests/Stubs/TaggedInjectionUntypedConsumer.php
create mode 100644 tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php
create mode 100644 tests/Stubs/TaggedLogger.php
create mode 100644 tests/Stubs/TaggedScopedService.php
create mode 100644 tests/Stubs/TaggedSingletonService.php
create mode 100644 tests/Stubs/TaggedTransientService.php
create mode 100644 tests/Stubs/TaggedUnregisteredService.php
create mode 100644 tests/Stubs/TestConsoleLogger.php
create mode 100644 tests/Stubs/TestFileLogger.php
create mode 100644 tests/Stubs/TestLogger.php
create mode 100644 tests/Stubs/TestResettable.php
create mode 100644 tests/Stubs/UnionA.php
create mode 100644 tests/Stubs/UnionAmbiguous.php
create mode 100644 tests/Stubs/UnionB.php
create mode 100644 tests/Stubs/UnionInterface.php
create mode 100644 tests/Stubs/UnionNullable.php
create mode 100644 tests/Stubs/UnionOnlyA.php
create mode 100644 tests/Stubs/UserService.php
create mode 100644 tests/Stubs/VariadicService.php
create mode 100644 tests/Support/ExceptionAssertions.php
create mode 100644 tests/Unit/AutowiringTest.php
create mode 100644 tests/Unit/ContextualBindingTest.php
create mode 100644 tests/Unit/DnfResolutionTest.php
create mode 100644 tests/Unit/ParameterBindingTest.php
create mode 100644 tests/Unit/TaggedInjectionTest.php
create mode 100644 tests/Unit/TaggedServicesTest.php
diff --git a/src/Attribute/Tagged.php b/src/Attribute/Tagged.php
new file mode 100644
index 0000000..6457c52
--- /dev/null
+++ b/src/Attribute/Tagged.php
@@ -0,0 +1,69 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container
+ */
+
+namespace FireHub\Container\Attribute;
+
+use Attribute;
+
+/**
+ * ### Marks a constructor parameter for tagged service injection
+ *
+ * Declares that a constructor parameter should receive all services associated with the specified tag.
+ *
+ * Tagged services are resolved in their registration order and retain their configured singleton, scoped, or
+ * transient lifetimes.
+ *
+ * The attribute is intended for array-typed constructor parameters.
+ *
+ *
+ * use FireHub\Container\Attribute\Tagged;
+ *
+ * final readonly class MiddlewarePipeline {
+ *
+ * public function __construct (
+ * #[Tagged('http.middleware')]
+ * public array $middlewares
+ * ) {}
+ *
+ * }
+ *
+ *
+ * @since 1.0.0
+ */
+#[Attribute(Attribute::TARGET_PARAMETER)]
+final readonly class Tagged {
+
+ /**
+ * ### Constructor
+ *
+ *
+ * use FireHub\Container\Attribute\Tagged;
+ *
+ * $attribute = new Tagged('http.middleware');
+ *
+ * $attribute->tag; // 'http.middleware'
+ *
+ *
+ * @since 1.0.0
+ *
+ * @param non-empty-string $tag
+ * Name of the service tag to resolve.
+ *
+ *
+ * @return void
+ */
+ public function __construct (
+ public string $tag
+ ) {}
+
+}
\ No newline at end of file
diff --git a/src/Internal/Autowirer.php b/src/Internal/Autowirer.php
index 14bf5d0..fe1370f 100644
--- a/src/Internal/Autowirer.php
+++ b/src/Internal/Autowirer.php
@@ -38,6 +38,9 @@
* @param Closure(class-string, \FireHub\Container\Internal\ResolutionContext):object $resolve
* Context-aware service resolver.
*
+ * @param Closure(non-empty-string, \FireHub\Container\Internal\ResolutionContext):list $resolve_tagged
+ * Context-aware tagged service collection resolver.
+ *
* @param Closure(class-string):bool $has
* Explicit service registration checker.
*
@@ -61,6 +64,7 @@
*/
public function __construct (
private Closure $resolve,
+ private Closure $resolve_tagged,
private Closure $has,
private TypeMatcher $matcher,
private CandidateSelector $selector,
@@ -159,6 +163,9 @@ private function resolveParameter (string $class, ParameterPlan $plan, Resolutio
if ($this->bindings->has($class, $name))
return $this->bindings->get($class, $name);
+ if ($plan->tag !== null)
+ return ($this->resolve_tagged)($plan->tag, $context);
+
$id = $this->contextual_selector->select($class, $plan);
if ($id !== null)
diff --git a/src/Internal/DependencyPlanCache.php b/src/Internal/DependencyPlanCache.php
index 3617655..8ba1f86 100644
--- a/src/Internal/DependencyPlanCache.php
+++ b/src/Internal/DependencyPlanCache.php
@@ -13,7 +13,10 @@
namespace FireHub\Container\Internal;
-use ReflectionNamedType, ReflectionUnionType, ReflectionIntersectionType;
+use FireHub\Container\Attribute\Tagged;
+use FireHub\Container\Exception\ServiceInstantiationException;
+use FireHub\Runtime;
+use ReflectionNamedType, ReflectionParameter, ReflectionUnionType, ReflectionIntersectionType;
/**
* ### Compiles and caches dependency construction plans
@@ -60,13 +63,17 @@ public function __construct(
* Returns the cached dependency plan or compiles one from constructor reflection metadata.
* @since 1.0.0
*
- * @uses \FireHub\Container\Internal\TypeMatcher::classId() To resolve named dependency identifiers.
+ * @uses \FireHub\Container\Internal\DependencyPlanCache::compileParameter() To compile constructor parameter
+ * metadata.
* @uses \FireHub\Container\Internal\ConstructorMetadataCache::get() To retrieve constructor metadata.
*
* @param class-string $class
* Service implementation class.
*
*
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared,
+ * the parameter type is unsupported, or the tag name is empty.
+ *
* @return \FireHub\Container\Internal\DependencyPlan Compiled dependency plan.
*/
public function get (string $class):DependencyPlan {
@@ -83,28 +90,7 @@ public function get (string $class):DependencyPlan {
if ($parameter->isVariadic())
continue;
- $type = $parameter->getType();
-
- $id = $type instanceof ReflectionNamedType
- ? $this->matcher->classId($parameter, $type)
- : null;
-
- $composite = $type instanceof ReflectionUnionType
- || $type instanceof ReflectionIntersectionType
- ? $type
- : null;
-
- $hasDefault = $parameter->isDefaultValueAvailable();
-
- $parameters[] = new ParameterPlan(
- $parameter,
- $id,
- $composite,
- $hasDefault,
- $hasDefault
- ? $parameter->getDefaultValue()
- : null
- );
+ $parameters[] = $this->compileParameter($parameter);
}
@@ -115,4 +101,116 @@ public function get (string $class):DependencyPlan {
}
+ /**
+ * ### Compiles constructor parameter metadata
+ *
+ * Extracts dependency type information, default values, and tagged service injection metadata.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\TypeMatcher::classId() To resolve named dependency identifiers.
+ * @uses \FireHub\Container\Internal\DependencyPlanCache::resolveTag() To resolve tagged injection metadata.
+ *
+ * @param ReflectionParameter $parameter
+ * Constructor parameter reflection.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared,
+ * the parameter type is unsupported, or the tag name is empty.
+ *
+ * @return \FireHub\Container\Internal\ParameterPlan Compiled parameter plan.
+ */
+ private function compileParameter (ReflectionParameter $parameter):ParameterPlan {
+
+ $type = $parameter->getType();
+
+ $id = $type instanceof ReflectionNamedType
+ ? $this->matcher->classId($parameter, $type)
+ : null;
+
+ $composite = $type instanceof ReflectionUnionType
+ || $type instanceof ReflectionIntersectionType
+ ? $type
+ : null;
+
+ $has_default = $parameter->isDefaultValueAvailable();
+
+ return new ParameterPlan(
+ $parameter,
+ $id,
+ $composite,
+ $has_default,
+ $has_default
+ ? $parameter->getDefaultValue()
+ : null,
+ $this->resolveTag($parameter)
+ );
+
+ }
+
+ /**
+ * ### Resolves tagged injection metadata
+ *
+ * Extracts and validates the tagged service injection attribute associated with a constructor parameter.
+ *
+ * Tagged injection requires a non-nullable array parameter and a non-empty tag name.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Runtime\Arr\Inspection::count() To count the number of declared tagged attributes.
+ * @uses \FireHub\Runtime\Str\SB\Transform::trim() To trim whitespace from tag names.
+ *
+ * @param \ReflectionParameter $parameter
+ * Constructor parameter reflection.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared,
+ * the parameter type is unsupported, or the tag name is empty.
+ *
+ * @return null|non-empty-string Tag name, or null if the parameter is not tagged.
+ */
+ private function resolveTag (ReflectionParameter $parameter):?string {
+
+ $attributes = $parameter->getAttributes(Tagged::class);
+
+ if ($attributes === [])
+ return null;
+
+ if (Runtime\Arr\Inspection::count($attributes) > 1)
+ throw new ServiceInstantiationException(
+ 'Multiple tagged attributes are not supported',
+ context: [
+ 'class' => $parameter->getDeclaringClass()?->getName(),
+ 'parameter' => $parameter->getName(),
+ ]
+ );
+
+ $type = $parameter->getType();
+
+ if (
+ !$type instanceof ReflectionNamedType
+ || $type->getName() !== 'array'
+ || $type->allowsNull()
+ ) throw new ServiceInstantiationException(
+ 'Tagged injection requires a non-nullable array parameter.',
+ context: [
+ 'class' => $parameter->getDeclaringClass()?->getName(),
+ 'parameter' => $parameter->getName(),
+ ]
+ );
+
+ /** @var \FireHub\Container\Attribute\Tagged $attribute */
+ $attribute = $attributes[0]->newInstance();
+
+ if (Runtime\Str\SB\Transform::trim($attribute->tag) === '')
+ throw new ServiceInstantiationException(
+ context: [
+ 'class' => $parameter->getDeclaringClass()?->getName(),
+ 'parameter' => $parameter->getName(),
+ 'reason' => 'Tagged injection requires a non-empty tag name.'
+ ]
+ );
+
+ return $attribute->tag;
+
+ }
+
}
\ No newline at end of file
diff --git a/src/Internal/ParameterPlan.php b/src/Internal/ParameterPlan.php
index 66e463a..6c9b51e 100644
--- a/src/Internal/ParameterPlan.php
+++ b/src/Internal/ParameterPlan.php
@@ -21,6 +21,8 @@
* Stores the statically determined information required to resolve a constructor parameter.
*
* Dependency availability and candidate selection remain dynamic and are evaluated during plan execution.
+ *
+ * Tagged service injection metadata is extracted during plan compilation and reused for later dependency resolutions.
* @since 1.0.0
*
* @internal
@@ -46,6 +48,9 @@
* @param mixed $default
* Default parameter value.
*
+ * @param null|non-empty-string $tag [optional]
+ * Service tag associated with the constructor parameter.
+ *
*
* @return void
*/
@@ -54,7 +59,8 @@ public function __construct (
public ?string $id,
public ReflectionUnionType|ReflectionIntersectionType|null $composite,
public bool $hasDefault,
- public mixed $default
+ public mixed $default,
+ public ?string $tag = null
) {}
}
\ No newline at end of file
diff --git a/src/Resolver.php b/src/Resolver.php
index 27c4140..baec675 100644
--- a/src/Resolver.php
+++ b/src/Resolver.php
@@ -111,6 +111,7 @@ public function __construct (
$this->autowirer = new Autowirer(
fn (string $id, ResolutionContext $context):object => $this->resolve($id, $context),
+ fn (string $tag, ResolutionContext $context):array => $this->tagged($tag, $context),
fn (string $id):bool => $this->has($id),
$matcher,
$selector,
diff --git a/tests/Stubs/AbstractLogger.php b/tests/Stubs/AbstractLogger.php
new file mode 100644
index 0000000..d5b3799
--- /dev/null
+++ b/tests/Stubs/AbstractLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+abstract class AbstractLogger {}
diff --git a/tests/Stubs/AnotherTestLogger.php b/tests/Stubs/AnotherTestLogger.php
new file mode 100644
index 0000000..e086d3c
--- /dev/null
+++ b/tests/Stubs/AnotherTestLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.4
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class AnotherTestLogger implements TestLogger, TestResettable {}
\ No newline at end of file
diff --git a/tests/Stubs/BoundConnection.php b/tests/Stubs/BoundConnection.php
new file mode 100644
index 0000000..b87dffe
--- /dev/null
+++ b/tests/Stubs/BoundConnection.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * ### BoundConnection
+ * @since 1.0.0
+ */
+final readonly class BoundConnection {
+
+ public function __construct (
+ public string $host,
+ public int $port,
+ public string $database
+ ) {}
+
+}
diff --git a/tests/Stubs/BoundDefaultService.php b/tests/Stubs/BoundDefaultService.php
new file mode 100644
index 0000000..8e2d498
--- /dev/null
+++ b/tests/Stubs/BoundDefaultService.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class BoundDefaultService {
+
+ public function __construct (
+ public int $port = 8080
+ ) {}
+
+}
diff --git a/tests/Stubs/BoundMixedService.php b/tests/Stubs/BoundMixedService.php
new file mode 100644
index 0000000..7716af8
--- /dev/null
+++ b/tests/Stubs/BoundMixedService.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class BoundMixedService {
+
+ public function __construct (
+ public Logger $logger,
+ public string $host,
+ public int $port = 3306
+ ) {}
+
+}
diff --git a/tests/Stubs/BoundNullableService.php b/tests/Stubs/BoundNullableService.php
new file mode 100644
index 0000000..6cd4813
--- /dev/null
+++ b/tests/Stubs/BoundNullableService.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class BoundNullableService {
+
+ public function __construct (
+ public ?Logger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/BoundObjectService.php b/tests/Stubs/BoundObjectService.php
new file mode 100644
index 0000000..bfa0658
--- /dev/null
+++ b/tests/Stubs/BoundObjectService.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class BoundObjectService {
+
+ public function __construct (
+ public Logger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/BoundOtherConnection.php b/tests/Stubs/BoundOtherConnection.php
new file mode 100644
index 0000000..6c0b20e
--- /dev/null
+++ b/tests/Stubs/BoundOtherConnection.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class BoundOtherConnection {
+
+ public function __construct (
+ public string $host
+ ) {}
+
+}
diff --git a/tests/Stubs/CircularA.php b/tests/Stubs/CircularA.php
new file mode 100644
index 0000000..2c60cf9
--- /dev/null
+++ b/tests/Stubs/CircularA.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CircularA {
+
+ public function __construct (
+ public CircularB $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/CircularB.php b/tests/Stubs/CircularB.php
new file mode 100644
index 0000000..85ea091
--- /dev/null
+++ b/tests/Stubs/CircularB.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CircularB {
+
+ public function __construct (
+ public CircularA $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualCache.php b/tests/Stubs/ContextualCache.php
new file mode 100644
index 0000000..bf3abfc
--- /dev/null
+++ b/tests/Stubs/ContextualCache.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface ContextualCache {}
diff --git a/tests/Stubs/ContextualCacheService.php b/tests/Stubs/ContextualCacheService.php
new file mode 100644
index 0000000..7e4f50d
--- /dev/null
+++ b/tests/Stubs/ContextualCacheService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualCacheService implements ContextualCache {}
diff --git a/tests/Stubs/ContextualCircularA.php b/tests/Stubs/ContextualCircularA.php
new file mode 100644
index 0000000..207456e
--- /dev/null
+++ b/tests/Stubs/ContextualCircularA.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualCircularA {
+
+ public function __construct (
+ public ContextualCircularB $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualCircularB.php b/tests/Stubs/ContextualCircularB.php
new file mode 100644
index 0000000..e61e347
--- /dev/null
+++ b/tests/Stubs/ContextualCircularB.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualCircularB {
+
+ public function __construct (
+ public ContextualCircularA $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualConsoleLogger.php b/tests/Stubs/ContextualConsoleLogger.php
new file mode 100644
index 0000000..7eb4b23
--- /dev/null
+++ b/tests/Stubs/ContextualConsoleLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualConsoleLogger implements ContextualLogger {}
diff --git a/tests/Stubs/ContextualDatabaseLogger.php b/tests/Stubs/ContextualDatabaseLogger.php
new file mode 100644
index 0000000..7f25c9a
--- /dev/null
+++ b/tests/Stubs/ContextualDatabaseLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualDatabaseLogger implements ContextualLogger {}
diff --git a/tests/Stubs/ContextualDefaultConsumer.php b/tests/Stubs/ContextualDefaultConsumer.php
new file mode 100644
index 0000000..8773d1f
--- /dev/null
+++ b/tests/Stubs/ContextualDefaultConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualDefaultConsumer {
+
+ public function __construct (
+ public ?ContextualLogger $logger = null
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualDnfConsumer.php b/tests/Stubs/ContextualDnfConsumer.php
new file mode 100644
index 0000000..9fae622
--- /dev/null
+++ b/tests/Stubs/ContextualDnfConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualDnfConsumer {
+
+ public function __construct (
+ public (ContextualLogger&ContextualResettable)|ContextualCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualFileLogger.php b/tests/Stubs/ContextualFileLogger.php
new file mode 100644
index 0000000..f9af963
--- /dev/null
+++ b/tests/Stubs/ContextualFileLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualFileLogger implements ContextualLogger, ContextualResettable {}
diff --git a/tests/Stubs/ContextualIntersectionConsumer.php b/tests/Stubs/ContextualIntersectionConsumer.php
new file mode 100644
index 0000000..5af38b8
--- /dev/null
+++ b/tests/Stubs/ContextualIntersectionConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualIntersectionConsumer {
+
+ public function __construct (
+ public ContextualLogger&ContextualResettable $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualLogger.php b/tests/Stubs/ContextualLogger.php
new file mode 100644
index 0000000..014439c
--- /dev/null
+++ b/tests/Stubs/ContextualLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface ContextualLogger {}
diff --git a/tests/Stubs/ContextualNamedConsumer.php b/tests/Stubs/ContextualNamedConsumer.php
new file mode 100644
index 0000000..57ab77e
--- /dev/null
+++ b/tests/Stubs/ContextualNamedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualNamedConsumer {
+
+ public function __construct (
+ public ContextualLogger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualNestedConsumer.php b/tests/Stubs/ContextualNestedConsumer.php
new file mode 100644
index 0000000..08f43f6
--- /dev/null
+++ b/tests/Stubs/ContextualNestedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualNestedConsumer {
+
+ public function __construct (
+ public ContextualNamedConsumer $consumer
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualOtherConsumer.php b/tests/Stubs/ContextualOtherConsumer.php
new file mode 100644
index 0000000..f1b550b
--- /dev/null
+++ b/tests/Stubs/ContextualOtherConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualOtherConsumer {
+
+ public function __construct (
+ public ContextualLogger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualResettable.php b/tests/Stubs/ContextualResettable.php
new file mode 100644
index 0000000..a8140d4
--- /dev/null
+++ b/tests/Stubs/ContextualResettable.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface ContextualResettable {}
diff --git a/tests/Stubs/ContextualScopedConsumer.php b/tests/Stubs/ContextualScopedConsumer.php
new file mode 100644
index 0000000..fc97347
--- /dev/null
+++ b/tests/Stubs/ContextualScopedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualScopedConsumer {
+
+ public function __construct (
+ public ContextualLogger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualSerializable.php b/tests/Stubs/ContextualSerializable.php
new file mode 100644
index 0000000..3fc73b1
--- /dev/null
+++ b/tests/Stubs/ContextualSerializable.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface ContextualSerializable {}
diff --git a/tests/Stubs/ContextualSerializableCache.php b/tests/Stubs/ContextualSerializableCache.php
new file mode 100644
index 0000000..6df395b
--- /dev/null
+++ b/tests/Stubs/ContextualSerializableCache.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualSerializableCache implements ContextualCache, ContextualSerializable {}
diff --git a/tests/Stubs/ContextualSingletonConsumer.php b/tests/Stubs/ContextualSingletonConsumer.php
new file mode 100644
index 0000000..9c4ce5e
--- /dev/null
+++ b/tests/Stubs/ContextualSingletonConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualSingletonConsumer {
+
+ public function __construct (
+ public ContextualLogger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualTwoIntersectionsConsumer.php b/tests/Stubs/ContextualTwoIntersectionsConsumer.php
new file mode 100644
index 0000000..109758e
--- /dev/null
+++ b/tests/Stubs/ContextualTwoIntersectionsConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualTwoIntersectionsConsumer {
+
+ public function __construct (
+ public (ContextualLogger&ContextualResettable)|(ContextualCache&ContextualSerializable) $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualUnionConsumer.php b/tests/Stubs/ContextualUnionConsumer.php
new file mode 100644
index 0000000..3bf2120
--- /dev/null
+++ b/tests/Stubs/ContextualUnionConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualUnionConsumer {
+
+ public function __construct (
+ public ContextualLogger|ContextualCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualUniversalService.php b/tests/Stubs/ContextualUniversalService.php
new file mode 100644
index 0000000..2bbf837
--- /dev/null
+++ b/tests/Stubs/ContextualUniversalService.php
@@ -0,0 +1,23 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualUniversalService implements
+ ContextualLogger,
+ ContextualResettable,
+ ContextualCache,
+ ContextualSerializable {}
diff --git a/tests/Stubs/DnfCache.php b/tests/Stubs/DnfCache.php
new file mode 100644
index 0000000..47bcd0c
--- /dev/null
+++ b/tests/Stubs/DnfCache.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface DnfCache {}
diff --git a/tests/Stubs/DnfCacheService.php b/tests/Stubs/DnfCacheService.php
new file mode 100644
index 0000000..a4f25d0
--- /dev/null
+++ b/tests/Stubs/DnfCacheService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfCacheService implements DnfCache {}
diff --git a/tests/Stubs/DnfConsoleLogger.php b/tests/Stubs/DnfConsoleLogger.php
new file mode 100644
index 0000000..57f1225
--- /dev/null
+++ b/tests/Stubs/DnfConsoleLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfConsoleLogger implements DnfLogger {}
diff --git a/tests/Stubs/DnfDefaultConsumer.php b/tests/Stubs/DnfDefaultConsumer.php
new file mode 100644
index 0000000..cc6b884
--- /dev/null
+++ b/tests/Stubs/DnfDefaultConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfDefaultConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache|null $dependency = null
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfFactoryConsumer.php b/tests/Stubs/DnfFactoryConsumer.php
new file mode 100644
index 0000000..9bd19df
--- /dev/null
+++ b/tests/Stubs/DnfFactoryConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfFactoryConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfFileLogger.php b/tests/Stubs/DnfFileLogger.php
new file mode 100644
index 0000000..eac86c3
--- /dev/null
+++ b/tests/Stubs/DnfFileLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfFileLogger implements DnfLogger, DnfResettable {}
diff --git a/tests/Stubs/DnfFirstBranchConsumer.php b/tests/Stubs/DnfFirstBranchConsumer.php
new file mode 100644
index 0000000..df7bee5
--- /dev/null
+++ b/tests/Stubs/DnfFirstBranchConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfFirstBranchConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfLogger.php b/tests/Stubs/DnfLogger.php
new file mode 100644
index 0000000..a16ff1d
--- /dev/null
+++ b/tests/Stubs/DnfLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface DnfLogger {}
diff --git a/tests/Stubs/DnfLoggerCache.php b/tests/Stubs/DnfLoggerCache.php
new file mode 100644
index 0000000..ca3bec2
--- /dev/null
+++ b/tests/Stubs/DnfLoggerCache.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfLoggerCache implements DnfLogger, DnfCache {}
diff --git a/tests/Stubs/DnfMultipleNamedConsumer.php b/tests/Stubs/DnfMultipleNamedConsumer.php
new file mode 100644
index 0000000..d615c56
--- /dev/null
+++ b/tests/Stubs/DnfMultipleNamedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfMultipleNamedConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache|DnfSerializable $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfNestedConsumer.php b/tests/Stubs/DnfNestedConsumer.php
new file mode 100644
index 0000000..bc2db67
--- /dev/null
+++ b/tests/Stubs/DnfNestedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfNestedConsumer {
+
+ public function __construct (
+ public DnfFirstBranchConsumer $consumer
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfNoCandidateConsumer.php b/tests/Stubs/DnfNoCandidateConsumer.php
new file mode 100644
index 0000000..eee3bb8
--- /dev/null
+++ b/tests/Stubs/DnfNoCandidateConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfNoCandidateConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|(DnfCache&DnfSerializable) $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfRequiredNullableConsumer.php b/tests/Stubs/DnfRequiredNullableConsumer.php
new file mode 100644
index 0000000..339f89a
--- /dev/null
+++ b/tests/Stubs/DnfRequiredNullableConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfRequiredNullableConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache|null $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfResettable.php b/tests/Stubs/DnfResettable.php
new file mode 100644
index 0000000..1572ae7
--- /dev/null
+++ b/tests/Stubs/DnfResettable.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface DnfResettable {}
diff --git a/tests/Stubs/DnfScopedConsumer.php b/tests/Stubs/DnfScopedConsumer.php
new file mode 100644
index 0000000..4792138
--- /dev/null
+++ b/tests/Stubs/DnfScopedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfScopedConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfSecondBranchConsumer.php b/tests/Stubs/DnfSecondBranchConsumer.php
new file mode 100644
index 0000000..1fd0fde
--- /dev/null
+++ b/tests/Stubs/DnfSecondBranchConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfSecondBranchConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfSerializable.php b/tests/Stubs/DnfSerializable.php
new file mode 100644
index 0000000..e9afb37
--- /dev/null
+++ b/tests/Stubs/DnfSerializable.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface DnfSerializable {}
diff --git a/tests/Stubs/DnfSerializableCache.php b/tests/Stubs/DnfSerializableCache.php
new file mode 100644
index 0000000..c725e42
--- /dev/null
+++ b/tests/Stubs/DnfSerializableCache.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfSerializableCache implements DnfCache, DnfSerializable {}
diff --git a/tests/Stubs/DnfSingletonConsumer.php b/tests/Stubs/DnfSingletonConsumer.php
new file mode 100644
index 0000000..2094968
--- /dev/null
+++ b/tests/Stubs/DnfSingletonConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfSingletonConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfTwoIntersectionsConsumer.php b/tests/Stubs/DnfTwoIntersectionsConsumer.php
new file mode 100644
index 0000000..4163f9a
--- /dev/null
+++ b/tests/Stubs/DnfTwoIntersectionsConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfTwoIntersectionsConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|(DnfCache&DnfSerializable) $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfUniversalService.php b/tests/Stubs/DnfUniversalService.php
new file mode 100644
index 0000000..a244202
--- /dev/null
+++ b/tests/Stubs/DnfUniversalService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfUniversalService implements DnfLogger, DnfResettable, DnfCache, DnfSerializable {}
diff --git a/tests/Stubs/IntersectionConsumer.php b/tests/Stubs/IntersectionConsumer.php
new file mode 100644
index 0000000..b5c5730
--- /dev/null
+++ b/tests/Stubs/IntersectionConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class IntersectionConsumer {
+
+ public function __construct (
+ public TestLogger&TestResettable $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/Logger.php b/tests/Stubs/Logger.php
new file mode 100644
index 0000000..3e3b3c1
--- /dev/null
+++ b/tests/Stubs/Logger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class Logger {}
diff --git a/tests/Stubs/LoggerInterface.php b/tests/Stubs/LoggerInterface.php
new file mode 100644
index 0000000..f4ebba0
--- /dev/null
+++ b/tests/Stubs/LoggerInterface.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface LoggerInterface {}
diff --git a/tests/Stubs/MixedDefault.php b/tests/Stubs/MixedDefault.php
new file mode 100644
index 0000000..e159e30
--- /dev/null
+++ b/tests/Stubs/MixedDefault.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class MixedDefault {
+
+ public function __construct (
+ public mixed $value = null
+ ) {}
+
+}
diff --git a/tests/Stubs/MixedRequired.php b/tests/Stubs/MixedRequired.php
new file mode 100644
index 0000000..721b6ae
--- /dev/null
+++ b/tests/Stubs/MixedRequired.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class MixedRequired {
+
+ public function __construct (
+ public mixed $value
+ ) {}
+
+}
diff --git a/tests/Stubs/NullableConcrete.php b/tests/Stubs/NullableConcrete.php
new file mode 100644
index 0000000..f815dd3
--- /dev/null
+++ b/tests/Stubs/NullableConcrete.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class NullableConcrete {
+
+ public function __construct (
+ public ?Logger $logger = null
+ ) {}
+
+}
diff --git a/tests/Stubs/NullableDefault.php b/tests/Stubs/NullableDefault.php
new file mode 100644
index 0000000..7c34ccc
--- /dev/null
+++ b/tests/Stubs/NullableDefault.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class NullableDefault {
+
+ public function __construct (
+ public ?LoggerInterface $logger = null
+ ) {}
+
+}
diff --git a/tests/Stubs/NullableRequired.php b/tests/Stubs/NullableRequired.php
new file mode 100644
index 0000000..6aba9ea
--- /dev/null
+++ b/tests/Stubs/NullableRequired.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class NullableRequired {
+
+ public function __construct (
+ public ?LoggerInterface $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/Repository.php b/tests/Stubs/Repository.php
new file mode 100644
index 0000000..1bc9873
--- /dev/null
+++ b/tests/Stubs/Repository.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class Repository {
+
+ public function __construct (
+ public Logger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/ScalarDefault.php b/tests/Stubs/ScalarDefault.php
new file mode 100644
index 0000000..7e054f1
--- /dev/null
+++ b/tests/Stubs/ScalarDefault.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ScalarDefault {
+
+ public function __construct (
+ public int $port = 8080,
+ public string $host = 'localhost'
+ ) {}
+
+}
diff --git a/tests/Stubs/ScalarRequired.php b/tests/Stubs/ScalarRequired.php
new file mode 100644
index 0000000..5008e23
--- /dev/null
+++ b/tests/Stubs/ScalarRequired.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ScalarRequired {
+
+ public function __construct (
+ public int $port
+ ) {}
+
+}
diff --git a/tests/Stubs/ScopedConsumer.php b/tests/Stubs/ScopedConsumer.php
new file mode 100644
index 0000000..00867fa
--- /dev/null
+++ b/tests/Stubs/ScopedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ScopedConsumer {
+
+ public function __construct (
+ public ScopedDependency $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ScopedDependency.php b/tests/Stubs/ScopedDependency.php
new file mode 100644
index 0000000..def3efe
--- /dev/null
+++ b/tests/Stubs/ScopedDependency.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ScopedDependency {}
diff --git a/tests/Stubs/SingletonConsumer.php b/tests/Stubs/SingletonConsumer.php
new file mode 100644
index 0000000..ca030a9
--- /dev/null
+++ b/tests/Stubs/SingletonConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class SingletonConsumer {
+
+ public function __construct (
+ public Logger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedConsoleLogger.php b/tests/Stubs/TaggedConsoleLogger.php
new file mode 100644
index 0000000..e244de2
--- /dev/null
+++ b/tests/Stubs/TaggedConsoleLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedConsoleLogger implements TaggedLogger {}
diff --git a/tests/Stubs/TaggedDatabaseLogger.php b/tests/Stubs/TaggedDatabaseLogger.php
new file mode 100644
index 0000000..42a70a0
--- /dev/null
+++ b/tests/Stubs/TaggedDatabaseLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedDatabaseLogger implements TaggedLogger {}
diff --git a/tests/Stubs/TaggedFactoryService.php b/tests/Stubs/TaggedFactoryService.php
new file mode 100644
index 0000000..20d3a66
--- /dev/null
+++ b/tests/Stubs/TaggedFactoryService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedFactoryService {}
diff --git a/tests/Stubs/TaggedFileLogger.php b/tests/Stubs/TaggedFileLogger.php
new file mode 100644
index 0000000..0714453
--- /dev/null
+++ b/tests/Stubs/TaggedFileLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedFileLogger implements TaggedLogger {}
diff --git a/tests/Stubs/TaggedInjectionCircularA.php b/tests/Stubs/TaggedInjectionCircularA.php
new file mode 100644
index 0000000..0576045
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionCircularA.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\Tagged;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionCircularA {
+
+ public function __construct (
+ #[Tagged('circular')]
+ public array $dependencies
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionCircularB.php b/tests/Stubs/TaggedInjectionCircularB.php
new file mode 100644
index 0000000..1148b0b
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionCircularB.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionCircularB {
+
+ public function __construct (
+ public TaggedInjectionCircularA $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionConsumer.php b/tests/Stubs/TaggedInjectionConsumer.php
new file mode 100644
index 0000000..9a157b0
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionConsumer.php
@@ -0,0 +1,31 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\Tagged;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionConsumer {
+
+ /**
+ * @param list $loggers
+ */
+ public function __construct (
+ #[Tagged('logging')]
+ public array $loggers
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionDefaultConsumer.php b/tests/Stubs/TaggedInjectionDefaultConsumer.php
new file mode 100644
index 0000000..8ad0490
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionDefaultConsumer.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\Tagged;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionDefaultConsumer {
+
+ public function __construct (
+ #[Tagged('logging')]
+ public array $loggers = []
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionEmptyTagConsumer.php b/tests/Stubs/TaggedInjectionEmptyTagConsumer.php
new file mode 100644
index 0000000..0c15f43
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionEmptyTagConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionEmptyTagConsumer {
+
+ public function __construct (
+ #[Tagged('')]
+ public array $services
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionNestedConsumer.php b/tests/Stubs/TaggedInjectionNestedConsumer.php
new file mode 100644
index 0000000..9fd2312
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionNestedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionNestedConsumer {
+
+ public function __construct (
+ public TaggedInjectionConsumer $consumer
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionNullableConsumer.php b/tests/Stubs/TaggedInjectionNullableConsumer.php
new file mode 100644
index 0000000..881022a
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionNullableConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionNullableConsumer {
+
+ public function __construct (
+ #[Tagged('logging')]
+ public ?array $loggers
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionScalarConsumer.php b/tests/Stubs/TaggedInjectionScalarConsumer.php
new file mode 100644
index 0000000..03d83a7
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionScalarConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionScalarConsumer {
+
+ public function __construct (
+ #[Tagged('logging')]
+ public string $loggers
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionUnknownConsumer.php b/tests/Stubs/TaggedInjectionUnknownConsumer.php
new file mode 100644
index 0000000..cbd872f
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionUnknownConsumer.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\Tagged;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionUnknownConsumer {
+
+ public function __construct (
+ #[Tagged('unknown')]
+ public array $services
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionUntypedConsumer.php b/tests/Stubs/TaggedInjectionUntypedConsumer.php
new file mode 100644
index 0000000..8011df9
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionUntypedConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class TaggedInjectionUntypedConsumer {
+
+ public function __construct (
+ #[Tagged('logging')]
+ public $loggers
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php b/tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php
new file mode 100644
index 0000000..a89230f
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionWhitespaceTagConsumer {
+
+ public function __construct (
+ #[Tagged(' ')]
+ public array $services
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedLogger.php b/tests/Stubs/TaggedLogger.php
new file mode 100644
index 0000000..cd22d5d
--- /dev/null
+++ b/tests/Stubs/TaggedLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface TaggedLogger {}
diff --git a/tests/Stubs/TaggedScopedService.php b/tests/Stubs/TaggedScopedService.php
new file mode 100644
index 0000000..1535fe7
--- /dev/null
+++ b/tests/Stubs/TaggedScopedService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedScopedService {}
diff --git a/tests/Stubs/TaggedSingletonService.php b/tests/Stubs/TaggedSingletonService.php
new file mode 100644
index 0000000..1097014
--- /dev/null
+++ b/tests/Stubs/TaggedSingletonService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedSingletonService {}
diff --git a/tests/Stubs/TaggedTransientService.php b/tests/Stubs/TaggedTransientService.php
new file mode 100644
index 0000000..f6997f4
--- /dev/null
+++ b/tests/Stubs/TaggedTransientService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedTransientService {}
diff --git a/tests/Stubs/TaggedUnregisteredService.php b/tests/Stubs/TaggedUnregisteredService.php
new file mode 100644
index 0000000..e01d3a5
--- /dev/null
+++ b/tests/Stubs/TaggedUnregisteredService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedUnregisteredService {}
diff --git a/tests/Stubs/TestConsoleLogger.php b/tests/Stubs/TestConsoleLogger.php
new file mode 100644
index 0000000..2706f59
--- /dev/null
+++ b/tests/Stubs/TestConsoleLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TestConsoleLogger implements TestLogger {}
diff --git a/tests/Stubs/TestFileLogger.php b/tests/Stubs/TestFileLogger.php
new file mode 100644
index 0000000..27cf9c6
--- /dev/null
+++ b/tests/Stubs/TestFileLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TestFileLogger implements TestLogger, TestResettable {}
diff --git a/tests/Stubs/TestLogger.php b/tests/Stubs/TestLogger.php
new file mode 100644
index 0000000..334fe70
--- /dev/null
+++ b/tests/Stubs/TestLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface TestLogger {}
diff --git a/tests/Stubs/TestResettable.php b/tests/Stubs/TestResettable.php
new file mode 100644
index 0000000..ac70612
--- /dev/null
+++ b/tests/Stubs/TestResettable.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface TestResettable {}
diff --git a/tests/Stubs/UnionA.php b/tests/Stubs/UnionA.php
new file mode 100644
index 0000000..418483a
--- /dev/null
+++ b/tests/Stubs/UnionA.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UnionA {}
diff --git a/tests/Stubs/UnionAmbiguous.php b/tests/Stubs/UnionAmbiguous.php
new file mode 100644
index 0000000..4309043
--- /dev/null
+++ b/tests/Stubs/UnionAmbiguous.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UnionAmbiguous {
+
+ public function __construct (
+ public UnionA|UnionB $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/UnionB.php b/tests/Stubs/UnionB.php
new file mode 100644
index 0000000..d2cd3da
--- /dev/null
+++ b/tests/Stubs/UnionB.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UnionB {}
diff --git a/tests/Stubs/UnionInterface.php b/tests/Stubs/UnionInterface.php
new file mode 100644
index 0000000..ab0adb6
--- /dev/null
+++ b/tests/Stubs/UnionInterface.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface UnionInterface {}
diff --git a/tests/Stubs/UnionNullable.php b/tests/Stubs/UnionNullable.php
new file mode 100644
index 0000000..5cbc812
--- /dev/null
+++ b/tests/Stubs/UnionNullable.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UnionNullable {
+
+ public function __construct (
+ public UnionInterface|UnionB|null $dependency = null
+ ) {}
+
+}
diff --git a/tests/Stubs/UnionOnlyA.php b/tests/Stubs/UnionOnlyA.php
new file mode 100644
index 0000000..a8c0e29
--- /dev/null
+++ b/tests/Stubs/UnionOnlyA.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UnionOnlyA {
+
+ public function __construct (
+ public UnionA|UnionInterface $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/UserService.php b/tests/Stubs/UserService.php
new file mode 100644
index 0000000..2727e51
--- /dev/null
+++ b/tests/Stubs/UserService.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UserService {
+
+ public function __construct (
+ public Repository $repository
+ ) {}
+
+}
diff --git a/tests/Stubs/VariadicService.php b/tests/Stubs/VariadicService.php
new file mode 100644
index 0000000..26b5067
--- /dev/null
+++ b/tests/Stubs/VariadicService.php
@@ -0,0 +1,30 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class VariadicService {
+
+ /** @var list */
+ public array $loggers;
+
+ public function __construct (Logger ...$loggers) {
+
+ $this->loggers = $loggers;
+
+ }
+
+}
diff --git a/tests/Support/ExceptionAssertions.php b/tests/Support/ExceptionAssertions.php
new file mode 100644
index 0000000..45fd3bf
--- /dev/null
+++ b/tests/Support/ExceptionAssertions.php
@@ -0,0 +1,53 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.4
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Support;
+
+use Closure, Throwable;
+
+/**
+ * ### Assertions for exceptions inside nested callbacks
+ *
+ * Allows tests to validate expected failures and continue asserting state afterward.
+ * @since 1.0.0
+ */
+trait ExceptionAssertions {
+
+ /**
+ * ### Asserts that a callback throws an exception of the expected type
+ * @since 1.0.0
+ *
+ * @param class-string $expected Expected exception class.
+ * @param Closure():mixed $callback Callback expected to throw.
+ *
+ * @return void
+ */
+ private function assertThrows (string $expected, Closure $callback):void {
+
+ try {
+
+ $callback();
+
+ } catch (Throwable $exception) {
+
+ self::assertInstanceOf($expected, $exception);
+
+ return;
+
+ }
+
+ self::fail("Expected exception {$expected} was not thrown.");
+
+ }
+
+}
diff --git a/tests/Unit/AutowiringTest.php b/tests/Unit/AutowiringTest.php
new file mode 100644
index 0000000..70d8214
--- /dev/null
+++ b/tests/Unit/AutowiringTest.php
@@ -0,0 +1,580 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ AmbiguousDependencyException, CircularDependencyException, ServiceInstantiationException,
+ ServiceNotFoundException, ScopeRequiredException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ AbstractLogger, AnotherTestLogger, CircularA, IntersectionConsumer, Logger, LoggerInterface, MixedDefault,
+ MixedRequired, NullableConcrete, NullableDefault, NullableRequired, Repository, ScalarDefault, ScalarRequired,
+ ScopedConsumer, ScopedDependency, SingletonConsumer, TestFileLogger, TestLogger, UnionA, UnionAmbiguous, UnionB,
+ UnionInterface, UnionNullable, UnionOnlyA, UserService, VariadicService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests constructor dependency autowiring
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class AutowiringTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Implicit autowiring resolves nested dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testImplicitAutowiringResolvesNestedDependencies ():void {
+
+ $container = new Container();
+
+ $service = $container->get(UserService::class);
+
+ self::assertInstanceOf(UserService::class, $service);
+ self::assertInstanceOf(Repository::class, $service->repository);
+ self::assertInstanceOf(Logger::class, $service->repository->logger);
+
+ }
+
+ /**
+ * ### Implicit services are transient
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testImplicitServicesAreTransient ():void {
+
+ $container = new Container();
+
+ $first = $container->get(UserService::class);
+ $second = $container->get(UserService::class);
+
+ self::assertNotSame($first, $second);
+ self::assertNotSame($first->repository, $second->repository);
+ self::assertNotSame($first->repository->logger, $second->repository->logger);
+
+ }
+
+ /**
+ * ### Implicit resolution does not register services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testImplicitResolutionDoesNotRegisterServices ():void {
+
+ $container = new Container();
+
+ self::assertNotTrue($container->has(UserService::class));
+
+ $container->get(UserService::class);
+
+ self::assertNotTrue($container->has(UserService::class));
+
+ }
+
+ /**
+ * ### Unregistered interfaces cannot be instantiated
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnregisteredInterfacesCannotBeInstantiated ():void {
+
+ $this->assertThrows(ServiceNotFoundException::class, function ():void {
+
+ new Container()->get(LoggerInterface::class);
+
+ });
+
+ }
+
+ /**
+ * ### Abstract classes cannot be instantiated
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAbstractClassesCannotBeInstantiated ():void {
+
+ $this->assertThrows(ServiceNotFoundException::class, function ():void {
+
+ new Container()->get(AbstractLogger::class);
+
+ });
+
+ }
+
+ /**
+ * ### Required scalar parameters fail
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRequiredScalarParametersFail ():void {
+
+ $this->assertThrows(ServiceInstantiationException::class, function ():void {
+
+ new Container()->get(ScalarRequired::class);
+
+ });
+
+ }
+
+ /**
+ * ### Scalar parameters use default values
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScalarParametersUseDefaultValues ():void {
+
+ $service = new Container()->get(ScalarDefault::class);
+
+ self::assertSame(8080, $service->port);
+ self::assertSame('localhost', $service->host);
+
+ }
+
+ /**
+ * ### Nullable dependencies without defaults remain required
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNullableDependenciesWithoutDefaultsRemainRequired ():void {
+
+ $this->assertThrows(ServiceInstantiationException::class, function ():void {
+
+ new Container()->get(NullableRequired::class);
+
+ });
+
+ }
+
+ /**
+ * ### Nullable dependencies use null defaults
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNullableDependenciesUseNullDefaults ():void {
+
+ $service = new Container()->get(NullableDefault::class);
+
+ self::assertNull($service->logger);
+
+ }
+
+ /**
+ * ### Nullable concrete dependencies are autowired
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNullableConcreteDependenciesAreAutowired ():void {
+
+ $service = new Container()->get(NullableConcrete::class);
+
+ self::assertInstanceOf(Logger::class, $service->logger);
+
+ }
+
+ /**
+ * ### Variadic parameters accept zero dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testVariadicParametersAcceptZeroDependencies ():void {
+
+ $service = new Container()->get(VariadicService::class);
+
+ self::assertSame($service->loggers, []);
+
+ }
+
+ /**
+ * ### Required mixed parameters fail
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRequiredMixedParametersFail ():void {
+
+ $this->assertThrows(ServiceInstantiationException::class, function ():void {
+
+ new Container()->get(MixedRequired::class);
+
+ });
+
+ }
+
+ /**
+ * ### Mixed parameters use defaults
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testMixedParametersUseDefaults ():void {
+
+ $service = new Container()->get(MixedDefault::class);
+
+ self::assertNull($service->value);
+
+ }
+
+ /**
+ * ### Circular dependencies are detected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testCircularDependenciesAreDetected ():void {
+
+ $this->assertThrows(CircularDependencyException::class, function ():void {
+
+ new Container()->get(CircularA::class);
+
+ });
+
+ }
+
+ /**
+ * ### Explicit singletons are reused inside implicit graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitSingletonsAreReusedInsideImplicitGraphs ():void {
+
+ $container = new Container();
+
+ $container->singleton(Logger::class);
+
+ $first = $container->get(UserService::class);
+ $second = $container->get(UserService::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->repository->logger, $second->repository->logger);
+
+ }
+
+ /**
+ * ### Explicit singleton consumers retain transient dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitSingletonConsumersRetainTransientDependencies ():void {
+
+ $container = new Container();
+
+ $container->singleton(SingletonConsumer::class);
+
+ $first = $container->get(SingletonConsumer::class);
+ $second = $container->get(SingletonConsumer::class);
+
+ self::assertSame($first, $second);
+ self::assertSame($first->logger, $second->logger);
+
+ }
+
+ /**
+ * ### Scoped dependencies require an active scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopedDependenciesRequireAnActiveScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(ScopedDependency::class);
+
+ $this->assertThrows(ScopeRequiredException::class, function () use ($container):void {
+
+ $container->get(ScopedConsumer::class);
+
+ });
+
+ }
+
+ /**
+ * ### Scoped dependencies resolve within a scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopedDependenciesResolveWithinAScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(ScopedDependency::class);
+
+ $scope = $container->createScope();
+
+ $first = $scope->get(ScopedConsumer::class);
+ $second = $scope->get(ScopedConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->dependency, $second->dependency);
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Implicit autowiring works after freezing the container
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testImplicitAutowiringWorksAfterFreezingTheContainer ():void {
+
+ $container = new Container();
+
+ $container->freeze();
+
+ $service = $container->get(UserService::class);
+
+ self::assertInstanceOf(UserService::class, $service);
+ self::assertTrue($container->isFrozen());
+
+ }
+
+ /**
+ * ### Union resolves a single implicit candidate
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnionResolvesASingleImplicitCandidate ():void {
+
+ $service = new Container()->get(UnionOnlyA::class);
+
+ self::assertInstanceOf(UnionA::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Union rejects multiple implicit candidates
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnionRejectsMultipleImplicitCandidates ():void {
+
+ $this->assertThrows(AmbiguousDependencyException::class, function ():void {
+
+ new Container()->get(UnionAmbiguous::class);
+
+ });
+
+ }
+
+ /**
+ * ### Union prefers an explicit registration
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnionPrefersAnExplicitRegistration ():void {
+
+ $container = new Container();
+
+ $container->singleton(UnionB::class);
+
+ $service = $container->get(UnionAmbiguous::class);
+
+ self::assertInstanceOf(UnionB::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Union rejects multiple explicit registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnionRejectsMultipleExplicitRegistrations ():void {
+
+ $container = new Container();
+
+ $container->singleton(UnionA::class);
+ $container->singleton(UnionB::class);
+
+ $this->assertThrows(AmbiguousDependencyException::class, function () use ($container):void {
+
+ $container->get(UnionAmbiguous::class);
+
+ });
+
+ }
+
+ /**
+ * ### Union resolves an implicit candidate before using null default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnionResolvesAnImplicitCandidateBeforeUsingNullDefault ():void {
+
+ $service = new Container()->get(UnionNullable::class);
+
+ self::assertInstanceOf(UnionB::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Intersection resolves a unique registered implementation
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testIntersectionResolvesAUniqueRegisteredImplementation ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ TestLogger::class,
+ TestFileLogger::class
+ );
+
+ $service = $container->get(IntersectionConsumer::class);
+
+ self::assertInstanceOf(TestFileLogger::class, $service->logger);
+
+ }
+
+ /**
+ * ### Intersection fails without matching implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testIntersectionFailsWithoutMatchingImplementations ():void {
+
+ $this->assertThrows(ServiceInstantiationException::class, function ():void {
+
+ new Container()->get(IntersectionConsumer::class);
+
+ });
+
+ }
+
+ /**
+ * ### Intersection rejects multiple matching implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testIntersectionRejectsMultipleMatchingImplementations ():void {
+
+ $container = new Container();
+
+ $container->bind(TestFileLogger::class, TestFileLogger::class);
+ $container->bind(AnotherTestLogger::class, AnotherTestLogger::class);
+
+ $this->assertThrows(AmbiguousDependencyException::class, function () use ($container):void {
+
+ $container->get(IntersectionConsumer::class);
+
+ });
+
+ }
+
+ /**
+ * ### Intersection does not execute factories during candidate discovery
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testIntersectionDoesNotExecuteFactoriesDuringCandidateDiscovery ():void {
+
+ $container = new Container();
+
+ $executed = false;
+
+ $container->factory(
+ TestLogger::class,
+ function ($resolver) use (&$executed):object {
+
+ $executed = true;
+
+ return new TestFileLogger();
+
+ }
+ );
+
+ $this->assertThrows(ServiceInstantiationException::class, function () use ($container):void {
+
+ $container->get(IntersectionConsumer::class);
+
+ });
+
+ self::assertFalse($executed);
+
+ }
+
+ /**
+ * ### Metadata caching preserves transient dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testMetadataCachingPreservesTransientDependencies ():void {
+
+ $container = new Container();
+
+ $first = $container->get(UserService::class);
+ $second = $container->get(UserService::class);
+
+ self::assertNotSame($first, $second);
+ self::assertNotSame($first->repository, $second->repository);
+ self::assertNotSame($first->repository->logger, $second->repository->logger);
+
+ }
+
+ /**
+ * ### Metadata caching respects new registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testMetadataCachingRespectsNewRegistrations ():void {
+
+ $container = new Container();
+
+ $first = $container->get(UnionOnlyA::class);
+
+ self::assertInstanceOf(UnionA::class, $first->dependency);
+
+ $container->singleton(UnionInterface::class, UnionA::class);
+
+ }
+
+}
diff --git a/tests/Unit/ContextualBindingTest.php b/tests/Unit/ContextualBindingTest.php
new file mode 100644
index 0000000..24a4834
--- /dev/null
+++ b/tests/Unit/ContextualBindingTest.php
@@ -0,0 +1,1018 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ AmbiguousDependencyException, CircularDependencyException, ServiceInstantiationException, ScopeRequiredException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ ContextualCache, ContextualCacheService, ContextualCircularA, ContextualCircularB, ContextualConsoleLogger,
+ ContextualDefaultConsumer, ContextualDnfConsumer, ContextualFileLogger, ContextualIntersectionConsumer,
+ ContextualLogger, ContextualNamedConsumer, ContextualNestedConsumer, ContextualOtherConsumer, ContextualResettable,
+ ContextualScopedConsumer, ContextualSerializableCache, ContextualSingletonConsumer,
+ ContextualTwoIntersectionsConsumer, ContextualUnionConsumer, ContextualUniversalService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests contextual dependency bindings
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class ContextualBindingTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Contextual binding resolves a named dependency
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingResolvesANamedDependency ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualNamedConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->logger);
+
+ }
+
+ /**
+ * ### Contextual bindings are isolated by consumer class
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingsAreIsolatedByConsumerClass ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualOtherConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Different consumers resolve different contextual implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDifferentConsumersResolveDifferentContextualImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualOtherConsumer::class,
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ $first = $container->get(ContextualNamedConsumer::class);
+ $second = $container->get(ContextualOtherConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $first->logger);
+ self::assertInstanceOf(ContextualConsoleLogger::class, $second->logger);
+
+ }
+
+ /**
+ * ### Contextual binding overrides global dependency registration
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingOverridesGlobalDependencyRegistration ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualNamedConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->logger);
+
+ }
+
+ /**
+ * ### Explicit parameter binding overrides contextual binding
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitParameterBindingOverridesContextualBinding ():void {
+
+ $container = new Container();
+
+ $logger = new ContextualConsoleLogger();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindParameters(
+ ContextualNamedConsumer::class,
+ [
+ 'logger' => $logger
+ ]
+ );
+
+ $service = $container->get(ContextualNamedConsumer::class);
+
+ self::assertSame($service->logger, $logger);
+
+ }
+
+ /**
+ * ### Contextual binding resolves inside nested dependency graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingResolvesInsideNestedDependencyGraphs ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualNestedConsumer::class);
+
+ self::assertInstanceOf(ContextualNamedConsumer::class, $service->consumer);
+ self::assertInstanceOf(ContextualFileLogger::class, $service->consumer->logger);
+
+ }
+
+ /**
+ * ### Contextual union resolves the first alternative
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionResolvesTheFirstAlternative ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualUnionConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual union resolves the second alternative
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionResolvesTheSecondAlternative ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualCache::class,
+ ContextualCacheService::class
+ );
+
+ $service = $container->get(ContextualUnionConsumer::class);
+
+ self::assertInstanceOf(ContextualCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual union rejects different implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionRejectsDifferentImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualCache::class,
+ ContextualCacheService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualUnionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual union deduplicates identical implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionDeduplicatesIdenticalImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualLogger::class,
+ ContextualUniversalService::class
+ );
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualCache::class,
+ ContextualUniversalService::class
+ );
+
+ $service = $container->get(ContextualUnionConsumer::class);
+
+ self::assertInstanceOf(ContextualUniversalService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual union takes precedence over standard candidates
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionTakesPrecedenceOverStandardCandidates ():void {
+
+ $container = new Container();
+
+ $container->singleton(ContextualCacheService::class);
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualUnionConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual intersection resolves a compatible implementation
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualIntersectionResolvesACompatibleImplementation ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualIntersectionConsumer::class);
+
+ self::assertInstanceOf(ContextualLogger::class, $service->dependency);
+ self::assertInstanceOf(ContextualResettable::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual intersection deduplicates identical implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualIntersectionDeduplicatesIdenticalImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualResettable::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualIntersectionConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual intersection rejects different implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualIntersectionRejectsDifferentImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualResettable::class,
+ ContextualUniversalService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualIntersectionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual intersection rejects incompatible implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualIntersectionRejectsIncompatibleImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualIntersectionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Invalid contextual intersection does not fall back to global candidates
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInvalidContextualIntersectionDoesNotFallBackToGlobalCandidates ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ ContextualFileLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualIntersectionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual DNF resolves an intersection branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfResolvesAnIntersectionBranch ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualDnfConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualDnfConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual DNF resolves a named branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfResolvesANamedBranch ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualDnfConsumer::class,
+ ContextualCache::class,
+ ContextualCacheService::class
+ );
+
+ $service = $container->get(ContextualDnfConsumer::class);
+
+ self::assertInstanceOf(ContextualCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual DNF resolves the second intersection branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfResolvesTheSecondIntersectionBranch ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualTwoIntersectionsConsumer::class,
+ ContextualCache::class,
+ ContextualSerializableCache::class
+ );
+
+ $service = $container->get(ContextualTwoIntersectionsConsumer::class);
+
+ self::assertInstanceOf(ContextualSerializableCache::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual DNF rejects multiple distinct implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfRejectsMultipleDistinctImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualDnfConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualDnfConsumer::class,
+ ContextualCache::class,
+ ContextualCacheService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualDnfConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual DNF deduplicates a universal implementation
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfDeduplicatesAUniversalImplementation ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualTwoIntersectionsConsumer::class,
+ ContextualLogger::class,
+ ContextualUniversalService::class
+ );
+
+ $container->bindContextual(
+ ContextualTwoIntersectionsConsumer::class,
+ ContextualCache::class,
+ ContextualUniversalService::class
+ );
+
+ $service = $container->get(ContextualTwoIntersectionsConsumer::class);
+
+ self::assertInstanceOf(ContextualUniversalService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual DNF rejects implementations incompatible with all branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfRejectsImplementationsIncompatibleWithAllBranches ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualTwoIntersectionsConsumer::class,
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualTwoIntersectionsConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual dependencies are transient by default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDependenciesAreTransientByDefault ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $first = $container->get(ContextualNamedConsumer::class);
+ $second = $container->get(ContextualNamedConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertNotSame($first->logger, $second->logger);
+
+ }
+
+ /**
+ * ### Contextual bindings preserve singleton lifetimes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingsPreserveSingletonLifetimes ():void {
+
+ $container = new Container();
+
+ $container->singleton(ContextualFileLogger::class);
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $first = $container->get(ContextualNamedConsumer::class);
+ $second = $container->get(ContextualNamedConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->logger, $second->logger);
+
+ }
+
+ /**
+ * ### Contextual scoped dependencies require an active scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualScopedDependenciesRequireAnActiveScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(ContextualFileLogger::class);
+
+ $container->bindContextual(
+ ContextualScopedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualScopedConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual scoped dependencies are reused within a scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualScopedDependenciesAreReusedWithinAScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(ContextualFileLogger::class);
+
+ $container->bindContextual(
+ ContextualScopedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $scope = $container->createScope();
+
+ $first = $scope->get(ContextualScopedConsumer::class);
+ $second = $scope->get(ContextualScopedConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->logger, $second->logger);
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Contextual scoped dependencies are isolated between scopes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualScopedDependenciesAreIsolatedBetweenScopes ():void {
+
+ $container = new Container();
+
+ $container->scoped(ContextualFileLogger::class);
+
+ $container->bindContextual(
+ ContextualScopedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ $first = $firstScope->get(ContextualScopedConsumer::class);
+ $second = $secondScope->get(ContextualScopedConsumer::class);
+
+ self::assertNotSame($first->logger, $second->logger);
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ /**
+ * ### Contextual singleton consumers retain their dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualSingletonConsumersRetainTheirDependencies ():void {
+
+ $container = new Container();
+
+ $container->singleton(ContextualSingletonConsumer::class);
+
+ $container->bindContextual(
+ ContextualSingletonConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $first = $container->get(ContextualSingletonConsumer::class);
+ $second = $container->get(ContextualSingletonConsumer::class);
+
+ self::assertSame($first, $second);
+ self::assertSame($first->logger, $second->logger);
+
+ }
+
+ /**
+ * ### Contextual binding overrides a nullable default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingOverridesANullableDefault ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualDefaultConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualDefaultConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->logger);
+
+ }
+
+ /**
+ * ### Contextual bindings added after resolution are recognized
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingsAddedAfterResolutionAreRecognized ():void {
+
+ $container = new Container();
+
+ $first = $container->get(ContextualDefaultConsumer::class);
+
+ self::assertTrue($first->logger === null);
+
+ $container->bindContextual(
+ ContextualDefaultConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $second = $container->get(ContextualDefaultConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $second->logger);
+
+ }
+
+ /**
+ * ### Contextual union detects ambiguity after new registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionDetectsAmbiguityAfterNewRegistrations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $first = $container->get(ContextualUnionConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $first->dependency);
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualCache::class,
+ ContextualCacheService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualUnionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Duplicate contextual bindings are rejected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDuplicateContextualBindingsAreRejected ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\DuplicateContextualBindingException::class,
+ function () use ($container):void {
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual bindings cannot be registered after freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingsCannotBeRegisteredAfterFreezing ():void {
+
+ $container = new Container();
+
+ $container->freeze();
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\ContainerFrozenException::class,
+ function () use ($container):void {
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual bindings resolve after freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingsResolveAfterFreezing ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->freeze();
+
+ $service = $container->get(ContextualNamedConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->logger);
+ self::assertTrue($container->isFrozen());
+
+ }
+
+ /**
+ * ### Contextual resolution preserves circular dependency detection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualResolutionPreservesCircularDependencyDetection ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualCircularA::class,
+ ContextualCircularB::class,
+ ContextualCircularB::class
+ );
+
+ $this->assertThrows(
+ CircularDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualCircularA::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual named binding rejects incompatible implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualNamedBindingRejectsIncompatibleImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualCacheService::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualNamedConsumer::class);
+
+ }
+ );
+
+ }
+
+}
diff --git a/tests/Unit/DnfResolutionTest.php b/tests/Unit/DnfResolutionTest.php
new file mode 100644
index 0000000..eb74cdb
--- /dev/null
+++ b/tests/Unit/DnfResolutionTest.php
@@ -0,0 +1,777 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ AmbiguousDependencyException, ServiceInstantiationException, ScopeRequiredException, ServiceTypeMismatchException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ DnfCache, DnfCacheService, DnfConsoleLogger, DnfDefaultConsumer, DnfFactoryConsumer, DnfFileLogger,
+ DnfFirstBranchConsumer, DnfLogger, DnfMultipleNamedConsumer, DnfNestedConsumer, DnfNoCandidateConsumer,
+ DnfRequiredNullableConsumer, DnfResettable, DnfScopedConsumer, DnfSecondBranchConsumer, DnfSingletonConsumer,
+ DnfSerializableCache, DnfTwoIntersectionsConsumer, DnfUniversalService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests DNF and composite dependency resolution
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class DnfResolutionTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### DNF resolves the first intersection branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesTheFirstIntersectionBranch ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $service = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $service->dependency);
+ self::assertInstanceOf(DnfLogger::class, $service->dependency);
+ self::assertInstanceOf(DnfResettable::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves the second named branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesTheSecondNamedBranch ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfCacheService::class,
+ DnfCacheService::class
+ );
+
+ $service = $container->get(DnfSecondBranchConsumer::class);
+
+ self::assertInstanceOf(DnfCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves the first of two intersection branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesTheFirstOfTwoIntersectionBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $service = $container->get(DnfTwoIntersectionsConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves the second of two intersection branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesTheSecondOfTwoIntersectionBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfSerializableCache::class,
+ DnfSerializableCache::class
+ );
+
+ $service = $container->get(DnfTwoIntersectionsConsumer::class);
+
+ self::assertInstanceOf(DnfSerializableCache::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves a service satisfying multiple branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesAServiceSatisfyingMultipleBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfUniversalService::class,
+ DnfUniversalService::class
+ );
+
+ $service = $container->get(DnfTwoIntersectionsConsumer::class);
+
+ self::assertInstanceOf(DnfUniversalService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves a named alternative among multiple branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesANamedAlternativeAmongMultipleBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfCacheService::class,
+ DnfCacheService::class
+ );
+
+ $service = $container->get(DnfMultipleNamedConsumer::class);
+
+ self::assertInstanceOf(DnfCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF rejects multiple matching implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfRejectsMultipleMatchingImplementations ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $container->bind(
+ DnfCacheService::class,
+ DnfCacheService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFirstBranchConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF rejects multiple implementations satisfying the same branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfRejectsMultipleImplementationsSatisfyingTheSameBranch ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $container->bind(
+ DnfUniversalService::class,
+ DnfUniversalService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFirstBranchConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF rejects multiple implementations across intersection branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfRejectsMultipleImplementationsAcrossIntersectionBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $container->bind(
+ DnfSerializableCache::class,
+ DnfSerializableCache::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(DnfTwoIntersectionsConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF ignores implementations satisfying only part of an intersection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfIgnoresImplementationsSatisfyingOnlyPartOfAnIntersection ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfConsoleLogger::class,
+ DnfConsoleLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFirstBranchConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF ignores implementations satisfying neither branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfIgnoresImplementationsSatisfyingNeitherBranch ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfConsoleLogger::class,
+ DnfConsoleLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(DnfNoCandidateConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF resolves a single candidate despite matching multiple branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesASingleCandidateDespiteMatchingMultipleBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfUniversalService::class,
+ DnfUniversalService::class
+ );
+
+ $service = $container->get(DnfMultipleNamedConsumer::class);
+
+ self::assertInstanceOf(DnfUniversalService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF uses null default when no candidate exists
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfUsesNullDefaultWhenNoCandidateExists ():void {
+
+ $service = new Container()->get(DnfDefaultConsumer::class);
+
+ self::assertSame($service->dependency, null);
+
+ }
+
+ /**
+ * ### DNF resolves a candidate before using null default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesACandidateBeforeUsingNullDefault ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $service = $container->get(DnfDefaultConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF nullable dependency without default remains required
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfNullableDependencyWithoutDefaultRemainsRequired ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function ():void {
+
+ new Container()->get(DnfRequiredNullableConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF nullable dependency resolves when candidate exists
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfNullableDependencyResolvesWhenCandidateExists ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfCacheService::class,
+ DnfCacheService::class
+ );
+
+ $service = $container->get(DnfRequiredNullableConsumer::class);
+
+ self::assertInstanceOf(DnfCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves dependencies inside nested graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesDependenciesInsideNestedGraphs ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $service = $container->get(DnfNestedConsumer::class);
+
+ self::assertInstanceOf(DnfFirstBranchConsumer::class, $service->consumer);
+ self::assertInstanceOf(DnfFileLogger::class, $service->consumer->dependency);
+
+ }
+
+ /**
+ * ### DNF dependencies remain transient by default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfDependenciesRemainTransientByDefault ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $first = $container->get(DnfFirstBranchConsumer::class);
+ $second = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertNotSame($first->dependency, $second->dependency);
+
+ }
+
+ /**
+ * ### DNF reuses singleton dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfReusesSingletonDependencies ():void {
+
+ $container = new Container();
+
+ $container->singleton(DnfFileLogger::class);
+
+ $first = $container->get(DnfFirstBranchConsumer::class);
+ $second = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->dependency, $second->dependency);
+
+ }
+
+ /**
+ * ### DNF singleton consumers retain their dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfSingletonConsumersRetainTheirDependencies ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $container->singleton(DnfSingletonConsumer::class);
+
+ $first = $container->get(DnfSingletonConsumer::class);
+ $second = $container->get(DnfSingletonConsumer::class);
+
+ self::assertSame($first, $second);
+ self::assertSame($first->dependency, $second->dependency);
+
+ }
+
+ /**
+ * ### DNF scoped dependencies require an active scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfScopedDependenciesRequireAnActiveScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(DnfFileLogger::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ function () use ($container):void {
+
+ $container->get(DnfScopedConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF scoped dependencies are reused within the same scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfScopedDependenciesAreReusedWithinTheSameScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(DnfFileLogger::class);
+
+ $scope = $container->createScope();
+
+ $first = $scope->get(DnfScopedConsumer::class);
+ $second = $scope->get(DnfScopedConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->dependency, $second->dependency);
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### DNF scoped dependencies are isolated between scopes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfScopedDependenciesAreIsolatedBetweenScopes ():void {
+
+ $container = new Container();
+
+ $container->scoped(DnfFileLogger::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ $first = $firstScope->get(DnfScopedConsumer::class);
+ $second = $secondScope->get(DnfScopedConsumer::class);
+
+ self::assertNotSame($first->dependency, $second->dependency);
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ /**
+ * ### DNF resolves a factory registered under a named alternative
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesAFactoryRegisteredUnderANamedAlternative ():void {
+
+ $container = new Container();
+
+ $executed = false;
+
+ $container->factory(
+ DnfCache::class,
+ function ($resolver) use (&$executed):object {
+
+ $executed = true;
+
+ return new DnfCacheService();
+
+ }
+ );
+
+ $service = $container->get(DnfFactoryConsumer::class);
+
+ self::assertTrue($executed);
+ self::assertInstanceOf(DnfCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF does not execute factories during intersection candidate discovery
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfDoesNotExecuteFactoriesDuringIntersectionCandidateDiscovery ():void {
+
+ $container = new Container();
+
+ $executed = false;
+
+ $container->factory(
+ DnfLogger::class,
+ function ($resolver) use (&$executed):object {
+
+ $executed = true;
+
+ return new DnfFileLogger();
+
+ }
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFactoryConsumer::class);
+
+ }
+ );
+
+ self::assertSame($executed, false);
+
+ }
+
+ /**
+ * ### DNF rejects an incompatible factory result
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfRejectsAnIncompatibleFactoryResult ():void {
+
+ $container = new Container();
+
+ $container->factory(
+ DnfCache::class,
+ function ($resolver):object {
+
+ return new DnfConsoleLogger();
+
+ }
+ );
+
+ $this->assertThrows(
+ ServiceTypeMismatchException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFactoryConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF dependency plans preserve transient semantics
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfDependencyPlansPreserveTransientSemantics ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $first = $container->get(DnfFirstBranchConsumer::class);
+ $second = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertNotSame($first->dependency, $second->dependency);
+
+ }
+
+ /**
+ * ### DNF dependency plans respect registrations added after first resolution
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfDependencyPlansRespectRegistrationsAddedAfterFirstResolution ():void {
+
+ $container = new Container();
+
+ $first = $container->get(DnfDefaultConsumer::class);
+
+ self::assertNull($first->dependency);
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $second = $container->get(DnfDefaultConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $second->dependency);
+
+ }
+
+ /**
+ * ### DNF dependency plans detect new ambiguities after registration
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfDependencyPlansDetectNewAmbiguitiesAfterRegistration ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $first = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $first->dependency);
+
+ $container->bind(
+ DnfCacheService::class,
+ DnfCacheService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFirstBranchConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF autowiring works after freezing the container
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfAutowiringWorksAfterFreezingTheContainer ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $container->freeze();
+
+ $service = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $service->dependency);
+ self::assertTrue($container->isFrozen());
+
+ }
+
+}
diff --git a/tests/Unit/ParameterBindingTest.php b/tests/Unit/ParameterBindingTest.php
new file mode 100644
index 0000000..d71a5a5
--- /dev/null
+++ b/tests/Unit/ParameterBindingTest.php
@@ -0,0 +1,272 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ServiceInstantiationException;
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ BoundConnection, BoundDefaultService, BoundMixedService, BoundNullableService, BoundObjectService,
+ BoundOtherConnection, Logger
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests explicit parameter bindings
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class ParameterBindingTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Explicit parameter bindings resolve scalar dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitParameterBindingsResolveScalarDependencies ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundConnection::class,
+ [
+ 'host' => 'localhost',
+ 'port' => 3306,
+ 'database' => 'firehub'
+ ]
+ );
+
+ $service = $container->get(BoundConnection::class);
+
+ self::assertSame('localhost', $service->host);
+ self::assertSame(3306, $service->port);
+ self::assertSame('firehub', $service->database);
+
+ }
+
+ /**
+ * ### Explicit bindings combine with automatic dependency resolution
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsCombineWithAutomaticDependencyResolution ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundMixedService::class,
+ [
+ 'host' => 'localhost'
+ ]
+ );
+
+ $service = $container->get(BoundMixedService::class);
+
+ self::assertInstanceOf(Logger::class, $service->logger);
+ self::assertSame('localhost', $service->host);
+ self::assertSame(3306, $service->port);
+
+ }
+
+ /**
+ * ### Explicit bindings override constructor defaults
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsOverrideConstructorDefaults ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundDefaultService::class,
+ [
+ 'port' => 9000
+ ]
+ );
+
+ $service = $container->get(BoundDefaultService::class);
+
+ self::assertSame(9000, $service->port);
+
+ }
+
+ /**
+ * ### Explicit bindings support null values
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsSupportNullValues ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundNullableService::class,
+ [
+ 'logger' => null
+ ]
+ );
+
+ $service = $container->get(BoundNullableService::class);
+
+ self::assertNull($service->logger);
+
+ }
+
+ /**
+ * ### Explicit bindings support object instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsSupportObjectInstances ():void {
+
+ $container = new Container();
+
+ $logger = new Logger();
+
+ $container->bindParameters(
+ BoundObjectService::class,
+ [
+ 'logger' => $logger
+ ]
+ );
+
+ $service = $container->get(BoundObjectService::class);
+
+ self::assertSame($service->logger, $logger);
+
+ }
+
+ /**
+ * ### Explicit bindings are isolated by implementation class
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsAreIsolatedByImplementationClass ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundConnection::class,
+ [
+ 'host' => 'localhost',
+ 'port' => 3306,
+ 'database' => 'firehub'
+ ]
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(BoundOtherConnection::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Explicit bindings preserve transient lifetimes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsPreserveTransientLifetimes ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundDefaultService::class,
+ [
+ 'port' => 9000
+ ]
+ );
+
+ $first = $container->get(BoundDefaultService::class);
+ $second = $container->get(BoundDefaultService::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->port, $second->port);
+
+ }
+
+ /**
+ * ### Explicit bindings work with singleton services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsWorkWithSingletonServices ():void {
+
+ $container = new Container();
+
+ $container->singleton(BoundDefaultService::class);
+
+ $container->bindParameters(
+ BoundDefaultService::class,
+ [
+ 'port' => 9000
+ ]
+ );
+
+ $first = $container->get(BoundDefaultService::class);
+ $second = $container->get(BoundDefaultService::class);
+
+ self::assertSame($first, $second);
+ self::assertSame(9000, $first->port);
+
+ }
+
+ /**
+ * ### Explicit bindings respect container freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsRespectContainerFreezing ():void {
+
+ $container = new Container();
+
+ $container->freeze();
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\ContainerFrozenException::class,
+ function () use ($container):void {
+
+ $container->bindParameters(
+ BoundDefaultService::class,
+ [
+ 'port' => 9000
+ ]
+ );
+
+ }
+ );
+
+ }
+
+}
diff --git a/tests/Unit/TaggedInjectionTest.php b/tests/Unit/TaggedInjectionTest.php
new file mode 100644
index 0000000..c08a879
--- /dev/null
+++ b/tests/Unit/TaggedInjectionTest.php
@@ -0,0 +1,524 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ CircularDependencyException, ServiceInstantiationException, ScopeRequiredException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ TaggedConsoleLogger, TaggedFileLogger, TaggedInjectionCircularA, TaggedInjectionCircularB, TaggedInjectionConsumer,
+ TaggedInjectionDefaultConsumer, TaggedInjectionEmptyTagConsumer, TaggedInjectionNestedConsumer,
+ TaggedInjectionNullableConsumer, TaggedInjectionScalarConsumer, TaggedInjectionUnknownConsumer,
+ TaggedInjectionUntypedConsumer, TaggedInjectionWhitespaceTagConsumer, TaggedLogger
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests tagged constructor injection
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class TaggedInjectionTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Tagged attribute injects services in registration order
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedAttributeInjectsServicesInRegistrationOrder ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+
+ $container->tag(
+ 'logging',
+ TaggedConsoleLogger::class,
+ TaggedFileLogger::class
+ );
+
+ $consumer = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertCount(2, $consumer->loggers);
+ self::assertInstanceOf(TaggedConsoleLogger::class, $consumer->loggers[0]);
+ self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[1]);
+
+ }
+
+ /**
+ * ### Tagged attribute injects an empty array for unknown tags
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedAttributeInjectsAnEmptyArrayForUnknownTags ():void {
+
+ $consumer = (new Container)->get(TaggedInjectionUnknownConsumer::class);
+
+ self::assertSame([], $consumer->services);
+
+ }
+
+ /**
+ * ### Tagged attribute overrides an array constructor default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedAttributeOverridesAnArrayConstructorDefault ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $consumer = $container->get(TaggedInjectionDefaultConsumer::class);
+
+ self::assertCount(1, $consumer->loggers);
+ self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[0]);
+
+ }
+
+ /**
+ * ### Tagged attribute works in nested dependency graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedAttributeWorksInNestedDependencyGraphs ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $nested = $container->get(TaggedInjectionNestedConsumer::class);
+
+ self::assertInstanceOf(TaggedInjectionConsumer::class, $nested->consumer);
+ self::assertInstanceOf(TaggedFileLogger::class, $nested->consumer->loggers[0]);
+
+ }
+
+ /**
+ * ### Explicit parameter bindings override tagged injection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitParameterBindingsOverrideTaggedInjection ():void {
+
+ $container = new Container;
+
+ $override = [new TaggedConsoleLogger];
+
+ $container->bindParameters(
+ TaggedInjectionConsumer::class,
+ ['loggers' => $override]
+ );
+
+ $consumer = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertSame($consumer->loggers, $override);
+
+ }
+
+ /**
+ * ### Tagged injection uses singleton instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionUsesSingletonInstances ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $first = $container->get(TaggedInjectionConsumer::class);
+ $second = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->loggers[0], $second->loggers[0]);
+ self::assertSame($first->loggers[0], $container->get(TaggedFileLogger::class));
+
+ }
+
+ /**
+ * ### Tagged injection creates new transient instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionCreatesNewTransientInstances ():void {
+
+ $container = new Container;
+
+ $container->transient(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $first = $container->get(TaggedInjectionConsumer::class);
+ $second = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertNotSame($first->loggers[0], $second->loggers[0]);
+
+ }
+
+ /**
+ * ### Tagged injection requires a scope for scoped services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRequiresAScopeForScopedServices ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ function () use ($container):void {
+
+ $container->get(TaggedInjectionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection reuses scoped services within a scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionReusesScopedServicesWithinAScope ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $scope = $container->createScope();
+
+ try {
+
+ $first = $scope->get(TaggedInjectionConsumer::class);
+ $second = $scope->get(TaggedInjectionConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->loggers[0], $second->loggers[0]);
+ self::assertSame($first->loggers[0], $scope->get(TaggedFileLogger::class));
+
+ } finally {
+
+ $scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Tagged injection isolates scoped services between scopes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionIsolatesScopedServicesBetweenScopes ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ try {
+
+ $first = $firstScope->get(TaggedInjectionConsumer::class);
+ $second = $secondScope->get(TaggedInjectionConsumer::class);
+
+ self::assertTrue($first->loggers[0] !== $second->loggers[0]);
+
+ } finally {
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Tagged injection uses the active scope in nested graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionUsesTheActiveScopeInNestedGraphs ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $scope = $container->createScope();
+
+ try {
+
+ $nested = $scope->get(TaggedInjectionNestedConsumer::class);
+
+ self::assertSame($nested->consumer->loggers[0], $scope->get(TaggedFileLogger::class));
+
+ } finally {
+
+ $scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Tagged injection resolves aliases
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionResolvesAliases ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->alias(TaggedLogger::class, TaggedFileLogger::class);
+ $container->tag('logging', TaggedLogger::class);
+
+ $consumer = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertCount(1, $consumer->loggers);
+ self::assertSame($consumer->loggers[0], $container->get(TaggedFileLogger::class));
+
+ }
+
+ /**
+ * ### Tagged injection resolves factories
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionResolvesFactories ():void {
+
+ $container = new Container;
+
+ $executions = 0;
+
+ $container->factory(
+ TaggedFileLogger::class,
+ static function ($resolver) use (&$executions):object {
+
+ $executions++;
+
+ return new TaggedFileLogger;
+
+ }
+ );
+
+ $container->tag('logging', TaggedFileLogger::class);
+
+ self::assertSame($executions, 0);
+
+ $consumer = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[0]);
+ self::assertSame($executions, 1);
+
+ }
+
+ /**
+ * ### Tagged injection respects dynamically added tag associations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRespectsDynamicallyAddedTagAssociations ():void {
+
+ $container = new Container;
+
+ $first = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertSame($first->loggers, []);
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $second = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertCount(1, $second->loggers);
+ self::assertInstanceOf(TaggedFileLogger::class, $second->loggers[0]);
+
+ }
+
+ /**
+ * ### Tagged injection works after container freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionWorksAfterContainerFreezing ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+ $container->freeze();
+
+ $consumer = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[0]);
+
+ }
+
+ /**
+ * ### Tagged injection rejects nullable array parameters
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRejectsNullableArrayParameters ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(TaggedInjectionNullableConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection rejects scalar parameters
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRejectsScalarParameters ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(TaggedInjectionScalarConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection rejects untyped parameters
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRejectsUntypedParameters ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(TaggedInjectionUntypedConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection rejects empty tag names
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRejectsEmptyTagNames ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(TaggedInjectionEmptyTagConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection rejects whitespace-only tag names
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRejectsWhitespaceOnlyTagNames ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(TaggedInjectionWhitespaceTagConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection preserves circular dependency detection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionPreservesCircularDependencyDetection ():void {
+
+ $container = new Container;
+
+ $container->transient(TaggedInjectionCircularB::class);
+ $container->tag('circular', TaggedInjectionCircularB::class);
+
+ $this->assertThrows(
+ CircularDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(TaggedInjectionCircularA::class);
+
+ }
+ );
+
+ }
+
+}
diff --git a/tests/Unit/TaggedServicesTest.php b/tests/Unit/TaggedServicesTest.php
new file mode 100644
index 0000000..bd23a80
--- /dev/null
+++ b/tests/Unit/TaggedServicesTest.php
@@ -0,0 +1,831 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ ServiceNotFoundException, ScopeRequiredException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ TaggedConsoleLogger, TaggedDatabaseLogger, TaggedFactoryService, TaggedFileLogger, TaggedLogger, TaggedScopedService,
+ TaggedSingletonService, TaggedTransientService, TaggedUnregisteredService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests tagged services
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class TaggedServicesTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Tags can be registered for explicit services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagsCanBeRegisteredForExplicitServices ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+
+ $container->tag(
+ 'logging',
+ TaggedFileLogger::class,
+ TaggedConsoleLogger::class
+ );
+
+ self::assertTrue($container->hasTag('logging'));
+
+ self::assertSame(
+ [
+ TaggedFileLogger::class,
+ TaggedConsoleLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ }
+
+ /**
+ * ### Unknown tags return empty collections
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnknownTagsReturnEmptyCollections ():void {
+
+ $container = new Container;
+
+ self::assertNotTrue($container->hasTag('unknown'));
+ self::assertSame([], $container->taggedIds('unknown'));
+ self::assertSame([], $container->tagged('unknown'));
+
+ $scope = $container->createScope();
+
+ self::assertSame([], $scope->tagged('unknown'));
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Tagged services preserve association order
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedServicesPreserveAssociationOrder ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+ $container->singleton(TaggedDatabaseLogger::class);
+
+ $container->tag(
+ 'logging',
+ TaggedDatabaseLogger::class,
+ TaggedFileLogger::class,
+ TaggedConsoleLogger::class
+ );
+
+ self::assertSame(
+ [
+ TaggedDatabaseLogger::class,
+ TaggedFileLogger::class,
+ TaggedConsoleLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ $services = $container->tagged('logging');
+
+ self::assertCount(3, $services);
+ self::assertInstanceOf(TaggedDatabaseLogger::class, $services[0]);
+ self::assertInstanceOf(TaggedFileLogger::class, $services[1]);
+ self::assertInstanceOf(TaggedConsoleLogger::class, $services[2]);
+
+ }
+
+ /**
+ * ### Tags support multiple registration calls
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagsSupportMultipleRegistrationCalls ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+
+ $container->tag('logging', TaggedFileLogger::class);
+ $container->tag('logging', TaggedConsoleLogger::class);
+
+ self::assertSame(
+ [
+ TaggedFileLogger::class,
+ TaggedConsoleLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ }
+
+ /**
+ * ### Duplicate tagged services are ignored
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDuplicateTaggedServicesAreIgnored ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $container->tag(
+ 'logging',
+ TaggedFileLogger::class,
+ TaggedFileLogger::class
+ );
+
+ $container->tag('logging', TaggedFileLogger::class);
+
+ self::assertSame(
+ [
+ TaggedFileLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ self::assertCount(1, $container->tagged('logging'));
+
+ }
+
+ /**
+ * ### Services can belong to multiple tags
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testServicesCanBelongToMultipleTags ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $container->tag('logging', TaggedFileLogger::class);
+ $container->tag('monitoring', TaggedFileLogger::class);
+
+ self::assertTrue($container->hasTag('logging'));
+ self::assertTrue($container->hasTag('monitoring'));
+
+ $logging = $container->tagged('logging');
+ $monitoring = $container->tagged('monitoring');
+
+ self::assertSame($logging[0], $monitoring[0]);
+
+ }
+
+ /**
+ * ### Tag registration does not instantiate services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagRegistrationDoesNotInstantiateServices ():void {
+
+ $container = new Container;
+
+ $executed = false;
+
+ $container->factory(
+ TaggedFactoryService::class,
+ function ($resolver) use (&$executed):object {
+
+ $executed = true;
+
+ return new TaggedFactoryService;
+
+ }
+ );
+
+ $container->tag('services', TaggedFactoryService::class);
+
+ self::assertFalse($executed);
+
+ $container->hasTag('services');
+ $container->taggedIds('services');
+
+ self::assertFalse($executed);
+
+ $container->tagged('services');
+
+ self::assertTrue($executed);
+
+ }
+
+ /**
+ * ### Tag registration requires explicitly registered services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagRegistrationRequiresExplicitlyRegisteredServices ():void {
+
+ $container = new Container;
+
+ $this->assertThrows(
+ ServiceNotFoundException::class,
+ function () use ($container):void {
+
+ $container->tag(
+ 'services',
+ TaggedUnregisteredService::class
+ );
+
+ }
+ );
+
+ self::assertNotTrue($container->hasTag('services'));
+
+ }
+
+ /**
+ * ### Tag registration is atomic
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagRegistrationIsAtomic ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $this->assertThrows(
+ ServiceNotFoundException::class,
+ function () use ($container):void {
+
+ $container->tag(
+ 'logging',
+ TaggedFileLogger::class,
+ TaggedUnregisteredService::class
+ );
+
+ }
+ );
+
+ self::assertNotTrue($container->hasTag('logging'));
+ self::assertSame([], $container->taggedIds('logging'));
+
+ }
+
+ /**
+ * ### Failed tag registration preserves existing associations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testFailedTagRegistrationPreservesExistingAssociations ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $this->assertThrows(
+ ServiceNotFoundException::class,
+ function () use ($container):void {
+
+ $container->tag(
+ 'logging',
+ TaggedConsoleLogger::class,
+ TaggedUnregisteredService::class
+ );
+
+ }
+ );
+
+ self::assertSame(
+ [
+ TaggedFileLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ }
+
+ /**
+ * ### Empty tag names are rejected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testEmptyTagNamesAreRejected ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\InvalidTagNameException::class,
+ function () use ($container):void {
+
+ $container->tag('', TaggedFileLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Whitespace-only tag names are rejected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testWhitespaceOnlyTagNamesAreRejected ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\InvalidTagNameException::class,
+ function () use ($container):void {
+
+ $container->tag(' ', TaggedFileLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged singleton services are reused
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedSingletonServicesAreReused ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedSingletonService::class);
+
+ $container->tag('services', TaggedSingletonService::class);
+
+ $first = $container->tagged('services');
+ $second = $container->tagged('services');
+
+ self::assertCount(1, $first);
+ self::assertSame($first[0], $second[0]);
+ self::assertSame($first[0], $container->get(TaggedSingletonService::class));
+
+ }
+
+ /**
+ * ### Tagged transient services are recreated
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedTransientServicesAreRecreated ():void {
+
+ $container = new Container;
+
+ $container->transient(TaggedTransientService::class);
+
+ $container->tag('services', TaggedTransientService::class);
+
+ $first = $container->tagged('services');
+ $second = $container->tagged('services');
+
+ self::assertInstanceOf(TaggedTransientService::class, $first[0]);
+ self::assertInstanceOf(TaggedTransientService::class, $second[0]);
+ self::assertNotSame($first[0], $second[0]);
+
+ }
+
+ /**
+ * ### Tagged scoped services require an active scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedScopedServicesRequireAnActiveScope ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ function () use ($container):void {
+
+ $container->tagged('services');
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged scoped services are reused within the same scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedScopedServicesAreReusedWithinTheSameScope ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $scope = $container->createScope();
+
+ $first = $scope->tagged('services');
+ $second = $scope->tagged('services');
+
+ self::assertInstanceOf(TaggedScopedService::class, $first[0]);
+ self::assertSame($first[0], $second[0]);
+ self::assertSame($first[0], $scope->get(TaggedScopedService::class));
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Tagged scoped services are isolated between scopes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedScopedServicesAreIsolatedBetweenScopes ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ $first = $firstScope->tagged('services');
+ $second = $secondScope->tagged('services');
+
+ self::assertNotSame($first[0], $second[0]);
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ /**
+ * ### Tagged services preserve mixed lifetimes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedServicesPreserveMixedLifetimes ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedSingletonService::class);
+ $container->scoped(TaggedScopedService::class);
+ $container->transient(TaggedTransientService::class);
+
+ $container->tag(
+ 'services',
+ TaggedSingletonService::class,
+ TaggedScopedService::class,
+ TaggedTransientService::class
+ );
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ $first = $firstScope->tagged('services');
+ $second = $firstScope->tagged('services');
+ $third = $secondScope->tagged('services');
+
+ self::assertCount(3, $first);
+
+ // Singleton instances are shared across scopes.
+ self::assertSame($first[0], $second[0]);
+ self::assertSame($first[0], $third[0]);
+
+ // Scoped instances are shared only within their scope.
+ self::assertSame($first[1], $second[1]);
+ self::assertNotSame($first[1], $third[1]);
+
+ // Transient instances are always recreated.
+ self::assertNotSame($first[2], $second[2]);
+ self::assertNotSame($first[2], $third[2]);
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ /**
+ * ### Tagged singleton instances are shared between container and scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedSingletonInstancesAreSharedBetweenContainerAndScope ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedSingletonService::class);
+
+ $container->tag('services', TaggedSingletonService::class);
+
+ $scope = $container->createScope();
+
+ $fromContainer = $container->tagged('services');
+ $fromScope = $scope->tagged('services');
+
+ self::assertSame($fromContainer[0], $fromScope[0]);
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Tagged aliases resolve their target services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedAliasesResolveTheirTargetServices ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $container->alias(
+ TaggedLogger::class,
+ TaggedFileLogger::class
+ );
+
+ $container->tag('logging', TaggedLogger::class);
+
+ self::assertSame(
+ [
+ TaggedLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ $services = $container->tagged('logging');
+
+ self::assertCount(1, $services);
+ self::assertInstanceOf(TaggedFileLogger::class, $services[0]);
+ self::assertSame($services[0], $container->get(TaggedFileLogger::class));
+
+ }
+
+ /**
+ * ### Tagged factory services are resolved
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedFactoryServicesAreResolved ():void {
+
+ $container = new Container;
+
+ $container->factory(
+ TaggedFactoryService::class,
+ static fn ($resolver):object => new TaggedFactoryService
+ );
+
+ $container->tag('services', TaggedFactoryService::class);
+
+ $first = $container->tagged('services');
+ $second = $container->tagged('services');
+
+ self::assertInstanceOf(TaggedFactoryService::class, $first[0]);
+ self::assertNotSame($first[0], $second[0]);
+
+ }
+
+ /**
+ * ### Tagged factory services preserve singleton lifetime
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedFactoryServicesPreserveSingletonLifetime ():void {
+
+ $container = new Container;
+
+ $container->factory(
+ TaggedFactoryService::class,
+ static fn ($resolver):object => new TaggedFactoryService,
+ \FireHub\Core\Meta\Enum\Container\Lifetime::SINGLETON
+ );
+
+ $container->tag('services', TaggedFactoryService::class);
+
+ $first = $container->tagged('services');
+ $second = $container->tagged('services');
+
+ self::assertSame($first[0], $second[0]);
+
+ }
+
+ /**
+ * ### Tags cannot be registered after freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagsCannotBeRegisteredAfterFreezing ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $container->freeze();
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\ContainerFrozenException::class,
+ function () use ($container):void {
+
+ $container->tag('logging', TaggedFileLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged services remain resolvable after freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedServicesRemainResolvableAfterFreezing ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $container->freeze();
+
+ self::assertTrue($container->hasTag('logging'));
+
+ self::assertSame(
+ [
+ TaggedFileLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ $services = $container->tagged('logging');
+
+ self::assertInstanceOf(TaggedFileLogger::class, $services[0]);
+
+ }
+
+ /**
+ * ### Tagged scoped services remain resolvable after freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedScopedServicesRemainResolvableAfterFreezing ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $container->freeze();
+
+ $scope = $container->createScope();
+
+ $services = $scope->tagged('services');
+
+ self::assertInstanceOf(TaggedScopedService::class, $services[0]);
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Tagged resolution fails after closing the scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedResolutionFailsAfterClosingTheScope ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $scope = $container->createScope();
+
+ $scope->close();
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\ScopeClosedException::class,
+ function () use ($scope):void {
+
+ $scope->tagged('services');
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged resolution works inside scope run
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedResolutionWorksInsideScopeRun ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $scope = $container->createScope();
+
+ $scope->run(
+ function ($resolver) use ($scope):void {
+
+ $services = $scope->tagged('services');
+
+ self::assertCount(1, $services);
+ self::assertInstanceOf(TaggedScopedService::class, $services[0]);
+
+ }
+ );
+
+ self::assertTrue($scope->isClosed());
+
+ }
+
+ /**
+ * ### Tags are isolated between containers
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagsAreIsolatedBetweenContainers ():void {
+
+ $firstContainer = new Container;
+ $secondContainer = new Container;
+
+ $firstContainer->singleton(TaggedFileLogger::class);
+ $secondContainer->singleton(TaggedFileLogger::class);
+
+ $firstContainer->tag('logging', TaggedFileLogger::class);
+
+ self::assertTrue($firstContainer->hasTag('logging'));
+ self::assertNotTrue($secondContainer->hasTag('logging'));
+
+ self::assertSame([], $secondContainer->taggedIds('logging'));
+
+ }
+
+}
From cbb2db7ab5e0b0297aaa8fd6f57607e57815d12e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Wed, 21 Oct 2026 15:39:03 +0200
Subject: [PATCH 3/5] Add collection logger interfaces and services for
dependency injection
---
src/Attribute/InjectAll.php | 62 ++
src/Container.php | 49 ++
src/Internal/Autowirer.php | 7 +
src/Internal/DependencyPlanCache.php | 105 +++-
src/Internal/ParameterPlan.php | 6 +-
src/Resolver.php | 264 ++++++++-
src/Scope.php | 56 ++
tests/Stubs/CollectionAbstractLogger.php | 19 +
.../CollectionAlternateImplementation.php | 19 +
tests/Stubs/CollectionAlternateLogger.php | 19 +
.../CollectionConcreteAbstractLogger.php | 19 +
tests/Stubs/CollectionConsoleLogger.php | 19 +
tests/Stubs/CollectionDatabaseLogger.php | 19 +
tests/Stubs/CollectionFileLogger.php | 19 +
tests/Stubs/CollectionInjectionCircularA.php | 26 +
tests/Stubs/CollectionInjectionCircularB.php | 21 +
...InjectionConflictingAttributesConsumer.php | 29 +
tests/Stubs/CollectionInjectionConsumer.php | 27 +
.../CollectionInjectionDefaultConsumer.php | 27 +
.../CollectionInjectionEmptyTypeConsumer.php | 26 +
...ionInjectionMultipleAttributesConsumer.php | 27 +
.../CollectionInjectionNestedConsumer.php | 21 +
.../CollectionInjectionNullableConsumer.php | 26 +
.../CollectionInjectionScalarConsumer.php | 26 +
...CollectionInjectionUnknownTypeConsumer.php | 26 +
.../CollectionInjectionUntypedConsumer.php | 26 +
tests/Stubs/CollectionLogger.php | 19 +
tests/Stubs/CollectionOtherService.php | 19 +
tests/Stubs/CollectionUnrelatedService.php | 19 +
tests/Unit/CollectionInjectionTest.php | 521 +++++++++++++++++
tests/Unit/CollectionServicesTest.php | 532 ++++++++++++++++++
31 files changed, 2068 insertions(+), 32 deletions(-)
create mode 100644 src/Attribute/InjectAll.php
create mode 100644 tests/Stubs/CollectionAbstractLogger.php
create mode 100644 tests/Stubs/CollectionAlternateImplementation.php
create mode 100644 tests/Stubs/CollectionAlternateLogger.php
create mode 100644 tests/Stubs/CollectionConcreteAbstractLogger.php
create mode 100644 tests/Stubs/CollectionConsoleLogger.php
create mode 100644 tests/Stubs/CollectionDatabaseLogger.php
create mode 100644 tests/Stubs/CollectionFileLogger.php
create mode 100644 tests/Stubs/CollectionInjectionCircularA.php
create mode 100644 tests/Stubs/CollectionInjectionCircularB.php
create mode 100644 tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php
create mode 100644 tests/Stubs/CollectionInjectionConsumer.php
create mode 100644 tests/Stubs/CollectionInjectionDefaultConsumer.php
create mode 100644 tests/Stubs/CollectionInjectionEmptyTypeConsumer.php
create mode 100644 tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php
create mode 100644 tests/Stubs/CollectionInjectionNestedConsumer.php
create mode 100644 tests/Stubs/CollectionInjectionNullableConsumer.php
create mode 100644 tests/Stubs/CollectionInjectionScalarConsumer.php
create mode 100644 tests/Stubs/CollectionInjectionUnknownTypeConsumer.php
create mode 100644 tests/Stubs/CollectionInjectionUntypedConsumer.php
create mode 100644 tests/Stubs/CollectionLogger.php
create mode 100644 tests/Stubs/CollectionOtherService.php
create mode 100644 tests/Stubs/CollectionUnrelatedService.php
create mode 100644 tests/Unit/CollectionInjectionTest.php
create mode 100644 tests/Unit/CollectionServicesTest.php
diff --git a/src/Attribute/InjectAll.php b/src/Attribute/InjectAll.php
new file mode 100644
index 0000000..9e8d85c
--- /dev/null
+++ b/src/Attribute/InjectAll.php
@@ -0,0 +1,62 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container
+ */
+
+namespace FireHub\Container\Attribute;
+
+use Attribute;
+
+/**
+ * ### Marks a constructor parameter for collection injection
+ *
+ * Automatically injects all explicitly registered services compatible with the specified type.
+ *
+ * Services are resolved in registration order while preserving their configured lifetimes and the active resolution
+ * context.
+ *
+ * If no matching services are registered, an empty array is injected.
+ *
+ * Only non-nullable array constructor parameters are supported.
+ *
+ *
+ * use FireHub\Container\Attribute\InjectAll;
+ *
+ * final readonly class LoggerManager {
+ *
+ * public function __construct(
+ * #[InjectAll(LoggerInterface::class)]
+ * public array $loggers
+ * ) {}
+ *
+ * }
+ *
+ *
+ * @since 1.0.0
+ */
+#[Attribute(Attribute::TARGET_PARAMETER)]
+final readonly class InjectAll {
+
+ /**
+ * ### Constructor
+ * @since 1.0.0
+ *
+ * @param class-string $type
+ * Required service type.
+ *
+ *
+ * @return void
+ */
+ public function __construct(
+ public string $type
+ ) {}
+
+}
\ No newline at end of file
diff --git a/src/Container.php b/src/Container.php
index 2ad969e..a7f9328 100644
--- a/src/Container.php
+++ b/src/Container.php
@@ -561,6 +561,55 @@ public function bindParameters (string $class, array $parameters):self {
}
+ /**
+ * ### Resolves all registered services matching a type
+ *
+ * Resolves all explicitly registered services compatible with the specified class or interface.
+ *
+ * Matching services are returned in registration order while preserving their configured lifetimes.
+ *
+ * Scoped services require an active scope and cannot be resolved directly through the container.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(FileLogger::class);
+ * $container->singleton(ConsoleLogger::class);
+ *
+ * $loggers = $container->all(LoggerInterface::class);
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Resolver::all() To resolve matching services.
+ *
+ * @template TService of object
+ *
+ * @param class-string $type
+ * Required service type.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ *
+ * @return list Matching service instances.
+ */
+ public function all (string $type):array {
+
+ return $this->resolver->all(
+ $type,
+ new ResolutionContext
+ );
+
+ }
+
/**
* ### Associates registered services with a tag
*
diff --git a/src/Internal/Autowirer.php b/src/Internal/Autowirer.php
index fe1370f..6b8bf9b 100644
--- a/src/Internal/Autowirer.php
+++ b/src/Internal/Autowirer.php
@@ -41,6 +41,9 @@
* @param Closure(non-empty-string, \FireHub\Container\Internal\ResolutionContext):list $resolve_tagged
* Context-aware tagged service collection resolver.
*
+ * @param Closure(class-string, \FireHub\Container\Internal\ResolutionContext):list $resolve_all
+ * Context-aware service collection resolver.
+ *
* @param Closure(class-string):bool $has
* Explicit service registration checker.
*
@@ -65,6 +68,7 @@
public function __construct (
private Closure $resolve,
private Closure $resolve_tagged,
+ private Closure $resolve_all,
private Closure $has,
private TypeMatcher $matcher,
private CandidateSelector $selector,
@@ -166,6 +170,9 @@ private function resolveParameter (string $class, ParameterPlan $plan, Resolutio
if ($plan->tag !== null)
return ($this->resolve_tagged)($plan->tag, $context);
+ if ($plan->collection !== null)
+ return ($this->resolve_all)($plan->collection, $context);
+
$id = $this->contextual_selector->select($class, $plan);
if ($id !== null)
diff --git a/src/Internal/DependencyPlanCache.php b/src/Internal/DependencyPlanCache.php
index 8ba1f86..962b6d0 100644
--- a/src/Internal/DependencyPlanCache.php
+++ b/src/Internal/DependencyPlanCache.php
@@ -13,7 +13,9 @@
namespace FireHub\Container\Internal;
-use FireHub\Container\Attribute\Tagged;
+use FireHub\Container\Attribute\ {
+ InjectAll, Tagged
+};
use FireHub\Container\Exception\ServiceInstantiationException;
use FireHub\Runtime;
use ReflectionNamedType, ReflectionParameter, ReflectionUnionType, ReflectionIntersectionType;
@@ -116,6 +118,10 @@ public function get (string $class):DependencyPlan {
*
* @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged attributes are declared,
* the parameter type is unsupported, or the tag name is empty.
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If tagged and collection injection are
+ * combined.
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple collection attributes are
+ * declared, the parameter type is unsupported, or the requested service type does not exist.
*
* @return \FireHub\Container\Internal\ParameterPlan Compiled parameter plan.
*/
@@ -124,16 +130,26 @@ private function compileParameter (ReflectionParameter $parameter):ParameterPlan
$type = $parameter->getType();
$id = $type instanceof ReflectionNamedType
- ? $this->matcher->classId($parameter, $type)
- : null;
+ ? $this->matcher->classId($parameter, $type) : null;
$composite = $type instanceof ReflectionUnionType
|| $type instanceof ReflectionIntersectionType
- ? $type
- : null;
+ ? $type : null;
$has_default = $parameter->isDefaultValueAvailable();
+ $tag = $this->resolveTag($parameter);
+ $collection = $this->resolveCollection($parameter);
+
+ if ($tag !== null && $collection !== null)
+ throw new ServiceInstantiationException(
+ 'Tagged and collection injection cannot be combined.',
+ context: [
+ 'class' => $parameter->getDeclaringClass()?->getName(),
+ 'parameter' => $parameter->getName(),
+ ]
+ );
+
return new ParameterPlan(
$parameter,
$id,
@@ -142,7 +158,8 @@ private function compileParameter (ReflectionParameter $parameter):ParameterPlan
$has_default
? $parameter->getDefaultValue()
: null,
- $this->resolveTag($parameter)
+ $tag,
+ $collection
);
}
@@ -213,4 +230,80 @@ private function resolveTag (ReflectionParameter $parameter):?string {
}
+ /**
+ * ### Resolves collection injection metadata
+ *
+ * Extracts and validates the collection injection attribute associated with a constructor parameter.
+ *
+ * Collection injection requires a non-nullable array parameter and an existing class or interface as its element
+ * type.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Runtime\Arr\Inspection::count() To count the number of declared collection attributes.
+ * @uses \FireHub\Runtime\ObjectModel\Inspection::isClass() To check if the collection type is a class.
+ * @uses \FireHub\Runtime\ObjectModel\Inspection::isInterface() To check if the collection type is an interface.
+ *
+ * @param \ReflectionParameter $parameter
+ * Constructor parameter reflection.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple collection attributes are
+ * declared, the parameter type is unsupported, or the requested service type does not exist.
+ *
+ * @return null|class-string Requested collection element type.
+ */
+ private function resolveCollection (ReflectionParameter $parameter):?string {
+
+ $attributes = $parameter->getAttributes(InjectAll::class);
+
+ if ($attributes === [])
+ return null;
+
+ if (Runtime\Arr\Inspection::count($attributes) > 1)
+ throw new ServiceInstantiationException(
+ 'Multiple collection injection attributes are not supported.',
+ context: [
+ 'class' => $parameter->getDeclaringClass()?->getName(),
+ 'parameter' => $parameter->getName(),
+ ]
+ );
+
+ $type = $parameter->getType();
+
+ if (
+ !$type instanceof ReflectionNamedType
+ || $type->getName() !== 'array'
+ || $type->allowsNull()
+ ) throw new ServiceInstantiationException(
+ 'Collection injection requires a non-nullable array parameter.',
+ context: [
+ 'class' => $parameter->getDeclaringClass()?->getName(),
+ 'parameter' => $parameter->getName(),
+ ]
+ );
+
+ /** @var \FireHub\Container\Attribute\InjectAll $attribute */
+ $attribute = $attributes[0]->newInstance();
+
+ $collection = $attribute->type;
+
+ if (
+ $collection === ''
+ || (
+ !Runtime\ObjectModel\Inspection::isClass($collection)
+ && !Runtime\ObjectModel\Inspection::isInterface($collection)
+ )
+ ) throw new ServiceInstantiationException(
+ 'Collection injection requires an existing class or interface.',
+ context: [
+ 'class' => $parameter->getDeclaringClass()?->getName(),
+ 'parameter' => $parameter->getName(),
+ 'type' => $collection,
+ ]
+ );
+
+ return $collection;
+
+ }
+
}
\ No newline at end of file
diff --git a/src/Internal/ParameterPlan.php b/src/Internal/ParameterPlan.php
index 6c9b51e..392a2ef 100644
--- a/src/Internal/ParameterPlan.php
+++ b/src/Internal/ParameterPlan.php
@@ -51,6 +51,9 @@
* @param null|non-empty-string $tag [optional]
* Service tag associated with the constructor parameter.
*
+ * @param null|class-string $collection [optional]
+ * Service type requested for collection injection.
+ *
*
* @return void
*/
@@ -60,7 +63,8 @@ public function __construct (
public ReflectionUnionType|ReflectionIntersectionType|null $composite,
public bool $hasDefault,
public mixed $default,
- public ?string $tag = null
+ public ?string $tag = null,
+ public ?string $collection = null
) {}
}
\ No newline at end of file
diff --git a/src/Resolver.php b/src/Resolver.php
index baec675..7d2a1f8 100644
--- a/src/Resolver.php
+++ b/src/Resolver.php
@@ -24,7 +24,8 @@
TagRegistry, TypeMatcher
};
use FireHub\Container\Exception\ {
- ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException, UnsupportedDefinitionException
+ CircularDependencyException, ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException,
+ UnsupportedDefinitionException
};
use FireHub\Runtime;
@@ -52,6 +53,15 @@
*/
private ConstructorMetadataCache $metadata;
+ /**
+ * ### Dependency type matcher
+ *
+ * Provides shared type compatibility checks for dependency resolution, candidate selection, and collection
+ * injection.
+ * @since 1.0.0
+ */
+ private TypeMatcher $matcher;
+
/**
* ### Reusable dependency autowirer
* @since 1.0.0
@@ -94,26 +104,27 @@ public function __construct (
$this->metadata = new ConstructorMetadataCache;
- $matcher = new TypeMatcher(
+ $this->matcher = new TypeMatcher(
$this->metadata
);
$selector = new CandidateSelector(
- $matcher,
+ $this->matcher,
fn (string $id):bool => $this->has($id),
fn ():iterable => $this->registry->implementations()
);
$plans = new DependencyPlanCache(
$this->metadata,
- $matcher
+ $this->matcher
);
$this->autowirer = new Autowirer(
fn (string $id, ResolutionContext $context):object => $this->resolve($id, $context),
fn (string $tag, ResolutionContext $context):array => $this->tagged($tag, $context),
+ fn (string $type, ResolutionContext $context):array => $this->all($type, $context),
fn (string $id):bool => $this->has($id),
- $matcher,
+ $this->matcher,
$selector,
$plans,
$parameters,
@@ -122,6 +133,26 @@ public function __construct (
}
+ /**
+ * ### Checks whether a service is registered
+ *
+ * Checks whether a service identifier exists without constructing or resolving its instance.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Registry::has() To check for service existence.
+ *
+ * @param class-string $id
+ * Service identifier.
+ *
+ *
+ * @return bool True if the service is registered.
+ */
+ public function has (string $id):bool {
+
+ return $this->registry->has($id);
+
+ }
+
/**
* ### Gets a registered service
*
@@ -217,6 +248,128 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope)
}
+ /**
+ * ### Resolves all registered services matching a type
+ *
+ * Resolves explicitly registered services compatible with the specified class or interface.
+ *
+ * Services are returned in registration order while preserving their configured lifetimes and the active
+ * resolution context.
+ *
+ * Service aliases are resolved to their underlying targets and do not produce duplicate collection entries.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Registry::all() To retrieve all registered service identifiers.
+ * @uses \FireHub\Container\Resolver::canonicalId() To resolve service aliases.
+ * @uses \FireHub\Container\Resolver::matchesCollectionType() To check whether a service matches the specified type.
+ *
+ * @template TService of object
+ *
+ * @param class-string $type
+ * Required service type.
+ *
+ * @param \FireHub\Container\Internal\ResolutionContext $context
+ * Active resolution context.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ *
+ * @return list Matching service instances.
+ */
+ public function all (string $type, ResolutionContext $context):array {
+
+ $services = []; $resolved = [];
+ foreach ($this->registry->all() as $id => $definition) {
+
+ $target = $this->canonicalId($id);
+
+ if (isset($resolved[$target]))
+ continue;
+
+ if (!$this->matchesCollectionType($id, $target, $type))
+ continue;
+
+ $instance = $this->resolve($id, $context);
+
+ if (!$instance instanceof $type)
+ throw new ServiceTypeMismatchException(
+ context: [
+ 'service' => $id,
+ 'implementation' => $instance::class,
+ ]
+ );
+
+ $services[] = $instance;
+
+ $resolved[$target] = true;
+
+ }
+
+ return $services;
+
+ }
+
+ /**
+ * ### Resolves all matching services within a scope
+ *
+ * Resolves collection services using the supplied scoped instance store and scope state.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Resolver::all() To resolve all matching services.
+ * @uses \FireHub\Container\Internal\ScopeState::enter() To enter the scope.
+ * @uses \FireHub\Container\Internal\ScopeState::leave() To leave the scope.
+ *
+ * @template TService of object
+ *
+ * @param class-string $type
+ * Required service type.
+ *
+ * @param \FireHub\Container\Internal\InstanceStore $scoped
+ * Scoped instance store.
+ *
+ * @param \FireHub\Container\Internal\ScopeState $scope
+ * Scope state.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ *
+ * @return list Matching service instances.
+ */
+ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scope):array {
+
+ $scope->enter();
+
+ try {
+
+ return $this->all(
+ $type,
+ new ResolutionContext(
+ scoped: $scoped,
+ scope: $scope
+ )
+ );
+
+ } finally {
+
+ $scope->leave();
+
+ }
+
+ }
+
/**
* ### Resolves services associated with a tag
*
@@ -319,26 +472,6 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc
}
- /**
- * ### Checks whether a service is registered
- *
- * Checks whether a service identifier exists without constructing or resolving its instance.
- * @since 1.0.0
- *
- * @uses \FireHub\Container\Registry::has() To check for service existence.
- *
- * @param class-string $id
- * Service identifier.
- *
- *
- * @return bool True if the service is registered.
- */
- public function has (string $id):bool {
-
- return $this->registry->has($id);
-
- }
-
/**
* ### Resolves a service within the specified context
* @since 1.0.0
@@ -596,6 +729,87 @@ function (string $id) use ($context):bool {
}
+ /**
+ * ### Resolves the canonical service identifier
+ *
+ * Follows registered service aliases until a non-alias definition is reached.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Registry::find() To retrieve a service definition.
+ *
+ * @param class-string $id
+ * Service identifier.
+ *
+ *
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ *
+ * @return class-string Canonical service identifier.
+ */
+ private function canonicalId (string $id):string {
+
+ $visited = []; $path = [];
+ while (true) {
+
+ if (isset($visited[$id]))
+ throw new CircularDependencyException(
+ context: [
+ 'path' => [...$path, $id],
+ ]
+ );
+
+ $visited[$id] = true;
+ $path[] = $id;
+
+ $definition = $this->registry->find($id);
+
+ if (!$definition instanceof Alias)
+ return $id;
+
+ $id = $definition->target;
+
+ }
+
+ }
+
+ /**
+ * ### Checks collection candidate compatibility
+ *
+ * Determines whether a registered service is compatible with the requested collection element type.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Registry::find() To retrieve the service definition.
+ * @uses \FireHub\Container\Internal\TypeMatcher::matches() To check if the service identifier matches the type.
+ *
+ * @param class-string $id
+ * Service identifier.
+ *
+ * @param class-string $target
+ * Canonical service identifier.
+ *
+ * @param class-string $type
+ * Required collection element type.
+ *
+ *
+ * @return bool True if the service matches the requested type.
+ */
+ private function matchesCollectionType (string $id, string $target, string $type):bool {
+
+ if ($this->matcher->matches($id, $type))
+ return true;
+
+ if ($this->matcher->matches($target, $type))
+ return true;
+
+ $definition = $this->registry->find($target);
+
+ return match (true) {
+ $definition instanceof Cls => $this->matcher->matches($definition->class, $type),
+ $definition instanceof Instance => $definition->instance instanceof $type,
+ default => false
+ };
+
+ }
+
/**
* ### Creates a service instance
*
diff --git a/src/Scope.php b/src/Scope.php
index de5ad84..f1be407 100644
--- a/src/Scope.php
+++ b/src/Scope.php
@@ -245,6 +245,62 @@ public function has (string $id):bool {
}
+ /**
+ * ### Resolves all matching services within the current scope
+ *
+ * Resolves all explicitly registered services compatible with the specified class or interface.
+ *
+ * Scoped services are reused within the current scope, singleton services are shared with the parent container,
+ * and transient services are created for each resolution.
+ *
+ * Resolution is prohibited after the scope has been closed.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->scoped(FileLogger::class);
+ * $container->singleton(ConsoleLogger::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * $loggers = $scope->all(LoggerInterface::class);
+ *
+ * $scope->close();
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Resolver::allScoped() To resolve matching services within the scope.
+ *
+ * @template TService of object
+ *
+ * @param class-string $type
+ * Required service type.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ *
+ * @return list Matching service instances.
+ */
+ public function all (string $type):array {
+
+ return $this->resolver->allScoped(
+ $type,
+ $this->instances,
+ $this->state
+ );
+
+ }
+
/**
* ### Executes a callback within the scope
*
diff --git a/tests/Stubs/CollectionAbstractLogger.php b/tests/Stubs/CollectionAbstractLogger.php
new file mode 100644
index 0000000..4809620
--- /dev/null
+++ b/tests/Stubs/CollectionAbstractLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+abstract class CollectionAbstractLogger {}
diff --git a/tests/Stubs/CollectionAlternateImplementation.php b/tests/Stubs/CollectionAlternateImplementation.php
new file mode 100644
index 0000000..a5e7e14
--- /dev/null
+++ b/tests/Stubs/CollectionAlternateImplementation.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionAlternateImplementation implements CollectionAlternateLogger {}
diff --git a/tests/Stubs/CollectionAlternateLogger.php b/tests/Stubs/CollectionAlternateLogger.php
new file mode 100644
index 0000000..5ea4368
--- /dev/null
+++ b/tests/Stubs/CollectionAlternateLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface CollectionAlternateLogger extends CollectionLogger {}
diff --git a/tests/Stubs/CollectionConcreteAbstractLogger.php b/tests/Stubs/CollectionConcreteAbstractLogger.php
new file mode 100644
index 0000000..d0d549e
--- /dev/null
+++ b/tests/Stubs/CollectionConcreteAbstractLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionConcreteAbstractLogger extends CollectionAbstractLogger {}
diff --git a/tests/Stubs/CollectionConsoleLogger.php b/tests/Stubs/CollectionConsoleLogger.php
new file mode 100644
index 0000000..4eb1969
--- /dev/null
+++ b/tests/Stubs/CollectionConsoleLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionConsoleLogger implements CollectionLogger {}
diff --git a/tests/Stubs/CollectionDatabaseLogger.php b/tests/Stubs/CollectionDatabaseLogger.php
new file mode 100644
index 0000000..c4e378d
--- /dev/null
+++ b/tests/Stubs/CollectionDatabaseLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionDatabaseLogger implements CollectionLogger {}
diff --git a/tests/Stubs/CollectionFileLogger.php b/tests/Stubs/CollectionFileLogger.php
new file mode 100644
index 0000000..05f2040
--- /dev/null
+++ b/tests/Stubs/CollectionFileLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionFileLogger implements CollectionLogger {}
diff --git a/tests/Stubs/CollectionInjectionCircularA.php b/tests/Stubs/CollectionInjectionCircularA.php
new file mode 100644
index 0000000..945dd38
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionCircularA.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionCircularA {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionCircularB.php b/tests/Stubs/CollectionInjectionCircularB.php
new file mode 100644
index 0000000..7ea9611
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionCircularB.php
@@ -0,0 +1,21 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionCircularB implements CollectionLogger {
+ public function __construct (public CollectionInjectionCircularA $dependency) {}
+}
diff --git a/tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php b/tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php
new file mode 100644
index 0000000..9f9db85
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php
@@ -0,0 +1,29 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\ {
+ InjectAll, Tagged
+};
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionConflictingAttributesConsumer {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ #[Tagged('logging')]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionConsumer.php b/tests/Stubs/CollectionInjectionConsumer.php
new file mode 100644
index 0000000..9ccde8c
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionConsumer.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionConsumer {
+ /** @param list $loggers */
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionDefaultConsumer.php b/tests/Stubs/CollectionInjectionDefaultConsumer.php
new file mode 100644
index 0000000..8de04d8
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionDefaultConsumer.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionDefaultConsumer {
+ /** @param list $loggers */
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public array $loggers = []
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionEmptyTypeConsumer.php b/tests/Stubs/CollectionInjectionEmptyTypeConsumer.php
new file mode 100644
index 0000000..7e35d96
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionEmptyTypeConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionEmptyTypeConsumer {
+ public function __construct (
+ #[InjectAll('')]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php b/tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php
new file mode 100644
index 0000000..779a04c
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionMultipleAttributesConsumer {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ #[InjectAll(CollectionLogger::class)]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionNestedConsumer.php b/tests/Stubs/CollectionInjectionNestedConsumer.php
new file mode 100644
index 0000000..87519c5
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionNestedConsumer.php
@@ -0,0 +1,21 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionNestedConsumer {
+ public function __construct (public CollectionInjectionConsumer $consumer) {}
+}
diff --git a/tests/Stubs/CollectionInjectionNullableConsumer.php b/tests/Stubs/CollectionInjectionNullableConsumer.php
new file mode 100644
index 0000000..8a6e5dd
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionNullableConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionNullableConsumer {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public ?array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionScalarConsumer.php b/tests/Stubs/CollectionInjectionScalarConsumer.php
new file mode 100644
index 0000000..2ba327f
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionScalarConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionScalarConsumer {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public string $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionUnknownTypeConsumer.php b/tests/Stubs/CollectionInjectionUnknownTypeConsumer.php
new file mode 100644
index 0000000..4035dab
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionUnknownTypeConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionUnknownTypeConsumer {
+ public function __construct (
+ #[InjectAll('FireHub\Tests\Container\Stubs\UnknownCollectionType')]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionUntypedConsumer.php b/tests/Stubs/CollectionInjectionUntypedConsumer.php
new file mode 100644
index 0000000..1e81e97
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionUntypedConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionInjectionUntypedConsumer {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionLogger.php b/tests/Stubs/CollectionLogger.php
new file mode 100644
index 0000000..b226b4b
--- /dev/null
+++ b/tests/Stubs/CollectionLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface CollectionLogger {}
diff --git a/tests/Stubs/CollectionOtherService.php b/tests/Stubs/CollectionOtherService.php
new file mode 100644
index 0000000..c57f64e
--- /dev/null
+++ b/tests/Stubs/CollectionOtherService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionOtherService {}
diff --git a/tests/Stubs/CollectionUnrelatedService.php b/tests/Stubs/CollectionUnrelatedService.php
new file mode 100644
index 0000000..2bb7455
--- /dev/null
+++ b/tests/Stubs/CollectionUnrelatedService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionUnrelatedService {}
diff --git a/tests/Unit/CollectionInjectionTest.php b/tests/Unit/CollectionInjectionTest.php
new file mode 100644
index 0000000..a8198fb
--- /dev/null
+++ b/tests/Unit/CollectionInjectionTest.php
@@ -0,0 +1,521 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ CircularDependencyException, ScopeRequiredException, ServiceInstantiationException};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ CollectionConsoleLogger, CollectionFileLogger, CollectionInjectionCircularA, CollectionInjectionCircularB,
+ CollectionInjectionConflictingAttributesConsumer, CollectionInjectionConsumer, CollectionInjectionDefaultConsumer,
+ CollectionInjectionEmptyTypeConsumer, CollectionInjectionMultipleAttributesConsumer,
+ CollectionInjectionNestedConsumer, CollectionInjectionNullableConsumer, CollectionInjectionScalarConsumer,
+ CollectionInjectionUnknownTypeConsumer, CollectionInjectionUntypedConsumer, CollectionUnrelatedService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests collection constructor injection
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class CollectionInjectionTest extends FireHubTestCase {
+
+ /**
+ * @since 1.0.0
+ */
+ use ExceptionAssertions;
+
+ /**
+ * ### Collection attribute injects services in registration order
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectsRegisteredImplementationsInRegistrationOrder ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionConsoleLogger::class);
+ $container->singleton(CollectionUnrelatedService::class);
+ $container->singleton(CollectionFileLogger::class);
+
+ $consumer = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertCount(2, $consumer->loggers);
+ self::assertInstanceOf(CollectionConsoleLogger::class, $consumer->loggers[0]);
+ self::assertInstanceOf(CollectionFileLogger::class, $consumer->loggers[1]);
+
+ }
+
+ /**
+ * ### Collection attribute injects an empty array for no registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNoRegistrationsInjectEmptyArray ():void {
+
+ $consumer = (new Container)->get(CollectionInjectionConsumer::class);
+
+ self::assertSame([], $consumer->loggers);
+
+ }
+
+ /**
+ * ### Collection attribute ignores unregistered implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnregisteredConstructibleImplementationIsNotDiscovered ():void {
+
+ $container = new Container;
+
+ $consumer = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertSame([], $consumer->loggers);
+
+ }
+
+ /**
+ * ### Collection attribute overrides constructor default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionOverridesConstructorDefault ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+
+ $consumer = $container->get(CollectionInjectionDefaultConsumer::class);
+
+ self::assertCount(1, $consumer->loggers);
+
+ }
+
+ /**
+ * ### Collection attribute overrides parameter binding
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitParameterBindingOverridesInjection ():void {
+
+ $container = new Container;
+
+ $override = [new CollectionConsoleLogger];
+
+ $container->bindParameters(
+ CollectionInjectionConsumer::class,
+ ['loggers' => $override]
+ );
+
+ $consumer = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertSame($override, $consumer->loggers);
+
+ }
+
+ /**
+ * ### Collection attribute works in nested dependency graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNestedGraphUsesCollectionInjection ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+
+ $nested = $container->get(CollectionInjectionNestedConsumer::class);
+
+ self::assertInstanceOf(
+ CollectionFileLogger::class,
+ $nested->consumer->loggers[0]
+ );
+
+ }
+
+ /**
+ * ### Collection attribute uses singleton instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionUsesSingletonInstances ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+
+ $first = $container->get(CollectionInjectionConsumer::class);
+ $second = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertSame($first->loggers[0], $second->loggers[0]);
+ self::assertSame(
+ $first->loggers[0],
+ $container->get(CollectionFileLogger::class)
+ );
+
+ }
+
+ /**
+ * ### Collection attribute creates transient instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionCreatesTransientInstances ():void {
+
+ $container = new Container;
+
+ $container->transient(CollectionFileLogger::class);
+
+ $first = $container->get(CollectionInjectionConsumer::class);
+ $second = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertNotSame($first->loggers[0], $second->loggers[0]);
+
+ }
+
+ /**
+ * ### Collection attribute requires a scope for scoped services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionRequiresScopeForScopedServices ():void {
+
+ $container = new Container;
+
+ $container->scoped(CollectionFileLogger::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ static function () use ($container):void {
+
+ $container->get(CollectionInjectionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute reuses scoped instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionReusesScopedInstances ():void {
+
+ $container = new Container;
+
+ $container->scoped(CollectionFileLogger::class);
+
+ $scope = $container->createScope();
+
+ try {
+
+ $first = $scope->get(CollectionInjectionConsumer::class);
+ $second = $scope->get(CollectionInjectionConsumer::class);
+
+ self::assertSame($first->loggers[0], $second->loggers[0]);
+ self::assertSame(
+ $first->loggers[0],
+ $scope->get(CollectionFileLogger::class)
+ );
+
+ } finally {
+
+ $scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Collection attribute isolates different scopes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionIsolatesDifferentScopes ():void {
+
+ $container = new Container;
+
+ $container->scoped(CollectionFileLogger::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ try {
+
+ $first = $firstScope->get(CollectionInjectionConsumer::class);
+ $second = $secondScope->get(CollectionInjectionConsumer::class);
+
+ self::assertNotSame($first->loggers[0], $second->loggers[0]);
+
+ } finally {
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Collection attribute supports factories
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionSupportsFactories ():void {
+
+ $container = new Container;
+
+ $executions = 0;
+
+ $container->factory(
+ CollectionFileLogger::class,
+ static function ($resolver) use (&$executions):object {
+
+ $executions++;
+
+ return new CollectionFileLogger;
+
+ }
+ );
+
+ self::assertSame(0, $executions);
+
+ $consumer = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertInstanceOf(CollectionFileLogger::class, $consumer->loggers[0]);
+ self::assertSame(1, $executions);
+
+ }
+
+ /**
+ * ### Collection attribute sees registrations added after first resolution
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionSeesRegistrationsAddedAfterFirstResolution ():void {
+
+ $container = new Container;
+
+ self::assertSame(
+ [],
+ $container->get(CollectionInjectionConsumer::class)->loggers
+ );
+
+ $container->singleton(CollectionFileLogger::class);
+
+ self::assertCount(
+ 1,
+ $container->get(CollectionInjectionConsumer::class)->loggers
+ );
+
+ }
+
+ /**
+ * ### Collection attribute works after container freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionWorksAfterFreeze ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+ $container->freeze();
+
+ self::assertCount(
+ 1,
+ $container->get(CollectionInjectionConsumer::class)->loggers
+ );
+
+ }
+
+ /**
+ * ### Collection attribute preserves circular dependency detection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionPreservesCircularDependencyDetection ():void {
+
+ $container = new Container;
+
+ $container->transient(CollectionInjectionCircularB::class);
+
+ $this->assertThrows(
+ CircularDependencyException::class,
+ static function () use ($container):void {
+
+ $container->get(CollectionInjectionCircularA::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects nullable array
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsNullableArray ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionNullableConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects scalar types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsScalar ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionScalarConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects untyped elements
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsUntyped ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionUntypedConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects unknown element types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsUnknownElementType ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionUnknownTypeConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects empty element types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsEmptyElementType ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionEmptyTypeConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects duplicate attributes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsDuplicateAttributes ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionMultipleAttributesConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects conflicting tagged and inject all
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsConflictingTaggedAndInjectAll ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionConflictingAttributesConsumer::class);
+
+ }
+ );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Unit/CollectionServicesTest.php b/tests/Unit/CollectionServicesTest.php
new file mode 100644
index 0000000..e4c2768
--- /dev/null
+++ b/tests/Unit/CollectionServicesTest.php
@@ -0,0 +1,532 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ CircularDependencyException, ScopeClosedException, ScopeRequiredException, ServiceTypeMismatchException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ CollectionAbstractLogger, CollectionAlternateImplementation, CollectionConsoleLogger,
+ CollectionConcreteAbstractLogger, CollectionDatabaseLogger, CollectionFileLogger, CollectionLogger,
+ CollectionOtherService, CollectionUnrelatedService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests collection service resolution
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class CollectionServicesTest extends FireHubTestCase {
+
+ /**
+ * @since 1.0.0
+ */
+ use ExceptionAssertions;
+
+ /**
+ * ### All returns empty for unknown implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllReturnsEmptyForUnknownImplementations ():void {
+
+ $container = new Container;
+
+ self::assertSame([], $container->all(CollectionLogger::class));
+
+ $scope = $container->createScope();
+
+ try {
+
+ self::assertSame([], $scope->all(CollectionLogger::class));
+
+ } finally {
+
+ $scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### All preserves registration order and filters types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllPreservesRegistrationOrderAndFiltersTypes ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionDatabaseLogger::class);
+ $container->singleton(CollectionUnrelatedService::class);
+ $container->singleton(CollectionFileLogger::class);
+
+ $services = $container->all(CollectionLogger::class);
+
+ self::assertCount(2, $services);
+ self::assertInstanceOf(CollectionDatabaseLogger::class, $services[0]);
+ self::assertInstanceOf(CollectionFileLogger::class, $services[1]);
+
+ }
+
+ /**
+ * ### All does not discover unregistered classes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllDoesNotDiscoverUnregisteredClasses ():void {
+
+ self::assertSame(
+ [],
+ (new Container)->all(CollectionLogger::class)
+ );
+
+ }
+
+ /**
+ * ### All supports concrete types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllSupportsConcreteType ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+
+ self::assertCount(
+ 1,
+ $container->all(CollectionFileLogger::class)
+ );
+
+ }
+
+ /**
+ * ### All supports abstract types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllSupportsAbstractType ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionConcreteAbstractLogger::class);
+
+ $services = $container->all(CollectionAbstractLogger::class);
+
+ self::assertCount(1, $services);
+ self::assertInstanceOf(
+ CollectionConcreteAbstractLogger::class,
+ $services[0]
+ );
+
+ }
+
+ /**
+ * ### All supports inherited interfaces
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllSupportsInheritedInterfaces ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionAlternateImplementation::class);
+
+ self::assertCount(
+ 1,
+ $container->all(CollectionLogger::class)
+ );
+
+ }
+
+ /**
+ * ### All reuses singletons
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllReusesSingletons ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+
+ self::assertSame(
+ $container->all(CollectionLogger::class)[0],
+ $container->all(CollectionLogger::class)[0]
+ );
+
+ }
+
+ /**
+ * ### All recreates transients
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllRecreatesTransients ():void {
+
+ $container = new Container;
+
+ $container->transient(CollectionFileLogger::class);
+
+ self::assertNotSame(
+ $container->all(CollectionLogger::class)[0],
+ $container->all(CollectionLogger::class)[0]
+ );
+
+ }
+
+ /**
+ * ### All requires scope for scoped services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllRequiresScopeForScopedServices ():void {
+
+ $container = new Container;
+
+ $container->scoped(CollectionFileLogger::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ static function () use ($container):void {
+
+ $container->all(CollectionLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Scope::all preserves mixed lifetimes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopeAllPreservesMixedLifetimes ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+ $container->scoped(CollectionConsoleLogger::class);
+ $container->transient(CollectionDatabaseLogger::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ try {
+
+ $first = $firstScope->all(CollectionLogger::class);
+ $second = $firstScope->all(CollectionLogger::class);
+ $third = $secondScope->all(CollectionLogger::class);
+
+ self::assertCount(3, $first);
+
+ // Singleton instances are shared across scopes.
+ self::assertSame($first[0], $second[0]);
+ self::assertSame($first[0], $third[0]);
+
+ // Scoped instances are shared within the same scope.
+ self::assertSame($first[1], $second[1]);
+ self::assertNotSame($first[1], $third[1]);
+
+ // Transient instances are recreated.
+ self::assertNotSame($first[2], $second[2]);
+ self::assertNotSame($first[2], $third[2]);
+
+ } finally {
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Scope::all rejects closed scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopeAllRejectsClosedScope ():void {
+
+ $scope = (new Container)->createScope();
+
+ $scope->close();
+
+ $this->assertThrows(
+ ScopeClosedException::class,
+ static function () use ($scope):void {
+
+ $scope->all(CollectionLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### All deduplicates alias after target
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllDeduplicatesAliasAfterTarget ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+ $container->alias(CollectionLogger::class, CollectionFileLogger::class);
+
+ $services = $container->all(CollectionLogger::class);
+
+ self::assertCount(1, $services);
+ self::assertSame(
+ $container->get(CollectionFileLogger::class),
+ $services[0]
+ );
+
+ }
+
+ /**
+ * ### All deduplicates alias before target
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllDeduplicatesAliasBeforeTarget ():void {
+
+ $container = new Container;
+
+ $container->alias(CollectionLogger::class, CollectionFileLogger::class);
+ $container->singleton(CollectionFileLogger::class);
+
+ $services = $container->all(CollectionLogger::class);
+
+ self::assertCount(1, $services);
+ self::assertSame(
+ $container->get(CollectionFileLogger::class),
+ $services[0]
+ );
+
+ }
+
+ /**
+ * ### All resolves factory without eager discovery
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllResolvesFactoryWithoutEagerDiscovery ():void {
+
+ $container = new Container;
+
+ $calls = 0;
+
+ $container->factory(
+ CollectionFileLogger::class,
+ static function ($resolver) use (&$calls):object {
+
+ $calls++;
+
+ return new CollectionFileLogger;
+
+ }
+ );
+
+ self::assertSame(0, $calls);
+
+ $services = $container->all(CollectionLogger::class);
+
+ self::assertCount(1, $services);
+ self::assertSame(1, $calls);
+
+ }
+
+ /**
+ * ### All rejects factory returning incompatible type
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllRejectsFactoryReturningIncompatibleType ():void {
+
+ $container = new Container;
+
+ $container->factory(
+ CollectionFileLogger::class,
+ static fn ($resolver):object => new CollectionOtherService
+ );
+
+ $this->assertThrows(
+ ServiceTypeMismatchException::class,
+ static function () use ($container):void {
+
+ $container->all(CollectionLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### All works after a freeze
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllWorksAfterFreeze ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+ $container->freeze();
+
+ self::assertCount(
+ 1,
+ $container->all(CollectionLogger::class)
+ );
+
+ }
+
+ /**
+ * ### All detects self-referential alias
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllDetectsSelfReferentialAlias ():void {
+
+ $container = new Container;
+
+ $container->alias(
+ CollectionLogger::class,
+ CollectionLogger::class
+ );
+
+ $this->assertThrows(
+ CircularDependencyException::class,
+ static function () use ($container):void {
+
+ $container->all(CollectionLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### All detects multi-step alias cycle
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllDetectsMultiStepAliasCycle ():void {
+
+ $container = new Container;
+
+ $container->alias(
+ CollectionLogger::class,
+ CollectionFileLogger::class
+ );
+
+ $container->alias(
+ CollectionFileLogger::class,
+ CollectionConsoleLogger::class
+ );
+
+ $container->alias(
+ CollectionConsoleLogger::class,
+ CollectionLogger::class
+ );
+
+ $this->assertThrows(
+ CircularDependencyException::class,
+ static function () use ($container):void {
+
+ $container->all(CollectionLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### All matches instance definitions
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllMatchesInstanceDefinitions ():void {
+
+ $container = new Container;
+
+ $instance = new CollectionFileLogger;
+
+ $container->instance(
+ CollectionFileLogger::class,
+ $instance
+ );
+
+ self::assertSame(
+ $instance,
+ $container->all(CollectionLogger::class)[0]
+ );
+
+ }
+
+ /**
+ * ### All reflects registrations after earlier lookup
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllReflectsRegistrationsAfterEarlierLookup ():void {
+
+ $container = new Container;
+
+ self::assertSame(
+ [],
+ $container->all(CollectionLogger::class)
+ );
+
+ $container->singleton(CollectionFileLogger::class);
+
+ self::assertCount(
+ 1,
+ $container->all(CollectionLogger::class)
+ );
+
+ }
+
+}
\ No newline at end of file
From a4b55a081a8a210f5c84d2126db080ce67ac0d56 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Wed, 21 Oct 2026 15:47:15 +0200
Subject: [PATCH 4/5] Update changelog for version 0.2.0 and README for version
badge
---
CHANGELOG.md | 19 ++++++++++++++++++-
README.md | 2 +-
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f7a7a4..40b0c6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [[v0.2.0](https://github.com/The-FireHub-Project/Container/compare/v0.1.0...v0.2.0)] – 2026-10-21
+
+### Added
+* Contextual bindings by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2
+* Union i intersection dependency resolution by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2
+* Tagged services by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2
+* Tagged constructor injection by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2
+* Collection injection by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2
+* Circular dependency detection by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2
+* Alias deduplication by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/2
+
## [v0.1.0] – 2026-10-21
### Added
-* Implement initial functionality by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1
\ No newline at end of file
+* Service registration i resolution by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1
+* Singleton, scoped, transient lifetimes by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1
+* Constructor autowiring by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1
+* Parameter binding by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1
+* Service aliases i factories by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1
+* Scope management by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1
+* Container freezing by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/1
\ No newline at end of file
diff --git a/README.md b/README.md
index 5be8a96..cdd9560 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
/>
From d6b999bd1bd10583b13feb09de27edad4033c2d0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
<56673130+dieselxxx@users.noreply.github.com>
Date: Wed, 21 Oct 2026 15:48:32 +0200
Subject: [PATCH 5/5] Update README to reflect current repository status
Removed outdated badges and updated repository purpose section.
---
README.md | 86 +++++++++++++++++++++++++++++++++----------------------
1 file changed, 52 insertions(+), 34 deletions(-)
diff --git a/README.md b/README.md
index cdd9560..a6976a8 100644
--- a/README.md
+++ b/README.md
@@ -22,10 +22,6 @@
/>
-[](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPStan.yml)
-[](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPUnit.yml)
-[](https://app.codecov.io/gh/The-FireHub-Project/Container/tree/develop)
-
Development Branch
+## Repository Purpose
+
+FireHub Container is the dependency injection and service resolution component of the FireHub ecosystem.
+
+Built on the contracts provided by FireHub Core, it supplies the mechanisms required to register, construct, resolve, and manage application services.
+
+The purpose of this repository is to centralize dependency management while preserving separation of concerns between service definitions, runtime implementation, and application execution.
-⚠️ **This is the `develop` branch**
+The Container architecture is designed to support:
-- Unstable
-- APIs may change without notice
-- Not intended for production use
+- Service registration and dependency resolution
+- Interface-to-implementation bindings
+- Instance, factory, and class-based service definitions
+- Singleton and transient service lifetimes
+- Dependency graph resolution and circular dependency detection
+- Immutable service registrations after container construction
+- Future support for autowiring and execution-scoped services
-## Branch Purpose
+FireHub Container follows the architectural principles of The FireHub Project by separating responsibilities between contracts, dependency management, and application lifecycle orchestration.
-The `develop` branch is the **primary integration branch** for all ongoing development.
+The Container does not control application execution or kernel lifecycle transitions. Instead, it provides dependency management services that can be integrated into applications through the appropriate bootstrap and composition mechanisms.
-It serves as the staging area where:
-- Feature branches are merged
-- Bug fixes are integrated
-- Experimental work is stabilized
-- Code is prepared for upcoming releases
+## Security
-All **release branches** are created **from `develop`**.
+For security reporting and guidelines, please refer to our [SECURITY.md](https://github.com/The-FireHub-Project/.github/blob/master/.github/SECURITY.md) file.
-## Stability Guarantee
+## Community
-❌ No backward compatibility guarantee
-❌ APIs may change without notice
-❌ Behavior may be incomplete or inconsistent
-❌ Breaking changes are expected
+
+
+
-This branch is intended **only for contributors and advanced testers**.
+- Be sure to review the [Code of Conduct](https://github.com/The-FireHub-Project/.github/blob/master/.github/CODE_OF_CONDUCT.md).
+- Read a [Contributing](https://github.com/The-FireHub-Project/.github/blob/master/.github/CONTRIBUTING.md) file to see how you can contribute to this project.
+- Read our [Security policy](https://github.com/The-FireHub-Project/.github/blob/master/.github/SECURITY.md) file to see how you can report a vulnerability.
+- Review the project [Governance model](https://github.com/The-FireHub-Project/.github/blob/master/.github/GOVERNANCE.md), including roles, decision-making, RFC process, and releases.
-## Composer Usage (Not Recommended)
+## Support
+
+
+
+
+
+
+
+
+
-For internal testing only:
+For help with FireHub products, please refer to the following resources:
-```json
-{
- "require": {
- "the-firehub-project/container": "dev-develop"
- }
-}
-```
-⚠️ Never use dev-develop in production.
+- **General questions & discussions**: [GitHub Discussions](https://github.com/orgs/The-FireHub-Project/discussions)
+- **Bug reports & issues**: [Support](https://github.com/The-FireHub-Project/Container/issues)
+- **Documentation & guides**: [FireHub Docs](https://the-firehub-project.github.io)
+- **Commercial support** (Pro/Enterprise): contact your account manager
## Authors and Contributors
@@ -134,4 +152,4 @@ Architecture guidelines, design principles, and ecosystem documentation are avai
This software is licensed under the Apache-2.0 License.
-For more details, read the full license [here](./LICENSE).
\ No newline at end of file
+For more details, read the full license [here](./LICENSE).