bind-it — The DI Container Engine
The phpshots/bind-it package provides the reflection-based dependency injection container that powers both CoreContainer and ModuleContainer. It is a lightweight, framework-independent DI engine with contextual bindings, extenders, and PSR-11 compliance.
Why bind-it?
- Reflection-based autowiring: the container inspects constructor signatures and resolves dependencies automatically, even for unbound classes
- Flexible bindings: abstract interfaces to concrete implementations, via closures, callables, or class strings
- Contextual bindings: different implementations of the same interface depending on which class requests it
- Singletons and factories: manage lifecycle — app-lifetime instances or fresh instances per request
- Extenders: modify resolved instances after construction (decoration pattern)
- Callbacks: react to resolution events with
resolving()andrebinding()hooks - PSR-11 compliance: implements
Psr\Container\ContainerInterfacefor standard interop
The kernel layers its scope-isolation rules on top of bind-it's core, so modules cannot escape to internals of other modules and the framework cannot be bypassed.
Container API
The core Container class from bind-it provides reflection-based dependency injection. The kernel's CoreContainer extends it with additional methods like instance() for pre-built objects.
Binding — multiple shapes
$container = new Container();
// 1. Simple binding — class-string concrete
$container->bind(UserRepository::class, UserMySQLRepository::class);
// 2. Binding with a factory closure
$container->bind(DatabasePort::class, function ($container) {
return new MySQLAdapter(config('database'));
});
// 3. Singleton — instantiate once, reuse forever
$container->singleton(TransactionManager::class, TransactionManager::class);
// 4. Conditional binding — only if not already bound
$container->bindIf(MailPort::class, SendgridMailAdapter::class);
// 5. Pre-built instance (CoreContainer only)
$core->instance(CachePort::class, $redisAdapter);Resolution
// Fetch an instance — autowires constructor dependencies
$user = $container->make(UserService::class);
// PSR-11: has() and get()
if ($container->has(UserService::class)) {
$user = $container->get(UserService::class);
}
// Check if a key is bound
$bound = $container->bound(UserService::class);Autowiring and optional dependencies
The container inspects constructor parameters using reflection. For each parameter:
- If a type hint exists, the container attempts to resolve it
- If the parameter has a default value and resolution fails, the default is used
- If the parameter is required and cannot be resolved, an exception is thrown
class InvoiceService
{
// Both DatabasePort and TransactionManager are autowired.
// TransactionManager is optional — if it cannot be resolved, its default (null) is used.
public function __construct(
private readonly DatabasePort $db,
private readonly ?TransactionManager $txn = null,
) {}
}
$service = $container->make(InvoiceService::class);
// $txn is null if TransactionManager is unbound or cannot be resolvedNew in 1.17.1: optional constructor dependencies (parameters with = null or other defaults) now receive their declared default when the container cannot resolve them, instead of throwing an exception. This allows graceful degradation when optional ports or services are not bound.
Contextual bindings
A contextual binding lets you specify: "when class A requests interface I, give it implementation X; when class B requests I, give it Y."
// Default: all requesters get MySQLRepository
$container->bind(UserRepository::class, MySQLRepository::class);
// But when InvoiceService requests UserRepository, use CacheWrapper instead
$container->when(InvoiceService::class)
->needs(UserRepository::class)
->give(CacheWrapper::class);
// Factory closure also works
$container->when(PaymentService::class)
->needs(PaymentGateway::class)
->give(function ($container) {
return new StripeGateway(env('STRIPE_KEY'));
});The most specific binding wins: a contextual binding overrides the default for that requestor only.
Extenders — decorate after construction
Extend a service to modify every instance after it is built:
// Every DatabasePort gets wrapped with timing instrumentation
$container->extend(DatabasePort::class, function ($db, $container) {
return new TimedDatabasePort($db);
});Extenders run in the order registered, and chain together — the output of one becomes the input to the next.
Lifecycle hooks — resolving() and rebinding()
React when an instance is resolved or rebound:
// Fires every time a TransactionManager is resolved
$container->resolving(TransactionManager::class, function ($instance, $container) {
// Log it, cache it, attach observers, etc.
});
// Fires when a binding is rebound after resolution
$container->rebinding(TransactionManager::class, function ($new, $container) {
// Instances that hold the old binding can update references here
});Frozen containers
In the kernel, the CoreContainer is frozen after the Kernel materializes (first entry-point call):
$core->freeze(); // Lock the container from further bindings
$core->isFrozen(): bool // Check if locked
// After freeze, bind() and singleton() throw LogicException
// This prevents accidental runtime modifications and forces configuration
// to happen during bootstrap, not mid-requestShared instances (singletons)
An instance is "shared" (singleton) if:
- It was bound with
singleton() - It was stored with
instance() - The container marks it as shared via internal
share()call
// Both calls return the SAME instance
$txn1 = $container->make(TransactionManager::class);
$txn2 = $container->make(TransactionManager::class);
// $txn1 === $txn2 is trueNon-singleton bindings return a fresh instance on each make() call.
Method bindings
Call a method on a resolved instance with container-injected arguments:
$container->bindMethod('send-mail', function ($mailer) {
return $mailer->send(...);
});
$result = $container->callMethodBinding('send-mail', $mailerInstance);This is a low-level tool; most code uses dependency injection directly instead.
How the kernel uses bind-it
CoreContainer — app-lifetime bindings
The CoreContainer extends Container and holds:
- Port implementations (DatabasePort, CachePort, etc.)
- App-lifetime singleton services (TransactionManager, EventBus, etc.)
- Frozen after materialize (first entry-point call) to prevent mid-request changes
$core = new CoreContainer();
$core->instance(DatabasePort::class, new MySQLAdapter(...));
$core->singleton(TransactionManager::class, ...);
$core->freeze(); // Lock it from further changesModuleContainer — request-scoped bindings
The ModuleContainer extends Container and adds scope enforcement:
- Holds module-scoped services, repositories, and gateways
- Falls back to the
CoreContainerfor unbound keys (ports + kernel services) - Built fresh for every request and job by the
OnDemandLoader, and dropped afterwards, so bindings never leak between requests
// Inside a Provider::register(ModuleContainer $container)
$container->bindInternal(InvoiceRepository::class, ...); // only resolvable inside this module
$container->bind(InvoiceServiceContract::class, ...); // public contractModuleContainer::reset() clears every binding and callback. The kernel never needs it because it never reuses a container; it is there for code that pools containers itself.
Common patterns
Registering a module's services
class Provider implements ModuleContract
{
public function register(ModuleContainer $container): void
{
// Internal — hidden from other modules
$container->bindInternal(InvoiceRepository::class, fn($c) =>
new InvoiceRepository($c->make(DatabasePort::class))
);
// Public — exported contract
$container->bind(InvoiceServiceContract::class, fn($c) =>
new InvoiceService(
repository: $c->make(InvoiceRepository::class),
transaction: $c->make(TransactionManager::class),
)
);
}
}Conditional port binding
// Use a real adapter in production, a fake in tests
if (env('APP_ENV') === 'testing') {
$core->instance(DatabasePort::class, new FakeDatabasePort());
} else {
$core->instance(DatabasePort::class, new MySQLAdapter(...));
}Lazy loading with closures
// The closure is called only when the key is requested
$core->singleton(HeavyService::class, function ($container) {
return new HeavyService(...); // Built only on first resolution
});Gotchas and pitfalls
Never use getInstance() or setInstance()
The kernel disables Container::getInstance() and Container::setInstance() by throwing LogicException. These methods are forbidden to prevent hidden global state that causes:
- Coroutine safety issues in Swoole (request isolation breaks)
- Debugging difficulty (state comes from nowhere)
- Testing complexity (global state must be reset)
Always inject the container as a constructor dependency instead.
Frozen containers cannot be rebound
After CoreContainer::freeze(), attempting to call bind(), singleton(), or instance() throws LogicException. Freeze happens during Kernel::materialize() — the first entry-point call (HTTP, CLI, or worker). All bootstrap-time configuration must complete before materialize.
This is by design: it forces wiring to happen during startup, not mid-request.
Contextual bindings require the requesting class
The container resolves contextual bindings by inspecting what CLASS requested the dependency. This only works if:
- The requesting class is being built by the container (via
make()) - The container can inspect its constructor signature
You cannot provide a contextual binding for an already-instantiated object requesting something; it must be part of a constructor dependency chain.