DEV Community

Cover image for Symfony Console Commands as Thin Adapters Over Use Cases
Gabriel Anhaia
Gabriel Anhaia

Posted on

Symfony Console Commands as Thin Adapters Over Use Cases


You open a Symfony app and find app:import-orders. The execute() method is 180 lines. It reads a CSV, validates rows, maps them to entities, checks for duplicates against the database, sends a Slack notification on failure, wraps everything in a transaction, and prints a progress bar. All of it lives inside one method that only runs when someone types the command in a terminal.

Then a requirement lands: the same import needs to run from an HTTP webhook. Now you're copy-pasting 180 lines into a controller, or you're calling the console command from the controller through Application::run(), which is the kind of thing that gets a pull request rejected.

The command did too much. A console command is an adapter. Its job is to translate the terminal into a call your application already understands, take the result, and translate it back into text. The business logic was never supposed to live in execute().

What "thin" actually means

A thin command does three things, in order:

  1. Parse input. Arguments, options, STDIN become plain PHP values.
  2. Call a use case with those values.
  3. Format the result. Success line, table, exit code.

Nothing between step 1 and step 3 knows it's running in a terminal. The use case takes a request object, does the work, returns a response object. It has no idea whether a human typed the command or a cron job fired it.

Here's the use case first, because it's the part that matters. It's a plain class. No Symfony Console imports.

<?php
// src/Application/ImportOrders/ImportOrders.php
namespace App\Application\ImportOrders;

use App\Domain\Order\OrderRepository;

final class ImportOrders
{
    public function __construct(
        private readonly OrderParser $parser,
        private readonly OrderRepository $orders,
    ) {
    }

    public function __invoke(
        ImportOrdersRequest $request,
    ): ImportOrdersResponse {
        $rows = $this->parser->parse($request->csvPath);

        $imported = 0;
        $skipped = [];

        foreach ($rows as $row) {
            if ($this->orders->existsByRef($row->reference)) {
                $skipped[] = $row->reference;
                continue;
            }
            if (!$request->dryRun) {
                $this->orders->add($row->toOrder());
            }
            $imported++;
        }

        return new ImportOrdersResponse($imported, $skipped);
    }
}
Enter fullscreen mode Exit fullscreen mode

The request and response are dumb DTOs. They carry data across the boundary and nothing else.

<?php
// src/Application/ImportOrders/ImportOrdersRequest.php
namespace App\Application\ImportOrders;

