Containers: CoreContainer and ModuleContainer
HKM Kernel uses two DI containers with distinct lifetimes and purposes: CoreContainer for app-lifetime bindings and ModuleContainer for request-scoped bindings. Both extend the bind-it container for reflection-based autowiring, with the ModuleContainer adding scope isolation enforcement. This page covers their API, lifecycle, and OpenSwoole safety.
CoreContainer: App-Lifetime
The core container is constructed during Kernel::build() and exists for the lifetime of the process. It holds:
- Ports — adapters bound by
Kernel::withPorts()(DatabasePort → MySQLAdapter, etc.). - Kernel services — long-lived objects like EventBus, pipelines, ErrorPipeline (bound during materialize).
- Shared kernel utilities — UrlGenerator, Scheduler (lazy singletons).
Every request-scoped ModuleContainer delegates to the core when a binding is not found locally (the core is its fallback).
API
instance(string $abstract, object $instance): void
Bind a pre-built object eagerly. Used for ports and kernel services.
$core->instance(DatabasePort::class, $mysqlAdapter);
$core->instance(CachePort::class, $redisAdapter);Must be called before materialize. After that, throws LogicException.
bind(string $abstract, Closure|string|null $concrete = null, bool $shared = false): void
Bind a factory (lazy or not). A closure is resolved (and cached if $shared = true) on first use.
$core->bind(MyService::class, fn($c) => new MyService($c->make(DatabasePort::class)));Must be called before materialize.
singleton(string $abstract, Closure|string|null $concrete = null): void
Shorthand for bind(..., shared: true). Resolved once, cached for the lifetime of the core.
$core->singleton(UrlGenerator::class, fn() => UrlGenerator::fromManifest(...));extend(string $abstract, Closure $callback): void
Wrap an existing binding with decorators.
$core->extend(MailPort::class, fn($mail, $c) => new LoggingMailAdapter($mail));make(string $abstract, array $parameters = []): mixed
Resolve and return an instance. Uses reflection-based autowiring for concrete classes.
$db = $core->make(DatabasePort::class);
$service = $core->make(InvoiceService::class); // autowiredhas(string $id): bool
Check if a binding or concrete class is resolvable.
if ($core->has(CachePort::class)) {
$cache = $core->make(CachePort::class);
}freeze(): void
Lock the container against further registration. Called by Kernel::materialize() after every module's boot() has completed.
After this, any call to bind(), singleton(), instance(), or extend() throws LogicException.
isFrozen(): bool
Check whether the container is frozen.
if ($core->isFrozen()) {
// No new bindings may be registered
}Freezing: Why and When
Why it matters:
- Under OpenSwoole/Swoole, a binding registered in one coroutine would leak into all others (shared app-lifetime state).
- Under PHP-FPM, a binding registered in one request would persist into the next (unexpected shared state across requests).
When it happens:
- Materialize calls
freeze()after every module'sboot()completes and all pipelines are wired. - A request handler or test that tries to bind something now fails with a clear error.
Error message:
CoreContainer::bind() was called after the kernel froze the container.
Do not register bindings into the core (app-lifetime) container during a request.Disabled: getInstance() and setInstance()
Both are disabled and throw LogicException:
CoreContainer::getInstance(); // ✗ throws
CoreContainer::setInstance($c); // ✗ throwsWhy: A global container singleton is unsafe under OpenSwoole (shared across coroutines) and incorrect under FPM (persists across requests). Inject the container via:
Kernel::container()(for tests and CLI tooling).- Constructor injection into a Provider's
register()method.
ModuleContainer: Request-Scoped
A fresh ModuleContainer is created for every request (or every job) and discarded when the request ends. It holds request-scoped bindings from every module that is loaded for that request, plus request-scoped kernel services (Identity, TransactionManager, DomainEventCollector).
Lifetime
- OnDemandLoader creates a fresh
ModuleContainerper request/job. - Each loaded module's
Provider::register()is called with the container as the active scope. - The controller is resolved and executed.
- Request ends → the container is dropped and garbage-collected. The kernel never reuses a
ModuleContainer, under FPM or OpenSwoole alike, so nothing needs to callreset(). - Zero state leaks to the next request.
API (Public Contract)
Most methods are inherited from the bind-it Container, with a few additions for scope enforcement.
bind(string $abstract, Closure|string|null $concrete = null, bool $shared = false): void
Bind a public contract (resolvable by any module that requires it).
// Inside PaymentModule::register()
$container->bind(PaymentServiceContract::class, fn($c) =>
new PaymentService($c->make(PaymentGateway::class))
);Automatically threads the current scope so the binding is marked as public (resolvable from anywhere).
singleton(string $abstract, Closure|string|null $concrete = null): void
Bind a public singleton (resolved once, shared within the request).
$container->singleton(PaymentGateway::class, fn($c) => new PaymentGateway(...));instance(string $abstract, object $instance): void
Bind a pre-built instance (public).
$container->instance(PaymentGateway::class, $gateway);Mirrors CoreContainer::instance() but threads the current scope so the binding is public.
bindInternal(string $abstract, Closure $factory): void
Bind an INTERNAL binding — only resolvable from within the owning module's scope. Throws ScopeViolationException if resolved from another scope.
// Inside PaymentModule::register()
$container->bindInternal(PaymentRepository::class, fn($c) =>
new PaymentRepository($c->make(DatabasePort::class), ...)
);Always a singleton within the request (internal bindings are never transient).
Used for: hiding module implementation details (repositories, internal services).
setScope(string $scope): void
Set the current module domain for bindings. Called by OnDemandLoader before each register() call.
// Inside OnDemandLoader::loadWithIdentity()
$container->setScope('invoice.generation');
$provider->register($container); // bindings registered now are scoped to 'invoice.generation'make(string $abstract, array $parameters = []): mixed
Resolve a binding, enforcing scope isolation.
Checks:
- If the binding is
internaland the caller scope does not match the binding's scope, throwScopeViolationException. - If the binding is in this module, thread its scope so nested
make()calls (dependencies) inherit it. - Delegate to the CoreContainer if not found locally.
- Autowire a concrete class if it exists.
- Throw
EntryNotFoundExceptionif nothing matches.
// Inside an InvoiceModule service
$repo = $container->make(InvoiceRepository::class); // ✓ internal, same scope
$payment = $container->make(PaymentServiceContract::class); // ✓ public, cross-module
// From PaymentModule
$repo = $container->make(InvoiceRepository::class); // ✗ throws ScopeViolationExceptionmakeInScope(string $abstract, string $scope): mixed
Resolve an entry on behalf of an explicit caller scope. Used by ExecuteStage so a module entry point (controller) can reach its own internal bindings.
// Inside ExecuteStage (HTTP pipeline)
$controller = $container->makeInScope(
'InvoiceModule\\Http\\Controllers\\InvoiceController',
'invoice.generation' // scope of the route's owning module
);
// The controller's constructor can now autowire InvoiceRepository (internal to invoice.generation)has(string $id): bool
Check if a binding is resolvable (from this module or the core).
if ($container->has(PaymentServiceContract::class)) {
$payment = $container->make(PaymentServiceContract::class);
}Scope Isolation at Runtime
The container tracks which scope registered each binding:
private array $bindingScope = []; // abstract => scope domain
private array $internal = []; // abstract => is internal?When a binding is resolved, the resolver's scope is compared against the binding's owning scope:
// Inside ModuleContainer::make()
if (($this->internal[$resolved] ?? false) && $caller !== ($this->bindingScope[$resolved] ?? '')) {
throw new ScopeViolationException(
"Cannot resolve internal [{$abstract}] from scope [" . ($caller ?: 'kernel') . "].\n"
. 'It is internal to scope [' . ($this->bindingScope[$resolved] ?? '') . "].\n"
. 'Use the module\'s published contract from API/Contracts/ instead.'
);
}The caller scope is threaded automatically:
- When a binding starts resolving, its owning scope is pushed onto a resolution stack.
- Nested
make()calls during that resolution inherit the scope from the stack. - When resolution completes, the scope is popped.
This enforces the rule: a module's internal bindings are accessible only from within that module.
Default Parameter Fallback
A constructor parameter with a default value receives it if the binding is unbound:
final class InvoiceService {
public function __construct(
private readonly InvoiceRepository $repo,
private readonly ?MailPort $mail = null, // ← optional
) {}
}If MailPort is unbound, the service receives null instead of an exception. If InvoiceRepository is unbound, the exception is thrown (required).
Why: Modules often depend on optional cross-cutting concerns (logging, caching) that may not be loaded in a particular request.
Disabled: getInstance() and setInstance()
Both throw LogicException:
ModuleContainer::getInstance(); // ✗ throws
ModuleContainer::setInstance($c); // ✗ throwsWhy: Request-scoped state must never be stored as a global singleton. It would leak Identity, transaction state, and module bindings across requests under OpenSwoole.
Lifecycle: reset()
Under standard PHP-FPM/CLI, the container is garbage-collected when the request ends. Under OpenSwoole/Swoole, you may want to pool or reuse container instances. Call reset() before reuse:
public function reset(): voidClears all internal state (bindings, resolved instances, callbacks) so the container can be reused safely. The kernel's default design creates a fresh container per request (via OnDemandLoader), so this is a safety escape-hatch rather than required.
Relationship: Core ↔ Module
CoreContainer (app-lifetime, frozen)
↑ fallback / delegation
│
ModuleContainer (request-scoped, fresh per request)
├─ public bindings (resolvable by any module)
├─ internal bindings (scoped to owning module)
└─ request-scoped kernel services
(Identity, TransactionManager, EventBus)How delegation works:
- Module container tries to resolve the binding locally.
- If not found, delegates to the core container.
- If the core has it, returns the value.
- If not found anywhere, throws
EntryNotFoundException(or autowires a concrete class).
What lives where:
- Core: Ports (DatabasePort, CachePort, …), kernel services (EventBus, pipelines, UrlGenerator, Scheduler, ErrorPipeline), configuration.
- Module: Services (public and internal), repositories, gateways, domain entities.
- Kernel services (request-scoped): Identity, TransactionManager, DomainEventCollector, EventBus rebind.
Common Patterns
Opt-In Cross-Module Dependency
Module A depends on a service from Module B only when explicitly loaded:
// Module A's module.json
"requires": ["invoice.generation"] // load Module B when Module A is needed
// Module A's Provider::register()
$container->bind(MyService::class, fn($c) =>
new MyService(
$c->make(InvoiceServiceContract::class) // ✓ cross-module, public contract
)
);Request-Scoped Infrastructure Override
Override a core port for a single request (e.g. tenant-specific database):
// Inside an after.load hook
$request->container()->instance(DatabasePort::class, $tenantDb);The module container now uses $tenantDb for the rest of the request, while the core's binding stays unchanged (used by other requests).
Lazy Service Construction
Use a closure to defer expensive work until first use:
$container->singleton(CachableService::class, fn($c) =>
new CachableService($c->make(CachePort::class))
);The service is built only on first resolution, not when register() runs.
OpenSwoole Safety
The two-container design is essential for OpenSwoole worker safety:
- CoreContainer is frozen — no mutations during requests.
- ModuleContainer is request-scoped — fresh per coroutine, garbage-collected immediately.
- No static properties — request-scoped classes never store state in statics (would leak across coroutines).
- No global singletons —
getInstance()/setInstance()are disabled.
A request-scoped binding stored as a static or global singleton would leak Identity, transaction state, and module bindings into subsequent requests. The framework prevents this with structure, not just convention.
Pitfalls
Storing a ModuleContainer:
class MyCache {
private $container;
public function __construct(ModuleContainer $container) {
$this->container = $container; // ✗ stale after request
}
}The container is request-scoped. Store what you need from it, not the container itself.
Trying to rebind a core port:
// ✗ throws LogicException (core is frozen)
$core->instance(DatabasePort::class, $newAdapter);Rebind it into the module container instead (via an after.load hook).
Assuming autowiring works for abstract types:
// ✗ throws EntryNotFoundException
class MyService {
public function __construct(SomeInterfaceContract $dep) {} // not bound
}Explicitly bind the interface to an implementation in register().
Extending Containers: The bind-it Foundation
Both containers extend PHPShots\Common\Container, which provides:
- Reflection-based autowiring — constructor parameter types are auto-resolved.
- Contextual bindings — bind different implementations for different contexts.
- Extenders — wrap existing bindings with decorators.
- Resolving callbacks — hooks that fire when a type is resolved.
- PSR-11 compliance —
get(),has()interface.
See the bind-it documentation for complete API reference.