agentleFS
fatonhfatonh/symfony-skills/skills/console-commands/SKILL.md

console-commands

Skill35 starsChanged 5 months agoMITView on GitHub

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.

1 other project uses a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 0 are distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Before writing anything, read the project's symfony/console constraint in composer.json and choose:

    line 24
  2. Very specific:

    If composer.json is missing or the constraint is ambiguous, check symfony/console in composer.lock, or ask which version the project …

    line 34
  3. Very specific:

    Use the invokable command (__invoke) — no extends Command, no configure()/execute() boilerplate.

    line 125
  4. Very specific:

    Never extends Command: Import Symfony\Component\Console\Command\Command only for the return constants.

    line 188
  5. Very specific:

    Use Symfony Validator constraints (#[Assert\Url], #[Assert\Email], …) for validation — idiomatic and consistent with the rest of Symfony.

    line 237

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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;
    }

    // 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');
    }
}
  • 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 {
        // 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.

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().

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.