From symfony-skills
Creates or refactors Symfony console commands using modern attribute-based patterns (method-based on 8.1+, invokable class on 7.x) instead of extending Command.
How this skill is triggered — by the user, by Claude, or both
Slash command
/symfony-skills:console-commandsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
A console command is just another transport into the service layer — treat the
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.
Before writing anything, read the project's symfony/console constraint in
composer.json and choose:
#[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.#[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.
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.
<?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;
}
}
__invoke) — no extends Command, no
configure()/execute() boilerplate.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;
}
// Shared by the sibling commands above — the reason they live together.
private function findOrFail(SiteUrl $url): Site
{
return $this->sites->findOneByUrl($url) ?? throw new \RuntimeException('unknown site');
}
}
site:add, site:scan,
site:list) that share injected dependencies and private helpers.SiteService, not SiteCommands. The CLI is just another transport into it.extends Command. Import Symfony\Component\Console\Command\Command
only for the return constants.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:.SymfonyStyle $io (when you want its helpers —
success, table, progressBar) or OutputInterface $output (leaner). Pick
per command.declare(strict_types=1); in every file.$io->writeln() gated on $io->isVerbose() /
$io->isVeryVerbose(). No dump()/dd() in committed commands.Validation, normalization, and parsing belong in value objects and their resolvers, not in command bodies. (Available with attribute-based commands.)
ValueResolverInterface. The value object's fromString() factory
validates; the resolver maps the raw CLI string to the typed object. Used as
#[Argument] SiteUrl $url.#[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.ApplyInput { public ScanInput $scan; /* + options */ }); Symfony merges
them automatically.'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 {
// validate in a property hook, not the constructor
set => $value;
}
}
Use Symfony Validator constraints (#[Assert\Url], #[Assert\Email], …) for
validation — idiomatic and consistent with the rest of Symfony.
extends Command with configure()/execute()/addArgument()
boilerplate — that's legacy Symfony 6/7 style; use the attribute command on
every supported version.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.#[AsCommand] on a class on Symfony 8.1+ when several related
commands exist — prefer one service class with method-level #[AsCommand].*Commands — name it *Service; the CLI is a transport
into the service.description: named parameter — descriptions are positional
on #[AsCommand], #[Argument], and #[Option].ValueResolverInterface, or a #[MapInput] DTO.#[MapInput] DTO constructor — it's never
called; use property hooks (PHP 8.4) or the Validator.0/1 integers — use Command::SUCCESS / Command::FAILURE /
Command::INVALID.dump()/dd() or ungated verbose output — gate on
$io->isVerbose().npx claudepluginhub fatonh/symfony-skills --plugin symfony-skillsReference for all 38 Symfony components covering APIs, configuration, best practices, and common pitfalls for PHP 8.3+ and Symfony 7.x.
Provides Symfony framework reference with architecture patterns, DDD integration, clean architecture checklists, common violations, and antipatterns for auditing PHP projects.
Guides Symfony project workflows, architecture refinements, and safe change execution with checkpoints and validation.