0

Handling unresolvable parameters — primitives and defaults

Expert5 min read·fw-02-006

Concept

Auto-wiring breaks for exactly two categories of constructor parameter: built-in scalar types (int, string, float, bool, array) and parameters typed as mixed or left untyped. Both cases share the same root problem — the container cannot infer what value to inject from the type alone. A string $dsn could be any string; a int $timeout could be any number. The Reflection API provides the type but not the value.

The solution has three layers. First, check for a default value via ReflectionParameter::isDefaultValueAvailable() — if the class author supplied one, use it. Second, check the container's "with" stack: a set of primitive overrides the caller passes for a specific resolution call. Third, fall back to throwing BindingResolutionException with a message that tells the developer exactly which parameter is unresolvable and in which class.

Laravel handles this in Container::resolveNonClass(). It checks the current "override" stack ($this->with) for a matching key, then falls back to the default, then throws. Our implementation mirrors this with a $with parameter on make() so the caller can supply primitives when needed.

The design decision here is about DX: the error message must name the class and parameter clearly, because silent failures or vague exceptions waste hours of debugging time. When you see "Unresolvable dependency resolving [$dsn] in class [Database\Connection]" you know immediately what to fix.

This lesson also introduces the $with pattern — passing primitive overrides through the resolution chain — which becomes the foundation for contextual binding of scalars in the next lesson.

Code Example

php
<?php
declare(strict_types=1);

namespace Framework\Container;

use ReflectionParameter;
use ReflectionNamedType;
use Framework\Container\Exceptions\BindingResolutionException;

/**
 * Extended resolveParameter() that handles primitives, defaults,
 * and caller-supplied overrides via the $with stack.
 *
 * Drop these methods into the Container class from fw-02-005.
 */
trait ResolvesParameters
{
    /**
     * Stack of primitive overrides for the current resolution chain.
     * Each entry is an associative array: ['paramName' => value].
     *
     * @var array<int, array<string, mixed>>
     */
    protected array $with = [];

    /**
     * Resolve a class from the container, optionally injecting primitive overrides.
     *
     * Usage:
     *   $container->makeWith(Connection::class, ['dsn' => 'sqlite::memory:']);
     */
    public function makeWith(string $abstract, array $primitives = []): mixed
    {
        $this->with[] = $primitives;

        try {
            $result = $this->make($abstract);
        } finally {
            // Always pop — even if an exception is thrown.
            array_pop($this->with);
        }

        return $result;
    }

    /**
     * Resolve a single constructor parameter.
     *
     * Priority:
     *  1. Typed class/interface — recurse into make()
     *  2. Caller override ($with stack) — keyed by parameter name
     *  3. Default value declared in the class
     *  4. Throw BindingResolutionException
     */
    protected function resolveParameter(ReflectionParameter $parameter): mixed
    {
        $type = $parameter->getType();

        // 1. Class or interface type → recurse.
        if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) {
            return $this->resolveClass($parameter, $type->getName());
        }

        // 2. Check caller-supplied primitive overrides (top of stack first).
        $override = $this->getOverride($parameter->getName());
        if ($override !== null) {
            return $override;
        }

        // 3. Fall back to the parameter's own default value.
        if ($parameter->isDefaultValueAvailable()) {
            return $parameter->getDefaultValue();
        }