final class ImportOrdersRequest
{
    public function __construct(
        public readonly string $csvPath,
        public readonly bool $dryRun = false,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode
<?php
// src/Application/ImportOrders/ImportOrdersResponse.php
namespace App\Application\ImportOrders;

final class ImportOrdersResponse
{
    /** @param list<string> $skippedRefs */
    public function __construct(
        public readonly int $imported,
        public readonly array $skippedRefs,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

The command, now that it's boring

With the use case doing the work, the command has almost nothing left. That's the goal. Read it top to bottom and you can see the whole shape in one screen.

<?php
// src/Infrastructure/Cli/ImportOrdersCommand.php
namespace App\Infrastructure\Cli;

use App\Application\ImportOrders\ImportOrders;
use App\Application\ImportOrders\ImportOrdersRequest;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:import-orders',
    description: 'Import orders from a CSV file.',
)]
final class ImportOrdersCommand extends Command
{
    public function __construct(
        private readonly ImportOrders $importOrders,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument(
                'file',
                InputArgument::REQUIRED,
                'Path to the CSV file',
            )
            ->addOption(
                'dry-run',
                null,
                InputOption::VALUE_NONE,
                'Parse and report, write nothing',
            );
    }
Enter fullscreen mode Exit fullscreen mode

And execute(), the three steps in order:

    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        $io = new SymfonyStyle($input, $output);

        // 1. parse input
        $request = new ImportOrdersRequest(
            csvPath: $input->getArgument('file'),
            dryRun: (bool) $input->getOption('dry-run'),
        );

        // 2. call the use case
        $response = ($this->importOrders)($request);

        // 3. format output
        $io->success(sprintf(
            '%d orders imported.',
            $response->imported,
        ));

        if ($response->skippedRefs !== []) {
            $io->warning(sprintf(
                '%d skipped (duplicates): %s',
                count($response->skippedRefs),
                implode(', ', $response->skippedRefs),
            ));
        }

        return Command::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

The command imports two things from your application: the use case and its request DTO. It imports a pile of things from Symfony Console. That ratio is the tell. Console types stay on the command side of the wall. Domain types stay on the other side. The DTO is the only thing that crosses.

The test that gets cheaper

Here's the payoff. You want to test the import logic: duplicates are skipped, the count is right, nothing writes on a dry run. None of that needs a terminal, a CommandTester, or a booted kernel.

<?php
// tests/Application/ImportOrdersTest.php
namespace App\Tests\Application;

use App\Application\ImportOrders\ImportOrders;
use App\Application\ImportOrders\ImportOrdersRequest;
use PHPUnit\Framework\TestCase;

final class ImportOrdersTest extends TestCase
{
    public function test_skips_existing_references(): void
    {
        $orders = new InMemoryOrderRepository(
            existingRefs: ['ORD-1'],
        );
        $useCase = new ImportOrders(
            new FakeParser(refs: ['ORD-1', 'ORD-2']),
            $orders,
        );

        $response = $useCase(
            new ImportOrdersRequest('/tmp/x.csv'),
        );

        self::assertSame(1, $response->imported);
        self::assertSame(['ORD-1'], $response->skippedRefs);
    }
}
Enter fullscreen mode Exit fullscreen mode

Plain TestCase. No KernelTestCase. No container boot. The test constructs the use case with fakes and asserts on the response object. It runs in milliseconds and it fails for exactly one reason: the import logic is wrong.

Compare that to testing through CommandTester, where a failure could mean the argument name changed, the output string changed, the exit code changed, or the actual logic broke. When the command is thin, there's barely anything left in it worth testing that way. A single smoke test that the command wires input to the use case is plenty.

<?php
// tests/Infrastructure/ImportOrdersCommandTest.php
namespace App\Tests\Infrastructure;

use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;

final class ImportOrdersCommandTest extends KernelTestCase
{
    public function test_reports_import_count(): void
    {
        $app = new Application(self::bootKernel());
        $tester = new CommandTester(
            $app->find('app:import-orders'),
        );

        $tester->execute(['file' => __DIR__.'/two.csv']);

        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString(
            '2 orders imported',
            $tester->getDisplay(),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

One test crosses the wire and boots the kernel. The rest of your coverage sits in fast unit tests against the use case. That split is the whole reason to keep the command thin.

Where the terminal-only concerns go

A thin command still owns things that are genuinely about the terminal. Keep them there:

  • Progress bars. A ProgressBar is output formatting. If the use case needs to report progress, hand it a callback or a small progress-reporter interface, and let the command supply the terminal implementation. The use case calls $onRow($ref); it doesn't know a bar is drawing.
  • Interactive prompts. $io->ask(), confirmations, password input. Gather them in execute() and fold the answers into the request DTO before the use case runs.
  • Exit codes. Mapping a response to Command::SUCCESS, Command::FAILURE, or Command::INVALID is the command's call, because exit codes are a terminal contract.

The rule of thumb: if you'd need a different implementation when the same operation runs from HTTP, it's a use case concern. If it only makes sense in front of a person at a shell, it stays in the command.

Errors: catch at the edge, throw in the core

The use case throws domain exceptions. FileNotReadable, InvalidCsvRow, whatever your domain calls them. It never touches an exit code, because it doesn't know it's in a command.

The command catches them and decides what the terminal should see.

protected function execute(
    InputInterface $input,
    OutputInterface $output,
): int {
    $io = new SymfonyStyle($input, $output);

    try {
        $response = ($this->importOrders)(
            new ImportOrdersRequest(
                csvPath: $input->getArgument('file'),
            ),
        );
    } catch (FileNotReadable $e) {
        $io->error($e->getMessage());
        return Command::FAILURE;
    }

    $io->success(sprintf(
        '%d orders imported.',
        $response->imported,
    ));

    return Command::SUCCESS;
}
Enter fullscreen mode Exit fullscreen mode

The HTTP adapter for the same use case catches the same FileNotReadable and turns it into a 422. Two adapters, two translations of one domain error. The core threw once and stayed ignorant of both.

The shape scales past one command

Once your commands are thin, a directory of them all looks the same: parse, call, format. New command for an existing use case? A few lines. New delivery mechanism (HTTP, a message handler, a scheduled task) for an existing use case? You write another thin adapter and reuse the exact same core. The app:import-orders command, the POST /imports controller, and the ImportOrdersMessage handler all construct the same request DTO and invoke the same class.

That's the point of treating the command as an adapter. The terminal is one of several ways into your application, not the place your application lives. When the second entry point shows up (and it always does), you add an adapter instead of duplicating a method.


If this was useful

Keeping the console command at the edge is one instance of a bigger habit: the framework's entry points are adapters, and your use cases sit behind a boundary that doesn't know which one called it. Get that boundary right and the same core serves a CLI, an HTTP request, and a queue worker without changing. Decoupled PHP is the book I wrote about drawing those lines and keeping them drawn as the app grows.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)