| name | console-commands |
| description | Use when creating or refactoring Symfony console commands — never extends Command. On Symfony 8.1+ put #[AsCommand] on a method of a service class (even for a single command); on 7.x use the invokable attribute class. Also covers typed CLI inputs via value resolvers and #[MapInput] DTOs. Use when the task mentions console command, #[AsCommand], bin/console, #[Argument], #[Option], or a CLI task.
|
Symfony Console commands
A console command is just another transport into the service layer — treat the
CLI like a controller: thin, typed at the edges, delegating to services.
Never extends Command. No configure(), no execute(), no
addArgument()/addOption(), no #[AsCommand(name: ..., description: ...)]
named parameters. That is the legacy Symfony 6/7 boilerplate this skill exists to
replace — on every supported version, including a single one-off command.
Step 1 — detect the Symfony version, then pick the form
Before writing anything, read the project's symfony/console constraint in
composer.json and choose:
- Symfony 8.1 or newer → always the method-based form (
#[AsCommand] on a
method of a service class). This holds even for a single command — one
method on one class. Do not drop to the class-based form just because there
is only one command.
- Symfony 7.x → the invokable class form (
#[AsCommand] on the class, an
__invoke() method, no extends).
If composer.json is missing or the constraint is ambiguous, check
symfony/console in composer.lock, or ask which version the project targets —
don't guess. The two forms differ enough that picking the wrong one is worse than
a one-line question.
Symfony 8.1+: a single command (method on a service class)
Even one command uses the method-based form on 8.1+. This is the shape to reach
for first:
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
final class UserService
{
public function __construct(
private readonly UserRepository $users,
private readonly EntityManagerInterface $em,
) {}
#[AsCommand('app:user:delete', 'delete a user by email')]
public function delete(SymfonyStyle $io, #[Argument('email of the user to delete')] string $email): int
{
$user = $this->users->findOneBy(['email' => $email]);
if (null === $user) {
$io->error(sprintf('No user found with email "%s".', $email));
return Command::FAILURE;
}
if (!$io->confirm(sprintf('Delete user "%s"? This cannot be undone.', $email), false)) {
$io->comment('Aborted.');
return Command::SUCCESS;
}
$this->em->remove($user);
$this->em->flush();
$io->success(sprintf('User "%s" deleted.', $email));
return Command::SUCCESS;
}
}
Note what is absent: no extends Command, no configure(), no
execute(), no addArgument(). The argument is declared inline with
#[Argument]; dependencies are constructor-injected. When a second related
command appears (app:user:create, app:user:promote), add it as another
method on this same class — see the family form below.
Symfony 7.x fallback: one command per class
<?php
declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('site:scan', 'scan monitored sites for availability')]
final class SiteScanCommand
{
public function __construct(
private readonly SiteRepository $sites,
) {}
public function __invoke(SymfonyStyle $io): int
{
$io->success(sprintf('%d sites scanned', \count($this->sites->findAll())));
return Command::SUCCESS;
}
}
- Use the invokable command (
__invoke) — no extends Command, no
configure()/execute() boilerplate.
- Inject dependencies through the constructor.
Symfony 8.1+: a family of commands on one service class
The single-command form above scales directly to a family: keep adding
method-level #[AsCommand] to the same service class. One class exposes a whole
family of related commands that share a single constructor and private helpers.
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
final class SiteService
{
public function __construct(
private readonly SiteRepository $sites,
private readonly EntityManagerInterface $em,
private readonly HttpClientInterface $http,
) {}
#[AsCommand('site:add', 'add a site to monitor')]
public function add(SymfonyStyle $io, #[Argument] SiteUrl $url): int
{
return Command::SUCCESS;
}
#[AsCommand('site:scan', 'scan monitored sites for availability')]
public function scan(SymfonyStyle $io, #[MapInput] ScanInput $input): int
{
return Command::SUCCESS;
}
private function findOrFail(SiteUrl $url): Site
{
return $this->sites->findOneByUrl($url) ?? throw new \RuntimeException('unknown site');
}
}
- One class holds a cohesive family of commands (
site:add, site:scan,
site:list) that share injected dependencies and private helpers.
- The class name reflects its identity as a service, not a command holder:
SiteService, not SiteCommands. The CLI is just another transport into it.
- When a method's private helpers stop being shared with its siblings,
that's the signal to split it into its own class. Don't build god-classes, and
don't go back to one-class-per-command.
Rules
- Never
extends Command. Import Symfony\Component\Console\Command\Command
only for the return constants.
- Return
Command::SUCCESS / Command::FAILURE / Command::INVALID.
#[AsCommand('name', 'description')] — positional description (second
argument), not the description: named parameter.
#[Argument('desc')] and #[Option('desc')] — same convention, positional
description string. Never description:.
- First parameter is typically
SymfonyStyle $io (when you want its helpers —
success, table, progressBar) or OutputInterface $output (leaner). Pick
per command.
declare(strict_types=1); in every file.
- Verbose output via
$io->writeln() gated on $io->isVerbose() /
$io->isVeryVerbose(). No dump()/dd() in committed commands.
Typed inputs — value resolvers and DTOs
Validation, normalization, and parsing belong in value objects and their
resolvers, not in command bodies. (Available with attribute-based commands.)
- Single typed atoms (URL, email, ULID, path) → a value object with a
custom
ValueResolverInterface. The value object's fromString() factory
validates; the resolver maps the raw CLI string to the typed object. Used as
#[Argument] SiteUrl $url.
- Groups of related inputs (several args/options that travel together) →
#[MapInput] DTO classes. Public properties carry #[Argument] /
#[Option]. Validate in property hooks (PHP 8.4) or via the Symfony
Validator — not in the constructor, because #[MapInput] DTOs are hydrated
without calling the constructor.
- Composition — DTOs can contain other DTOs
(
ApplyInput { public ScanInput $scan; /* + options */ }); Symfony merges
them automatically.
- Why this matters — a description like
'url of the site to monitor' lives
once on the value object or DTO, not repeated across five command methods.
Same for the validation logic.
final class ScanInput
{
#[Argument('url of the site to scan')]
public SiteUrl $url;
#[Option('follow redirects')]
public bool $follow = false {
set => $value;
}
}
Use Symfony Validator constraints (#[Assert\Url], #[Assert\Email], …) for
validation — idiomatic and consistent with the rest of Symfony.
Gotchas
- Agent writes
extends Command with configure()/execute()/addArgument()
boilerplate — that's legacy Symfony 6/7 style; use the attribute command on
every supported version.
- Agent drops to the class-based (or
extends Command) form on Symfony 8.1+
just because it's a single command — a single command on 8.1+ is still one
method with #[AsCommand] on a service class. Check composer.json first.
- Agent puts
#[AsCommand] on a class on Symfony 8.1+ when several related
commands exist — prefer one service class with method-level #[AsCommand].
- Agent names the class
*Commands — name it *Service; the CLI is a transport
into the service.
- Agent uses the
description: named parameter — descriptions are positional
on #[AsCommand], #[Argument], and #[Option].
- Agent parses/validates CLI strings inside the command body — move it into a
value object +
ValueResolverInterface, or a #[MapInput] DTO.
- Agent puts validation in a
#[MapInput] DTO constructor — it's never
called; use property hooks (PHP 8.4) or the Validator.
- Agent splits a cohesive command family into one-class-per-command — keep them
together while they share helpers; split only when they stop sharing.
- Agent returns
0/1 integers — use Command::SUCCESS / Command::FAILURE /
Command::INVALID.
- Agent leaves
dump()/dd() or ungated verbose output — gate on
$io->isVerbose().