0

Contextual bindings — when() interface implementation

Expert5 min read·fw-02-007
laravel-src

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
<?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
<?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
<?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.