The symptom
I recently added a new command to my Laravel app, and locally everything worked exactly as expected. Tests passed, the command ran fine, nothing looked wrong. But when I pushed and CI ran, it failed before a single test had even started:
TypeError: Cannot assign null to property ThirdPartyApi::$baseUrl of type stringNo test file mentioned in the trace. No test runner output at all. Just a hard failure, during composer install.
A minimal reproduction
ThirdPartyApi itself isn’t new, it’s been in the codebase for months, before we added the new command, everything has been quietly working without any issues, the Ci had been working, all tests would pass, and no issues in production.
class ThirdPartyApi{ private string $baseUrl;
public function __construct() { $this->baseUrl = config('services.third-party.url'); }
public function getUrl(): string { return $this->baseUrl; }}The $baseUrl is loaded from config/services.php, which is pulling an env variable:
'third-party' => [ 'url' => env('THIRD_PARTY_URL'),],If THIRD_PARTY_URL isn’t set anywhere, env() returns null, and assigning null to a non-nullable typed property blows up with a TypeError. Fine, that’s a bug, and we should deal with it, but it’s in my .env.ci file, so why is this throwing a TypeError when my CI is trying to run?
The only thing that changed was the new command, which injected ThirdPartyApi through the constructor, this was done because we use it in a couple of functions in this command.
class SomeNewCommand extends Command{ protected $signature = 'app:some-new-command';
public function __construct( private ThirdPartyApi $api ) { parent::__construct(); }}So what’s happening?
When composer install is run, it will load in all of the composer vendor files, before triggering the composer post-autoload-dump script, which in turn run @php artisan package:discover --ansi. And this is where we start to encounter our issue.
TypeError
Cannot assign null to property App\Services\ThirdPartyApi::$baseUrl of type string
at app\Services\ThirdPartyApi.php:11
7▕ private string $baseUrl; 8▕ 9▕ public function __construct() 10▕ { -> 11▕ $this->baseUrl = config('services.third-party.url'); 12▕ } 13▕ 14▕ public function getUrl(): string 15▕ {
1 [internal]:0 App\Services\ThirdPartyApi::__construct()
2 vendor\laravel\framework\src\Illuminate\Container\Container.php:1211 ReflectionClass::newInstanceArgs([])The package:discover command doesn’t call app:some-new-command, and has no reason to know it exists. But Laravel’s console kernel doesn’t see it that way.
So what is actually happening? By default, Laravel registers every class in app/Console/Commands for you via ApplicationBuilder::withCommands(), which falls back to app_path('Console/Commands') when you haven’t listed commands explicitly:
public function withCommands(array $commands = []){ if (empty($commands)) { $commands = [$this->app->path('Console/Commands')]; }
$this->app->afterResolving(ConsoleKernel::class, function ($kernel) use ($commands) { // ... });}That will eventually reach Kernel::load(), which walks every file in the directory and, for anything that looks like a command, registers a callback that resolves it out of the container as soon as the console application starts:
protected function load($paths){ // ... foreach (Finder::create()->in($paths)->files() as $file) { $command = $this->commandClassFromFile($file, $namespace);
if (is_subclass_of($command, Command::class) && ! (new ReflectionClass($command))->isAbstract()) { Artisan::starting(function ($artisan) use ($command) { $artisan->resolve($command); }); } }}Artisan::starting callbacks fire on every artisan boot, not just when that specific command is invoked.
So the container builds SomeNewCommand (and therefore ThirdPartyApi, and therefore reads THIRD_PARTY_URL) any time you run any artisan command, purely so it can register the command’s signature and description. handle() never runs, but the constructor always does.
Why did it break the CI specifically?
composer install and composer dump-autoload both trigger a post-autoload-dump script, and Laravel’s skeleton wires that up to php artisan package:discover --ansi.
"scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ]}That means package:discover runs as a side effect of installing composer dependencies, at the very start of the pipeline, before secrets have been injected and before a .env file exists at all.
Every environment variable is unset at that point, so the job dies during dependency install, before it reaches the test step.
Locally I never noticed, because my own .env already had THIRD_PARTY_URL set.
The fix
Giving the config value a fallback in config/services.php (env('THIRD_PARTY_URL', '')) or making the property nullable will stop the crash, but it just relocates the problem to wherever getUrl() is actually used, and it hides a genuinely missing configuration value instead of surfacing it.
The better fix is to RTFM, Laravel supports dependency injection in the handle() function, so the service is only resolved when the command is actually executed, not every time artisan boots.
Note that we are able to request any dependencies we need via the command’s handle method. The Laravel service container will automatically inject all dependencies that are type-hinted in this method’s signature
class SomeNewCommand extends Command{ protected $signature = 'app:some-new-command';
public function handle(ThirdPartyApi $api) { // Command function here }}With no constructor at all, the container never has a reason to build ThirdPartyApi during command discovery. package:discover, and every other artisan invocation, goes back to only paying for what it actually calls.
tl;dr
Just RTFM.
If you want to inject dependencies into your commands, don’t use the __construct method, use the handle method instead. It’ll stop a lot of headaches before they begin.
