Laravel 10 to 12: when an optional dependency becomes null

The same constructor resolves differently across three locked Laravel versions. A runnable fixture exposes the failure, required-dependency fix and explicit-binding exception.
A resolved service can still fail at its first method call
The container builds a service successfully, but its first call throws an Error because its clock is null. The same service receives a clock under the older framework. That is the upgrade regression reproduced here: a nullable constructor parameter whose caller behaves as though the dependency were required.
This explainer is for an engineer checking a specified Laravel 10 to 11 to 12 path. It tests Laravel 10.50.3, 11.0.0 and 12.69.3 on the same PHP 8.3.35 runtime with separate dependency locks. It does not upgrade a complete application. These older pins are comparison fixtures, not installation recommendations. The included dated audit reports advisories in the Laravel 10 and 11 locks. The Laravel 12 lock has none.
Laravel lists security support for 10 ending on 4 February 2025 and for 11 on 12 March 2026. Keep the historical fixtures isolated. The versioned upgrade guides cover the broader migration inventory. This page concentrates on one constructor assumption that a successful dependency install does not exercise.
The default was not acting as the caller assumed
UpgradeClock in the bundle is a concrete class with a now method returning the fixed string 2026-10-13T00:00:00Z. It performs no I/O and measures no elapsed time. The optional consumer below immediately dereferences its clock without checking for null.
With no binding registered, the Laravel 10 and 11 containers construct UpgradeClock and the timestamp call returns the fixed string. Laravel 12 leaves the property null. Its timestamp call throws an Error, which the probe captures explicitly. Construction succeeding is therefore insufficient evidence that the service can perform its job.
The pinned older Container implementations try to resolve the class before using an available default after a BindingResolutionException. The pinned Laravel 12 implementation checks for a default first when no explicit or contextual binding exists. Our executed binding case covers an explicit binding. Contextual binding behavior is source evidence, not an additional tested case.
<?php
declare(strict_types=1);
final class OptionalClockConsumer
{
public function __construct(public ?UpgradeClock $clock = null) {}
public function timestamp(): string { return $this->clock->now(); }
}
Require the dependency when the operation requires it
The required consumer removes both the nullable type and the null default. The container supplies UpgradeClock and timestamp returns the same fixed string under all three locked frameworks. This makes the constructor match the method, which cannot operate without a clock.
When absence is valid, define how the method behaves without a clock. Returning a fallback value is a different application decision, and this fixture does not test one. Making the dependency required also does not prove that an application-specific clock interface has the binding it needs.
<?php
declare(strict_types=1);
final class RequiredClockConsumer
{
public function __construct(public UpgradeClock $clock) {}
public function timestamp(): string { return $this->clock->now(); }
}
An explicit binding changes the nullable case
This helper registers an explicit UpgradeClock binding before constructing the original optional consumer. Even under Laravel 12, the consumer receives an object and returns the fixed string. The null-default behavior applies to the unbound case in this fixture. It is not a rule that every nullable dependency resolves to null.
The pinned Laravel 12 default check tests whether the class is bound before returning its default. The probe uses a separate container for this case so the registration cannot make the unbound case pass accidentally. A binding is useful when the container needs an implementation choice. It can also conceal a nullable declaration that no longer reflects what the method requires.
<?php
declare(strict_types=1);
use Illuminate\Container\Container;
function resolveBoundClock(Container $container): OptionalClockConsumer
{
$container->bind(UpgradeClock::class, static fn () => new UpgradeClock());
return $container->make(OptionalClockConsumer::class);
}
Make an incomplete fix fail the same check
The runner changes the required consumer back to a nullable parameter with a null default. Under Laravel 12, that variant fails requiredValue_ASSERTION. A second variant removes the explicit binding and fails boundIsNull_ASSERTION. Both must exit 1 with their exact preceding observations and changed source hashes. An unrelated exception is not accepted as proof.
Download and extract the bundle, then run python3 verify.py with Docker available. Building downloads the pinned PHP image and locked packages. Probe execution has no network access. Expected JSON has status pass and five results: three normal version cases exit 0, and the two intended defects exit 1. The verifier exits 0 only when every runtime, source hash and observation matches.
The PHP probes run as an unprivileged user with a read-only root and bounded memory, process count, temporary storage, captured output and elapsed time. The bundle includes the locks and Composer audits dated 13 October 2026. Those audits describe these fixtures, not the security of your application or future dependency state.
Keep the rest of the migration outside this result
A constructor probe cannot establish schema compatibility, authentication behavior, queued-job compatibility or safe rollback. Laravel 11 also changes how omitted column modifiers behave during change migrations, and its upgrade guide supports retaining the Laravel 10 application structure. Neither migrating a skeleton nor passing this probe verifies those database changes.
I recommend giving each major upgrade its own reviewed change and application regression evidence, because a failing flow then has a smaller set of candidate changes. Exercise both construction and the calls that consume optional dependencies. Keep database and mixed-worker checks separate. This sequential container fixture cannot show that old and new releases can safely share stored data or jobs.
Reproduce the examples
Three framework locks on the same PHP runtime, actual optional and required consumers, explicit binding, two deliberate defects and a bounded verifier. Requires Docker and Python 3. Historical Laravel 10 and 11 locks have advisories; this does not certify an application upgrade.
Download the source and tests (.zip)
