- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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:
- Parse input. Arguments, options,
STDINbecome plain PHP values. - Call a use case with those values.
- 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);
}
}
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,
) {
}
}
<?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,
) {
}
}
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',
);
}
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;
}
}
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);
}
}
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(),
);
}
}
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
ProgressBaris 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 inexecute()and fold the answers into the request DTO before the use case runs. -
Exit codes. Mapping a response to
Command::SUCCESS,Command::FAILURE, orCommand::INVALIDis 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;
}
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.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)