bind() — storing closures as factories
Concept
bind() is the fundamental registration method of a dependency injection container. It answers the question: "When someone asks for an AbstractInterface, what should I build for them?" The answer is a factory — a callable that knows how to construct the concrete implementation. Every time the abstract is requested, the factory runs and produces a fresh instance.
The storage structure for bindings is simple: a hash map keyed by the abstract identifier (usually a class name or interface name), storing the concrete (the factory callable) and a shared flag (for singleton behaviour, covered in fw-02-003). In Laravel, this is $this->bindings on Illuminate\Container\Container, an array of shape ['abstract' => ['concrete' => ..., 'shared' => bool]].
The bind() method accepts two forms for $concrete: a Closure (the factory explicitly provided by the caller) or a class name string (which the container will auto-wire by reflection in fw-02-005). For now, we handle closures. The closure receives the container itself as its first argument, which allows factory code to resolve sub-dependencies: fn($c) => new UserRepository($c->make(DatabaseConnection::class)).
A subtle API design decision: should bind() accept the concrete as a class name string, allowing callers to write bind(UserRepositoryInterface::class, UserRepository::class) without a closure? Yes — this is more convenient and is what Laravel supports. When a string is passed, the container normalises it internally into a closure: fn($c) => $c->make(UserRepository::class). This normalisation happens inside getClosure().
The contract for bindings is: calling make($abstract) after bind($abstract, $concrete) produces a new instance each time (non-shared). This is the factory behaviour. Singletons (shared instances) are the exception, not the rule — stateless services like Repositories and Mailers are usually non-shared.
Code Example
<?php
declare(strict_types=1);
namespace Lumen\Container;
/**
* Adds bind() to the Container scaffold from fw-02-001.
*
* Laravel equivalent: Illuminate\Container\Container::bind()
*/
class Container implements \Psr\Container\ContainerInterface
{
protected array $bindings = [];
protected array $instances = [];
// ------------------------------------------------------------------
// Registration
// ------------------------------------------------------------------
/**
* Register a binding with the container.
*
* @param string $abstract Interface or class name
* @param Closure|string|null $concrete Factory, class name, or null (auto-wire $abstract)
* @param bool $shared true = singleton behaviour
*/
public function bind(
string $abstract,
\Closure|string|null $concrete = null,
bool $shared = false
): void {
// If no concrete is provided, use the abstract as its own concrete.
// This allows: $c->bind(UserRepository::class) → auto-wires itself.
if ($concrete === null) {
$concrete = $abstract;
}
// Normalise a class-name string into a closure for uniform storage.
if (!$concrete instanceof \Closure) {
$concrete = $this->getClosure($abstract, $concrete);
}
$this->bindings[$abstract] = [
'concrete' => $concrete,
'shared' => $shared,
];
// If a singleton was already resolved, its cached instance is now stale.
// Remove it so the next make() uses the new binding.
unset($this->instances[$abstract]);
}
/**
* Register a binding only if no binding already exists for the abstract.
* Useful in packages: register defaults without overriding app customisation.
*/
public function bindIf(string $abstract, \Closure|string|null $concrete = null): void
{
if (!$this->has($abstract)) {
$this->bind($abstract, $concrete);
}
}
/**
* Store a pre-built instance. Equivalent to binding a singleton
* where the instance is already created.
*/
public function instance(string $abstract, mixed $instance): void
{
$this->instances[$abstract] = $instance;
}
// ------------------------------------------------------------------
// Resolution (stub — make() is fully implemented in fw-02-004)
// ------------------------------------------------------------------
public function make(string $abstract, array $parameters = []): mixed
{
// Return a cached singleton if available.
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
if (!isset($this->bindings[$abstract])) {
throw new NotFoundException("No binding for [{$abstract}].");
}
$binding = $this->bindings[$abstract];
$concrete = $binding['concrete'];
$shared = $binding['shared'];
// Invoke the factory closure with the container as the first argument.
$instance = $concrete($this, $parameters);
// Cache if shared (singleton).
if ($shared) {
$this->instances[$abstract] = $instance;
}
return $instance;
}
// ------------------------------------------------------------------
// PSR-11
// ------------------------------------------------------------------
public function get(string $id): mixed
{
if (!$this->has($id)) {
throw new NotFoundException("No binding for [{$id}].");
}
return $this->make($id);
}
public function has(string $id): bool
{
return isset($this->bindings[$id]) || isset($this->instances[$id]);
}
// ------------------------------------------------------------------
// Private helpers
// ------------------------------------------------------------------
/**
* Wrap a class-name string in a closure so all bindings are uniform.
*
* When $abstract !== $concrete, we call make($concrete) which may
* itself trigger further auto-wiring or binding lookups.
* When $abstract === $concrete, we call build() directly to avoid
* infinite recursion.
*/
private function getClosure(string $abstract, string $concrete): \Closure
{
return function (Container $container, array $parameters) use ($abstract, $concrete) {
if ($abstract === $concrete) {
// Direct instantiation — covered fully in fw-02-005
return $container->build($concrete, $parameters);
}
return $container->make($concrete, $parameters);
};
}
/**
* Placeholder: Reflection-based instantiation arrives in fw-02-005.
*/
protected function build(string $concrete, array $parameters = []): mixed
{
// Stub: assume no constructor arguments for now.
return new $concrete(...$parameters);
}
}
// ------------------------------------------------------------------
// Usage demonstration
// ------------------------------------------------------------------
interface LoggerInterface
{
public function log(string $message): void;
}
class FileLogger implements LoggerInterface
{
public function __construct(private readonly string $path = '/tmp/app.log') {}
public function log(string $message): void
{
file_put_contents($this->path, date('Y-m-d H:i:s') . " {$message}\n", FILE_APPEND);
}
}
// Register: bind the interface to the implementation via a closure
$container = new Container();
$container->bind(LoggerInterface::class, fn($c) => new FileLogger('/var/log/app.log'));
// Resolve: each call returns a NEW instance
$logger1 = $container->make(LoggerInterface::class);
$logger2 = $container->make(LoggerInterface::class);
// $logger1 !== $logger2 — two separate FileLogger instancesInterview Q&A
Q: Why does bind() normalise string concrete values into closures rather than storing the string directly?
Uniform storage simplifies the resolution path. If some bindings store closures and others store strings, make() needs an if/else to handle both cases. By normalising strings to closures at bind-time, make() always calls $concrete($this, $parameters) with no branching. The normalisation cost is paid once at registration. This is exactly what Laravel's Container::getClosure() does: it wraps the string in a closure that calls $container->build($concrete). The resulting code in make() is clean and consistent regardless of what form the concrete was originally registered in.
Q: What is the purpose of bindIf()?
bindIf() registers a binding only if one does not already exist. This is critical for framework packages that want to provide default implementations: a logging package can call $container->bindIf(LoggerInterface::class, FileLogger::class) in its service provider. If the application has already registered a custom implementation, bindIf() is a no-op — the application's choice wins. Without bindIf(), the last bind() call would win, making load order a source of bugs. Laravel uses bindIf() extensively in its built-in service providers (SessionServiceProvider, QueueServiceProvider, etc.) to register defaults without clobbering application customisations.
Q: Why does bind() delete the cached singleton instance when re-binding?
If a singleton has already been resolved and cached in $this->instances, and you then call bind() with a new factory for the same abstract, the next make() call should use the new factory, not the old cached instance. Without the unset($this->instances[$abstract]), re-binding would have no effect for already-resolved singletons. This is critical during testing: tests often re-bind services with mocks using $container->bind(ServiceInterface::class, fn() => $mock), and if the real implementation was already resolved (e.g., during bootstrap), the re-bind would silently be ignored. Laravel's container does this cleanup in bind() via $this->dropStaleInstances($abstract).