+ *
+ * @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;
/**
From 703a68b44b8bb1f7bd573ef80ad6e7a3dcb6cf7f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Thu, 22 Oct 2026 10:19:32 +0200
Subject: [PATCH 04/11] Add @inheritDoc annotations to exception classes for
improved documentation
---
.../AmbiguousDependencyException.php | 5 +
src/Exception/CircularDependencyException.php | 5 +
src/Exception/ContainerFrozenException.php | 5 +
.../DisposalTrackerClosedException.php | 36 +++++
.../DuplicateContextualBindingException.php | 5 +
src/Exception/DuplicateServiceException.php | 5 +
.../InvalidMethodArgumentException.php | 3 +-
src/Exception/InvalidTagNameException.php | 3 +-
src/Exception/ScopeActiveException.php | 5 +
src/Exception/ScopeClosedException.php | 5 +
src/Exception/ScopeRequiredException.php | 5 +
src/Exception/ServiceDisposalException.php | 36 +++++
.../ServiceInstantiationException.php | 5 +
src/Exception/ServiceNotFoundException.php | 5 +
.../ServiceTypeMismatchException.php | 5 +
.../UnsupportedDefinitionException.php | 5 +
src/Internal/DisposalTracker.php | 141 ++++++++++++++++++
17 files changed, 277 insertions(+), 2 deletions(-)
create mode 100644 src/Exception/DisposalTrackerClosedException.php
create mode 100644 src/Exception/ServiceDisposalException.php
create mode 100644 src/Internal/DisposalTracker.php
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/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
index 29fae1c..a77517b 100644
--- a/src/Exception/InvalidMethodArgumentException.php
+++ b/src/Exception/InvalidMethodArgumentException.php
@@ -27,7 +27,8 @@
final class InvalidMethodArgumentException extends InvalidArgumentException {
/**
- * ### Default exception message
+ * @inheritDoc
+ *
* @since 1.0.0
*/
protected const string DEFAULT_MESSAGE = 'Invalid method argument(s)';
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/DisposalTracker.php b/src/Internal/DisposalTracker.php
new file mode 100644
index 0000000..738565e
--- /dev/null
+++ b/src/Internal/DisposalTracker.php
@@ -0,0 +1,141 @@
+
+ * @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 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
+ *
+ * @param \FireHub\Core\Boundary\Lifecycle\Disposable $instance
+ * Disposable instance to register.
+ *
+ *
+ * @throws \LogicException If registration is attempted after disposal has started.
+ *
+ * @return void
+ */
+ public function track (Disposable $instance):void {
+
+ if ($this->disposed)
+ throw new \LogicException(
+ 'Cannot track disposable instances after disposal has started.'
+ );
+
+ $id = spl_object_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\Core\Boundary\Lifecycle\Disposable::dispose() To release instance resources.
+ *
+ * @throws Throwable If one or more instances fail during disposal.
+ *
+ * @return void
+ */
+ public function dispose ():void {
+
+ if ($this->disposed) return;
+
+ $this->disposed = true;
+
+ $failure = null;
+
+ for ($index = count($this->instances) - 1; $index >= 0; $index--) {
+
+ try {
+
+ $this->instances[$index]->dispose();
+
+ } catch (Throwable $exception) {
+
+ $failure ??= $exception;
+
+ }
+
+ }
+
+ $this->instances = [];
+ $this->identities = [];
+
+ if ($failure !== null)
+ throw $failure;
+
+ }
+
+}
\ No newline at end of file
From a5e3bfbfd5885866ff844ece425c17747ba97eb6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Thu, 22 Oct 2026 10:25:56 +0200
Subject: [PATCH 05/11] Refactor DisposalTracker to improve exception handling
and utilize runtime utilities
---
src/Internal/DisposalTracker.php | 24 +++++++++++++++---------
1 file changed, 15 insertions(+), 9 deletions(-)
diff --git a/src/Internal/DisposalTracker.php b/src/Internal/DisposalTracker.php
index 738565e..e7cf52e 100644
--- a/src/Internal/DisposalTracker.php
+++ b/src/Internal/DisposalTracker.php
@@ -14,6 +14,10 @@
namespace FireHub\Container\Internal;
use FireHub\Core\Boundary\Lifecycle\Disposable;
+use FireHub\Container\Exception\ {
+ DisposalTrackerClosedException, ServiceDisposalException
+};
+use FireHub\Runtime;
use Throwable;
/**
@@ -67,22 +71,22 @@ final class DisposalTracker {
* 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 \LogicException If registration is attempted after disposal has started.
+ * @throws \FireHub\Container\Exception\DisposalTrackerClosedException If disposal has already started.
*
* @return void
*/
public function track (Disposable $instance):void {
if ($this->disposed)
- throw new \LogicException(
- 'Cannot track disposable instances after disposal has started.'
- );
+ throw new DisposalTrackerClosedException;
- $id = spl_object_id($instance);
+ $id = Runtime\ObjectModel\Identity::id($instance);
if (isset($this->identities[$id])) return;
@@ -102,9 +106,11 @@ public function track (Disposable $instance):void {
* 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 Throwable If one or more instances fail during disposal.
+ * @throws \FireHub\Container\Exception\ServiceDisposalException If an instance fails to release its resources.
*
* @return void
*/
@@ -116,11 +122,11 @@ public function dispose ():void {
$failure = null;
- for ($index = count($this->instances) - 1; $index >= 0; $index--) {
+ for ($index = Runtime\Arr\Inspection::count($this->instances) - 1; $index >= 0; $index--) {
try {
- $this->instances[$index]->dispose();
+ $this->instances[$index]->dispose(); // @phpstan-ignore offsetAccess.notFound
} catch (Throwable $exception) {
@@ -134,7 +140,7 @@ public function dispose ():void {
$this->identities = [];
if ($failure !== null)
- throw $failure;
+ throw new ServiceDisposalException(previous: $failure);
}
From fbd7709d25d81584eb4049403b36002f148653ad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Thu, 22 Oct 2026 12:16:06 +0200
Subject: [PATCH 06/11] Add ContainerActiveScopesException and
ContainerClosedException classes; refactor container to manage active scopes
and closure state
---
src/Container.php | 205 +++++++++++++++++-
.../ContainerActiveScopesException.php | 31 +++
src/Exception/ContainerClosedException.php | 31 +++
src/Internal/ResolutionContext.php | 8 +-
src/Internal/ScopeRegistry.php | 111 ++++++++++
src/Resolver.php | 60 ++++-
src/Scope.php | 63 ++++--
7 files changed, 477 insertions(+), 32 deletions(-)
create mode 100644 src/Exception/ContainerActiveScopesException.php
create mode 100644 src/Exception/ContainerClosedException.php
create mode 100644 src/Internal/ScopeRegistry.php
diff --git a/src/Container.php b/src/Container.php
index 831137c..58bfb97 100644
--- a/src/Container.php
+++ b/src/Container.php
@@ -21,10 +21,12 @@
Alias, Cls, Factory, Instance
};
use FireHub\Container\Internal\ {
- ContextualBindingRegistry, InstanceStore, ParameterBindingRegistry, ResolutionContext, TagRegistry
+ ContextualBindingRegistry, DisposalTracker, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeRegistry,
+ TagRegistry
};
use FireHub\Container\Exception\ {
- ContainerFrozenException, InvalidTagNameException, ServiceNotFoundException
+ ContainerActiveScopesException, ContainerClosedException, ContainerFrozenException, InvalidTagNameException,
+ ServiceNotFoundException
};
use FireHub\Runtime;
use Closure;
@@ -71,12 +73,36 @@ final class Container implements ContainerBoundary, Registrar {
*/
private readonly ContextualBindingRegistry $contextual;
+ /**
+ * ### Singleton instance store
+ * @since 1.0.0
+ */
+ private readonly InstanceStore $singletons;
+
/**
* ### Service tag registry
* @since 1.0.0
*/
private readonly TagRegistry $tags;
+ /**
+ * ### Active scope registry
+ * @since 1.0.0
+ */
+ private readonly ScopeRegistry $scopes;
+
+ /**
+ * ### Singleton disposable resource tracker
+ * @since 1.0.0
+ */
+ private readonly DisposalTracker $disposables;
+
+ /**
+ * ### Container shutdown state
+ * @since 1.0.0
+ */
+ private bool $closed = false;
+
/**
* ### Container freeze state
* @var bool
@@ -94,14 +120,18 @@ public function __construct () {
$this->registry = new Registry;
$this->parameters = new ParameterBindingRegistry;
$this->contextual = new ContextualBindingRegistry;
+ $this->singletons = new InstanceStore;
$this->tags = new TagRegistry;
+ $this->scopes = new ScopeRegistry;
+ $this->disposables = new DisposalTracker;
$this->resolver = new Resolver(
$this->registry,
- new InstanceStore,
+ $this->singletons,
$this->parameters,
$this->contextual,
- $this->tags
+ $this->tags,
+ $this->disposables
);
}
@@ -118,10 +148,24 @@ public function __construct () {
*
*
* @since 1.0.0
+ *
+ * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution.
+ * @uses \FireHub\Container\Internal\ScopeRegistry::register() To register a new scope.
+ *
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*/
public function createScope ():ScopeBoundary {
- return new Scope($this->resolver);
+ $this->assertOpen();
+
+ $scope = new Scope(
+ $this->resolver,
+ $this->scopes
+ );
+
+ $this->scopes->register($scope);
+
+ return $scope;
}
@@ -156,6 +200,7 @@ public function createScope ():ScopeBoundary {
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
* @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return $this Container instance.
*/
@@ -209,6 +254,7 @@ public function register (string $id, Definition $definition):self {
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
* @throws \FireHub\Container\Exception\DuplicateContextualBindingException If the binding already exists.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return $this Container instance.
*/
@@ -243,6 +289,7 @@ public function bindContextual (string $consumer, string $dependency, string $im
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
* @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*/
public function bind (string $id, string $class, Lifetime $lifetime = Lifetime::TRANSIENT):self {
@@ -288,6 +335,7 @@ public function bind (string $id, string $class, Lifetime $lifetime = Lifetime::
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
* @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return $this Container instance.
*/
@@ -342,6 +390,7 @@ public function singleton (string $id, ?string $class = null):self {
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
* @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return $this Container instance.
*/
@@ -387,6 +436,7 @@ public function scoped (string $id, ?string $class = null):self {
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
* @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return $this Container instance.
*/
@@ -436,6 +486,7 @@ public function transient (string $id, ?string $class = null):self {
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
* @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*/
public function factory (string $id, Closure $factory, Lifetime $lifetime = Lifetime::TRANSIENT):self {
@@ -469,6 +520,7 @@ public function factory (string $id, Closure $factory, Lifetime $lifetime = Life
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
* @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*/
public function instance (string $id, object $instance):self {
@@ -499,6 +551,7 @@ public function instance (string $id, object $instance):self {
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
* @throws \FireHub\Container\Exception\DuplicateServiceException If the identifier is already registered.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*/
public function alias (string $id, string $target):self {
@@ -548,6 +601,7 @@ public function alias (string $id, string $target):self {
*
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return $this Container instance.
*/
@@ -583,6 +637,7 @@ public function bindParameters (string $class, array $parameters):self {
*
* @since 1.0.0
*
+ * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution.
* @uses \FireHub\Container\Resolver::all() To resolve matching services.
*
* @template TService of object
@@ -598,11 +653,14 @@ public function bindParameters (string $class, array $parameters):self {
* @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):array {
+ $this->assertOpen();
+
return $this->resolver->all(
$type,
new ResolutionContext
@@ -677,6 +735,7 @@ public function all (string $type):array {
*
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
* @throws \FireHub\Container\Exception\ServiceNotFoundException If a service is not explicitly registered.
* @throws \FireHub\Container\Exception\InvalidTagNameException If the tag name is empty.
*
@@ -775,15 +834,20 @@ public function hasTag (string $tag):bool {
* @since 1.0.0
*
* @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers.
+ * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution.
*
* @param non-empty-string $tag
* Tag name.
*
*
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
+ *
* @return list Tagged service identifiers.
*/
public function taggedIds (string $tag):array {
+ $this->assertOpen();
+
return $this->tags->get($tag);
}
@@ -817,6 +881,7 @@ public function taggedIds (string $tag):array {
* @since 1.0.0
*
* @uses \FireHub\Container\Resolver::tagged() To resolve tagged services.
+ * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution.
*
* @param non-empty-string $tag
* Service tag name.
@@ -831,11 +896,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->assertOpen();
+
return $this->resolver->tagged(
$tag,
new ResolutionContext
@@ -871,6 +939,7 @@ public function tagged (string $tag):array {
* @since 1.0.0
*
* @uses \FireHub\Container\Resolver::invoke() To execute the callable using dependency injection.
+ * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution.
*
* @param callable $callback
* Callable to execute.
@@ -883,11 +952,14 @@ public function tagged (string $tag):array {
* @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->assertOpen();
+
return $this->resolver->invoke(
$callback,
$arguments
@@ -916,6 +988,9 @@ public function invoke (callable $callback, array $arguments = []):mixed {
*
* @since 1.0.0
*
+ * @uses \FireHub\Container\Resolver::get() To resolve the service.
+ * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open.
+ *
* @template TService of object
*
* @param class-string $id
@@ -930,11 +1005,14 @@ public function invoke (callable $callback, array $arguments = []):mixed {
* @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->assertOpen();
+
return $this->resolver->get($id);
}
@@ -960,18 +1038,115 @@ public function get (string $id):object {
*
* @since 1.0.0
*
+ * @uses \FireHub\Container\Container::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->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.
+ *
+ * @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->closed) return;
+
+ if ($this->scopes->hasActive())
+ throw new ContainerActiveScopesException;
+
+ $this->closed = true;
+
+ 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
+ *
+ * @return bool True if the container has been shut down.
+ */
+ public function isClosed ():bool {
+
+ return $this->closed;
+
+ }
+
/**
* ### Freezes the container
*
@@ -1032,6 +1207,23 @@ public function isFrozen ():bool {
}
+ /**
+ * ### Ensures the container is open
+ *
+ * Prevents operations requiring an active container after shutdown.
+ * @since 1.0.0
+ *
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
+ *
+ * @return void
+ */
+ private function assertOpen ():void {
+
+ if ($this->closed)
+ throw new ContainerClosedException;
+
+ }
+
/**
* ### Ensures the container accepts registrations
*
@@ -1039,11 +1231,14 @@ public function isFrozen ():bool {
* @since 1.0.0
*
* @throws \FireHub\Container\Exception\ContainerFrozenException If the container is frozen.
+ * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*
* @return void
*/
private function assertMutable ():void {
+ $this->assertOpen();
+
if ($this->frozen)
throw new ContainerFrozenException;
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/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
+ * 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.
@@ -250,7 +259,7 @@ public function get (string $id):object {
*
* @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();
@@ -260,7 +269,8 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope)
$id,
new ResolutionContext(
scoped: $scoped,
- scope: $scope
+ scope: $scope,
+ disposables: $disposables
)
);
@@ -360,6 +370,9 @@ public function all (string $type, ResolutionContext $context):array {
* @param \FireHub\Container\Internal\ScopeState $scope
+ * 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
@@ -372,7 +385,7 @@ public function all (string $type, ResolutionContext $context):array {
*
* @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();
@@ -382,7 +395,8 @@ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scop
$type,
new ResolutionContext(
scoped: $scoped,
- scope: $scope
+ scope: $scope,
+ disposables: $disposables
)
);
@@ -460,6 +474,9 @@ public function tagged (string $tag, ResolutionContext $context):array {
* @param \FireHub\Container\Internal\ScopeState $scope
*
* @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,8 @@ public function tagged (string $tag):array {
return $this->resolver->taggedScoped(
$tag,
$this->instances,
- $this->state
+ $this->state,
+ $this->disposables
);
}
@@ -203,7 +216,8 @@ public function invoke (callable $callback, array $arguments = []):mixed {
$callback,
$arguments,
$this->instances,
- $this->state
+ $this->state,
+ $this->disposables
);
}
@@ -261,7 +275,8 @@ public function get (string $id):object {
return $this->resolver->getScoped(
$id,
$this->instances,
- $this->state
+ $this->state,
+ $this->disposables
);
}
@@ -362,7 +377,8 @@ public function all (string $type):array {
return $this->resolver->allScoped(
$type,
$this->instances,
- $this->state
+ $this->state,
+ $this->disposables
);
}
@@ -413,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.
*/
@@ -473,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;
@@ -491,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
*
*
@@ -502,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
*/
@@ -511,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);
+
+ }
+
+ }
}
From 7ab7da34edb6d47f03578aebf04a929d88c6289a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Thu, 22 Oct 2026 12:46:20 +0200
Subject: [PATCH 07/11] Add ContainerState class to manage dependency injection
lifecycle states
---
src/Container.php | 143 +++++++++++------------------
src/Internal/ContainerState.php | 158 ++++++++++++++++++++++++++++++++
src/Internal/ScopeRegistry.php | 4 +-
src/Resolver.php | 68 +++++++++++---
4 files changed, 271 insertions(+), 102 deletions(-)
create mode 100644 src/Internal/ContainerState.php
diff --git a/src/Container.php b/src/Container.php
index 58bfb97..ba31a08 100644
--- a/src/Container.php
+++ b/src/Container.php
@@ -21,12 +21,11 @@
Alias, Cls, Factory, Instance
};
use FireHub\Container\Internal\ {
- ContextualBindingRegistry, DisposalTracker, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeRegistry,
- TagRegistry
+ ContainerState, ContextualBindingRegistry, DisposalTracker, InstanceStore, ParameterBindingRegistry,
+ ResolutionContext, ScopeRegistry, TagRegistry
};
use FireHub\Container\Exception\ {
- ContainerActiveScopesException, ContainerClosedException, ContainerFrozenException, InvalidTagNameException,
- ServiceNotFoundException
+ ContainerActiveScopesException, InvalidTagNameException, ServiceNotFoundException
};
use FireHub\Runtime;
use Closure;
@@ -47,67 +46,61 @@
* resolution scope.
* @since 1.0.0
*/
-final class Container implements ContainerBoundary, Registrar {
+final readonly class Container implements ContainerBoundary, Registrar {
/**
* ### Service registry
* @since 1.0.0
*/
- private readonly Registry $registry;
+ private Registry $registry;
/**
* ### Service resolver
* @since 1.0.0
*/
- private readonly Resolver $resolver;
+ private Resolver $resolver;
/**
* ### Constructor parameter binding registry
* @since 1.0.0
*/
- private readonly ParameterBindingRegistry $parameters;
+ private ParameterBindingRegistry $parameters;
/**
* ### Contextual dependency binding registry
* @since 1.0.0
*/
- private readonly ContextualBindingRegistry $contextual;
+ private ContextualBindingRegistry $contextual;
/**
* ### Singleton instance store
* @since 1.0.0
*/
- private readonly InstanceStore $singletons;
+ private InstanceStore $singletons;
/**
* ### Service tag registry
* @since 1.0.0
*/
- private readonly TagRegistry $tags;
+ private TagRegistry $tags;
/**
* ### Active scope registry
* @since 1.0.0
*/
- private readonly ScopeRegistry $scopes;
+ private ScopeRegistry $scopes;
/**
* ### Singleton disposable resource tracker
* @since 1.0.0
*/
- private readonly DisposalTracker $disposables;
+ private DisposalTracker $disposables;
/**
- * ### Container shutdown state
+ * ### Container lifecycle state
* @since 1.0.0
*/
- private bool $closed = false;
-
- /**
- * ### Container freeze state
- * @var bool
- */
- private bool $frozen = false;
+ private ContainerState $state;
/**
* ### Constructor
@@ -124,6 +117,7 @@ public function __construct () {
$this->tags = new TagRegistry;
$this->scopes = new ScopeRegistry;
$this->disposables = new DisposalTracker;
+ $this->state = new ContainerState;
$this->resolver = new Resolver(
$this->registry,
@@ -131,7 +125,8 @@ public function __construct () {
$this->parameters,
$this->contextual,
$this->tags,
- $this->disposables
+ $this->disposables,
+ $this->state
);
}
@@ -149,14 +144,14 @@ public function __construct () {
*
* @since 1.0.0
*
- * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution.
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution.
* @uses \FireHub\Container\Internal\ScopeRegistry::register() To register a new scope.
*
* @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
*/
public function createScope ():ScopeBoundary {
- $this->assertOpen();
+ $this->state->assertOpen();
$scope = new Scope(
$this->resolver,
@@ -186,7 +181,7 @@ public function createScope ():ScopeBoundary {
*
* @since 1.0.0
*
- * @uses \FireHub\Container\Container::assertMutable() To check if the container is frozen.
+ * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To check if the container is frozen.
* @uses \FireHub\Container\Registry::register() To register a service definition.
*
* @template TService of object
@@ -206,7 +201,7 @@ public function createScope ():ScopeBoundary {
*/
public function register (string $id, Definition $definition):self {
- $this->assertMutable();
+ $this->state->assertMutable();
$this->registry->register($id, $definition);
@@ -239,8 +234,8 @@ public function register (string $id, Definition $definition):self {
*
* @since 1.0.0
*
- * @uses \FireHub\Container\Container::assertMutable()
- * @uses \FireHub\Container\Internal\ContextualBindingRegistry::register()
+ * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To check if the container is frozen.
+ * @uses \FireHub\Container\Internal\ContextualBindingRegistry::register() To register a contextual binding.
*
* @param class-string $consumer
* Consumer implementation class.
@@ -260,7 +255,7 @@ public function register (string $id, Definition $definition):self {
*/
public function bindContextual (string $consumer, string $dependency, string $implementation):self {
- $this->assertMutable();
+ $this->state->assertMutable();
$this->contextual->register(
$consumer,
@@ -590,7 +585,8 @@ public function alias (string $id, string $target):self {
*
* @since 1.0.0
*
- * @uses \FireHub\Container\Container::assertMutable() To ensure the container accepts configuration changes.
+ * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To ensure the container accepts
+ * configuration changes.
* @uses \FireHub\Container\Internal\ParameterBindingRegistry::register() To register parameter bindings.
*
* @param class-string $class
@@ -607,7 +603,7 @@ public function alias (string $id, string $target):self {
*/
public function bindParameters (string $class, array $parameters):self {
- $this->assertMutable();
+ $this->state->assertMutable();
$this->parameters->register($class, $parameters);
@@ -637,7 +633,7 @@ public function bindParameters (string $class, array $parameters):self {
*
* @since 1.0.0
*
- * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution.
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution.
* @uses \FireHub\Container\Resolver::all() To resolve matching services.
*
* @template TService of object
@@ -659,7 +655,7 @@ public function bindParameters (string $class, array $parameters):self {
*/
public function all (string $type):array {
- $this->assertOpen();
+ $this->state->assertOpen();
return $this->resolver->all(
$type,
@@ -722,7 +718,7 @@ public function all (string $type):array {
*
* @since 1.0.0
*
- * @uses \FireHub\Container\Container::assertMutable() To ensure the container is not frozen.
+ * @uses \FireHub\Container\Internal\ContainerState::assertMutable() To ensure the container is not frozen.
* @uses \FireHub\Container\Container::has() To verify explicit service registrations.
* @uses \FireHub\Container\Internal\TagRegistry::register() To register service tag associations.
* @uses \FireHub\Runtime\Str\SB\Transform::trim() To validate tag names.
@@ -743,7 +739,7 @@ public function all (string $type):array {
*/
public function tag (string $tag, string ...$services):self {
- $this->assertMutable();
+ $this->state->assertMutable();
if (Runtime\Str\SB\Transform::trim($tag) === '')
throw new InvalidTagNameException;
@@ -834,7 +830,7 @@ public function hasTag (string $tag):bool {
* @since 1.0.0
*
* @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers.
- * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution.
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution.
*
* @param non-empty-string $tag
* Tag name.
@@ -846,7 +842,7 @@ public function hasTag (string $tag):bool {
*/
public function taggedIds (string $tag):array {
- $this->assertOpen();
+ $this->state->assertOpen();
return $this->tags->get($tag);
@@ -881,7 +877,7 @@ public function taggedIds (string $tag):array {
* @since 1.0.0
*
* @uses \FireHub\Container\Resolver::tagged() To resolve tagged services.
- * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution.
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution.
*
* @param non-empty-string $tag
* Service tag name.
@@ -902,7 +898,7 @@ public function taggedIds (string $tag):array {
*/
public function tagged (string $tag):array {
- $this->assertOpen();
+ $this->state->assertOpen();
return $this->resolver->tagged(
$tag,
@@ -939,7 +935,7 @@ public function tagged (string $tag):array {
* @since 1.0.0
*
* @uses \FireHub\Container\Resolver::invoke() To execute the callable using dependency injection.
- * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open for resolution.
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open for resolution.
*
* @param callable $callback
* Callable to execute.
@@ -958,7 +954,7 @@ public function tagged (string $tag):array {
*/
public function invoke (callable $callback, array $arguments = []):mixed {
- $this->assertOpen();
+ $this->state->assertOpen();
return $this->resolver->invoke(
$callback,
@@ -989,7 +985,7 @@ public function invoke (callable $callback, array $arguments = []):mixed {
* @since 1.0.0
*
* @uses \FireHub\Container\Resolver::get() To resolve the service.
- * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open.
+ * @uses \FireHub\Container\Internal\ContainerState::assertOpen() To ensure the container is open.
*
* @template TService of object
*
@@ -1011,7 +1007,7 @@ public function invoke (callable $callback, array $arguments = []):mixed {
*/
public function get (string $id):object {
- $this->assertOpen();
+ $this->state->assertOpen();
return $this->resolver->get($id);
@@ -1038,7 +1034,7 @@ public function get (string $id):object {
*
* @since 1.0.0
*
- * @uses \FireHub\Container\Container::assertOpen() To ensure the container is open.
+ * @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
@@ -1051,7 +1047,7 @@ public function get (string $id):object {
*/
public function has (string $id):bool {
- $this->assertOpen();
+ $this->state->assertOpen();
return $this->registry->has($id);
@@ -1098,6 +1094,8 @@ public function has (string $id):bool {
* @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.
@@ -1106,12 +1104,12 @@ public function has (string $id):bool {
*/
public function close ():void {
- if ($this->closed) return;
+ if ($this->state->isClosed()) return;
if ($this->scopes->hasActive())
throw new ContainerActiveScopesException;
- $this->closed = true;
+ $this->state->close();
try {
@@ -1139,11 +1137,13 @@ public function close ():void {
*
* @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->closed;
+ return $this->state->isClosed();
}
@@ -1174,11 +1174,15 @@ public function isClosed ():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();
}
@@ -1199,48 +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 is open
- *
- * Prevents operations requiring an active container after shutdown.
- * @since 1.0.0
- *
- * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
- *
- * @return void
- */
- private function assertOpen ():void {
-
- if ($this->closed)
- throw new ContainerClosedException;
-
- }
-
- /**
- * ### 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.
- * @throws \FireHub\Container\Exception\ContainerClosedException If the container is closed.
- *
- * @return void
- */
- private function assertMutable ():void {
-
- $this->assertOpen();
-
- if ($this->frozen)
- throw new ContainerFrozenException;
+ return $this->state->isFrozen();
}
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/ScopeRegistry.php b/src/Internal/ScopeRegistry.php
index 7ca35de..5a5ea15 100644
--- a/src/Internal/ScopeRegistry.php
+++ b/src/Internal/ScopeRegistry.php
@@ -23,8 +23,8 @@
*
* Scopes are registered when created and removed when closed.
*
- * Weak references prevent the registry from retaining scopes solely because they
- * were registered.
+ * 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
diff --git a/src/Resolver.php b/src/Resolver.php
index 80e6d3a..eaac630 100644
--- a/src/Resolver.php
+++ b/src/Resolver.php
@@ -20,10 +20,10 @@
Alias, Cls, Factory, Instance, Service
};
use FireHub\Container\Internal\ {
- Autowirer, CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector,
- ContextualResolver, DependencyPlanCache, DisposalTracker, InstanceStore, MethodInvoker, MethodPlanCache,
- ParameterBindingRegistry, ParameterPlanCompiler, ParameterResolver, 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,
@@ -107,6 +107,9 @@
* @param \FireHub\Container\Internal\DisposalTracker $disposables
*
* @return void
*/
@@ -116,7 +119,8 @@ public function __construct (
ParameterBindingRegistry $parameters,
ContextualBindingRegistry $contextual,
private TagRegistry $tags,
- private DisposalTracker $disposables
+ private DisposalTracker $disposables,
+ private ContainerState $state
) {
$this->metadata = new ConstructorMetadataCache;
@@ -169,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);
}
@@ -191,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.
*
@@ -208,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)
@@ -256,6 +269,7 @@ 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.
*/
@@ -293,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.
@@ -313,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) {
@@ -382,6 +400,7 @@ 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.
*/
@@ -419,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.
*
@@ -438,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)
@@ -488,6 +511,7 @@ 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.
*/
@@ -523,6 +547,7 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc
* 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
@@ -536,11 +561,14 @@ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $sc
* @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,
@@ -614,6 +642,7 @@ public function invokeScoped (callable $callback, array $arguments, InstanceStor
* ### 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.
@@ -636,11 +665,14 @@ public function invokeScoped (callable $callback, array $arguments, InstanceStor
* @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)
@@ -974,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.
*/
@@ -1013,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.
*/
From 45bb67128e98635376fd757927a908e87365052e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Thu, 22 Oct 2026 12:52:22 +0200
Subject: [PATCH 08/11] Add ContainerState class to manage dependency injection
lifecycle states
---
src/Resolver.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/Resolver.php b/src/Resolver.php
index eaac630..3214784 100644
--- a/src/Resolver.php
+++ b/src/Resolver.php
@@ -773,8 +773,6 @@ private function resolveService (string $id, Service $definition, ResolutionCont
if (!$instance instanceof $id)
throw new ServiceTypeMismatchException;
- $store?->set($id, $instance);
-
if (
$instance instanceof Disposable
&& !$definition instanceof Instance
@@ -788,6 +786,8 @@ private function resolveService (string $id, Service $definition, ResolutionCont
}
+ $store?->set($id, $instance);
+
return $instance;
} finally {
From cb12556f033a980eec00ddcf4cfb2c6b6258e5e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Thu, 22 Oct 2026 13:04:24 +0200
Subject: [PATCH 09/11] Add disposable service classes for container lifecycle
testing
---
tests/Stubs/DisposableTestService.php | 42 ++
tests/Stubs/FailingDisposableTestService.php | 47 ++
tests/Stubs/FirstOrderedDisposableService.php | 20 +
tests/Stubs/OrderedDisposableTestService.php | 50 ++
.../Stubs/ResolvingDisposableTestService.php | 49 ++
.../Stubs/SecondOrderedDisposableService.php | 20 +
tests/Unit/ContainerLifecycleTest.php | 505 ++++++++++++++++++
tests/Unit/DisposableServicesTest.php | 426 +++++++++++++++
8 files changed, 1159 insertions(+)
create mode 100644 tests/Stubs/DisposableTestService.php
create mode 100644 tests/Stubs/FailingDisposableTestService.php
create mode 100644 tests/Stubs/FirstOrderedDisposableService.php
create mode 100644 tests/Stubs/OrderedDisposableTestService.php
create mode 100644 tests/Stubs/ResolvingDisposableTestService.php
create mode 100644 tests/Stubs/SecondOrderedDisposableService.php
create mode 100644 tests/Unit/ContainerLifecycleTest.php
create mode 100644 tests/Unit/DisposableServicesTest.php
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
From 0939208689827ce201481a8eb8162235a6c2d7a0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
Date: Thu, 22 Oct 2026 13:09:35 +0200
Subject: [PATCH 10/11] Update CHANGELOG and README for version 0.3.0 release
---
CHANGELOG.md | 22 ++++++++++++++++++++++
README.md | 2 +-
2 files changed, 23 insertions(+), 1 deletion(-)
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..0ebc44e 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
/>
From 607190f02975e513a3a5d35b389a98994fbf66db Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Danijel=20Gali=C4=87?=
<56673130+dieselxxx@users.noreply.github.com>
Date: Thu, 22 Oct 2026 13:10:38 +0200
Subject: [PATCH 11/11] Update README.md
---
README.md | 86 +++++++++++++++++++++++++++++++++----------------------
1 file changed, 52 insertions(+), 34 deletions(-)
diff --git a/README.md b/README.md
index 0ebc44e..2e44657 100644
--- a/README.md
+++ b/README.md
@@ -22,10 +22,6 @@
/>
-[](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPStan.yml)
-[](https://github.com/The-FireHub-Project/Container/actions/workflows/PHPUnit.yml)
-[](https://app.codecov.io/gh/The-FireHub-Project/Container/tree/develop)
-
-For internal testing only:
+For help with FireHub products, please refer to the following resources:
-```json
-{
- "require": {
- "the-firehub-project/container": "dev-develop"
- }
-}
-```
-⚠️ Never use dev-develop in production.
+- **General questions & discussions**: [GitHub Discussions](https://github.com/orgs/The-FireHub-Project/discussions)
+- **Bug reports & issues**: [Support](https://github.com/The-FireHub-Project/Container/issues)
+- **Documentation & guides**: [FireHub Docs](https://the-firehub-project.github.io)
+- **Commercial support** (Pro/Enterprise): contact your account manager
## Authors and Contributors
@@ -134,4 +152,4 @@ Architecture guidelines, design principles, and ecosystem documentation are avai
This software is licensed under the Apache-2.0 License.
-For more details, read the full license [here](./LICENSE).
\ No newline at end of file
+For more details, read the full license [here](./LICENSE).