<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
SYMFONY · JUNIOR ЧАСТО ПИТАЮТЬ

Як написати консольну команду в Symfony і протестувати її?

Команда - це сервіс з атрибутом #[AsCommand]: у Symfony 7.3+ достатньо класу з методом __invoke() і параметрами під #[Argument] та #[Option], вивід іде через SymfonyStyle, тест пишеться на CommandTester, а від паралельних запусків з cron захищає Lock.

Напиши команду app:import, яка приймає шлях до файлу і прапорець --dry-run. Де тут аргумент, а де опція?
Cron запускає команду щохвилини, а імпорт триває пʼять хвилин. Що станеться і як це полагодити?
Як перевірити команду тестом, а не очима через bin/console?
Команда під cron зависає або падає з MissingInputException, хоча локально працює. Чому?
Console AsCommand CommandTester Lock SymfonyStyle

Команда в 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([]));
}
Що #[AsCommand] з symfony/console 5.3+ реєструє команду через autoconfigure (тег console.command) і тримає імʼя й опис для лінивого завантаження, тому bin/console list не створює всі команди.
Що з Symfony 7.3 команда може бути простим класом з __invoke(), а аргументи й опції описуються атрибутами #[Argument] і #[Option] на параметрах методу; класичний extends Command з configure() і execute() нікуди не подівся.
Різницю аргумент/опція: аргумент позиційний і може бути обовʼязковим, опція завжди має --імʼя і потребує значення за замовчуванням, а bool-параметр стає прапорцем без значення.
Що код виходу - частина контракту: Command::SUCCESS (0), FAILURE (1), INVALID (2), і що cron та системи моніторингу дивляться саме на нього.
Що логіка живе в сервісі, а команда лише парсить вхід і друкує результат, тому основне покриття - юніт-тести сервісу, а CommandTester перевіряє склейку: коди виходу, текст виводу, обробку опцій.
Що для команд з cron потрібен Lock, і що дефолтний flock/semaphore-стор працює лише в межах однієї машини.
Писати всю бізнес-логіку прямо в execute() чи __invoke(), а потім тестувати її тільки через CommandTester, бо іншого входу немає.
Друкувати через echo замість OutputInterface або SymfonyStyle: тоді -q, --no-ansi і перенаправлення виводу перестають працювати.
Повертати null або нічого з execute() у класі-нащадку Command: метод оголошений як : int, і PHP падає з TypeError.
У CommandTester передавати опцію без дефісів: ['dry-run' => true] вважається аргументом, треба ['--dry-run' => true].
Вважати, що успішний запуск = відсутність винятку, і повертати SUCCESS навіть коли частина рядків не імпортувалась; cron тоді ніколи не поскаржиться.
Питати щось через $io->ask() або $io->confirm() у команді, яку запускає cron: без TTY QuestionHelper кидає MissingInputException, тому потрібні перевірка $input->isInteractive() і розумні дефолти.
Захищатися від накладання запусків файлом-прапорцем на диску, який залишається після SIGKILL і назавжди блокує команду, замість Lock з ttl.
ПОРАДА

Відповідайте сценарієм, а не переліком API: «клас з #[AsCommand] і __invoke(), аргумент - шлях до файлу, --dry-run - bool-опція, вивід через SymfonyStyle, у тесті CommandTester з assertCommandIsSuccessful(), а бо це cron - на початку createLock() з ttl». Згадка про lock і код виходу відрізняє того, хто вже ловив подвійний імпорт у продакшені.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Autoconfigure сам чіпляє тег console.command до класів з #[AsCommand]; у CommandTester опції передаються з префіксом '--', інакше Console вважає ключ аргументом; накладання запусків вирішує Lock, а не код виходу.