677 words, 4 min read

When you chain multiple Artisan commands together in Laravel, there's a subtle trap waiting for you — one that's easy to miss because the commands still run, they just stop talking.

The setup

A common pattern in Laravel applications is a "meta-command" that runs a sequence of other commands in order. Think of a nightly job runner:

public function handle(): int
{
$commandsToRun = [
SomeCommand::class => [],
AnotherCommand::class => [],
DatabasePruneCommand::class => ['--quiet' => true],
MoreCommands::class => [],
FinalCommand::class => [],
];
foreach ($commandsToRun as $commandToRun => $parameters) {
$this->output->title($commandToRun);
Artisan::call($commandToRun, $parameters, $this->output);
}
return self::SUCCESS;
}

You pass $this->output into each Artisan::call so the sub-command's output flows through to the console. Clean and straightforward.

The problem

One of your commands — say, a prune command — is noisy by default. You don't want its output cluttering the logs, so you pass '--quiet' => true. The title still appears, the command runs, everything looks fine.

Then you notice something odd: the commands after that quiet one stop printing their titles. The log files confirm they're running, but the console goes dark after that one --quiet call.

Why it happens

When Laravel processes the --quiet flag on a command, it calls:

$output->setVerbosity(OutputInterface::VERBOSITY_QUIET);

The key word is on the output object you passed in. Because you shared $this->output across all Artisan::call invocations, that single setVerbosity call mutates the object in place. Every subsequent command — and every title(), info(), or line() call — now runs against a quietly-configured output. They're suppressed silently, with no error.

The commands still execute; they just can't speak.

The fix

Capture the verbosity before each call and restore it immediately after:

foreach ($commandsToRun as $commandToRun => $parameters) {
$this->output->title($commandToRun);
$verbosity = $this->output->getVerbosity();
Artisan::call($commandToRun, $parameters, $this->output);
$this->output->setVerbosity($verbosity);
}

Two lines. The sub-command can do whatever it likes to the output during its run; your sequence always gets a clean slate for the next iteration.

The lesson

Shared mutable objects are the classic source of action-at-a-distance bugs. The output object here is a perfect example: you pass it in expecting read-like behaviour (writing to the terminal), but the callee has full write access to its configuration too.

A few takeaways:

  • Passing an object "for output" also grants mutation rights. Laravel's OutputStyle is stateful — verbosity, decorations, and more can be changed by anyone holding a reference.
  • Commands that still run aren't necessarily commands that are working correctly. Silent output suppression looks identical to "nothing went wrong" if you're only checking exit codes or log files.
  • Test the observable side effects, not just execution. A test that asserts title() is called N times would have caught this immediately; a test that only checks Artisan::call was invoked N times would not.

Bonus: write the test first

If you'd written this test before the bug appeared, you'd have been protected from day one:

public function it_restores_verbosity_so_quiet_commands_do_not_suppress_subsequent_output(): void
{
// Simulate a sub-command that sets quiet mode
Artisan::shouldReceive('call')
->twice()
->andReturnUsing(function () use ($output): int {
$output->setVerbosity(OutputInterface::VERBOSITY_QUIET);
return 0;
});
// Both titles must still appear
$output->expects($this->exactly(2))->method('title');
$this->subject->runSequenceOfCommands($command, [
'first:command' => [],
'second:command' => [],
]);
}

Shared mutable state is everywhere in framework code. A little defensive save-and-restore goes a long way.