        // 4. Nothing works — tell the developer what to fix.
        $this->throwUnresolvable($parameter);
    }

    /**
     * Resolve a class-typed parameter.
     * Handles nullable types: if $type is nullable and resolution fails,
     * we return null rather than throwing.
     */
    protected function resolveClass(ReflectionParameter $parameter, string $className): mixed
    {
        try {
            return $this->make($className);
        } catch (BindingResolutionException $e) {
            // If the parameter is optional (nullable or has a default), swallow.
            if ($parameter->isOptional()) {
                return $parameter->isDefaultValueAvailable()
                    ? $parameter->getDefaultValue()
                    : null;
            }

            throw $e;
        }
    }

    /**
     * Look through the $with stack (newest first) for a primitive override
     * matching the given parameter name.
     */
    protected function getOverride(string $paramName): mixed
    {
        foreach (array_reverse($this->with) as $overrides) {
            if (array_key_exists($paramName, $overrides)) {
                return $overrides[$paramName];
            }
        }

        return null; // Sentinel: no override found.
    }

    /**
     * Throw a descriptive exception for an unresolvable primitive parameter.
     *
     * @throws BindingResolutionException
     */
    protected function throwUnresolvable(ReflectionParameter $parameter): never
    {
        $class = $parameter->getDeclaringClass()?->getName() ?? 'unknown';

        throw new BindingResolutionException(
            sprintf(
                "Unresolvable dependency resolving [\$%s] in class [%s]. " .
                "The parameter has no type-hint, no default value, and no override was supplied. " .
                "Use makeWith() to pass a value for this parameter.",
                $parameter->getName(),
                $class
            )
        );
    }
}

// -------------------------------------------------------------------------
// Example classes that demonstrate the three resolution paths
// -------------------------------------------------------------------------

class DatabaseConnection
{
    public function __construct(
        public readonly string $dsn,                      // primitive — needs override
        public readonly string $username    = 'root',     // primitive — has default
        public readonly string $password    = '',         // primitive — has default
        public readonly int    $timeout     = 30,         // primitive — has default
    ) {}
}

class QueryLogger {}

class Database
{
    public function __construct(
        public readonly DatabaseConnection $connection,   // class — auto-wired
        public readonly QueryLogger        $logger,       // class — auto-wired
    ) {}
}
php
<?php
// Resolution in action

$container = new Framework\Container\Container();

// Auto-wiring fails for DatabaseConnection because $dsn has no default.
// Use makeWith() to supply the primitive:
$connection = $container->makeWith(DatabaseConnection::class, [
    'dsn'      => 'mysql:host=localhost;dbname=myapp',
    'username' => 'myapp_user',
    'password' => 's3cret',
]);

// Now bind it so the container can inject it into Database.
$container->instance(DatabaseConnection::class, $connection);

// Database is fully auto-wired (DatabaseConnection now comes from instance()).
$database = $container->make(Database::class);

Interview Q&A

Q: Why can't the container auto-inject a string $dsn parameter even if only one string is registered in the container?

The container's resolution algorithm is type-driven, not value-driven. It looks up the type name as the key in its bindings map. The type of string $dsn is the PHP builtin string — not a class name, so there is no lookup key. Even if you registered $container->instance('string', 'mysql:...'), PHP's Reflection reports the type as 'string' (a builtin), which our code deliberately skips. This is a deliberate safety boundary: injecting all strings of a given value would be ambiguous and dangerous.


Q: How does Laravel's Container::resolveNonClass() differ from our resolveParameter() for the primitive case?

Laravel's implementation checks $this->with (the override stack, populated by makeWith()) using the parameter name as key, then falls back to $parameter->getDefaultValue(), then throws. Our implementation follows the identical three-step logic. The key difference is that Laravel also supports contextual binding for primitives via when()->needs()->give() — for example $this->app->when(Connection::class)->needs('$dsn')->give(config('db.dsn')). This is a syntactic wrapper that populates an internal $contextual map rather than the $with stack, which is covered in the next lesson.


Q: What is the purpose of the $with stack being an array of arrays rather than a flat map?

The stack design handles nested resolutions correctly. When make(A::class) triggers make(B::class) which triggers make(C::class), each makeWith() call pushes its own override set. Using a stack means overrides are scoped to their resolution depth: overrides for A do not bleed into C's resolution, and the finally block in makeWith() guarantees the correct set is popped even if an exception interrupts the chain. A flat map would allow inner resolutions to accidentally consume outer overrides, producing hard-to-debug incorrect injections.