Handling unresolvable parameters — primitives and defaults
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
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
// 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.