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-skillsConsolidated Symfony conventions (Symfony 6.4 / 7.x): attribute routing, controllers as services, autowiring/DI, Form types, Validation constraints, Voters for authorization, the Serializer/DTO contract, and Messenger. Apply when writing or reviewing Symfony backend code. Activated automatically by symfony-plugin/stack.md as a convention skill for the development phase. Apply when: writing or reviewing Symfony controllers, services, forms, validators, voters, and serialization. Do NOT use this skill for: - PHP language idioms (strict_types, enums, readonly) — see php-foundation:php-conventions. - Doctrine queries / entities — see symfony-plugin:doctrine-patterns. - Testing — injected by the QA phase (WebTestCase/KernelTestCase) + php-foundation:php-testing.
Reference 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.