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.