singleton() — caching resolved instances
Concept
A singleton binding is a binding where the concrete is built exactly once, and every subsequent resolution returns the same instance. This is one of the most important features of a container because many services are stateless and expensive to construct: database connections, configuration repositories, event dispatchers. You want one connection object shared across all parts of the application, not one connection per make() call.
The implementation is elegantly simple: after the factory runs and produces an instance, store it in an $instances array keyed by the abstract. On subsequent make() calls, check the $instances array first and return early without calling the factory again. This is a memoisation pattern applied at the container level.
The word "singleton" here is slightly different from the Gang of Four Singleton pattern. GoF Singleton hardcodes the instance inside the class itself with a private constructor and a static getInstance() method. Container singletons keep the instance inside the container, not the class. This is a crucial distinction: a class registered as a singleton in one container can be instantiated normally in another container (e.g., a test container). GoF Singletons make testing nearly impossible because you cannot swap the instance. Container singletons are testable because you control the container.
The singleton() method is just a convenience alias for bind($abstract, $concrete, shared: true). Laravel's implementation in Illuminate/Container/Container.php is literally: public function singleton($abstract, $concrete = null) { $this->bind($abstract, $concrete, true); }.
A related concept is scoped(), introduced in Laravel 8. Scoped bindings behave like singletons within a single request, but are reset between requests in long-running processes (Laravel Octane). Our implementation will skip scoped bindings for simplicity, but understanding why they exist is important: in a traditional PHP-FPM model, each request is a new process so all singletons are fresh. In Octane's long-lived process model, singletons from request N bleed into request N+1 unless explicitly reset.
Code Example
<?php
declare(strict_types=1);
namespace Lumen\Container;
/**
* Adds singleton() to the Container.
*
* singleton() = bind() with shared: true.
* The first make() call runs the factory; all subsequent calls return
* the same cached instance.
*
* Laravel equivalent: Illuminate\Container\Container::singleton()
*/
class Container implements \Psr\Container\ContainerInterface
{
protected array $bindings = [];
protected array $instances = [];
// ------------------------------------------------------------------
// Registration
// ------------------------------------------------------------------
public function bind(
string $abstract,
\Closure|string|null $concrete = null,
bool $shared = false
): void {
if ($concrete === null) {
$concrete = $abstract;
}
if (!$concrete instanceof \Closure) {
$concrete = $this->getClosure($abstract, $concrete);
}
$this->bindings[$abstract] = compact('concrete', 'shared');
// Invalidate any cached instance for this abstract.
unset($this->instances[$abstract]);
}
/**
* Register a shared binding (singleton).
* The factory runs once; subsequent make() calls return the cached instance.
*/
public function singleton(
string $abstract,
\Closure|string|null $concrete = null
): void {
$this->bind($abstract, $concrete, shared: true);
}
/**
* Register a pre-built instance as a singleton.
* Equivalent to calling singleton() and immediately caching the instance.
*/
public function instance(string $abstract, mixed $instance): mixed
{
$this->instances[$abstract] = $instance;
return $instance;
}
// ------------------------------------------------------------------
// Resolution
// ------------------------------------------------------------------
public function make(string $abstract, array $parameters = []): mixed
{
// 1. Pre-built instance? Return immediately (no factory call).
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
// 2. No binding at all? Attempt auto-wiring (fw-02-005) or throw.
if (!isset($this->bindings[$abstract])) {
// Auto-wire if it's a concrete class (not an interface).
// Full implementation in fw-02-005; stub here.
if (class_exists($abstract)) {
return $this->build($abstract, $parameters);
}
throw new NotFoundException("No binding for [{$abstract}].");
}
$binding = $this->bindings[$abstract];
$concrete = $binding['concrete'];
$shared = $binding['shared'];
// 3. Invoke the factory.
$instance = $concrete($this, $parameters);
// 4. Cache if shared.
if ($shared) {
$this->instances[$abstract] = $instance;
}
return $instance;
}
/**
* Remove a resolved singleton from the instance cache.
* Forces make() to build a fresh instance on the next call.
*/
public function forgetInstance(string $abstract): void
{
unset($this->instances[$abstract]);
}
/** Remove all resolved singleton instances. */
public function forgetInstances(): void
{
$this->instances = [];
}
// ------------------------------------------------------------------
// 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
// ------------------------------------------------------------------
private function getClosure(string $abstract, string $concrete): \Closure
{
return function (Container $container, array $parameters) use ($abstract, $concrete): mixed {
if ($abstract === $concrete) {
return $container->build($concrete, $parameters);
}
return $container->make($concrete, $parameters);
};
}
protected function build(string $concrete, array $parameters = []): mixed
{
return new $concrete(...$parameters);
}
}
// ------------------------------------------------------------------
// Demo: non-shared (factory) vs shared (singleton)
// ------------------------------------------------------------------
$container = new Container();
// Factory: new instance on every make()
$container->bind(
\DateTimeImmutable::class,
fn() => new \DateTimeImmutable()
);
$a = $container->make(\DateTimeImmutable::class);
usleep(1000); // 1ms pause
$b = $container->make(\DateTimeImmutable::class);
assert($a !== $b, 'Factory bindings produce different instances.');
// Singleton: same instance every time
$container->singleton(
\DateTimeImmutable::class,
fn() => new \DateTimeImmutable()
);
$c = $container->make(\DateTimeImmutable::class);
$d = $container->make(\DateTimeImmutable::class);
assert($c === $d, 'Singleton bindings return the same instance.');