All the logic inside execute(), exit code always zero, no lifecycle thinking at all. A controller without a request. I still see this console command in every second codebase. Symfony stopped being only an HTTP framework a long time ago, but habits are slower than releases, and with 8.1 giving console and workers first-class attention the excuse is gone.

An HTTP request lives for milliseconds. The kernel builds services, handles, throws everything away. A worker lives for hours. Same container, very different lifecycle. Every service that quietly keeps state, an in-memory cache, an accumulating buffer, an entity manager full of tracked objects, is invisible in HTTP and becomes a memory leak in a worker. If your consumer needs a nightly restart by cron, this is where the night went.

The fix that worked for us is structural. The command is an adapter, ten lines:

final class ImportCommand extends Command
{
    public function __construct(private ImportOrders $useCase)
    {
        parent::__construct();
    }

    protected function execute(InputInterface $in, OutputInterface $out): int
    {
        $report = $this->useCase->run(BatchSize::fromInput($in));
        $out->writeln($report->summary());

        return $report->failed() ? Command::FAILURE : Command::SUCCESS;
    }
}

The use case knows nothing about the console. The same use case is callable from a message handler, from the scheduler, from a test. Web and workers become equal adapters over one application. Which is what they always were. We just did not write it that way.

Two more worker rules. Handle the stop signal and finish the current item, do not die in the middle of it. And watch worker memory in production the way you watch response time for HTTP. A worker has no request duration graph to embarrass you, so nobody looks until the OOM killer does.

The nightly cron restart in that project was mine. The comment above it still says temporary.