diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40b0c6c..ba319e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,28 @@ 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.3.0](https://github.com/The-FireHub-Project/Container/compare/v0.2.0...v0.3.0)] – 2026-10-22
+
+### Added
+* Disposable service lifecycle management by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3
+* Automatic disposal of singleton and scoped services by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3
+* Container and scope shutdown support by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3
+* Disposable resource tracking with identity deduplication and reverse disposal order by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3
+* Active scope tracking to prevent premature container shutdown by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3
+* Shared container lifecycle state management by @dieselxxx in https://github.com/The-FireHub-Project/Container/pull/3
+
+### Changed
+* Centralized container freezing and closure state management in `ContainerState`.
+* Enforced container lifecycle restrictions across dependency resolution operations.
+* Improved scope cleanup and resource release during shutdown.
+
+### Fixed
+* Prevented dependency resolution after container closure, including cached singleton instances.
+* Ensured container shutdown remains idempotent.
+* Ensured disposable resources are released even when individual disposal operations fail.
+* Prevented container shutdown while active scopes remain registered.
+
+
## [[v0.2.0](https://github.com/The-FireHub-Project/Container/compare/v0.1.0...v0.2.0)] – 2026-10-21
### Added
diff --git a/README.md b/README.md
index cdd9560..2e44657 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
/>
@@ -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)
-
Tagged service identifiers.
*/
public function taggedIds (string $tag):array {
+ $this->state->assertOpen();
+
return $this->tags->get($tag);
}
@@ -817,6 +877,7 @@ public function taggedIds (string $tag):array {
* @since 1.0.0
*
* @uses \FireHub\Container\Resolver::tagged() To resolve tagged services.
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution.
*
* @param non-empty-string $tag
* Service tag name.
@@ -831,11 +892,14 @@ public function taggedIds (string $tag):array {
* @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\ContainerClosedException If the container is closed.
*
* @return list Resolved service instances.
*/
public function tagged (string $tag):array {
+ $this->state->assertOpen();
+
return $this->resolver->tagged(
$tag,
new ResolutionContext
@@ -843,6 +907,62 @@ public function tagged (string $tag):array {
}
+ /**
+ * ### Invokes a callable using dependency injection
+ *
+ * Executes the specified callable by combining explicitly supplied arguments with dependencies automatically
+ * resolved from the container.
+ *
+ * The callable may be a closure, named function, instance method, static method, or invokable object.
+ *
+ * Scoped dependencies require invocation through an active resolution scope.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(LoggerInterface::class, FileLogger::class);
+ *
+ * $service = new UserService;
+ *
+ * $result = $container->invoke(
+ * [$service, 'create'],
+ * ['username' => 'Danijel']
+ * );
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Resolver::invoke() To execute the callable using dependency injection.
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution.
+ *
+ * @param callable $callback
+ * Callable to execute.
+ *
+ * @param array $arguments [optional]
+ * Explicit positional and named arguments.
+ *
+ *
+ * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid.
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the
+ * resolved dependency does not satisfy its declared type.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
+ *
+ * @return mixed Value returned by the invoked callable.
+ */
+ public function invoke (callable $callback, array $arguments = []):mixed {
+
+ $this->state->assertOpen();
+
+ return $this->resolver->invoke(
+ $callback,
+ $arguments
+ );
+
+ }
+
/**
* ### Resolves a registered service
*
@@ -864,6 +984,9 @@ public function tagged (string $tag):array {
*
* @since 1.0.0
*
+ * @uses \FireHub\Container\Resolver::get() To resolve the service.
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open.
+ *
* @template TService of object
*
* @param class-string $id
@@ -878,11 +1001,14 @@ public function tagged (string $tag):array {
* @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.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return TService Resolved service.
*/
public function get (string $id):object {
+ $this->state->assertOpen();
+
return $this->resolver->get($id);
}
@@ -908,18 +1034,119 @@ public function get (string $id):object {
*
* @since 1.0.0
*
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open.
+ * @uses \FireHub\Container\Internal\ServiceRegistry::has() To check service registration.
+ *
* @param class-string $id
* Service identifier.
*
*
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
+ *
* @return bool True if registered, false otherwise.
*/
public function has (string $id):bool {
+ $this->state->assertOpen();
+
return $this->registry->has($id);
}
+ /**
+ * ### Closes the dependency injection container
+ *
+ * Permanently closes the container and releases all owned singleton disposable
+ * service instances.
+ *
+ * The container cannot be closed while active scopes exist.
+ *
+ * Disposable services are released in reverse registration order.
+ *
+ * Singleton instances are cleared even if resource disposal fails.
+ *
+ * Closing an already closed container has no additional effect.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(DatabaseConnection::class);
+ *
+ * $connection = $container->get(DatabaseConnection::class);
+ *
+ * try {
+ *
+ * $connection->connect();
+ *
+ * } finally {
+ *
+ * $container->close();
+ *
+ * }
+ *
+ * $container->isClosed(); // true
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\DisposalTracker::dispose() To release owned singleton resources.
+ * @uses \FireHub\Container\Internal\DisposableRegistry::clear() To clear owned singleton instances.
+ * @uses \FireHub\Container\Internal\ScopeRegistry::hasActive() To check for active scopes.
+ * @uses \FireHub\Container\Internal\ContainerState::close() To mark the container as closed.
+ * @uses \FireHub\Container\Internal\ContainerState::isClosed() To check if the container is closed.
+ *
+ * @throws \FireHub\Container\Exception\ServiceDisposalException If one or more services fail during disposal.
+ * @throws \FireHub\Container\Exception\ContainerActiveScopesException If a scope is still active.
+ *
+ * @return void
+ */
+ public function close ():void {
+
+ if ($this->state->isClosed()) return;
+
+ if ($this->scopes->hasActive())
+ throw new ContainerActiveScopesException;
+
+ $this->state->close();
+
+ try {
+
+ $this->disposables->dispose();
+
+ } finally {
+
+ $this->singletons->clear();
+
+ }
+
+ }
+
+ /**
+ * ### Checks whether the container is closed
+ *
+ *
+ * $container = new Container;
+ *
+ * $container->isClosed(); // false
+ *
+ * $container->close();
+ *
+ * $container->isClosed(); // true
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\ContainerState::isClosed() To check if the container is closed.
+ *
+ * @return bool True if the container has been shut down.
+ */
+ public function isClosed ():bool {
+
+ return $this->state->isClosed();
+
+ }
+
/**
* ### Freezes the container
*
@@ -947,11 +1174,15 @@ public function has (string $id):bool {
*
* @since 1.0.0
*
+ * @uses \FireHub\Container\Internal\ContainerState::freeze() To mark the container as frozen.
+ *
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
+ *
* @return void
*/
public function freeze ():void {
- $this->frozen = true;
+ $this->state->freeze();
}
@@ -972,28 +1203,13 @@ public function freeze ():void {
*
* @since 1.0.0
*
+ * @uses \FireHub\Container\Internal\ContainerState::isFrozen() To check if the container is frozen.
+ *
* @return bool True if frozen, false otherwise.
*/
public function isFrozen ():bool {
- return $this->frozen;
-
- }
-
- /**
- * ### Ensures the container accepts registrations
- *
- * Prevents modification of service definitions after the container has been frozen.
- * @since 1.0.0
- *
- * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
- *
- * @return void
- */
- private function assertMutable ():void {
-
- if ($this->frozen)
- throw new ContainerFrozenException;
+ return $this->state->isFrozen();
}
diff --git a/src/Exception/AmbiguousDependencyException.php b/src/Exception/AmbiguousDependencyException.php
index 24a9093..95f83fa 100644
--- a/src/Exception/AmbiguousDependencyException.php
+++ b/src/Exception/AmbiguousDependencyException.php
@@ -24,6 +24,11 @@
*/
final class AmbiguousDependencyException extends RuntimeException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'Multiple services match the requested dependency.';
}
\ No newline at end of file
diff --git a/src/Exception/CircularDependencyException.php b/src/Exception/CircularDependencyException.php
index 183e044..406a556 100644
--- a/src/Exception/CircularDependencyException.php
+++ b/src/Exception/CircularDependencyException.php
@@ -21,6 +21,11 @@
*/
final class CircularDependencyException extends InvariantException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'Circular dependency detected';
}
\ No newline at end of file
diff --git a/src/Exception/ContainerActiveScopesException.php b/src/Exception/ContainerActiveScopesException.php
new file mode 100644
index 0000000..31a793f
--- /dev/null
+++ b/src/Exception/ContainerActiveScopesException.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.1
+ * @package Container
+ */
+
+namespace FireHub\Container\Exception;
+
+use FireHub\Core\Exception\Runtime\System\InvariantException;
+
+/**
+ * ### Thrown when a container is attempted to be modified while it is active
+ * @since 1.0.0
+ */
+final class ContainerActiveScopesException extends InvariantException {
+
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
+ protected const string DEFAULT_MESSAGE = 'The dependency injection container is currently active.';
+
+}
\ No newline at end of file
diff --git a/src/Exception/ContainerClosedException.php b/src/Exception/ContainerClosedException.php
new file mode 100644
index 0000000..0147dcc
--- /dev/null
+++ b/src/Exception/ContainerClosedException.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.1
+ * @package Container
+ */
+
+namespace FireHub\Container\Exception;
+
+use FireHub\Core\Exception\Runtime\System\InvariantException;
+
+/**
+ * ### Indicates that a container is closed
+ * @since 1.0.0
+ */
+final class ContainerClosedException extends InvariantException {
+
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
+ protected const string DEFAULT_MESSAGE = 'The container is closed';
+
+}
\ No newline at end of file
diff --git a/src/Exception/ContainerFrozenException.php b/src/Exception/ContainerFrozenException.php
index 703317e..5a840c1 100644
--- a/src/Exception/ContainerFrozenException.php
+++ b/src/Exception/ContainerFrozenException.php
@@ -21,6 +21,11 @@
*/
final class ContainerFrozenException extends InvariantException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'Cannot register services in a frozen container';
}
\ No newline at end of file
diff --git a/src/Exception/DisposalTrackerClosedException.php b/src/Exception/DisposalTrackerClosedException.php
new file mode 100644
index 0000000..c190945
--- /dev/null
+++ b/src/Exception/DisposalTrackerClosedException.php
@@ -0,0 +1,36 @@
+
+ * @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;
+
+/**
+ * ### Indicates that a disposal tracker is closed
+ *
+ * Thrown when a disposable instance is registered after the disposal process has started.
+ *
+ * A closed disposal tracker cannot accept additional instances, regardless of whether resource disposal is completed
+ * successfully or failed.
+ * @since 1.0.0
+ */
+final class DisposalTrackerClosedException extends InvariantException {
+
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
+ protected const string DEFAULT_MESSAGE = 'The disposal tracker is closed';
+
+}
\ No newline at end of file
diff --git a/src/Exception/DuplicateContextualBindingException.php b/src/Exception/DuplicateContextualBindingException.php
index 0309a6d..6ee477e 100644
--- a/src/Exception/DuplicateContextualBindingException.php
+++ b/src/Exception/DuplicateContextualBindingException.php
@@ -24,6 +24,11 @@
*/
final class DuplicateContextualBindingException extends InvariantException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'A contextual binding for the specified consumer and dependency is already registered';
}
\ No newline at end of file
diff --git a/src/Exception/DuplicateServiceException.php b/src/Exception/DuplicateServiceException.php
index 282a8eb..233cb78 100644
--- a/src/Exception/DuplicateServiceException.php
+++ b/src/Exception/DuplicateServiceException.php
@@ -24,6 +24,11 @@
*/
final class DuplicateServiceException extends InvariantException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'A service with the specified identifier is already registered';
}
\ No newline at end of file
diff --git a/src/Exception/InvalidMethodArgumentException.php b/src/Exception/InvalidMethodArgumentException.php
new file mode 100644
index 0000000..a77517b
--- /dev/null
+++ b/src/Exception/InvalidMethodArgumentException.php
@@ -0,0 +1,36 @@
+
+ * @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\InvalidArgumentException;
+
+/**
+ * ### Indicates invalid explicit method invocation arguments
+ *
+ * Thrown when explicitly supplied callable arguments cannot be mapped to the declared parameter signature.
+ *
+ * This includes unknown parameter names, invalid positional indexes, duplicate parameter assignments, and
+ * invalid variadic argument collections.
+ * @since 1.0.0
+ */
+final class InvalidMethodArgumentException extends InvalidArgumentException {
+
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
+ protected const string DEFAULT_MESSAGE = 'Invalid method argument(s)';
+
+}
\ No newline at end of file
diff --git a/src/Exception/InvalidTagNameException.php b/src/Exception/InvalidTagNameException.php
index e1431bd..9356488 100644
--- a/src/Exception/InvalidTagNameException.php
+++ b/src/Exception/InvalidTagNameException.php
@@ -24,7 +24,8 @@
final class InvalidTagNameException extends InvariantException {
/**
- * ### Default exception message
+ * @inheritDoc
+ *
* @since 1.0.0
*/
protected const string DEFAULT_MESSAGE = 'Service tag name must not be empty or contain only whitespace';
diff --git a/src/Exception/ScopeActiveException.php b/src/Exception/ScopeActiveException.php
index 9ed842c..2fa5997 100644
--- a/src/Exception/ScopeActiveException.php
+++ b/src/Exception/ScopeActiveException.php
@@ -24,6 +24,11 @@
*/
final class ScopeActiveException extends RuntimeException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'The dependency injection scope is currently active.';
}
\ No newline at end of file
diff --git a/src/Exception/ScopeClosedException.php b/src/Exception/ScopeClosedException.php
index 95482c7..1b82b24 100644
--- a/src/Exception/ScopeClosedException.php
+++ b/src/Exception/ScopeClosedException.php
@@ -24,6 +24,11 @@
*/
final class ScopeClosedException extends RuntimeException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'Cannot access a closed dependency injection scope.';
}
\ No newline at end of file
diff --git a/src/Exception/ScopeRequiredException.php b/src/Exception/ScopeRequiredException.php
index 083c50e..1c5e859 100644
--- a/src/Exception/ScopeRequiredException.php
+++ b/src/Exception/ScopeRequiredException.php
@@ -24,6 +24,11 @@
*/
final class ScopeRequiredException extends InvariantException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'Scoped service requires an active scope';
}
\ No newline at end of file
diff --git a/src/Exception/ServiceDisposalException.php b/src/Exception/ServiceDisposalException.php
new file mode 100644
index 0000000..deafdd3
--- /dev/null
+++ b/src/Exception/ServiceDisposalException.php
@@ -0,0 +1,36 @@
+
+ * @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\RuntimeException;
+
+/**
+ * ### Indicates failure during service resource disposal
+ *
+ * Thrown when one or more disposable service instances fail to release their resources.
+ *
+ * The exception preserves all encountered disposal failures while exposing the first failure as the previous
+ * exception.
+ * @since 1.0.0
+ */
+final class ServiceDisposalException extends RuntimeException {
+
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
+ protected const string DEFAULT_MESSAGE = 'Failed to dispose service';
+
+}
\ No newline at end of file
diff --git a/src/Exception/ServiceInstantiationException.php b/src/Exception/ServiceInstantiationException.php
index bd23fe3..5e0c98d 100644
--- a/src/Exception/ServiceInstantiationException.php
+++ b/src/Exception/ServiceInstantiationException.php
@@ -24,6 +24,11 @@
*/
final class ServiceInstantiationException extends RuntimeException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'Failed to instantiate service';
}
\ No newline at end of file
diff --git a/src/Exception/ServiceNotFoundException.php b/src/Exception/ServiceNotFoundException.php
index 3f9d4c4..c543c25 100644
--- a/src/Exception/ServiceNotFoundException.php
+++ b/src/Exception/ServiceNotFoundException.php
@@ -23,6 +23,11 @@
*/
final class ServiceNotFoundException extends InvariantException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'No service definition found for the specified identifier';
}
\ No newline at end of file
diff --git a/src/Exception/ServiceTypeMismatchException.php b/src/Exception/ServiceTypeMismatchException.php
index 1dbc31b..f1d3d8f 100644
--- a/src/Exception/ServiceTypeMismatchException.php
+++ b/src/Exception/ServiceTypeMismatchException.php
@@ -24,6 +24,11 @@
*/
final class ServiceTypeMismatchException extends InvariantException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'Resolved service is not compatible with its registered identifier';
}
\ No newline at end of file
diff --git a/src/Exception/UnsupportedDefinitionException.php b/src/Exception/UnsupportedDefinitionException.php
index c40dbfa..8fd9723 100644
--- a/src/Exception/UnsupportedDefinitionException.php
+++ b/src/Exception/UnsupportedDefinitionException.php
@@ -24,6 +24,11 @@
*/
final class UnsupportedDefinitionException extends InvariantException {
+ /**
+ * @inheritDoc
+ *
+ * @since 1.0.0
+ */
protected const string DEFAULT_MESSAGE = 'Unsupported service definition';
}
\ No newline at end of file
diff --git a/src/Internal/Autowirer.php b/src/Internal/Autowirer.php
index 6b8bf9b..d025509 100644
--- a/src/Internal/Autowirer.php
+++ b/src/Internal/Autowirer.php
@@ -14,17 +14,17 @@
namespace FireHub\Container\Internal;
use FireHub\Container\Exception\ServiceInstantiationException;
-use Closure, ReflectionIntersectionType, ReflectionUnionType;
/**
* ### Constructs services using compiled dependency plans
*
- * Executes cached dependency construction plans to instantiate services and resolve their constructor dependencies.
+ * Executes cached constructor dependency plans to instantiate services using the shared parameter resolver.
*
- * Static parameter analysis is performed by the dependency plan cache, while service availability, contextual
- * bindings, and candidate selection are evaluated dynamically within the active resolution context.
+ * Static constructor analysis is performed by the dependency plan cache, while parameter bindings, contextual
+ * dependencies, service availability, and candidate selection are evaluated dynamically during construction.
*
- * Explicit parameter bindings take precedence over contextual bindings and automatic dependency resolution.
+ * Constructor dependencies are resolved within the active resolution context, preserving service lifetimes and
+ * circular dependency detection.
* @since 1.0.0
*
* @internal
@@ -35,70 +35,54 @@
* ### Constructor
* @since 1.0.0
*
- * @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, \FireHub\Container\Internal\ResolutionContext):list $resolve_all
- * Context-aware service collection resolver.
- *
- * @param Closure(class-string):bool $has
- * Explicit service registration checker.
- *
- * @param \FireHub\Container\Internal\TypeMatcher $matcher
- * Dependency type matcher.
- *
- * @param \FireHub\Container\Internal\CandidateSelector $selector
- * Dependency candidate selector.
- *
* @param \FireHub\Container\Internal\DependencyPlanCache $plans
- * Shared dependency plan cache.
+ * Shared dependency plan cache containing compiled constructor dependency information.
*
- * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings
- * Shared parameter binding registry.
- *
- * @param \FireHub\Container\Internal\ContextualCandidateSelector $contextual_selector
- * Shared contextual dependency selector.
+ * @param \FireHub\Container\Internal\ParameterResolver $parameters
+ * Shared parameter resolver responsible for resolving constructor dependencies.
*
*
* @return void
*/
public function __construct (
- private Closure $resolve,
- private Closure $resolve_tagged,
- private Closure $resolve_all,
- private Closure $has,
- private TypeMatcher $matcher,
- private CandidateSelector $selector,
private DependencyPlanCache $plans,
- private ParameterBindingRegistry $bindings,
- private ContextualCandidateSelector $contextual_selector
+ private ParameterResolver $parameters
) {}
/**
* ### Constructs a service using its compiled dependency plan
*
- * Resolves constructor dependencies dynamically while reusing previously compiled parameter information.
+ * Retrieves the compiled constructor dependency plan and creates a new instance of the specified implementation
+ * class.
+ *
+ * Classes without constructors are instantiated directly. For classes with constructors, each compiled parameter
+ * is resolved through the shared parameter resolver using the active dependency resolution context.
+ *
+ * The implementation class must be instantiable. Abstract classes and interfaces cannot be constructed.
* @since 1.0.0
*
- * @uses \FireHub\Container\Internal\DependencyPlanCache::get() To retrieve the compiled construction plan.
- * @uses \FireHub\Container\Internal\ConstructorMetadataCache::get() To get constructor metadata.
+ * @uses \FireHub\Container\Internal\DependencyPlanCache::get() To retrieve the compiled constructor dependency
+ * plan.
+ * @uses \FireHub\Container\Internal\ParameterResolver::resolve() To resolve constructor parameter dependencies.
*
* @param class-string $class
- * Concrete service class.
+ * Concrete service implementation class to instantiate.
*
* @param \FireHub\Container\Internal\ResolutionContext $context
- * Active dependency resolution context.
+ * Active dependency resolution context used to resolve constructor dependencies.
*
*
- * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter is not optional and cannot
- * be resolved.
- * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service is not registered.
- * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the implementation class is not
+ * instantiable or a required constructor dependency cannot be resolved.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a required dependency is not registered.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are
+ * eligible.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If a resolved dependency does not satisfy
+ * its registered service type.
*
- * @return object Constructed service instance.
+ * @return object Newly constructed service instance.
*/
public function construct (string $class, ResolutionContext $context):object {
@@ -115,7 +99,7 @@ public function construct (string $class, ResolutionContext $context):object {
$arguments = [];
foreach ($plan->parameters as $parameter)
- $arguments[] = $this->resolveParameter(
+ $arguments[] = $this->parameters->resolve(
$class,
$parameter,
$context
@@ -125,222 +109,4 @@ public function construct (string $class, ResolutionContext $context):object {
}
- /**
- * ### Resolves a compiled constructor parameter
- *
- * Resolves constructor arguments according to the following precedence:
- *
- * 1. Explicit parameter binding.
- * 2. Contextual dependency binding.
- * 3. Registered or implicitly constructible dependency.
- * 4. Constructor default value.
- *
- * Contextual bindings apply only to named dependency types and the exact consumer implementation class.
- * @since 1.0.0
- *
- * @uses \FireHub\Container\Internal\ParameterBindingRegistry::has() To check explicit parameter bindings.
- * @uses \FireHub\Container\Internal\ParameterBindingRegistry::get() To retrieve explicitly bound values.
- * @uses \FireHub\Container\Internal\ContextualBindingRegistry::find() To find contextual dependency bindings.
- * @uses \FireHub\Container\Internal\Autowirer::selectDependency() To select a dependency identifier.
- * @uses \FireHub\Container\Internal\Autowirer::resolveDependency() To resolve selected dependencies.
- * @uses \FireHub\Container\Internal\Autowirer::unresolvedParameter() To create resolution exceptions.
- *
- * @param class-string $class
- * Concrete service class.
- *
- * @param \FireHub\Container\Internal\ParameterPlan $plan
- * Compiled constructor parameter plan.
- *
- * @param \FireHub\Container\Internal\ResolutionContext $context
- * Active resolution context.
- *
- *
- * @throws \FireHub\Container\Exception\ServiceInstantiationException If the dependency cannot be resolved.
- * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
- *
- * @return mixed Resolved argument.
- */
- private function resolveParameter (string $class, ParameterPlan $plan, ResolutionContext $context):mixed {
-
- $name = $plan->parameter->getName();
-
- if ($this->bindings->has($class, $name))
- return $this->bindings->get($class, $name);
-
- 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)
- return $this->resolveDependency($id, $plan, $context, true);
-
- $id = $this->selectDependency($plan);
-
- if ($id !== null && (
- ($this->has)($id)
- || $this->matcher->isConstructible($id)
- )
- ) return $this->resolveDependency($id, $plan, $context);
-
- if ($plan->hasDefault)
- return $plan->default;
-
- throw $this->unresolvedParameter($plan);
-
- }
-
- /**
- * ### Selects a dependency identifier
- *
- * Determines the service identifier required by a constructor parameter.
- *
- * Named dependency types use their precompiled identifiers. Union and intersection dependency types are
- * evaluated dynamically against the currently registered service implementations.
- * @since 1.0.0
- *
- * @uses \FireHub\Container\Internal\CandidateSelector::union() To select a union dependency.
- * @uses \FireHub\Container\Internal\CandidateSelector::intersection() To select an intersection dependency.
- *
- * @param \FireHub\Container\Internal\ParameterPlan $plan
- * Compiled constructor parameter plan.
- *
- *
- * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
- * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
- * @throws \FireHub\Container\Exception\ServiceInstantiationException If the union contains an unsupported
- * intersection member.
- *
- * @return null|class-string Selected dependency identifier.
- */
- private function selectDependency (ParameterPlan $plan):?string {
-
- return match (true) {
- $plan->composite instanceof ReflectionUnionType =>
- $this->selector->union(
- $plan->parameter,
- $plan->composite
- ),
- $plan->composite instanceof ReflectionIntersectionType =>
- $this->selector->intersection(
- $plan->parameter,
- $plan->composite
- ),
- default => $plan->id
- };
-
- }
-
- /**
- * ### Resolves a selected dependency
- *
- * Resolves the selected service identifier within the active resolution context.
- *
- * For union and intersection dependency types, validates that the resolved instance satisfies the complete
- * declared parameter type.
- * @since 1.0.0
- *
- * @uses \FireHub\Container\Resolver::resolve() To resolve the selected dependency.
- * @uses \FireHub\Container\Internal\CandidateSelector::matchesType() To validate composite dependency types.
- *
- * @param class-string $id
- * Selected dependency identifier.
- *
- * @param \FireHub\Container\Internal\ParameterPlan $plan
- * Compiled constructor parameter plan.
- *
- * @param \FireHub\Container\Internal\ResolutionContext $context
- * Active resolution context.
- *
- * @param bool $contextual [optional]
- * Whether the dependency was selected through contextual binding.
- *
- *
- * @throws \FireHub\Container\Exception\ServiceInstantiationException If the resolved dependency does not satisfy
- * its declared composite type.
- *
- * @return object Resolved dependency.
- */
- private function resolveDependency (string $id, ParameterPlan $plan, ResolutionContext $context, bool $contextual = false):object {
-
- $instance = ($this->resolve)($id, $context);
-
- if (
- $plan->composite !== null
- && !$this->selector->matchesType(
- $instance::class,
- $plan->parameter,
- $plan->composite
- )
- ) throw new ServiceInstantiationException(
- context: [
- 'class' => $plan->parameter->getDeclaringClass()?->getName(),
- 'parameter' => $plan->parameter->getName(),
- 'type' => (string)$plan->composite,
- 'instance' => $instance::class,
- ]
- );
-
- if (
- $contextual
- && $plan->composite === null
- && $plan->id !== null
- ) {
-
- $expected = $plan->id;
-
- if (!$instance instanceof $expected)
- throw new ServiceInstantiationException(
- context: [
- 'class' => $plan->parameter->getDeclaringClass()?->getName(),
- 'parameter' => $plan->parameter->getName(),
- 'type' => $expected,
- 'implementation' => $id,
- 'instance' => $instance::class,
- ]
- );
-
- }
-
- return $instance;
-
- }
-
- /**
- * ### Creates an unresolved parameter exception
- *
- * Creates an exception containing diagnostic information about a constructor parameter that could not be
- * resolved through explicit bindings, contextual bindings, registered services, implicit construction, or
- * default values.
- * @since 1.0.0
- *
- * @param \FireHub\Container\Internal\ParameterPlan $plan
- * Compiled constructor parameter plan.
- *
- *
- * @return \FireHub\Container\Exception\ServiceInstantiationException Parameter resolution exception.
- */
- private function unresolvedParameter (ParameterPlan $plan):ServiceInstantiationException {
-
- $parameter = $plan->parameter;
-
- $type = $parameter->getType();
-
- return new ServiceInstantiationException(
- context: [
- 'class' => $parameter->getDeclaringClass()?->getName(),
- 'parameter' => $parameter->getName(),
- 'position' => $parameter->getPosition(),
- 'type' => $type?->__toString(),
- 'nullable' => $type?->allowsNull(),
- 'optional' => $parameter->isOptional(),
- 'has_default' => $plan->hasDefault
- ]
- );
-
- }
-
}
\ No newline at end of file
diff --git a/src/Internal/ContainerState.php b/src/Internal/ContainerState.php
new file mode 100644
index 0000000..02ff9d7
--- /dev/null
+++ b/src/Internal/ContainerState.php
@@ -0,0 +1,158 @@
+
+ * @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
+ */
+
+namespace FireHub\Container\Internal;
+
+use FireHub\Container\Exception\ {
+ ContainerClosedException, ContainerFrozenException
+};
+
+/**
+ * ### Manages dependency injection container lifecycle state
+ *
+ * Maintains the shared lifecycle state of a dependency injection container.
+ *
+ * The container supports three logical states:
+ * - Open: Registration and resolution are permitted.
+ * - Frozen: Registration is prohibited, but resolution remains permitted.
+ * - Closed: Registration and resolution are permanently prohibited.
+ *
+ * Freezing is irreversible and does not affect existing service instances.
+ *
+ * Closing permanently terminates the container lifecycle regardless of whether the container was previously frozen.
+ *
+ * The state is shared between the container and its resolver to enforce consistent lifecycle restrictions.
+ * @since 1.0.0
+ *
+ * @internal
+ */
+final class ContainerState {
+
+ /**
+ * ### Container freeze state
+ * @since 1.0.0
+ */
+ private bool $frozen = false;
+
+ /**
+ * ### Container closure state
+ * @since 1.0.0
+ */
+ private bool $closed = false;
+
+ /**
+ * ### Ensures the container is open
+ *
+ * Prevents operations requiring an active container after closure.
+ * @since 1.0.0
+ *
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
+ *
+ * @return void
+ */
+ public function assertOpen ():void {
+
+ if ($this->closed)
+ throw new ContainerClosedException;
+
+ }
+
+ /**
+ * ### Ensures the container accepts configuration changes
+ *
+ * Prevents service registration and configuration changes when the container is frozen or closed.
+ *
+ * Closure takes precedence over freezing when both conditions apply.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen()
+ *
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
+ * @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
+ *
+ * @return void
+ */
+ public function assertMutable ():void {
+
+ $this->assertOpen();
+
+ if ($this->frozen)
+ throw new ContainerFrozenException;
+
+ }
+
+ /**
+ * ### Freezes the container configuration
+ *
+ * Permanently prevents further registration and configuration changes.
+ *
+ * Existing services remain available for resolution.
+ *
+ * Repeated calls have no additional effect.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open.
+ *
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
+ *
+ * @return void
+ */
+ public function freeze ():void {
+
+ $this->assertOpen();
+
+ $this->frozen = true;
+
+ }
+
+ /**
+ * ### Checks whether the container is frozen
+ * @since 1.0.0
+ *
+ * @return bool True if the container configuration has been frozen.
+ */
+ public function isFrozen ():bool {
+
+ return $this->frozen;
+
+ }
+
+ /**
+ * ### Closes the container lifecycle
+ *
+ * Permanently marks the container as closed.
+ *
+ * Repeated calls have no additional effect.
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function close ():void {
+
+ $this->closed = true;
+
+ }
+
+ /**
+ * ### Checks whether the container is closed
+ * @since 1.0.0
+ *
+ * @return bool True if the container has been closed, false otherwise.
+ */
+ public function isClosed ():bool {
+
+ return $this->closed;
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/Internal/DependencyPlanCache.php b/src/Internal/DependencyPlanCache.php
index 962b6d0..9f2a022 100644
--- a/src/Internal/DependencyPlanCache.php
+++ b/src/Internal/DependencyPlanCache.php
@@ -7,26 +7,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
+ * @php-version >=8.1
* @package Container
*/
namespace FireHub\Container\Internal;
-use FireHub\Container\Attribute\ {
- InjectAll, Tagged
-};
-use FireHub\Container\Exception\ServiceInstantiationException;
-use FireHub\Runtime;
-use ReflectionNamedType, ReflectionParameter, ReflectionUnionType, ReflectionIntersectionType;
-
/**
- * ### Compiles and caches dependency construction plans
+ * ### Compiles and caches constructor dependency plans
+ *
+ * Analyzes constructor parameters once per implementation class and caches the resulting dependency plans.
*
- * Analyzes constructor parameters once per implementation class and stores the resulting dependency plans.
+ * Parameter compilation is delegated to the shared parameter plan compiler, allowing constructor and method
+ * injection to use identical parameter metadata and validation rules.
*
- * Compiled plans contain only static constructor information. Registered service availability, union and
- * intersection candidate selection, and dependency instantiation remain dynamic.
+ * Registered service availability, contextual bindings, and dependency candidate selection remain dynamic.
* @since 1.0.0
*
* @internal
@@ -48,35 +43,38 @@ final class DependencyPlanCache {
* @param \FireHub\Container\Internal\ConstructorMetadataCache $metadata
* Shared constructor metadata cache.
*
- * @param \FireHub\Container\Internal\TypeMatcher $matcher
- * Dependency type matcher.
+ * @param \FireHub\Container\Internal\ParameterPlanCompiler $compiler
+ * Shared parameter plan compiler responsible for analyzing dependency parameters.
*
*
* @return void
*/
- public function __construct(
+ public function __construct (
private readonly ConstructorMetadataCache $metadata,
- private readonly TypeMatcher $matcher
+ private readonly ParameterPlanCompiler $compiler
) {}
/**
- * ### Gets a compiled dependency plan
+ * ### Gets a compiled constructor dependency plan
+ *
+ * Returns a previously compiled dependency plan or creates one by analyzing the constructor parameters of the
+ * specified implementation class.
*
- * Returns the cached dependency plan or compiles one from constructor reflection metadata.
+ * Variadic constructor parameters are excluded from dependency injection, preserving existing constructor
+ * autowiring behavior.
* @since 1.0.0
*
- * @uses \FireHub\Container\Internal\DependencyPlanCache::compileParameter() To compile constructor parameter
- * metadata.
* @uses \FireHub\Container\Internal\ConstructorMetadataCache::get() To retrieve constructor metadata.
+ * @uses \FireHub\Container\Internal\ParameterPlanCompiler::compile() To compile constructor parameter metadata.
*
* @param class-string $class
- * Service implementation class.
+ * Service implementation class whose constructor dependency plan should be retrieved.
*
*
- * @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 a constructor parameter contains invalid
+ * injection attributes or unsupported dependency metadata.
*
- * @return \FireHub\Container\Internal\DependencyPlan Compiled dependency plan.
+ * @return \FireHub\Container\Internal\DependencyPlan Compiled constructor dependency plan.
*/
public function get (string $class):DependencyPlan {
@@ -92,7 +90,7 @@ public function get (string $class):DependencyPlan {
if ($parameter->isVariadic())
continue;
- $parameters[] = $this->compileParameter($parameter);
+ $parameters[] = $this->compiler->compile($parameter);
}
@@ -103,207 +101,4 @@ 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.
- * @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.
- */
- 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();
-
- $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,
- $composite,
- $has_default,
- $has_default
- ? $parameter->getDefaultValue()
- : null,
- $tag,
- $collection
- );
-
- }
-
- /**
- * ### 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;
-
- }
-
- /**
- * ### 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/DisposalTracker.php b/src/Internal/DisposalTracker.php
new file mode 100644
index 0000000..e7cf52e
--- /dev/null
+++ b/src/Internal/DisposalTracker.php
@@ -0,0 +1,147 @@
+
+ * @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
+ */
+
+namespace FireHub\Container\Internal;
+
+use FireHub\Core\Boundary\Lifecycle\Disposable;
+use FireHub\Container\Exception\ {
+ DisposalTrackerClosedException, ServiceDisposalException
+};
+use FireHub\Runtime;
+use Throwable;
+
+/**
+ * ### Tracks and disposes of owned resources
+ *
+ * Maintains a registry of disposable service instances owned by a dependency injection lifecycle.
+ *
+ * Each instance is registered only once using object identity. Resources are disposed of in reverse registration
+ * order, allowing dependent services to release their resources before the services they depend on.
+ *
+ * Disposal attempts to continue even when individual instances fail to release their resources. The first encountered
+ * exception is propagated after all registered instances have been processed.
+ *
+ * The tracker does not determine service ownership or lifetime. These responsibilities belong to the container or
+ * scope managing the tracker.
+ *
+ * Once disposal begins, the tracker cannot accept additional instances.
+ * @since 1.0.0
+ *
+ * @internal
+ */
+final class DisposalTracker {
+
+ /**
+ * ### Registered disposable instances
+ * @since 1.0.0
+ *
+ * @var list<\FireHub\Core\Boundary\Lifecycle\Disposable>
+ */
+ private array $instances = [];
+
+ /**
+ * ### Registered instance identities
+ * @since 1.0.0
+ *
+ * @var array
+ */
+ private array $identities = [];
+
+ /**
+ * ### Whether disposal has started
+ * @since 1.0.0
+ */
+ private bool $disposed = false;
+
+ /**
+ * ### Registers a disposable instance
+ *
+ * Adds an instance to the disposal registry unless the same object has already been registered.
+ *
+ * Registration is rejected after disposal has started.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Runtime\ObjectModel\Identity::id() To get the object identity.
+ *
+ * @param \FireHub\Core\Boundary\Lifecycle\Disposable $instance
+ * Disposable instance to register.
+ *
+ *
+ * @throws \FireHub\Container\Exception\DisposalTrackerClosedException If disposal has already started.
+ *
+ * @return void
+ */
+ public function track (Disposable $instance):void {
+
+ if ($this->disposed)
+ throw new DisposalTrackerClosedException;
+
+ $id = Runtime\ObjectModel\Identity::id($instance);
+
+ if (isset($this->identities[$id])) return;
+
+ $this->identities[$id] = true;
+
+ $this->instances[] = $instance;
+
+ }
+
+ /**
+ * ### Disposes of all tracked instances
+ *
+ * Releases registered resources in reverse registration order.
+ *
+ * Every instance is attempted even when disposal of another instance fails.
+ *
+ * Repeated calls have no effect.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Runtime\Arr\Inspection::count() To get the number of instances.
+ *
+ * @uses \FireHub\Core\Boundary\Lifecycle\Disposable::dispose() To release instance resources.
+ *
+ * @throws \FireHub\Container\Exception\ServiceDisposalException If an instance fails to release its resources.
+ *
+ * @return void
+ */
+ public function dispose ():void {
+
+ if ($this->disposed) return;
+
+ $this->disposed = true;
+
+ $failure = null;
+
+ for ($index = Runtime\Arr\Inspection::count($this->instances) - 1; $index >= 0; $index--) {
+
+ try {
+
+ $this->instances[$index]->dispose(); // @phpstan-ignore offsetAccess.notFound
+
+ } catch (Throwable $exception) {
+
+ $failure ??= $exception;
+
+ }
+
+ }
+
+ $this->instances = [];
+ $this->identities = [];
+
+ if ($failure !== null)
+ throw new ServiceDisposalException(previous: $failure);
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/Internal/MethodInvoker.php b/src/Internal/MethodInvoker.php
new file mode 100644
index 0000000..c88ff17
--- /dev/null
+++ b/src/Internal/MethodInvoker.php
@@ -0,0 +1,487 @@
+
+ * @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\Internal;
+
+use FireHub\Container\Exception\InvalidMethodArgumentException;
+use FireHub\Runtime;
+use Closure, ReflectionFunction, ReflectionMethod, ReflectionParameter;
+
+/**
+ * ### Invokes callables using dependency injection
+ *
+ * Executes closures, named functions, instance methods, static methods, and invokable objects by combining
+ * explicitly supplied arguments with dependencies resolved through the shared parameter resolver.
+ *
+ * Positional arguments are associated with their declared parameter positions, while named arguments are
+ * associated with their corresponding parameter names.
+ *
+ * Explicit arguments take precedence over dependency injection. Missing arguments are resolved within the active
+ * resolution context, preserving service lifetimes and circular dependency detection.
+ *
+ * Constructor parameter bindings are excluded from method invocation.
+ * @since 1.0.0
+ *
+ * @internal
+ */
+final readonly class MethodInvoker {
+
+ /**
+ * ### Constructor
+ * @since 1.0.0
+ *
+ * @param \FireHub\Container\Internal\MethodPlanCache $plans
+ * Shared cache containing compiled callable invocation plans.
+ *
+ * @param \FireHub\Container\Internal\ParameterResolver $parameters
+ * Shared parameter resolver responsible for resolving missing callable arguments.
+ *
+ *
+ * @return void
+ */
+ public function __construct (
+ private MethodPlanCache $plans,
+ private ParameterResolver $parameters
+ ) {}
+
+ /**
+ * ### Invokes a callable using dependency injection
+ *
+ * Retrieves the compiled invocation plan, normalizes explicit arguments, resolves missing dependencies, and
+ * executes the callable.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\MethodInvoker::plan() To retrieve the callable invocation plan.
+ * @uses \FireHub\Container\Internal\MethodInvoker::normalizeArguments() To normalize explicit arguments.
+ * @uses \FireHub\Container\Internal\MethodInvoker::resolveArguments() To resolve callable arguments.
+ *
+ * @param callable $callback
+ * Callable to execute using dependency injection.
+ *
+ * @param array $arguments
+ * Explicit positional and named arguments.
+ *
+ * @param \FireHub\Container\Internal\ResolutionContext $context
+ * Active dependency resolution context.
+ *
+ *
+ * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid.
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the
+ * resolved dependency does not satisfy its declared type.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible.
+ *
+ * @return mixed Value returned by the callable.
+ */
+ public function invoke (callable $callback, array $arguments, ResolutionContext $context):mixed {
+
+ $plan = $this->plan($callback);
+
+ [$positional, $named] = $this->normalizeArguments($plan, $arguments);
+
+ return $callback(
+ ...$this->resolveArguments($plan, $positional, $named, $context)
+ );
+
+ }
+
+ /**
+ * ### Resolves callable arguments
+ *
+ * Combines explicit positional and named arguments with automatically resolved dependencies.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\MethodInvoker::resolveParameter() To resolve individual parameters.
+ * @uses \FireHub\Container\Internal\MethodInvoker::resolveVariadic() To resolve variadic parameters.
+ *
+ * @param \FireHub\Container\Internal\MethodPlan $plan
+ * Compiled callable invocation plan.
+ *
+ * @param array $positional
+ * Explicit positional arguments.
+ *
+ * @param array $named
+ * Explicit named arguments.
+ *
+ * @param \FireHub\Container\Internal\ResolutionContext $context
+ * Active dependency resolution context.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the
+ * resolved dependency does not satisfy its declared type.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible.
+ *
+ * @return list Resolved callable arguments.
+ */
+ private function resolveArguments (MethodPlan $plan, array $positional, array $named, ResolutionContext $context):array {
+
+ $resolved = [];
+
+ foreach ($plan->parameters as $parameter) {
+
+ if ($parameter->parameter->isVariadic()) {
+
+ foreach ($this->resolveVariadic($parameter, $positional, $named) as $value)
+ $resolved[] = $value;
+
+ continue;
+
+ }
+
+ $resolved[] = $this->resolveParameter(
+ $plan,
+ $parameter,
+ $positional,
+ $named,
+ $context
+ );
+
+ }
+
+ return $resolved;
+
+ }
+
+ /**
+ * ### Resolves an individual callable parameter
+ *
+ * Returns an explicitly supplied named or positional argument when available. Otherwise, resolves the parameter
+ * through the shared dependency resolver.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\ParameterResolver::resolve() To resolve missing dependencies.
+ * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence.
+ *
+ * @param \FireHub\Container\Internal\MethodPlan $plan
+ * Compiled callable invocation plan.
+ *
+ * @param \FireHub\Container\Internal\ParameterPlan $parameter
+ * Compiled parameter metadata.
+ *
+ * @param array $positional
+ * Explicit positional arguments.
+ *
+ * @param array $named
+ * Explicit named arguments.
+ *
+ * @param \FireHub\Container\Internal\ResolutionContext $context
+ * Active dependency resolution context.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the
+ * resolved dependency does not satisfy its declared type.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible.
+ *
+ * @return mixed Resolved parameter value.
+ */
+ private function resolveParameter (MethodPlan $plan, ParameterPlan $parameter, array $positional, array $named, ResolutionContext $context):mixed {
+
+ $reflection = $parameter->parameter;
+
+ $name = $reflection->getName();
+ $position = $reflection->getPosition();
+
+ if (Runtime\Arr\Access::keyExists($named, $name))
+ return $named[$name]; // @phpstan-ignore offsetAccess.notFound
+
+ if (Runtime\Arr\Access::keyExists($positional, $position))
+ return $positional[$position]; // @phpstan-ignore offsetAccess.notFound
+
+ return $this->parameters->resolve(
+ $plan->consumer,
+ $parameter,
+ $context,
+ use_bindings: false
+ );
+
+ }
+
+ /**
+ * ### Resolves variadic callable arguments
+ *
+ * Retrieves variadic values from an explicitly supplied named list or from remaining positional arguments.
+ *
+ * Variadic parameters are not automatically resolved through dependency injection.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence.
+ *
+ * @param \FireHub\Container\Internal\ParameterPlan $parameter
+ * Compiled variadic parameter metadata.
+ *
+ * @param array $positional
+ * Explicit positional arguments.
+ *
+ * @param array $named
+ * Explicit named arguments.
+ *
+ *
+ * @return list Explicit variadic argument values.
+ */
+ private function resolveVariadic (ParameterPlan $parameter, array $positional, array $named):array {
+
+ $reflection = $parameter->parameter;
+
+ $name = $reflection->getName();
+
+ if (Runtime\Arr\Access::keyExists($named, $name)) {
+
+ /** @var list $values */
+ $values = $named[$name]; // @phpstan-ignore offsetAccess.notFound
+
+ return $values;
+
+ }
+
+ $values = [];
+
+ for (
+ $index = $reflection->getPosition();
+ Runtime\Arr\Access::keyExists($positional, $index);
+ $index++
+ ) $values[] = $positional[$index]; // @phpstan-ignore offsetAccess.notFound
+
+ return $values;
+
+ }
+
+ /**
+ * ### Normalizes explicit callable arguments
+ *
+ * Separates positional and named arguments and delegates signature validation to the argument validator.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\MethodInvoker::validateArguments() To validate argument assignments.
+ * @uses \FireHub\Runtime\DataIs::int() To check if the argument is an integer.
+ *
+ * @param \FireHub\Container\Internal\MethodPlan $plan
+ * Compiled callable invocation plan.
+ *
+ * @param array $arguments
+ * Explicit positional and named arguments.
+ *
+ *
+ * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If arguments are invalid.
+ *
+ * @return array{
+ * 0: array,
+ * 1: array
+ * } Normalized argument collections.
+ */
+ private function normalizeArguments (MethodPlan $plan, array $arguments):array {
+
+ $positional = [];
+ $named = [];
+
+ $next = 0;
+ $named_started = false;
+
+ foreach ($arguments as $key => $value) {
+
+ if (Runtime\DataIs::int($key)) {
+
+ if ($named_started || $key !== $next)
+ throw new InvalidMethodArgumentException(
+ 'Positional arguments must be consecutive and precede named arguments.'
+ );
+
+ $positional[$key] = $value;
+
+ $next++;
+
+ continue;
+
+ }
+
+ $named_started = true;
+
+ $named[$key] = $value;
+
+ }
+
+ $this->validateArguments($plan, $positional, $named);
+
+ return [$positional, $named];
+
+ }
+
+ /**
+ * ### Validates explicit callable arguments
+ *
+ * Ensures positional arguments do not exceed the callable signature and validates each named argument.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\MethodInvoker::validateNamedArgument() To validate named arguments.
+ * @uses \FireHub\Runtime\Arr\Inspection::count() To check if the callable has variadic parameters.
+ *
+ * @param \FireHub\Container\Internal\MethodPlan $plan
+ * Compiled callable invocation plan.
+ *
+ * @param array $positional
+ * Explicit positional arguments.
+ *
+ * @param array $named
+ * Explicit named arguments.
+ *
+ *
+ * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If argument assignments are invalid.
+ *
+ * @return void
+ */
+ private function validateArguments (MethodPlan $plan, array $positional, array $named):void {
+
+ $names = [];
+ $variadic = null;
+
+ foreach ($plan->parameters as $parameter) {
+
+ $reflection = $parameter->parameter;
+
+ $names[$reflection->getName()] = $reflection->getPosition();
+
+ if ($reflection->isVariadic())
+ $variadic = $reflection;
+
+ }
+
+ if (
+ $variadic === null
+ && Runtime\Arr\Inspection::count($positional) > Runtime\Arr\Inspection::count($plan->parameters)
+ ) throw new InvalidMethodArgumentException(
+ 'Too many positional arguments were supplied.'
+ );
+
+ foreach ($named as $name => $value)
+ $this->validateNamedArgument(
+ $name,
+ $value,
+ $names,
+ $positional,
+ $variadic
+ );
+
+ }
+
+ /**
+ * ### Validates an explicitly named argument
+ *
+ * Checks that the argument references an existing parameter, has not yet been supplied positionally, and
+ * satisfies variadic argument requirements.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Runtime\Arr\Access::keyExists() To check for array key existence.
+ * @uses \FireHub\Runtime\Arr\Inspection::count() To check if the callable has variadic parameters.
+ * @uses \FireHub\Runtime\Arr\Inspection::isList() To check if a value is a list.
+ * @uses \FireHub\Runtime\DataIs::array() To check if the value is an array.
+ *
+ * @param string $name
+ * Explicit argument name.
+ *
+ * @param mixed $value
+ * Explicit argument value.
+ *
+ * @param array $names
+ * Declared parameter names mapped to their positions.
+ *
+ * @param array $positional
+ * Explicit positional arguments.
+ *
+ * @param null|\ReflectionParameter $variadic
+ * Variadic parameter metadata, or null when the callable has no variadic parameter.
+ *
+ *
+ * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If the named argument is invalid.
+ *
+ * @return void
+ */
+ private function validateNamedArgument (string $name, mixed $value, array $names, array $positional, ?ReflectionParameter $variadic):void {
+
+ if (!Runtime\Arr\Access::keyExists($names, $name))
+ throw new InvalidMethodArgumentException(
+ "Unknown callable argument: {$name}."
+ );
+
+ $position = $names[$name]; // @phpstan-ignore offsetAccess.notFound
+
+ if (Runtime\Arr\Access::keyExists($positional, $position))
+ throw new InvalidMethodArgumentException(
+ "Callable argument '{$name}' was supplied more than once."
+ );
+
+ if ($variadic === null || $name !== $variadic->getName())
+ return;
+
+ if (!Runtime\DataIs::array($value) || !Runtime\Arr\Inspection::isList($value))
+ throw new InvalidMethodArgumentException(
+ "Named variadic argument '{$name}' must be a list."
+ );
+
+ if (Runtime\Arr\Inspection::count($positional) > $position)
+ throw new InvalidMethodArgumentException(
+ "Variadic argument '{$name}' cannot be supplied both positionally and by name."
+ );
+
+ }
+
+ /**
+ * ### Retrieves a callable invocation plan
+ *
+ * Determines the callable category and retrieves its compiled invocation plan.
+ *
+ * Method callables retain their declaring class for contextual dependency resolution. Standalone functions and
+ * closures do not have a consumer class.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\MethodPlanCache::method() To retrieve compiled method plans.
+ * @uses \FireHub\Container\Internal\MethodPlanCache::function() To retrieve compiled function plans.
+ * @uses \FireHub\Runtime\Str\SB\Search::contains() To check for class method syntax.
+ * @uses \FireHub\Runtime\DataIs::array() To check if the callable is an array.
+ * @uses \FireHub\Runtime\DataIs::object() To check if the callable is an object.
+ * @uses \FireHub\Runtime\DataIs::string() To check if the callable is a string.
+ *
+ * @param callable $callback
+ * Callable whose invocation metadata should be retrieved.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the callable cannot be compiled.
+ *
+ * @return \FireHub\Container\Internal\MethodPlan Compiled callable invocation plan.
+ */
+ private function plan (callable $callback):MethodPlan {
+
+ if (Runtime\DataIs::array($callback))
+ return $this->plans->method(
+ new ReflectionMethod($callback[0], $callback[1])
+ );
+
+ if (Runtime\DataIs::string($callback) && Runtime\Str\SB\Search::contains('::', $callback))
+ return $this->plans->method(
+ new ReflectionMethod($callback)
+ );
+
+ if (Runtime\DataIs::object($callback) && !$callback instanceof Closure)
+ return $this->plans->method(
+ new ReflectionMethod($callback, '__invoke')
+ );
+
+ if (Runtime\DataIs::string($callback))
+ return $this->plans->function(
+ new ReflectionFunction($callback)
+ );
+
+ return $this->plans->function(
+ new ReflectionFunction(Closure::fromCallable($callback))
+ );
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/Internal/MethodPlan.php b/src/Internal/MethodPlan.php
new file mode 100644
index 0000000..94032f6
--- /dev/null
+++ b/src/Internal/MethodPlan.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 >=8.2
+ * @package Container
+ */
+
+namespace FireHub\Container\Internal;
+
+use ReflectionFunctionAbstract;
+
+/**
+ * ### Represents a compiled callable invocation plan
+ *
+ * Stores the reflection metadata and compiled dependency parameters required to invoke a method or closure.
+ *
+ * The plan contains only static callable information. Explicit arguments, contextual bindings, dependency
+ * availability, and service resolution are evaluated dynamically during invocation.
+ * @since 1.0.0
+ *
+ * @internal
+ */
+final readonly class MethodPlan {
+
+ /**
+ * ### Constructor
+ * @since 1.0.0
+ *
+ * @param \ReflectionFunctionAbstract $reflection
+ * Reflection metadata describing the callable associated with this invocation plan.
+ *
+ * @param list<\FireHub\Container\Internal\ParameterPlan> $parameters
+ * Compiled dependency parameter plans in their original declaration order.
+ *
+ * @param null|class-string $consumer
+ * Declaring class used for contextual dependency resolution, or null for standalone functions and closures.
+ *
+ *
+ * @return void
+ */
+ public function __construct (
+ public ReflectionFunctionAbstract $reflection,
+ public array $parameters,
+ public ?string $consumer
+ ) {}
+
+}
\ No newline at end of file
diff --git a/src/Internal/MethodPlanCache.php b/src/Internal/MethodPlanCache.php
new file mode 100644
index 0000000..f82550c
--- /dev/null
+++ b/src/Internal/MethodPlanCache.php
@@ -0,0 +1,160 @@
+
+ * @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\Internal;
+
+use ReflectionFunction, ReflectionFunctionAbstract, ReflectionMethod;
+
+/**
+ * ### Compiles and caches callable invocation plans
+ *
+ * Analyzes callable reflection metadata and compiles dependency parameter plans using the shared parameter
+ * compiler.
+ *
+ * Method plans are cached by their declaring class and method name. Named function caches by function name, while
+ * closures are compiled independently to avoid retaining closure instances.
+ *
+ * Registered service availability, contextual bindings, and dependency candidate selection remain dynamic.
+ * @since 1.0.0
+ *
+ * @internal
+ */
+final class MethodPlanCache {
+
+ /**
+ * ### Cached method invocation plans
+ * @since 1.0.0
+ *
+ * @var array
+ */
+ private array $plans = [];
+
+ /**
+ * ### Constructor
+ * @since 1.0.0
+ *
+ * @param \FireHub\Container\Internal\ParameterPlanCompiler $compiler
+ * Shared parameter plan compiler used to analyze callable parameters.
+ *
+ *
+ * @return void
+ */
+ public function __construct (
+ private readonly ParameterPlanCompiler $compiler
+ ) {}
+
+ /**
+ * ### Gets a compiled method invocation plan
+ *
+ * Retrieves a cached invocation plan for the specified method or compiles one when no cached plan exists.
+ *
+ * The cache key uses the declaring class and canonical method name, allowing inherited methods to reuse their
+ * compiled metadata.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\MethodPlanCache::compile() To compile callable reflection metadata.
+ *
+ * @param \ReflectionMethod $method
+ * Reflected instance or static method to analyze.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If a parameter contains unsupported
+ * injection metadata.
+ *
+ * @return \FireHub\Container\Internal\MethodPlan Compiled method invocation plan.
+ */
+ public function method (ReflectionMethod $method):MethodPlan {
+
+ $key = $method->getDeclaringClass()->getName()
+ . '::'
+ . $method->getName();
+
+ return $this->plans[$key] ??= $this->compile(
+ $method,
+ $method->getDeclaringClass()->getName()
+ );
+
+ }
+
+ /**
+ * ### Gets a compiled function invocation plan
+ *
+ * Retrieves a cached plan for a named function or compiles a new plan for a closure.
+ *
+ * Closure invocation plans are not stored in the shared cache.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\MethodPlanCache::compile() To compile callable reflection metadata.
+ *
+ * @param \ReflectionFunction $function
+ * Reflected named function or closure to analyze.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If a parameter contains unsupported
+ * injection metadata.
+ *
+ * @return \FireHub\Container\Internal\MethodPlan Compiled function invocation plan.
+ */
+ public function function (ReflectionFunction $function):MethodPlan {
+
+ if ($function->isClosure())
+ return $this->compile($function, null);
+
+ $key = 'function:' . $function->getName();
+
+ return $this->plans[$key] ??= $this->compile(
+ $function,
+ null
+ );
+
+ }
+
+ /**
+ * ### Compiles callable invocation metadata
+ *
+ * Analyzes callable parameters and creates an immutable invocation plan.
+ *
+ * All parameters are retained in their declaration order, including variadic parameters. Variadic argument
+ * expansion is handled by the method invoker rather than the parameter compiler.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\ParameterPlanCompiler::compile() To compile callable parameter metadata.
+ *
+ * @param \ReflectionFunctionAbstract $reflection
+ * Reflected callable whose parameters should be compiled.
+ *
+ * @param null|class-string $consumer
+ * Declaring class used for contextual dependency resolution, or null for standalone functions and closures.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If a parameter contains unsupported
+ * injection metadata.
+ *
+ * @return \FireHub\Container\Internal\MethodPlan Compiled callable invocation plan.
+ */
+ private function compile (ReflectionFunctionAbstract $reflection, ?string $consumer):MethodPlan {
+
+ $parameters = [];
+
+ foreach ($reflection->getParameters() as $parameter)
+ $parameters[] = $this->compiler->compile($parameter);
+
+ return new MethodPlan(
+ $reflection,
+ $parameters,
+ $consumer
+ );
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/Internal/ParameterPlanCompiler.php b/src/Internal/ParameterPlanCompiler.php
new file mode 100644
index 0000000..381f242
--- /dev/null
+++ b/src/Internal/ParameterPlanCompiler.php
@@ -0,0 +1,257 @@
+
+ * @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.0
+ * @package Container
+ */
+
+namespace FireHub\Container\Internal;
+
+use FireHub\Container\Attribute\ {
+ InjectAll, Tagged
+};
+use FireHub\Container\Exception\ServiceInstantiationException;
+use FireHub\Runtime;
+use ReflectionNamedType, ReflectionParameter, ReflectionUnionType, ReflectionIntersectionType;
+
+/**
+ * ### Compiles dependency parameter metadata
+ *
+ * Analyzes reflected parameters and produces immutable parameter plans used by constructor and method injection.
+ *
+ * Compiled plans contain statically determined dependency identifiers, composite dependency types, default values,
+ * tagged service injection metadata, and collection injection metadata.
+ *
+ * Service availability, contextual bindings, and dependency candidate selection are evaluated dynamically during
+ * parameter resolution rather than compilation.
+ * @since 1.0.0
+ *
+ * @internal
+ */
+final readonly class ParameterPlanCompiler {
+
+ /**
+ * ### Constructor
+ * @since 1.0.0
+ *
+ * @param \FireHub\Container\Internal\TypeMatcher $matcher
+ * Shared dependency type matcher used to resolve named dependency identifiers.
+ *
+ *
+ * @return void
+ */
+ public function __construct (
+ private TypeMatcher $matcher
+ ) {}
+
+ /**
+ * ### Compiles dependency parameter metadata
+ *
+ * Extracts the declared parameter type, default value, tagged injection metadata, and collection injection
+ * metadata into an immutable parameter plan.
+ *
+ * Named dependency types are converted into service identifiers. Union and intersection types retain their
+ * reflection metadata for dynamic dependency candidate selection.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\TypeMatcher::classId() To resolve named dependency identifiers.
+ * @uses \FireHub\Container\Internal\ParameterPlanCompiler::resolveTag() To resolve tagged injection metadata.
+ * @uses \FireHub\Container\Internal\ParameterPlanCompiler::resolveCollection() To resolve collection injection
+ * metadata.
+ *
+ * @param \ReflectionParameter $parameter
+ * Reflected constructor or method parameter to compile.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If tagged and collection injection
+ * attributes are combined or an injection attribute is invalid.
+ *
+ * @return \FireHub\Container\Internal\ParameterPlan Compiled dependency parameter plan.
+ */
+ public function compile (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();
+
+ $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,
+ $composite,
+ $has_default,
+ $has_default ? $parameter->getDefaultValue() : null,
+ $tag,
+ $collection
+ );
+
+ }
+
+ /**
+ * ### Resolves tagged injection metadata
+ *
+ * Extracts and validates the tagged injection attribute associated with a reflected parameter.
+ *
+ * Tagged injection requires a non-nullable array parameter and a non-empty tag name. Multiple tagged injection
+ * attributes on the same parameter are not supported.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Runtime\Arr\Inspection::count() To count declared tagged injection attributes.
+ * @uses \FireHub\Runtime\Str\SB\Transform::trim() To validate the tag name.
+ *
+ * @param \ReflectionParameter $parameter
+ * Reflected parameter to inspect for tagged injection metadata.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple tagged injection attributes
+ * are declared, the parameter type is unsupported, or the tag name is empty.
+ *
+ * @return null|non-empty-string Tag name, or null when tagged injection is not declared.
+ */
+ 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;
+
+ }
+
+ /**
+ * ### Resolves collection injection metadata
+ *
+ * Extracts and validates the collection injection attribute associated with a reflected parameter.
+ *
+ * Collection injection requires a non-nullable array parameter and an existing class or interface as its element
+ * type. Multiple collection injection attributes on the same parameter are not supported.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Runtime\Arr\Inspection::count() To count declared collection injection attributes.
+ * @uses \FireHub\Runtime\ObjectModel\Inspection::isClass() To check whether the element type is a class.
+ * @uses \FireHub\Runtime\ObjectModel\Inspection::isInterface() To check whether the element type is an interface.
+ *
+ * @param \ReflectionParameter $parameter
+ * Reflected parameter to inspect for collection injection metadata.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If multiple collection injection
+ * attributes are declared, the parameter type is unsupported, or the requested element type does not exist.
+ *
+ * @return null|class-string Requested collection element type, or null when collection injection is not declared.
+ */
+ 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/ParameterResolver.php b/src/Internal/ParameterResolver.php
new file mode 100644
index 0000000..003369e
--- /dev/null
+++ b/src/Internal/ParameterResolver.php
@@ -0,0 +1,312 @@
+=8.0
+ * @package Container
+ */
+
+namespace FireHub\Container\Internal;
+
+use FireHub\Container\Exception\ServiceInstantiationException;
+use Closure, ReflectionIntersectionType, ReflectionUnionType;
+
+/**
+ * ### Resolves compiled dependency parameters
+ *
+ * Resolves parameter dependencies using explicit bindings, contextual bindings, tagged services, collection
+ * injection, and automatic dependency selection.
+ *
+ * The resolver operates within an existing dependency resolution context and does not manage service lifetimes
+ * directly.
+ * @since 1.0.0
+ *
+ * @internal
+ */
+final readonly class ParameterResolver {
+
+ /**
+ * ### Constructor
+ * @since 1.0.0
+ *
+ * @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, \FireHub\Container\Internal\ResolutionContext):list $resolve_all
+ * Context-aware service collection resolver.
+ *
+ * @param Closure(class-string):bool $has
+ * Explicit service registration checker.
+ *
+ * @param \FireHub\Container\Internal\TypeMatcher $matcher
+ * Dependency type matcher.
+ *
+ * @param \FireHub\Container\Internal\CandidateSelector $selector
+ * Dependency candidate selector.
+ *
+ * @param \FireHub\Container\Internal\ParameterBindingRegistry $bindings
+ * Shared parameter binding registry.
+ *
+ * @param \FireHub\Container\Internal\ContextualCandidateSelector $contextual_selector
+ * Contextual dependency candidate selector.
+ *
+ *
+ * @return void
+ */
+ public function __construct (
+ private Closure $resolve,
+ private Closure $resolve_tagged,
+ private Closure $resolve_all,
+ private Closure $has,
+ private TypeMatcher $matcher,
+ private CandidateSelector $selector,
+ private ParameterBindingRegistry $bindings,
+ private ContextualCandidateSelector $contextual_selector
+ ) {}
+
+ /**
+ * ### Resolves a compiled dependency parameter
+ *
+ * Resolves a parameter using explicit values, configured parameter bindings, injection attributes, contextual
+ * bindings, and automatic dependency resolution.
+ *
+ * Explicit values take precedence over all other resolution mechanisms. Constructor parameter bindings can be
+ * disabled for method invocation while preserving contextual dependency resolution.
+ *
+ * Dependency resolution is performed within the supplied context, preserving service lifetimes and circular
+ * dependency detection.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\ParameterBindingRegistry::has() To check explicit parameter bindings.
+ * @uses \FireHub\Container\Internal\ParameterBindingRegistry::get() To retrieve explicitly bound values.
+ * @uses \FireHub\Container\Internal\ContextualCandidateSelector::select() To select contextual dependencies.
+ * @uses \FireHub\Container\Internal\ParameterResolver::selectDependency() To select dependency identifiers.
+ * @uses \FireHub\Container\Internal\ParameterResolver::resolveDependency() To resolve selected dependencies.
+ * @uses \FireHub\Container\Internal\ParameterResolver::unresolvedParameter() To create resolution exceptions.
+ *
+ * @param null|class-string $consumer
+ * Consumer implementation class used for contextual and parameter bindings, or null when no consumer is available.
+ *
+ * @param \FireHub\Container\Internal\ParameterPlan $plan
+ * Compiled dependency parameter plan.
+ *
+ * @param \FireHub\Container\Internal\ResolutionContext $context
+ * Active dependency resolution context.
+ *
+ * @param bool $has_explicit [optional]
+ * Whether an explicit argument value has been supplied for the parameter.
+ *
+ * @param mixed $explicit [optional]
+ * Explicit argument value to use when provided.
+ *
+ * @param bool $use_bindings [optional]
+ * Whether configured parameter bindings should be considered during resolution.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the
+ * resolved dependency does not satisfy its declared type.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible.
+ *
+ * @return mixed Resolved parameter value.
+ */
+ public function resolve (?string $consumer, ParameterPlan $plan, ResolutionContext $context, bool $has_explicit = false, mixed $explicit = null, bool $use_bindings = true):mixed {
+
+ if ($has_explicit) return $explicit;
+
+ $name = $plan->parameter->getName();
+
+ if (
+ $use_bindings
+ && $consumer !== null
+ && $this->bindings->has($consumer, $name)
+ ) return $this->bindings->get($consumer, $name);
+
+ if ($plan->tag !== null)
+ return ($this->resolve_tagged)($plan->tag, $context);
+
+ if ($plan->collection !== null)
+ return ($this->resolve_all)($plan->collection, $context);
+
+ if ($consumer !== null) {
+
+ $id = $this->contextual_selector->select($consumer, $plan);
+
+ if ($id !== null)
+ return $this->resolveDependency($id, $plan, $context, true);
+
+ }
+
+ $id = $this->selectDependency($plan);
+
+ if (
+ $id !== null
+ && (
+ ($this->has)($id)
+ || $this->matcher->isConstructible($id)
+ )
+ ) return $this->resolveDependency($id, $plan, $context);
+
+ if ($plan->hasDefault)
+ return $plan->default;
+
+ throw $this->unresolvedParameter($plan);
+
+ }
+
+ /**
+ * ### Selects an eligible dependency
+ *
+ * Determines the service identifier required by a compiled dependency parameter.
+ *
+ * Named dependency types use their precompiled identifiers. Union and intersection dependency types are evaluated
+ * dynamically against currently registered service implementations.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\CandidateSelector::union() To select a union dependency.
+ * @uses \FireHub\Container\Internal\CandidateSelector::intersection() To select an intersection dependency.
+ *
+ * @param \FireHub\Container\Internal\ParameterPlan $plan
+ * Compiled dependency parameter plan.
+ *
+ *
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible.
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the dependency type contains unsupported
+ * members.
+ *
+ * @return null|class-string Selected dependency identifier, or null if no eligible dependency exists.
+ */
+ private function selectDependency (ParameterPlan $plan):?string {
+
+ return match (true) {
+ $plan->composite instanceof ReflectionUnionType =>
+ $this->selector->union(
+ $plan->parameter,
+ $plan->composite
+ ),
+ $plan->composite instanceof ReflectionIntersectionType =>
+ $this->selector->intersection(
+ $plan->parameter,
+ $plan->composite
+ ),
+ default => $plan->id
+ };
+
+ }
+
+ /**
+ * ### Resolves and validates a selected dependency
+ *
+ * Resolves the selected service identifier within the active dependency resolution context.
+ *
+ * Union and intersection dependencies are validated against their complete declared parameter types.
+ * Contextually selected named dependencies are additionally validated against their expected contracts.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\CandidateSelector::matchesType() To validate composite dependency types.
+ *
+ * @param class-string $id
+ * Selected service identifier.
+ *
+ * @param \FireHub\Container\Internal\ParameterPlan $plan
+ * Compiled dependency parameter plan.
+ *
+ * @param \FireHub\Container\Internal\ResolutionContext $context
+ * Active dependency resolution context.
+ *
+ * @param bool $contextual [optional]
+ * Whether the dependency was selected through contextual binding.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the resolved dependency does not satisfy
+ * its declared parameter type.
+ *
+ * @return object Resolved dependency instance.
+ */
+ private function resolveDependency (string $id, ParameterPlan $plan, ResolutionContext $context, bool $contextual = false):object {
+
+ $instance = ($this->resolve)($id, $context);
+
+ if (
+ $plan->composite !== null
+ && !$this->selector->matchesType(
+ $instance::class,
+ $plan->parameter,
+ $plan->composite
+ )
+ ) throw new ServiceInstantiationException(
+ context: [
+ 'class' => $plan->parameter->getDeclaringClass()?->getName(),
+ 'parameter' => $plan->parameter->getName(),
+ 'type' => (string)$plan->composite,
+ 'instance' => $instance::class,
+ ]
+ );
+
+ if (
+ $contextual
+ && $plan->composite === null
+ && $plan->id !== null
+ ) {
+
+ $expected = $plan->id;
+
+ if (!$instance instanceof $expected)
+ throw new ServiceInstantiationException(
+ context: [
+ 'class' => $plan->parameter->getDeclaringClass()?->getName(),
+ 'parameter' => $plan->parameter->getName(),
+ 'type' => $expected,
+ 'implementation' => $id,
+ 'instance' => $instance::class,
+ ]
+ );
+
+ }
+
+ return $instance;
+
+ }
+
+ /**
+ * ### Creates an unresolved parameter exception
+ *
+ * Creates an exception containing diagnostic information about a dependency parameter that could not be resolved
+ * through explicit bindings, contextual bindings, registered services, implicit construction, or default values.
+ *
+ * The diagnostic context includes the declaring class, parameter name, position, declared type, nullability,
+ * optionality, and default value availability.
+ * @since 1.0.0
+ *
+ * @param \FireHub\Container\Internal\ParameterPlan $plan
+ * Compiled dependency parameter plan that could not be resolved.
+ *
+ *
+ * @return \FireHub\Container\Exception\ServiceInstantiationException Parameter resolution exception.
+ */
+ private function unresolvedParameter (ParameterPlan $plan):ServiceInstantiationException {
+
+ $parameter = $plan->parameter;
+ $type = $parameter->getType();
+
+ return new ServiceInstantiationException(
+ context: [
+ 'class' => $parameter->getDeclaringClass()?->getName(),
+ 'parameter' => $parameter->getName(),
+ 'position' => $parameter->getPosition(),
+ 'type' => $type?->__toString(),
+ 'nullable' => $type?->allowsNull(),
+ 'optional' => $parameter->isOptional(),
+ 'has_default' => $plan->hasDefault
+ ]
+ );
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/Internal/ResolutionContext.php b/src/Internal/ResolutionContext.php
index d71208b..2866a64 100644
--- a/src/Internal/ResolutionContext.php
+++ b/src/Internal/ResolutionContext.php
@@ -41,13 +41,17 @@
* @param null|\FireHub\Container\Internal\ScopeState $scope
* Associated scope lifecycle state.
*
+ * @param null|\FireHub\Container\Internal\DisposalTracker $disposables
+ * Scoped disposable resource tracker.
+ *
*
* @return void
*/
public function __construct (
public ResolutionStack $stack = new ResolutionStack,
public ?InstanceStore $scoped = null,
- public ?ScopeState $scope = null
+ public ?ScopeState $scope = null,
+ public ?DisposalTracker $disposables = null
) {}
/**
@@ -61,7 +65,7 @@ public function __construct (
*/
public function root ():self {
- if ($this->scoped === null)
+ if ($this->scoped === null && $this->disposables === null)
return $this;
return new self(
diff --git a/src/Internal/ScopeRegistry.php b/src/Internal/ScopeRegistry.php
new file mode 100644
index 0000000..5a5ea15
--- /dev/null
+++ b/src/Internal/ScopeRegistry.php
@@ -0,0 +1,111 @@
+
+ * @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
+ */
+
+namespace FireHub\Container\Internal;
+
+use FireHub\Container\Scope;
+use SplObjectStorage;
+
+/**
+ * ### Tracks active dependency injection scopes
+ *
+ * Maintains a registry of scopes belonging to a dependency injection container.
+ *
+ * Scopes are registered when created and removed when closed.
+ *
+ * The registry maintains strong references to registered scopes, ensuring that
+ * active scopes remain tracked until they are explicitly closed.
+ *
+ * The registry does not control the scope lifecycle or dispose of scoped services.
+ * @since 1.0.0
+ */
+final class ScopeRegistry {
+
+ /**
+ * ### Registered scopes
+ * @since 1.0.0
+ *
+ * @var SplObjectStorage<\FireHub\Container\Scope, null>
+ */
+ private SplObjectStorage $scopes;
+
+ /**
+ * ### Constructor
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function __construct () {
+
+ $this->scopes = new SplObjectStorage;
+
+ }
+
+ /**
+ * ### Registers a scope
+ * @since 1.0.0
+ *
+ * @param \FireHub\Container\Scope $scope
+ * Scope to register.
+ *
+ *
+ * @return void
+ */
+ public function register (Scope $scope):void {
+
+ $this->scopes->offsetSet($scope);
+
+ }
+
+ /**
+ * ### Removes a scope from the registry
+ *
+ * @since 1.0.0
+ *
+ * @param \FireHub\Container\Scope $scope
+ * Scope to remove.
+ *
+ *
+ * @return void
+ */
+ public function unregister (Scope $scope):void {
+
+ $this->scopes->offsetUnset($scope);
+
+ }
+
+ /**
+ * ### Counts registered scopes
+ * @since 1.0.0
+ *
+ * @return int Number of registered scopes.
+ */
+ public function count ():int {
+
+ return $this->scopes->count();
+
+ }
+
+ /**
+ * ### Checks whether active scopes exist
+ * @since 1.0.0
+ *
+ * @return bool True if at least one scope is registered.
+ */
+ public function hasActive ():bool {
+
+ return $this->scopes->count() > 0;
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/Resolver.php b/src/Resolver.php
index 7d2a1f8..3214784 100644
--- a/src/Resolver.php
+++ b/src/Resolver.php
@@ -14,14 +14,16 @@
namespace FireHub\Container;
use FireHub\Core\Boundary\Resolver as ResolverBoundary;
+use FireHub\Core\Boundary\Lifecycle\Disposable;
use FireHub\Core\Meta\Enum\Container\Lifetime;
use FireHub\Container\Definition\ {
Alias, Cls, Factory, Instance, Service
};
use FireHub\Container\Internal\ {
- Autowirer, CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector,
- ContextualResolver, DependencyPlanCache, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeState,
- TagRegistry, TypeMatcher
+ Autowirer, CandidateSelector, ConstructorMetadataCache, ContainerState, ContextualBindingRegistry,
+ ContextualCandidateSelector, ContextualResolver, DependencyPlanCache, DisposalTracker, InstanceStore,
+ MethodInvoker, MethodPlanCache, ParameterBindingRegistry, ParameterPlanCompiler, ParameterResolver,
+ ResolutionContext, ScopeState, TagRegistry, TypeMatcher
};
use FireHub\Container\Exception\ {
CircularDependencyException, ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException,
@@ -68,12 +70,23 @@
*/
private Autowirer $autowirer;
+ /**
+ * ### Reusable callable invoker
+ *
+ * Executes callable invocation plans using the shared dependency resolution infrastructure.
+ * @since 1.0.0
+ */
+ private MethodInvoker $invoker;
+
/**
* ### Constructor
* @since 1.0.0
*
* @uses \FireHub\Container\Resolver::has() To check for service existence.
* @uses \FireHub\Container\Resolver::get() To retrieve a service definition.
+ * @uses \FireHub\Container\Resolver::resolve() To resolve a service.
+ * @uses \FireHub\Container\Resolver::tagged() To retrieve tagged service definitions.
+ * @uses \FireHub\Container\Resolver::all() To retrieve all service definitions.
* @uses \FireHub\Container\Registry::implementations() To get all registered implementations.
*
* @param \FireHub\Container\Registry $registry
@@ -91,6 +104,12 @@
* @param \FireHub\Container\Internal\TagRegistry $tags
* Service tag registry.
*
+ * @param \FireHub\Container\Internal\DisposalTracker $disposables
+ * Disposable service tracker.
+ *
+ * @param \FireHub\Container\Internal\ContainerState $state
+ * Container state.
+ *
*
* @return void
*/
@@ -99,7 +118,9 @@ public function __construct (
private InstanceStore $singletons,
ParameterBindingRegistry $parameters,
ContextualBindingRegistry $contextual,
- private TagRegistry $tags
+ private TagRegistry $tags,
+ private DisposalTracker $disposables,
+ private ContainerState $state
) {
$this->metadata = new ConstructorMetadataCache;
@@ -114,23 +135,36 @@ public function __construct (
fn ():iterable => $this->registry->implementations()
);
+ $compiler = new ParameterPlanCompiler(
+ $this->matcher
+ );
+
$plans = new DependencyPlanCache(
$this->metadata,
- $this->matcher
+ $compiler
);
- $this->autowirer = new Autowirer(
+ $parameters_resolver = new ParameterResolver(
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),
$this->matcher,
$selector,
- $plans,
$parameters,
new ContextualCandidateSelector($contextual)
);
+ $this->autowirer = new Autowirer(
+ $plans,
+ $parameters_resolver
+ );
+
+ $this->invoker = new MethodInvoker(
+ new MethodPlanCache($compiler),
+ $parameters_resolver
+ );
+
}
/**
@@ -139,16 +173,21 @@ public function __construct (
* Checks whether a service identifier exists without constructing or resolving its instance.
* @since 1.0.0
*
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open.
* @uses \FireHub\Container\Registry::has() To check for service existence.
*
* @param class-string $id
* Service identifier.
*
*
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
+ *
* @return bool True if the service is registered.
*/
public function has (string $id):bool {
+ $this->state->assertOpen();
+
return $this->registry->has($id);
}
@@ -161,6 +200,7 @@ public function has (string $id):bool {
* Aliases are followed until their underlying service definition is reached.
* @since 1.0.0
*
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open.
* @uses \FireHub\Container\Resolver::resolve() To resolve a service.
* @uses \FireHub\Container\Internal\InstanceStore::get() To retrieve a previously resolved instance.
*
@@ -178,11 +218,14 @@ public function has (string $id):bool {
* @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.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return TService Resolved service instance.
*/
public function get (string $id):object {
+ $this->state->assertOpen();
+
$instance = $this->singletons->get($id);
if ($instance !== null)
@@ -215,6 +258,9 @@ public function get (string $id):object {
* @param \FireHub\Container\Internal\ScopeState $scope
* Scope lifecycle state.
*
+ * @param \FireHub\Container\Internal\DisposalTracker $disposables
+ * Disposable service tracker.
+ * >
*
* @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.
@@ -223,10 +269,11 @@ public function get (string $id):object {
* @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.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return TService Resolved service.
*/
- public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope):object {
+ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables):object {
$scope->enter();
@@ -236,7 +283,8 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope)
$id,
new ResolutionContext(
scoped: $scoped,
- scope: $scope
+ scope: $scope,
+ disposables: $disposables
)
);
@@ -259,6 +307,7 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope)
* Service aliases are resolved to their underlying targets and do not produce duplicate collection entries.
* @since 1.0.0
*
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open.
* @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.
@@ -279,11 +328,14 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope)
* @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\ContainerClosedException If the container is closed.
*
* @return list Matching service instances.
*/
public function all (string $type, ResolutionContext $context):array {
+ $this->state->assertOpen();
+
$services = []; $resolved = [];
foreach ($this->registry->all() as $id => $definition) {
@@ -336,6 +388,9 @@ public function all (string $type, ResolutionContext $context):array {
* @param \FireHub\Container\Internal\ScopeState $scope
* Scope state.
*
+ * @param \FireHub\Container\Internal\DisposalTracker $disposables
+ * Disposable service tracker.
+ * >
*
* @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
* @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
@@ -345,10 +400,11 @@ public function all (string $type, ResolutionContext $context):array {
* @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.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return list Matching service instances.
*/
- public function allScoped (string $type, InstanceStore $scoped, ScopeState $scope):array {
+ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables):array {
$scope->enter();
@@ -358,7 +414,8 @@ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scop
$type,
new ResolutionContext(
scoped: $scoped,
- scope: $scope
+ scope: $scope,
+ disposables: $disposables
)
);
@@ -381,6 +438,7 @@ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scop
* An unknown tag produces an empty collection.
* @since 1.0.0
*
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open.
* @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers.
* @uses \FireHub\Container\Resolver::resolve() To resolve each tagged service.
*
@@ -400,11 +458,14 @@ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scop
* @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\ContainerClosedException If the container is closed.
*
* @return list Resolved service instances.
*/
public function tagged (string $tag, ResolutionContext $context):array {
+ $this->state->assertOpen();
+
$services = [];
foreach ($this->tags->get($tag) as $id)
@@ -436,6 +497,9 @@ public function tagged (string $tag, ResolutionContext $context):array {
* @param \FireHub\Container\Internal\ScopeState $scope
* Scope lifecycle state.
*
+ * @param \FireHub\Container\Internal\DisposalTracker $disposables
+ * Disposable service tracker.
+ * >
*
* @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
* @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered.
@@ -447,10 +511,11 @@ public function tagged (string $tag, ResolutionContext $context):array {
* @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\ContainerClosedException If the container is closed.
*
* @return list Resolved service instances.
*/
- public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $scope):array {
+ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables):array {
$scope->enter();
@@ -460,7 +525,108 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc
$tag,
new ResolutionContext(
scoped: $scoped,
- scope: $scope
+ scope: $scope,
+ disposables: $disposables
+ )
+ );
+
+ } finally {
+
+ $scope->leave();
+
+ }
+
+ }
+
+ /**
+ * ### Invokes a callable using dependency injection
+ *
+ * Executes the specified callable using explicit arguments and automatically resolved dependencies.
+ *
+ * Service dependencies are resolved using a root resolution context. Scoped services cannot be injected unless
+ * invocation is performed through an active scope.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open.
+ * @uses \FireHub\Container\Internal\MethodInvoker::invoke() To execute the callable.
+ *
+ * @param callable $callback
+ * Callable to execute using dependency injection.
+ *
+ * @param array $arguments [optional]
+ * Explicit positional and named arguments.
+ *
+ *
+ * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid.
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the
+ * resolved dependency does not satisfy its declared type.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
+ *
+ * @return mixed Value returned by the invoked callable.
+ */
+ public function invoke (callable $callback, array $arguments = []):mixed {
+
+ $this->state->assertOpen();
+
+ return $this->invoker->invoke(
+ $callback,
+ $arguments,
+ new ResolutionContext
+ );
+
+ }
+
+ /**
+ * ### Invokes a callable within a resolution scope
+ *
+ * Executes the specified callable using the provided scoped instance store and scope lifecycle state.
+ *
+ * Scoped dependencies are resolved and reused within the supplied scope. The scope remains open after invocation.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\ScopeState::enter() To enter the active scope.
+ * @uses \FireHub\Container\Internal\MethodInvoker::invoke() To execute the callable.
+ * @uses \FireHub\Container\Internal\ScopeState::leave() To leave the active scope.
+ *
+ * @param callable $callback
+ * Callable to execute using scoped dependency injection.
+ *
+ * @param array $arguments
+ * Explicit positional and named arguments.
+ *
+ * @param \FireHub\Container\Internal\InstanceStore $scoped
+ * Scoped service instance storage.
+ *
+ * @param \FireHub\Container\Internal\ScopeState $scope
+ * Active scope lifecycle state.
+ *
+ * @param \FireHub\Container\Internal\DisposalTracker $disposables
+ * Disposable service tracker.
+ *
+ *
+ * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid.
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the
+ * resolved dependency does not satisfy its declared type.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible.
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ *
+ * @return mixed Value returned by the invoked callable.
+ */
+ public function invokeScoped (callable $callback, array $arguments, InstanceStore $scoped, ScopeState $scope, DisposalTracker $disposables)
+ :mixed {
+
+ $scope->enter();
+
+ try {
+
+ return $this->invoker->invoke(
+ $callback,
+ $arguments,
+ new ResolutionContext(
+ scoped: $scoped,
+ scope: $scope,
+ disposables: $disposables
)
);
@@ -476,6 +642,7 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc
* ### Resolves a service within the specified context
* @since 1.0.0
*
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open.
* @uses \FireHub\Container\Registry::find() To retrieve a service definition.
* @uses \FireHub\Container\Internal\ResolutionStack::enter() To mark the service as being currently being resolved.
* @uses \FireHub\Container\Internal\ResolutionStack::leave() To remove the service from the resolution stack.
@@ -498,11 +665,14 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc
* @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\ContainerClosedException If the container is closed.
*
* @return TService Resolved service.
*/
private function resolve (string $id, ResolutionContext $context):object {
+ $this->state->assertOpen();
+
$definition = $this->registry->find($id);
if ($definition === null)
@@ -551,6 +721,7 @@ private function resolve (string $id, ResolutionContext $context):object {
* @uses \FireHub\Container\Internal\ResolutionStack::enter() To mark the service as being currently being resolved.
* @uses \FireHub\Container\Internal\ResolutionStack::leave() To remove the service from the resolution stack.
* @uses \FireHub\Container\Resolver::create() To construct a service instance.
+ * @uses \FireHub\Container\Internal\DisposalTracker::track() To track a disposable service.
*
* @template TService of object
*
@@ -581,7 +752,6 @@ private function resolveService (string $id, Service $definition, ResolutionCont
Lifetime::SCOPED => $context->scoped
?? throw new ScopeRequiredException,
Lifetime::TRANSIENT => null
-
};
if ($store !== null) {
@@ -603,6 +773,19 @@ private function resolveService (string $id, Service $definition, ResolutionCont
if (!$instance instanceof $id)
throw new ServiceTypeMismatchException;
+ if (
+ $instance instanceof Disposable
+ && !$definition instanceof Instance
+ ) {
+
+ match ($lifetime) {
+ Lifetime::SINGLETON => $this->disposables->track($instance),
+ Lifetime::SCOPED => $context->disposables?->track($instance),
+ Lifetime::TRANSIENT => null
+ };
+
+ }
+
$store?->set($id, $instance);
return $instance;
@@ -823,11 +1006,16 @@ private function matchesCollectionType (string $id, string $target, string $type
* Service definition.
*
*
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the implementation class is not
+ * instantiable or a required constructor dependency cannot be resolved.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a required dependency is not registered.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are
+ * eligible.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If a resolved dependency does not satisfy
+ * its registered service type.
* @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
- * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter is not optional and cannot
- * be resolved.
- * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service is not registered.
- * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
*
* @return object Created service instance.
*/
@@ -862,10 +1050,15 @@ private function create (Service $definition, ResolutionContext $context):object
* Active resolution context.
*
*
- * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter is not optional and cannot
- * be resolved.
- * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service is not registered.
- * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the implementation class is not
+ * instantiable or a required constructor dependency cannot be resolved.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a required dependency is not registered.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are
+ * eligible.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If a dependency requires an active scope.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If a resolved dependency does not satisfy
+ * its registered service type.
*
* @return object Constructed service instance.
*/
diff --git a/src/Scope.php b/src/Scope.php
index f1be407..07b0adb 100644
--- a/src/Scope.php
+++ b/src/Scope.php
@@ -15,7 +15,7 @@
use FireHub\Core\Boundary\Container\Scope as ScopeBoundary;
use FireHub\Container\Internal\ {
- InstanceStore, ScopeState
+ DisposalTracker, InstanceStore, ScopeRegistry, ScopeState
};
use Closure;
@@ -50,6 +50,12 @@
*/
private ScopeState $state;
+ /**
+ * ### Scoped disposable resource tracker
+ * @since 1.0.0
+ */
+ private DisposalTracker $disposables;
+
/**
* ### Constructor
* @since 1.0.0
@@ -57,17 +63,23 @@
* @param \FireHub\Container\Resolver $resolver
* Parent container's service resolver.
*
+ * @param \FireHub\Container\Internal\ScopeRegistry $registry
+ * Parent container's scope registry.
+ *
*
* @return void
*/
public function __construct (
- private Resolver $resolver
+ private Resolver $resolver,
+ private ScopeRegistry $registry
) {
$this->instances = new InstanceStore;
$this->state = new ScopeState;
+ $this->disposables = new DisposalTracker;
+
}
/**
@@ -137,7 +149,75 @@ public function tagged (string $tag):array {
return $this->resolver->taggedScoped(
$tag,
$this->instances,
- $this->state
+ $this->state,
+ $this->disposables
+ );
+
+ }
+
+ /**
+ * ### Invokes a callable within the current scope
+ *
+ * Executes the specified callable by combining explicitly supplied arguments with dependencies resolved from the
+ * current scope.
+ *
+ * Singleton services are shared with the parent container, scoped services are reused within this scope, and
+ * transient services are constructed for each resolution.
+ *
+ * The scope remains open after invocation and may be reused until explicitly closed.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->scoped(DatabaseConnection::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * try {
+ *
+ * $service = new UserService;
+ *
+ * $result = $scope->invoke(
+ * [$service, 'create'],
+ * ['username' => 'Danijel']
+ * );
+ *
+ * } finally {
+ *
+ * $scope->close();
+ *
+ * }
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Resolver::invokeScoped() To execute the callable within the current scope.
+ *
+ * @param callable $callback
+ * Callable to execute.
+ *
+ * @param array $arguments [optional]
+ * Explicit positional and named arguments.
+ *
+ *
+ * @throws \FireHub\Container\Exception\InvalidMethodArgumentException If explicit arguments are invalid.
+ * @throws \FireHub\Container\Exception\ServiceInstantiationException If the parameter cannot be resolved or the
+ * resolved dependency does not satisfy its declared type.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple dependency candidates are eligible.
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ *
+ * @return mixed Value returned by the invoked callable.
+ */
+ public function invoke (callable $callback, array $arguments = []):mixed {
+
+ return $this->resolver->invokeScoped(
+ $callback,
+ $arguments,
+ $this->instances,
+ $this->state,
+ $this->disposables
);
}
@@ -195,7 +275,8 @@ public function get (string $id):object {
return $this->resolver->getScoped(
$id,
$this->instances,
- $this->state
+ $this->state,
+ $this->disposables
);
}
@@ -296,7 +377,8 @@ public function all (string $type):array {
return $this->resolver->allScoped(
$type,
$this->instances,
- $this->state
+ $this->state,
+ $this->disposables
);
}
@@ -347,7 +429,8 @@ public function all (string $type):array {
*
*
* @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
- * @throws \FireHub\Container\Exception\ScopeActiveException If the scope is already executing.
+ * @throws \FireHub\Container\Exception\ScopeActiveException If service resolution is active.
+ * @throws \FireHub\Container\Exception\ServiceDisposalException If an instance fails to release its resources.
*
* @return TResult Callback result.
*/
@@ -407,12 +490,15 @@ public function isClosed ():bool {
/**
* ### Closes the scope
*
- * Releases all cached scoped service instances and prevents further service resolution through this scope.
+ * Prevents further service resolution and disposes of all owned scoped service instances.
+ *
+ * Disposable services are released in reverse registration order.
+ *
+ * The instance store is cleared even when resource disposal fails.
*
* Closing an already closed scope has no additional effect.
*
- * Closing a scope does not affect singleton instances maintained by the parent container or instances stored in
- * other scopes.
+ * Singleton instances and resources owned by other scopes remain unaffected.
*
*
* use FireHub\Container\Container;
@@ -425,10 +511,8 @@ public function isClosed ():bool {
*
* $connection = $scope->get(DatabaseConnection::class);
*
- * // Release cached scoped instances.
* $scope->close();
*
- * // The scope can no longer resolve services.
* $scope->isClosed(); // true
*
*
@@ -436,8 +520,11 @@ public function isClosed ():bool {
*
* @uses \FireHub\Container\Internal\ScopeState::close() To close the scope.
* @uses \FireHub\Container\Internal\InstanceStore::clear() To clear the instance store.
+ * @uses \FireHub\Container\Internal\DisposalTracker::dispose() To dispose of disposable resources.
+ * @uses \FireHub\Container\Internal\ScopeRegistry::unregister() To unregister the scope from the registry.
*
* @throws \FireHub\Container\Exception\ScopeActiveException If service resolution is active.
+ * @throws \FireHub\Container\Exception\ServiceDisposalException If an instance fails to release its resources.
*
* @return void
*/
@@ -445,7 +532,23 @@ public function close ():void {
$this->state->close();
- $this->instances->clear();
+ try {
+
+ $this->disposables->dispose();
+
+ } finally {
+
+ try {
+
+ $this->instances->clear();
+
+ } finally {
+
+ $this->registry->unregister($this);
+
+ }
+
+ }
}
diff --git a/tests/Stubs/DisposableTestService.php b/tests/Stubs/DisposableTestService.php
new file mode 100644
index 0000000..cc521c5
--- /dev/null
+++ b/tests/Stubs/DisposableTestService.php
@@ -0,0 +1,42 @@
+
+ * @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;
+
+use FireHub\Core\Boundary\Lifecycle\Disposable;
+
+/**
+ * ### Disposable test service
+ * @since 1.0.0
+ */
+final class DisposableTestService implements Disposable {
+
+ /**
+ * ### Number of disposal calls
+ * @since 1.0.0
+ */
+ public int $disposals = 0;
+
+ /**
+ * ### Releases the service
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function dispose ():void {
+
+ $this->disposals++;
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Stubs/FailingDisposableTestService.php b/tests/Stubs/FailingDisposableTestService.php
new file mode 100644
index 0000000..b536028
--- /dev/null
+++ b/tests/Stubs/FailingDisposableTestService.php
@@ -0,0 +1,47 @@
+
+ * @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;
+
+use FireHub\Core\Boundary\Lifecycle\Disposable;
+use RuntimeException;
+
+/**
+ * ### Failing disposable test service
+ *
+ * Throws an exception during disposal.
+ * @since 1.0.0
+ */
+final class FailingDisposableTestService implements Disposable {
+
+ /**
+ * ### Number of disposal calls
+ * @since 1.0.0
+ */
+ public int $disposals = 0;
+
+ /**
+ * ### Releases the service
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function dispose ():void {
+
+ $this->disposals++;
+
+ throw new RuntimeException('Disposal failed');
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Stubs/FirstOrderedDisposableService.php b/tests/Stubs/FirstOrderedDisposableService.php
new file mode 100644
index 0000000..cd07385
--- /dev/null
+++ b/tests/Stubs/FirstOrderedDisposableService.php
@@ -0,0 +1,20 @@
+
+ * @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;
+
+/**
+ * ### First ordered disposable service
+ * @since 1.0.0
+ */
+final class FirstOrderedDisposableService extends OrderedDisposableTestService {}
\ No newline at end of file
diff --git a/tests/Stubs/OrderedDisposableTestService.php b/tests/Stubs/OrderedDisposableTestService.php
new file mode 100644
index 0000000..1a3bd99
--- /dev/null
+++ b/tests/Stubs/OrderedDisposableTestService.php
@@ -0,0 +1,50 @@
+
+ * @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;
+
+use FireHub\Core\Boundary\Lifecycle\Disposable;
+
+/**
+ * ### Ordered disposable test service
+ *
+ * Records disposal order using a shared event collection.
+ * @since 1.0.0
+ */
+class OrderedDisposableTestService implements Disposable {
+
+ /**
+ * ### Constructor
+ * @since 1.0.0
+ *
+ * @param string $name Service name.
+ * @param array $events Shared disposal events.
+ */
+ public function __construct (
+ public string $name,
+ private array &$events
+ ) {}
+
+ /**
+ * ### Records disposal
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function dispose ():void {
+
+ $this->events[] = $this->name;
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Stubs/ResolvingDisposableTestService.php b/tests/Stubs/ResolvingDisposableTestService.php
new file mode 100644
index 0000000..3925e63
--- /dev/null
+++ b/tests/Stubs/ResolvingDisposableTestService.php
@@ -0,0 +1,49 @@
+
+ * @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;
+
+use FireHub\Core\Boundary\Lifecycle\Disposable;
+use FireHub\Container\Container;
+
+/**
+ * ### Disposable service that attempts resolution during disposal
+ * @since 1.0.0
+ */
+final class ResolvingDisposableTestService implements Disposable {
+
+ /**
+ * ### Constructor
+ * @param Container $container Parent container.
+ * @since 1.0.0
+ *
+ */
+ public function __construct(
+ private Container $container
+ ) {}
+
+ /**
+ * ### Attempts resolution during disposal
+ * @return void
+ * @throws ContainerClosedException If the container is closed.
+ *
+ * @since 1.0.0
+ *
+ */
+ public function dispose(): void {
+
+ $this->container->get(DisposableTestService::class);
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Stubs/SecondOrderedDisposableService.php b/tests/Stubs/SecondOrderedDisposableService.php
new file mode 100644
index 0000000..e08363a
--- /dev/null
+++ b/tests/Stubs/SecondOrderedDisposableService.php
@@ -0,0 +1,20 @@
+
+ * @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;
+
+/**
+ * ### Second ordered disposable service
+ * @since 1.0.0
+ */
+final class SecondOrderedDisposableService extends OrderedDisposableTestService {}
\ No newline at end of file
diff --git a/tests/Unit/ContainerLifecycleTest.php b/tests/Unit/ContainerLifecycleTest.php
new file mode 100644
index 0000000..7ce9572
--- /dev/null
+++ b/tests/Unit/ContainerLifecycleTest.php
@@ -0,0 +1,505 @@
+
+ * @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\ {
+ ContainerActiveScopesException, ContainerClosedException, ContainerFrozenException, ScopeActiveException,
+ ScopeClosedException
+};
+use FireHub\Tests\Container\Stubs\Logger;
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests dependency injection container lifecycle
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[Group('lifecycle')]
+#[CoversClass(Container::class)]
+final class ContainerLifecycleTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### New containers are open and mutable
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNewContainerIsOpenAndMutable ():void {
+
+ $container = new Container;
+
+ self::assertFalse($container->isFrozen());
+ self::assertFalse($container->isClosed());
+
+ $container->singleton(Logger::class);
+
+ self::assertTrue($container->has(Logger::class));
+
+ }
+
+ /**
+ * ### Freezing prevents further registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testFreezePreventsRegistrations ():void {
+
+ $container = new Container;
+
+ $container->freeze();
+
+ self::assertTrue($container->isFrozen());
+ self::assertFalse($container->isClosed());
+
+ $this->assertThrows(
+ ContainerFrozenException::class,
+ function () use ($container):void {
+
+ $container->singleton(Logger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Frozen containers continue resolving services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testFrozenContainerAllowsResolution ():void {
+
+ $container = new Container;
+
+ $container->singleton(Logger::class);
+
+ $container->freeze();
+
+ $first = $container->get(Logger::class);
+ $second = $container->get(Logger::class);
+
+ self::assertInstanceOf(Logger::class, $first);
+ self::assertSame($first, $second);
+
+ }
+
+ /**
+ * ### Freezing is idempotent
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testFreezeIsIdempotent ():void {
+
+ $container = new Container;
+
+ $container->freeze();
+ $container->freeze();
+
+ self::assertTrue($container->isFrozen());
+
+ }
+
+ /**
+ * ### Closing an open container changes its lifecycle state
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testCloseChangesContainerState ():void {
+
+ $container = new Container;
+
+ $container->close();
+
+ self::assertTrue($container->isClosed());
+
+ }
+
+ /**
+ * ### Closing is idempotent
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testCloseIsIdempotent ():void {
+
+ $container = new Container;
+
+ $container->close();
+ $container->close();
+
+ self::assertTrue($container->isClosed());
+
+ }
+
+ /**
+ * ### Closed containers reject resolution
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testClosedContainerRejectsResolution ():void {
+
+ $container = new Container;
+
+ $container->singleton(Logger::class);
+
+ $container->get(Logger::class);
+
+ $container->close();
+
+ $this->assertThrows(
+ ContainerClosedException::class,
+ function () use ($container):void {
+
+ $container->get(Logger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Closed containers reject registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testClosedContainerRejectsRegistrations ():void {
+
+ $container = new Container;
+
+ $container->close();
+
+ $this->assertThrows(
+ ContainerClosedException::class,
+ function () use ($container):void {
+
+ $container->singleton(Logger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Closed containers reject freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testClosedContainerRejectsFreeze ():void {
+
+ $container = new Container;
+
+ $container->close();
+
+ $this->assertThrows(
+ ContainerClosedException::class,
+ function () use ($container):void {
+
+ $container->freeze();
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Closed containers reject scope creation
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testClosedContainerRejectsScopeCreation ():void {
+
+ $container = new Container;
+
+ $container->close();
+
+ $this->assertThrows(
+ ContainerClosedException::class,
+ function () use ($container):void {
+
+ $container->createScope();
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Closed containers reject service queries
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testClosedContainerRejectsServiceQueries ():void {
+
+ $container = new Container;
+
+ $container->close();
+
+ $this->assertThrows(
+ ContainerClosedException::class,
+ function () use ($container):void {
+
+ $container->has(Logger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Frozen containers can be closed
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testFrozenContainerCanBeClosed ():void {
+
+ $container = new Container;
+
+ $container->freeze();
+ $container->close();
+
+ self::assertTrue($container->isFrozen());
+ self::assertTrue($container->isClosed());
+
+ }
+
+ /**
+ * ### Active scopes prevent container closure
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testActiveScopePreventsContainerClosure ():void {
+
+ $container = new Container;
+
+ $scope = $container->createScope();
+
+ $this->assertThrows(
+ ContainerActiveScopesException::class,
+ function () use ($container):void {
+
+ $container->close();
+
+ }
+ );
+
+ self::assertFalse($container->isClosed());
+ self::assertFalse($scope->isClosed());
+
+ $scope->close();
+
+ $container->close();
+
+ self::assertTrue($container->isClosed());
+
+ }
+
+ /**
+ * ### Every active scope must close before its container
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testMultipleScopesPreventContainerClosure ():void {
+
+ $container = new Container;
+
+ $first = $container->createScope();
+ $second = $container->createScope();
+
+ $first->close();
+
+ $this->assertThrows(
+ ContainerActiveScopesException::class,
+ function () use ($container):void {
+
+ $container->close();
+
+ }
+ );
+
+ $second->close();
+
+ $container->close();
+
+ self::assertTrue($container->isClosed());
+
+ }
+
+ /**
+ * ### Closing a scope is idempotent
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopeCloseIsIdempotent ():void {
+
+ $container = new Container;
+
+ $scope = $container->createScope();
+
+ $scope->close();
+ $scope->close();
+
+ self::assertTrue($scope->isClosed());
+
+ $container->close();
+
+ self::assertTrue($container->isClosed());
+
+ }
+
+ /**
+ * ### Closed scopes reject resolution
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testClosedScopeRejectsResolution ():void {
+
+ $container = new Container;
+
+ $scope = $container->createScope();
+
+ $scope->close();
+
+ $this->assertThrows(
+ ScopeClosedException::class,
+ function () use ($scope):void {
+
+ $scope->get(Logger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### A scope cannot close while its run callback is active
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopeCannotCloseDuringRun ():void {
+
+ $container = new Container;
+
+ $scope = $container->createScope();
+
+ $scope->run(
+ function () use ($scope):void {
+
+ $this->assertThrows(
+ ScopeActiveException::class,
+ function () use ($scope):void {
+
+ $scope->close();
+
+ }
+ );
+
+ }
+ );
+
+ self::assertTrue($scope->isClosed());
+
+ $container->close();
+
+ }
+
+ /**
+ * ### Scope run automatically closes the scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopeRunAutomaticallyClosesScope ():void {
+
+ $container = new Container;
+
+ $scope = $container->createScope();
+
+ $executed = false;
+
+ $scope->run(
+ function () use (&$executed):void {
+
+ $executed = true;
+
+ }
+ );
+
+ self::assertTrue($executed);
+ self::assertTrue($scope->isClosed());
+
+ $container->close();
+
+ self::assertTrue($container->isClosed());
+
+ }
+
+ /**
+ * ### Scope run closes the scope when its callback fails
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopeRunClosesScopeOnException ():void {
+
+ $container = new Container;
+
+ $scope = $container->createScope();
+
+ $this->assertThrows(
+ \RuntimeException::class,
+ function () use ($scope):void {
+
+ $scope->run(
+ function ():void {
+
+ throw new \RuntimeException('Execution failed');
+
+ }
+ );
+
+ }
+ );
+
+ self::assertTrue($scope->isClosed());
+
+ $container->close();
+
+ self::assertTrue($container->isClosed());
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Unit/DisposableServicesTest.php b/tests/Unit/DisposableServicesTest.php
new file mode 100644
index 0000000..f0036d7
--- /dev/null
+++ b/tests/Unit/DisposableServicesTest.php
@@ -0,0 +1,426 @@
+
+ * @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\Core\Meta\Enum\Container\Lifetime;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ServiceDisposalException;
+use FireHub\Tests\Container\Stubs\{
+ DisposableTestService, FailingDisposableTestService, FirstOrderedDisposableService, ResolvingDisposableTestService,
+ SecondOrderedDisposableService
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests disposable service lifecycle
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[Group('lifecycle')]
+#[Group('disposal')]
+#[CoversClass(Container::class)]
+final class DisposableServicesTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Singleton disposable services are released on container closure
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testSingletonDisposedOnContainerClose ():void {
+
+ $container = new Container;
+
+ $container->singleton(DisposableTestService::class);
+
+ $service = $container->get(DisposableTestService::class);
+
+ self::assertSame(0, $service->disposals);
+
+ $container->close();
+
+ self::assertSame(1, $service->disposals);
+
+ }
+
+ /**
+ * ### Singleton services are disposed of only once
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testSingletonDisposedOnlyOnce ():void {
+
+ $container = new Container;
+
+ $container->singleton(DisposableTestService::class);
+
+ $service = $container->get(DisposableTestService::class);
+
+ $container->close();
+ $container->close();
+
+ self::assertSame(1, $service->disposals);
+
+ }
+
+ /**
+ * ### Unresolved singleton services are not disposed
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnresolvedSingletonIsNotInstantiatedDuringClose ():void {
+
+ $container = new Container;
+
+ $created = 0;
+
+ $container->factory(
+ DisposableTestService::class,
+ function () use (&$created):DisposableTestService {
+
+ $created++;
+
+ return new DisposableTestService;
+
+ },
+ Lifetime::SINGLETON
+ );
+
+ $container->close();
+
+ self::assertSame(0, $created);
+
+ }
+
+ /**
+ * ### Scoped services are released on scope closure
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopedServiceDisposedOnScopeClose ():void {
+
+ $container = new Container;
+
+ $container->scoped(DisposableTestService::class);
+
+ $scope = $container->createScope();
+
+ $service = $scope->get(DisposableTestService::class);
+
+ self::assertSame(0, $service->disposals);
+
+ $scope->close();
+
+ self::assertSame(1, $service->disposals);
+
+ $container->close();
+
+ self::assertSame(1, $service->disposals);
+
+ }
+
+ /**
+ * ### Independent scopes dispose of their own instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testIndependentScopesDisposeTheirOwnInstances ():void {
+
+ $container = new Container;
+
+ $container->scoped(DisposableTestService::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ $first = $firstScope->get(DisposableTestService::class);
+ $second = $secondScope->get(DisposableTestService::class);
+
+ self::assertNotSame($first, $second);
+
+ $firstScope->close();
+
+ self::assertSame(1, $first->disposals);
+ self::assertSame(0, $second->disposals);
+
+ $secondScope->close();
+
+ self::assertSame(1, $second->disposals);
+
+ $container->close();
+
+ }
+
+ /**
+ * ### Closing a scope does not dispose of singleton services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopeCloseDoesNotDisposeSingleton ():void {
+
+ $container = new Container;
+
+ $container->singleton(DisposableTestService::class);
+
+ $scope = $container->createScope();
+
+ $service = $scope->get(DisposableTestService::class);
+
+ $scope->close();
+
+ self::assertSame(0, $service->disposals);
+
+ $container->close();
+
+ self::assertSame(1, $service->disposals);
+
+ }
+
+ /**
+ * ### Transient services are not automatically disposed
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTransientServicesAreNotAutomaticallyDisposed ():void {
+
+ $container = new Container;
+
+ $container->transient(DisposableTestService::class);
+
+ $first = $container->get(DisposableTestService::class);
+ $second = $container->get(DisposableTestService::class);
+
+ self::assertNotSame($first, $second);
+
+ $container->close();
+
+ self::assertSame(0, $first->disposals);
+ self::assertSame(0, $second->disposals);
+
+ }
+
+ /**
+ * ### Externally supplied instances remain caller-owned
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExternalInstancesAreNotDisposed ():void {
+
+ $container = new Container;
+
+ $service = new DisposableTestService;
+
+ $container->instance(
+ DisposableTestService::class,
+ $service
+ );
+
+ self::assertSame(
+ $service,
+ $container->get(DisposableTestService::class)
+ );
+
+ $container->close();
+
+ self::assertSame(0, $service->disposals);
+
+ }
+
+ /**
+ * ### Disposable singleton resources are released in reverse creation order
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testSingletonDisposalUsesReverseCreationOrder ():void {
+
+ $container = new Container;
+
+ $events = [];
+
+ $container->factory(
+ FirstOrderedDisposableService::class,
+ function () use (&$events):FirstOrderedDisposableService {
+
+ return new FirstOrderedDisposableService('first', $events);
+
+ },
+ Lifetime::SINGLETON
+ );
+
+ $container->factory(
+ SecondOrderedDisposableService::class,
+ function () use (&$events):SecondOrderedDisposableService {
+
+ return new SecondOrderedDisposableService('second', $events);
+
+ },
+ Lifetime::SINGLETON
+ );
+
+ $container->get(FirstOrderedDisposableService::class);
+ $container->get(SecondOrderedDisposableService::class);
+
+ $container->close();
+
+ self::assertSame(
+ ['second', 'first'],
+ $events
+ );
+
+ }
+
+ /**
+ * ### Disposal failures do not prevent container closure
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDisposalFailureDoesNotPreventContainerClosure ():void {
+
+ $container = new Container;
+
+ $container->singleton(FailingDisposableTestService::class);
+
+ $service = $container->get(FailingDisposableTestService::class);
+
+ $this->assertThrows(
+ ServiceDisposalException::class,
+ function () use ($container):void {
+
+ $container->close();
+
+ }
+ );
+
+ self::assertTrue($container->isClosed());
+ self::assertSame(1, $service->disposals);
+
+ $container->close();
+
+ self::assertSame(1, $service->disposals);
+
+ }
+
+ /**
+ * ### Disposal failures do not prevent remaining resources from being released
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDisposalContinuesAfterFailure ():void {
+
+ $container = new Container;
+
+ $container->singleton(DisposableTestService::class);
+ $container->singleton(FailingDisposableTestService::class);
+
+ $successful = $container->get(DisposableTestService::class);
+ $failing = $container->get(FailingDisposableTestService::class);
+
+ $this->assertThrows(
+ ServiceDisposalException::class,
+ function () use ($container):void {
+
+ $container->close();
+
+ }
+ );
+
+ self::assertSame(1, $successful->disposals);
+ self::assertSame(1, $failing->disposals);
+
+ }
+
+ /**
+ * ### Scoped disposal failures do not leave active scope registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopeDisposalFailureUnregistersScope ():void {
+
+ $container = new Container;
+
+ $container->scoped(FailingDisposableTestService::class);
+
+ $scope = $container->createScope();
+
+ $scope->get(FailingDisposableTestService::class);
+
+ $this->assertThrows(
+ ServiceDisposalException::class,
+ function () use ($scope):void {
+
+ $scope->close();
+
+ }
+ );
+
+ self::assertTrue($scope->isClosed());
+
+ $container->close();
+
+ self::assertTrue($container->isClosed());
+
+ }
+
+ /**
+ * ### Resolution is prohibited during container disposal
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testResolutionDuringDisposalIsRejected ():void {
+
+ $container = new Container;
+
+ $service = new ResolvingDisposableTestService($container);
+
+ $container->factory(
+ ResolvingDisposableTestService::class,
+ fn ():ResolvingDisposableTestService => $service,
+ Lifetime::SINGLETON
+ );
+
+ $container->get(ResolvingDisposableTestService::class);
+
+ $this->assertThrows(
+ ServiceDisposalException::class,
+ function () use ($container):void {
+
+ $container->close();
+
+ }
+ );
+
+ self::assertTrue($container->isClosed());
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Unit/MethodInjectionTest.php b/tests/Unit/MethodInjectionTest.php
new file mode 100644
index 0000000..f79f0aa
--- /dev/null
+++ b/tests/Unit/MethodInjectionTest.php
@@ -0,0 +1,864 @@
+
+ * @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\Container\Attribute\ {
+ InjectAll, Tagged
+};
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ InvalidMethodArgumentException, ScopeClosedException, ScopeRequiredException, ServiceInstantiationException
+};
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Tests\Container\Stubs\ {
+ CollectionConsoleLogger, CollectionFileLogger, CollectionLogger, ContextualFileLogger, ContextualLogger, Logger,
+ ScopedDependency, TaggedConsoleLogger, TaggedFileLogger
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use LogicException;
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests callable dependency injection and invocation arguments
+ *
+ * Covers supported callback forms, explicit argument precedence, signature validation, contextual bindings,
+ * collection attributes, scopes, and exception propagation.
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class MethodInjectionTest extends FireHubTestCase {
+
+ /**
+ * @since 1.0.0
+ */
+ use ExceptionAssertions;
+
+ /**
+ * ### Closure receives an implicitly constructed dependency
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testClosureReceivesImplicitDependency ():void {
+
+ $result = (new Container)->invoke(
+ static fn (Logger $logger):object => $logger
+ );
+
+ self::assertInstanceOf(Logger::class, $result);
+
+ }
+
+ /**
+ * ### Singleton dependency is reused across invocations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testSingletonDependencyIsReused ():void {
+
+ $container = new Container;
+ $container->singleton(Logger::class);
+
+ $first = $container->invoke(static fn (Logger $logger):Logger => $logger);
+ $second = $container->invoke(static fn (Logger $logger):Logger => $logger);
+
+ self::assertSame($first, $second);
+ self::assertSame($first, $container->get(Logger::class));
+
+ }
+
+ /**
+ * ### Explicit named value overrides dependency injection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitNamedDependencyOverridesAutowiring ():void {
+
+ $explicit = new Logger;
+
+ $result = (new Container)->invoke(
+ static fn (Logger $logger):Logger => $logger,
+ ['logger' => $explicit]
+ );
+
+ self::assertSame($explicit, $result);
+
+ }
+
+ /**
+ * ### Explicit positional value overrides dependency injection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitPositionalDependencyOverridesAutowiring ():void {
+
+ $explicit = new Logger;
+
+ $result = (new Container)->invoke(
+ static fn (Logger $logger):Logger => $logger,
+ [$explicit]
+ );
+
+ self::assertSame($explicit, $result);
+
+ }
+
+ /**
+ * ### Named scalar argument is combined with an injected service
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNamedScalarArgumentWithInjectedDependency ():void {
+
+ $result = (new Container)->invoke(
+ static fn (Logger $logger, string $message):array => [$logger, $message],
+ ['message' => 'FireHub']
+ );
+
+ self::assertInstanceOf(Logger::class, $result[0]);
+ self::assertSame('FireHub', $result[1]);
+
+ }
+
+ /**
+ * ### Multiple named arguments may be supplied out of declaration order
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNamedArgumentsCanBeOutOfOrder ():void {
+
+ $result = (new Container)->invoke(
+ static fn (string $first, string $second):array => [$first, $second],
+ ['second' => 'B', 'first' => 'A']
+ );
+
+ self::assertSame(['A', 'B'], $result);
+
+ }
+
+ /**
+ * ### Positional and named arguments can target different parameters
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testPositionalAndNamedArgumentsCanBeCombined ():void {
+
+ $result = (new Container)->invoke(
+ static fn (string $first, string $second, string $third):array => [$first, $second, $third],
+ [0 => 'A', 'third' => 'C', 'second' => 'B']
+ );
+
+ self::assertSame(['A', 'B', 'C'], $result);
+
+ }
+
+ /**
+ * ### Missing optional argument uses its declared default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDefaultArgumentIsApplied ():void {
+
+ self::assertSame(
+ 'default',
+ (new Container)->invoke(static fn (string $value = 'default'):string => $value)
+ );
+
+ }
+
+ /**
+ * ### Explicit null is not mistaken for an omitted argument
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitNullOverridesDefault ():void {
+
+ self::assertNull(
+ (new Container)->invoke(
+ static fn (?string $value = 'default'):?string => $value,
+ ['value' => null]
+ )
+ );
+
+ }
+
+ /**
+ * ### Required unbound scalar dependency fails
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testMissingRequiredScalarThrows ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+ (new Container)->invoke(static fn (string $required):string => $required);
+ }
+ );
+
+ }
+
+ /**
+ * ### Instance method receives dependencies and explicit arguments
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInstanceMethodInjection ():void {
+
+ $fixture = new MethodInvocationFixture;
+
+ $result = (new Container)->invoke(
+ [$fixture, 'execute'],
+ ['message' => 'instance']
+ );
+
+ self::assertSame('instance', $result);
+
+ }
+
+ /**
+ * ### Static method can be invoked through array notation
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testStaticMethodArrayCallable ():void {
+
+ self::assertSame(
+ Logger::class,
+ (new Container)->invoke([MethodInvocationFixture::class, 'staticExecute'])
+ );
+
+ }
+
+ /**
+ * ### Static method can be invoked through string notation
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testStaticMethodStringCallable ():void {
+
+ self::assertSame(
+ Logger::class,
+ (new Container)->invoke(MethodInvocationFixture::class . '::staticExecute')
+ );
+
+ }
+
+ /**
+ * ### Invokable object receives dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInvokableObjectInjection ():void {
+
+ self::assertSame(
+ Logger::class,
+ (new Container)->invoke(new MethodInvocationFixture)
+ );
+
+ }
+
+ /**
+ * ### Named function can receive dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNamedFunctionInjection ():void {
+
+ self::assertSame(
+ Logger::class,
+ (new Container)->invoke(__NAMESPACE__ . '\\methodInjectionFunction')
+ );
+
+ }
+
+ /**
+ * ### Empty variadic parameter produces an empty list
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testEmptyVariadicArgumentList ():void {
+
+ self::assertSame(
+ [],
+ (new Container)->invoke(static fn (Logger $logger, string ...$messages):array => $messages)
+ );
+
+ }
+
+ /**
+ * ### Named variadic list is expanded into individual values
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNamedVariadicArguments ():void {
+
+ self::assertSame(
+ ['one', 'two', 'three'],
+ (new Container)->invoke(
+ static fn (Logger $logger, string ...$messages):array => $messages,
+ ['messages' => ['one', 'two', 'three']]
+ )
+ );
+
+ }
+
+ /**
+ * ### Remaining positional arguments are assigned to variadic parameter
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testPositionalVariadicArguments ():void {
+
+ self::assertSame(
+ ['one', 'two'],
+ (new Container)->invoke(
+ static fn (string ...$messages):array => $messages,
+ ['one', 'two']
+ )
+ );
+
+ }
+
+ /**
+ * ### Unknown named argument is rejected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnknownNamedArgumentThrows ():void {
+
+ $this->assertThrows(
+ InvalidMethodArgumentException::class,
+ static function ():void {
+ (new Container)->invoke(static fn ():bool => true, ['unknown' => 1]);
+ }
+ );
+
+ }
+
+ /**
+ * ### Duplicate positional and named assignment is rejected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDuplicateArgumentAssignmentThrows ():void {
+
+ $this->assertThrows(
+ InvalidMethodArgumentException::class,
+ static function ():void {
+ (new Container)->invoke(
+ static fn (string $name):string => $name,
+ [0 => 'first', 'name' => 'second']
+ );
+ }
+ );
+
+ }
+
+ /**
+ * ### Named arguments cannot precede positional arguments
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testPositionalAfterNamedThrows ():void {
+
+ $this->assertThrows(
+ InvalidMethodArgumentException::class,
+ static function ():void {
+ (new Container)->invoke(
+ static fn (string $first, string $second):array => [$first, $second],
+ ['second' => 'B', 0 => 'A']
+ );
+ }
+ );
+
+ }
+
+ /**
+ * ### Nonconsecutive positional indexes are rejected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNonconsecutivePositionalIndexesThrow ():void {
+
+ $this->assertThrows(
+ InvalidMethodArgumentException::class,
+ static function ():void {
+ (new Container)->invoke(
+ static fn (string $first, string $second):array => [$first, $second],
+ [1 => 'B']
+ );
+ }
+ );
+
+ }
+
+ /**
+ * ### Excess positional arguments are rejected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTooManyPositionalArgumentsThrow ():void {
+
+ $this->assertThrows(
+ InvalidMethodArgumentException::class,
+ static function ():void {
+ (new Container)->invoke(static fn ():bool => true, ['extra']);
+ }
+ );
+
+ }
+
+ /**
+ * ### Named variadic argument requires a list
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNamedVariadicScalarThrows ():void {
+
+ $this->assertThrows(
+ InvalidMethodArgumentException::class,
+ static function ():void {
+ (new Container)->invoke(
+ static fn (string ...$messages):array => $messages,
+ ['messages' => 'not-a-list']
+ );
+ }
+ );
+
+ }
+
+ /**
+ * ### Associative array is not accepted as a named variadic list
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNamedVariadicAssociativeArrayThrows ():void {
+
+ $this->assertThrows(
+ InvalidMethodArgumentException::class,
+ static function ():void {
+ (new Container)->invoke(
+ static fn (string ...$messages):array => $messages,
+ ['messages' => ['label' => 'value']]
+ );
+ }
+ );
+
+ }
+
+ /**
+ * ### Named and positional variadic values cannot be combined
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDuplicateVariadicSourcesThrow ():void {
+
+ $this->assertThrows(
+ InvalidMethodArgumentException::class,
+ static function ():void {
+ (new Container)->invoke(
+ static fn (string ...$messages):array => $messages,
+ [0 => 'first', 'messages' => ['second']]
+ );
+ }
+ );
+
+ }
+
+ /**
+ * ### Explicit constructor bindings do not apply to method injection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testConstructorParameterBindingsDoNotApplyToMethods ():void {
+
+ $container = new Container;
+ $container->bindParameters(MethodInvocationFixture::class, ['message' => 'constructor-only']);
+
+ $fixture = new MethodInvocationFixture;
+
+ self::assertSame('default', $container->invoke([$fixture, 'execute']));
+
+ }
+
+ /**
+ * ### Contextual binding is applied to declaring a method class
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingOnInstanceMethod ():void {
+
+ $container = new Container;
+ $container->bindContextual(
+ MethodInvocationFixture::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ self::assertInstanceOf(
+ ContextualFileLogger::class,
+ $container->invoke([new MethodInvocationFixture, 'contextual'])
+ );
+
+ }
+
+ /**
+ * ### Tagged injection returns services in tag registration order
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedMethodInjection ():void {
+
+ $container = new Container;
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+ $container->tag('logging', TaggedConsoleLogger::class, TaggedFileLogger::class);
+
+ $result = $container->invoke(
+ static fn (#[Tagged('logging')] array $loggers):array => $loggers
+ );
+
+ self::assertCount(2, $result);
+ self::assertInstanceOf(TaggedConsoleLogger::class, $result[0]);
+ self::assertInstanceOf(TaggedFileLogger::class, $result[1]);
+
+ }
+
+ /**
+ * ### Collection injection uses the registered matching services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testCollectionMethodInjection ():void {
+
+ $container = new Container;
+ $container->singleton(CollectionFileLogger::class);
+ $container->singleton(CollectionConsoleLogger::class);
+
+ $result = $container->invoke(
+ static fn (#[InjectAll(CollectionLogger::class)] array $loggers):array => $loggers
+ );
+
+ self::assertCount(2, $result);
+ self::assertInstanceOf(CollectionFileLogger::class, $result[0]);
+ self::assertInstanceOf(CollectionConsoleLogger::class, $result[1]);
+
+ }
+
+ /**
+ * ### Scoped dependency cannot be resolved directly from the container
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopedDependencyRequiresScope ():void {
+
+ $container = new Container;
+ $container->scoped(ScopedDependency::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ static function () use ($container):void {
+ $container->invoke(static fn (ScopedDependency $service):object => $service);
+ }
+ );
+
+ }
+
+ /**
+ * ### Scoped service instance is reused across invocations in the same scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopedInvocationReusesScopedDependency ():void {
+
+ $container = new Container;
+ $container->scoped(ScopedDependency::class);
+ $scope = $container->createScope();
+
+ try {
+
+ $first = $scope->invoke(static fn (ScopedDependency $service):object => $service);
+ $second = $scope->invoke(static fn (ScopedDependency $service):object => $service);
+
+ self::assertSame($first, $second);
+ self::assertFalse($scope->isClosed());
+
+ } finally {
+
+ $scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Different scopes receive different scoped instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopedInstancesAreIsolated ():void {
+
+ $container = new Container;
+ $container->scoped(ScopedDependency::class);
+ $first_scope = $container->createScope();
+ $second_scope = $container->createScope();
+
+ try {
+
+ $first = $first_scope->invoke(static fn (ScopedDependency $service):object => $service);
+ $second = $second_scope->invoke(static fn (ScopedDependency $service):object => $service);
+
+ self::assertNotSame($first, $second);
+
+ } finally {
+
+ $first_scope->close();
+ $second_scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Invocation cannot use a closed scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInvokingClosedScopeThrows ():void {
+
+ $scope = (new Container)->createScope();
+ $scope->close();
+
+ $this->assertThrows(
+ ScopeClosedException::class,
+ static function () use ($scope):void {
+ $scope->invoke(static fn ():bool => true);
+ }
+ );
+
+ }
+
+ /**
+ * ### Callback failure propagates without being wrapped
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testCallbackExceptionIsNotWrapped ():void {
+
+ $expected = new LogicException('callback failure');
+
+ try {
+
+ (new Container)->invoke(static function () use ($expected):never {
+ throw $expected;
+ });
+
+ self::fail('Expected callback exception was not thrown.');
+
+ } catch (LogicException $exception) {
+
+ self::assertSame($expected, $exception);
+
+ }
+
+ }
+
+ /**
+ * ### Scoped callback failure does not close the scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopedCallbackExceptionLeavesScopeUsable ():void {
+
+ $scope = (new Container)->createScope();
+ $expected = new LogicException('callback failure');
+
+ try {
+
+ try {
+
+ $scope->invoke(static function () use ($expected):never {
+ throw $expected;
+ });
+
+ self::fail('Expected callback exception was not thrown.');
+
+ } catch (LogicException $exception) {
+
+ self::assertSame($expected, $exception);
+
+ }
+
+ self::assertFalse($scope->isClosed());
+ self::assertTrue($scope->invoke(static fn ():bool => true));
+
+ } finally {
+
+ $scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### A frozen container still supports invocation
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testFrozenContainerCanInvoke ():void {
+
+ $container = new Container;
+ $container->singleton(Logger::class);
+ $container->freeze();
+
+ self::assertInstanceOf(
+ Logger::class,
+ $container->invoke(static fn (Logger $logger):Logger => $logger)
+ );
+
+ }
+
+}
+
+/**
+ * ### Provides callable fixtures for method injection tests
+ * @since 1.0.0
+ */
+final class MethodInvocationFixture {
+
+ /**
+ * ### Executes an instance method with an injected dependency
+ * @since 1.0.0
+ *
+ * @param \FireHub\Tests\Container\Stubs\Logger $logger
+ * Logger dependency.
+ *
+ * @param string $message [optional]
+ * Message to return.
+ *
+ *
+ * @return string Explicit message.
+ */
+ public function execute (Logger $logger, string $message = 'default'):string {
+
+ return $message;
+
+ }
+
+ /**
+ * ### Resolves a dependency in a static method
+ * @since 1.0.0
+ *
+ * @param \FireHub\Tests\Container\Stubs\Logger $logger
+ * Logger dependency.
+ *
+ *
+ * @return class-string<\FireHub\Tests\Container\Stubs\Logger> Logger class name.
+ */
+ public static function staticExecute (Logger $logger):string {
+
+ return $logger::class;
+
+ }
+
+ /**
+ * ### Resolves a contextual dependency
+ * @since 1.0.0
+ *
+ * @param \FireHub\Tests\Container\Stubs\ContextualLogger $logger
+ * Contextually selected logger.
+ *
+ *
+ * @return \FireHub\Tests\Container\Stubs\ContextualLogger Selected logger.
+ */
+ public function contextual (ContextualLogger $logger):ContextualLogger {
+
+ return $logger;
+
+ }
+
+ /**
+ * ### Invokes an object with an injected logger
+ * @since 1.0.0
+ *
+ * @param \FireHub\Tests\Container\Stubs\Logger $logger
+ * Logger dependency.
+ *
+ *
+ * @return class-string<\FireHub\Tests\Container\Stubs\Logger> Logger class name.
+ */
+ public function __invoke (Logger $logger):string {
+
+ return $logger::class;
+
+ }
+
+}
+
+/**
+ * ### Named function fixture for callable injection
+ * @since 1.0.0
+ *
+ * @param \FireHub\Tests\Container\Stubs\Logger $logger
+ * Logger dependency.
+ *
+ *
+ * @return class-string<\FireHub\Tests\Container\Stubs\Logger> Logger class name.
+ */
+function methodInjectionFunction (Logger $logger):string {
+
+ return $logger::class;
+
+}
diff --git a/tests/Unit/ParameterBindingTest.php b/tests/Unit/ParameterBindingTest.php
index d71a5a5..1b3833b 100644
--- a/tests/Unit/ParameterBindingTest.php
+++ b/tests/Unit/ParameterBindingTest.php
@@ -34,6 +34,9 @@
#[CoversClass(Container::class)]
final class ParameterBindingTest extends FireHubTestCase {
+ /**
+ * @since 1.0.0
+ */
use ExceptionAssertions;
/**