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
OutputStyleis 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 checksArtisan::callwas 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.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.