Команда в Symfony - звичайний сервіс, який autoconfigure позначає тегом console.command, побачивши атрибут #[AsCommand] (є з symfony/console 5.3). Імʼя й опис лежать в атрибуті не для краси: CommandLoader читає їх, не створюючи обʼєкт, тому bin/console list не інстанціює півсотні команд разом з їхніми залежностями. Форм написання зараз дві. Класична: final class X extends Command з configure(), де через addArgument() і addOption() описано вхід, і execute(InputInterface $input, OutputInterface $output): int. Нова, з Symfony 7.3: клас без успадкування, один метод __invoke(), а вхід описано атрибутами на параметрах. Друга коротша і краще типізована, перша лишається потрібною, коли треба initialize(), interact() або власний InputDefinition.
Різниця між аргументом і опцією дається дивно часто. Аргумент позиційний: bin/console app:import-users users.csv, у invokable-варіанті string $file без дефолту стає обовʼязковим, а з дефолтом - необовʼязковим. Опція завжди іменована: --dry-run, --batch=1000, вона має дефолт, і саме тип параметра визначає, чого Console очікує: bool перетворюється на прапорець без значення, інші типи вимагають його. Імʼя опції виводиться з імені параметра в kebab-case, тому $dryRun дає --dry-run. Помилки вхідних даних краще розрізняти кодом виходу: Command::INVALID (2) для «ти передав дурню», Command::FAILURE (1) для справжнього збою, Command::SUCCESS (0) для всього іншого.
Друкувати варто через SymfonyStyle: $io->title(), $io->table(), $io->success(), $io->progressIterate() дають узгоджений вигляд і коректно реагують на -q, -v та --no-ansi, чого ніколи не зробить echo. Дві дрібниці, на яких спотикаються: SymfonyStyle за замовчуванням пише все у stdout, і щоб віддати повідомлення про помилку в stderr, потрібен $io->getErrorStyle(); а інтерактивні ask() і confirm() без TTY не мають звідки читати й кидають MissingInputException, тому команда, яку запускає cron або CI, має або перевіряти $input->isInteractive(), або мати опцію для кожного питання.
Тест починається з рішення, де живе логіка. Якщо імпорт лежить у сервісі UserImporter, його поведінка перевіряється звичайними юніт-тестами, а CommandTester відповідає за склейку: чи правильно розібрані аргументи, чи --dry-run справді нічого не пише, чи код виходу той, що обіцяли. Тестер викликає run() конкретної команди, тож ядро потрібне лише для того, щоб дістати команду з контейнера: KernelTestCase, new Application(self::bootKernel()), далі find('app:import-users'). У execute() аргументи передаються за іменем, а опції обовʼязково з --; setInputs() готує відповіді на питання, ['capture_stderr_separately' => true] розділяє потоки, а assertCommandIsSuccessful() дає зрозумілий діагностичний вивід замість порівняння з нулем. ApplicationTester тримайте для випадків, коли тестується сама диспетчеризація, і не забувайте там setAutoExit(false).
Окрема історія - запуск з cron. Crontab не знає, що попередній процес ще живий, тому щохвилинний імпорт, який триває пʼять хвилин, спокійно запуститься пʼять разів і продублює дані. Найпростіший захист - LockableTrait: $this->lock() на початку, $this->release() в кінці. У invokable-команді імʼя блокування треба передати явно, бо трейт розраховує на getName(). Дефолтна фабрика трейта створює SemaphoreStore або FlockStore, а вони локальні: на двох серверах за балансувальником обидва процеси спокійно візьмуть «своє» блокування, тому в кластері налаштовують framework.lock на Redis чи БД і інжектять LockFactory самостійно, щоб керувати ttl і autoRelease. І коли запуск пропущено через блокування, виходьте з нулем: непогашений код помилки перетворює нормальну ситуацію на щохвилинний лист від cron.
// src/Command/ImportUsersCommand.php - invokable-команда, Symfony 7.3+
#[AsCommand(name: 'app:import-users', description: 'Імпортує користувачів із CSV')]
final class ImportUsersCommand
{
public function __construct(
private UserImporter $importer, // логіка в сервісі, команда лише адаптер до CLI
private LockFactory $lockFactory, // потрібен framework.lock у config/packages/lock.yaml
) {
}
public function __invoke(
SymfonyStyle $io,
#[Argument(description: 'Шлях до CSV')] string $file, // позиційний, обовʼязковий
#[Option(description: 'Прочитати файл, але нічого не писати')] bool $dryRun = false, // --dry-run
#[Option(description: 'Скільки рядків між flush()')] int $batch = 500, // --batch=1000
): int {
// ttl більший за очікуваний час роботи: після SIGKILL блокування звільниться саме
$lock = $this->lockFactory->createLock('app:import-users', ttl: 1800);
if (!$lock->acquire()) {
$io->warning('Попередній запуск ще працює, цей пропускаю.');
return Command::SUCCESS; // 0, інакше cron щохвилини скаржиться на «помилку»
}
try {
$imported = 0;
foreach ($io->progressIterate($this->importer->rows($file)) as $row) {
$imported += $this->importer->import($row, $batch, $dryRun);
}
$io->success(sprintf('Імпортовано: %d%s', $imported, $dryRun ? ' (dry-run)' : ''));
return Command::SUCCESS;
} finally {
$lock->release();
}
}
}
// tests/Command/ImportUsersCommandTest.php - KernelTestCase, бо команді потрібні сервіси
public function testDryRunNothingIsWritten(): void
{
$tester = new CommandTester((new Application(self::bootKernel()))->find('app:import-users'));
// аргумент за іменем, опція обовʼязково з двома дефісами
$tester->execute(['file' => __DIR__.'/fixtures/users.csv', '--dry-run' => true]);
$tester->assertCommandIsSuccessful(); // те саме, що getStatusCode() === 0
self::assertStringContainsString('Імпортовано: 3 (dry-run)', $tester->getDisplay());
self::assertSame(0, $this->userRepository->count([]));
}
Відповідайте сценарієм, а не переліком API: «клас з #[AsCommand] і __invoke(), аргумент - шлях до файлу, --dry-run - bool-опція, вивід через SymfonyStyle, у тесті CommandTester з assertCommandIsSuccessful(), а бо це cron - на початку createLock() з ttl». Згадка про lock і код виходу відрізняє того, хто вже ловив подвійний імпорт у продакшені.