Contextual bindings — when() interface implementation
Concept
Contextual binding solves a concrete problem: the same interface needs different implementations depending on which class is consuming it. Consider a LoggerInterface — your PaymentService should write to a dedicated payment log file, while your UserService writes to the general application log. Both services use LoggerInterface as their type-hint, but you want different concrete classes injected. A flat bind() cannot express this because it is unconditional.
The API Laravel introduces for this is a fluent builder: $app->when(PaymentService::class)->needs(LoggerInterface::class)->give(PaymentLogger::class). Our framework mirrors this pattern with a ContextualBindingBuilder class that accumulates the three pieces of data (the concrete class being built, the abstract it needs, and what to give it) before writing them into the container's $contextual map.
The storage structure is a two-dimensional array: $contextual[concrete][abstract] = resolver. During resolveClass(), before falling back to the normal make() path, the container checks this map: "am I currently building PaymentService? Does it need LoggerInterface? If so, give it PaymentLogger." The "currently building" context comes from a $buildStack array — a LIFO stack of class names the container is in the middle of constructing.
This is the same structure Laravel uses in Illuminate\Container\Container. The $contextual property is a two-dimensional array, and findInContextualBindings() walks the build stack looking for a match. Our implementation is deliberately simplified — we only check the immediate parent in the build stack rather than the full chain — but that covers 99% of real-world usage.
The give() method accepts either a class name string or a Closure, matching Laravel's flexibility: you can give a pre-configured instance, a factory, or a primitive value.
Code Example
<?php
declare(strict_types=1);
namespace Framework\Container;
use Closure;
/**
* Fluent builder returned by Container::when().
*
* Usage:
* $container->when(PaymentService::class)
* ->needs(LoggerInterface::class)
* ->give(PaymentLogger::class);
*/
final class ContextualBindingBuilder
{
public function __construct(
private readonly Container $container,
private readonly string $concrete, // The class being built
) {}
private string $abstract = ''; // The dependency being needed
public function needs(string $abstract): static
{
$this->abstract = $abstract;
return $this;
}
/**
* @param Closure|string|mixed $implementation
*/
public function give(mixed $implementation): void
{
$this->container->addContextualBinding(
$this->concrete,
$this->abstract,
$implementation
);
}
}<?php
declare(strict_types=1);
namespace Framework\Container;
use Closure;
use ReflectionClass;
use ReflectionParameter;
use ReflectionNamedType;
use Framework\Container\Exceptions\BindingResolutionException;
class Container
{
protected array $bindings = [];
protected array $instances = [];
protected array $with = [];
/**
* Two-dimensional contextual binding map.
* Shape: $contextual[ConcreteClass][AbstractOrParamName] = resolver
*
* @var array<string, array<string, mixed>>
*/
protected array $contextual = [];
/**
* Stack of class names currently being built.
* Used to determine the "context" during resolution.
*
* @var string[]
*/
protected array $buildStack = [];
// -------------------------------------------------------------------------
// Contextual binding API
// -------------------------------------------------------------------------
public function when(string $concrete): ContextualBindingBuilder
{
return new ContextualBindingBuilder($this, $concrete);
}
public function addContextualBinding(string $concrete, string $abstract, mixed $implementation): void
{
$this->contextual[$concrete][$abstract] = $implementation;
}
// -------------------------------------------------------------------------
// Core binding / resolution (previous lessons)
// -------------------------------------------------------------------------
public function bind(string $abstract, Closure|string $concrete, bool $shared = false): void
{
if (is_string($concrete)) {
$concrete = fn (self $c) => $c->build($concrete);
}
$this->bindings[$abstract] = ['concrete' => $concrete, 'shared' => $shared];
}
public function singleton(string $abstract, Closure|string $concrete): void
{
$this->bind($abstract, $concrete, shared: true);
}
public function instance(string $abstract, object $instance): void
{
$this->instances[$abstract] = $instance;
}
public function has(string $id): bool
{
return isset($this->bindings[$id]) || isset($this->instances[$id]);
}
public function get(string $id): mixed
{
return $this->make($id);
}
public function make(string $abstract): mixed
{
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
// Check contextual bindings first — most-specific wins.
$contextual = $this->findContextualBinding($abstract);
if ($contextual !== null) {
return $this->resolveContextual($contextual);
}
if (isset($this->bindings[$abstract])) {
$binding = $this->bindings[$abstract];
$object = ($binding['concrete'])($this);
if ($binding['shared']) {
$this->instances[$abstract] = $object;
}
return $object;
}
return $this->build($abstract);
}
// -------------------------------------------------------------------------
// Contextual resolution helpers
// -------------------------------------------------------------------------
/**
* Find a contextual binding for $abstract given the current build stack.
* Checks the immediate parent (top of build stack) first.
*/
protected function findContextualBinding(string $abstract): mixed
{
if (empty($this->buildStack)) {
return null;
}
$parent = end($this->buildStack);
return $this->contextual[$parent][$abstract] ?? null;
}
/**
* Resolve a contextual binding value (class name, closure, or scalar).
*/
protected function resolveContextual(mixed $binding): mixed
{
if ($binding instanceof Closure) {
return $binding($this);
}
if (is_string($binding) && class_exists($binding)) {
return $this->make($binding);
}
// Primitive / scalar value (e.g. a config string for $dsn).
return $binding;
}
// -------------------------------------------------------------------------
// Reflection-based build
// -------------------------------------------------------------------------
public function build(string $concrete): object
{
$reflector = new ReflectionClass($concrete);
if (! $reflector->isInstantiable()) {
throw new BindingResolutionException(
"Target [{$concrete}] is not instantiable."
);
}
// Push onto build stack so nested resolveClass() calls know the context.
$this->buildStack[] = $concrete;
try {
$constructor = $reflector->getConstructor();
if ($constructor === null) {
return new $concrete();
}
$dependencies = array_map(
fn (ReflectionParameter $p) => $this->resolveParameter($p),
$constructor->getParameters()
);
return $reflector->newInstanceArgs($dependencies);
} finally {
array_pop($this->buildStack);
}
}
protected function resolveParameter(ReflectionParameter $parameter): mixed
{
$type = $parameter->getType();
if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) {
return $this->resolveClassParam($parameter, $type->getName());
}
if ($parameter->isDefaultValueAvailable()) {
return $parameter->getDefaultValue();
}
throw new BindingResolutionException(
"Unresolvable dependency [\${$parameter->getName()}]."
);
}
protected function resolveClassParam(ReflectionParameter $parameter, string $className): mixed
{
try {
return $this->make($className);
} catch (BindingResolutionException $e) {
if ($parameter->isOptional()) {
return $parameter->isDefaultValueAvailable()
? $parameter->getDefaultValue()
: null;
}
throw $e;
}
}
}<?php
// Contextual binding demonstration
interface LoggerInterface {
public function log(string $message): void;
}
class FileLogger implements LoggerInterface {
public function log(string $message): void { echo "[FILE] $message\n"; }
}
class PaymentLogger implements LoggerInterface {
public function log(string $message): void { echo "[PAYMENT] $message\n"; }
}
class UserService {
public function __construct(public readonly LoggerInterface $logger) {}
}
class PaymentService {
public function __construct(public readonly LoggerInterface $logger) {}
}
$container = new Framework\Container\Container();
// Default binding for LoggerInterface.
$container->bind(LoggerInterface::class, FileLogger::class);
// Contextual override: PaymentService gets PaymentLogger.
$container->when(PaymentService::class)
->needs(LoggerInterface::class)
->give(PaymentLogger::class);
$userService = $container->make(UserService::class); // FileLogger
$paymentService = $container->make(PaymentService::class); // PaymentLogger
// Contextual binding with a primitive (e.g. a DSN config value):
$container->when(DatabaseConnection::class)
->needs('$dsn')
->give('mysql:host=localhost;dbname=myapp');Interview Q&A
Q: How does the container know which class is "currently being built" when resolving a contextual binding?
The container maintains a $buildStack — an array of fully-qualified class names, pushed when build() starts and popped when it finishes (in a finally block to ensure cleanup on exceptions). When resolveParameter() calls make(LoggerInterface::class), findContextualBinding() peeks at the top of the stack to get the parent class name, then checks $contextual[parent][LoggerInterface]. Laravel uses the identical mechanism in Illuminate\Container\Container, where the property is also called $buildStack.
Q: What is the difference between a contextual binding and a standard bind() call, and when does each win?
A standard bind() is unconditional — it applies whenever the abstract is resolved from anywhere. A contextual binding is conditional — it applies only when the abstract is resolved while building a specific parent class. Contextual bindings take precedence over regular bindings because findContextualBinding() is checked before the $bindings map in make(). This mirrors Laravel's resolution order in Container::resolve(): contextual bindings are checked via findInContextualBindings() first, falling back to getConcrete() (which reads the regular bindings) only when no contextual match is found.
Q: Can you use a contextual binding to inject a primitive scalar (a string or int) rather than a class instance?
Yes — this is one of the most practical uses. In give(), you pass the scalar directly. The container's resolveContextual() detects that the value is neither a Closure nor a class name and returns it as-is. Laravel supports the same pattern via ->give('mysql:host=...') or ->give(fn () => config('db.dsn')). The parameter name (e.g. '$dsn') is used as the key in $contextual rather than a type name, so in findContextualBinding() you must also check against the parameter name, not just the type. This is a common interview follow-up: the lookup key is the abstract type or the dollar-sign-prefixed parameter name.