Свои команды в Symfony: три способа объявления и что выбрать
Атрибут AsCommand, наследование от Command и Invokable-команды из Symfony 7.3 решают одну задачу по-разному. Разбираем цену каждого подхода и когда какой уместен.

Одну CLI-команду в Symfony можно объявить как минимум тремя способами, и они не взаимозаменяемы. Классический класс-наследник Command с методом configure() тянет за собой самый многословный код. Атрибут #[AsCommand] из Symfony 5.3 убирает часть обвязки. А #[AsCommand] на методе __invoke() из Symfony 7.3 делает описание аргументов чище, но требует свежей версии. Разберём, что вы выигрываете и чем платите в каждом случае, и по каким критериям выбирать для конкретной команды.
Задача, на которой всё видно
Возьмём типичную команду: app:user:notify с обязательным аргументом email, необязательным флагом --dry-run и опцией --limit с значением по умолчанию. На этом наборе разница между подходами перестаёт быть косметической: видно, где описание аргументов дублируется, где типы приходится приводить руками и где IDE подсказывает, а где нет.
Проверено на Symfony 7.3 с symfony/console той же ветки и PHP 8.2. Первые два подхода работают начиная с Symfony 5.3, третий — только с 7.3.
Подход 1. Класс-наследник Command с configure()
Способ, который вы найдёте в любой старой документации. Аргументы описываются в configure(), а достаются из InputInterface вручную.
<?php
// src/Command/UserNotifyCommand.php — Symfony 5.3+
namespace App\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class UserNotifyCommand extends Command
{
protected static $defaultName = 'app:user:notify';
protected function configure(): void
{
$this
->addArgument('email', InputArgument::REQUIRED, 'Кому слать')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Не отправлять')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Максимум писем', 100);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$email = $input->getArgument('email');
$isDryRun = $input->getOption('dry-run');
$limit = (int) $input->getOption('limit'); // приведение типа вручную
// логика уведомления
return Command::SUCCESS;
}
}
Цена: имя аргумента email встречается дважды — в configure() и в getArgument(). Опечатку во втором месте вы поймаете только в рантайме. Тип у опции всегда строка, --limit приходится приводить к int руками.
Подход 2. Атрибут AsCommand + configure()
С Symfony 5.3 имя и описание переезжают в атрибут #[AsCommand], а $defaultName помечен как устаревший. Логика execute() и разбор аргументов остаются прежними.
<?php
// src/Command/UserNotifyCommand.php — Symfony 5.3+
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:user:notify',
description: 'Рассылает уведомления пользователю',
)]
class UserNotifyCommand extends Command
{
protected function configure(): void
{
$this
->addArgument('email', InputArgument::REQUIRED, 'Кому слать')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Не отправлять')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Максимум писем', 100);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$email = $input->getArgument('email');
// остальное как в подходе 1
return Command::SUCCESS;
}
}
Что изменилось: имя команды видно сразу под use, без него команда не попадёт в реестр без ручной регистрации. Плюс — метаданные (имя, описание) выносятся из тела метода, их читает контейнер без инстанцирования класса, то есть bin/console list работает быстрее. Минус — дублирование имён аргументов и ручное приведение типов никуда не делись. Это тот же подход 1 с более аккуратной шапкой.
Подход 3. Invokable-команда (Symfony 7.3+)
С Symfony 7.3 #[AsCommand] можно повесить на класс с методом __invoke(), а аргументы и опции описывать прямо в его сигнатуре через атрибуты #[Argument] и #[Option]. Console сам разберёт значения и приведёт типы.
<?php
// src/Command/UserNotifyCommand.php — Symfony 7.3+
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:user:notify',
description: 'Рассылает уведомления пользователю',
)]
class UserNotifyCommand
{
public function __invoke(
SymfonyStyle $io,
#[Argument(description: 'Кому слать')]
string $email,
#[Option(description: 'Не отправлять')]
bool $dryRun = false,
#[Option(description: 'Максимум писем')]
int $limit = 100,
): int {
// $limit уже int, $dryRun уже bool — приведение не нужно
$io->text(sprintf('Рассылка для %s, лимит %d', $email, $limit));
return Command::SUCCESS;
}
}
Класс больше не наследует Command. Имя аргумента живёт в одном месте — в параметре метода. Тип int в сигнатуре означает, что --limit придёт уже приведённым, а неверное значение отсекается Console до вызова тела. Флаг --dry-run выводится из имени параметра $dryRun. IDE подсказывает и параметры, и их типы.
Цена подхода — версия. На Symfony 6.4 LTS этих атрибутов нет, и код просто не соберётся. Кроме того, сложную логику конфигурации (динамические аргументы, условное описание) в сигнатуре не выразишь — там всё ещё нужен configure().
Меньше кода — не всегда цель. Invokable-подход убирает дублирование имён и ручное приведение типов; ровно за это вы платите привязкой к Symfony 7.3 и потерей гибкой конфигурации.
Сравнение по делу
| Критерий | Наследник + configure() | AsCommand + configure() | Invokable (7.3) |
|---|---|---|---|
| Минимальная версия | любая современная | 5.3 | 7.3 |
| Имя аргумента дублируется | да | да | нет |
| Приведение типов | вручную | вручную | автоматически по сигнатуре |
| Подсказки IDE по аргументам | нет | нет | да |
| Динамическая конфигурация | да | да | нет, нужен configure() |
| Ленивая загрузка метаданных | нет | да | да |
Когда что выбирать
- Проект на Symfony 7.3 и команда простая — набор скалярных аргументов и опций без условной логики: берите invokable. Это самый короткий и типобезопасный вариант.
- Проект на 6.4 LTS или ниже: invokable недоступен, выбирайте
#[AsCommand]+configure(). Атрибут даёт ленивую загрузку, а$defaultNameв новом коде не используйте. - Нужна динамическая конфигурация — аргументы зависят от окружения, описание собирается на лету, число опций не фиксировано: только
configure(), независимо от версии. - Голое наследование без атрибута оправдано, только если вы поддерживаете совсем старый код и не хотите его трогать. Для нового кода смысла нет.
Регистрация команды во всех трёх случаях автоматическая, если у вас включён autoconfigure в config/services.yaml (по умолчанию так в Symfony Flex-проектах). Класс с #[AsCommand] или наследник Command попадает в реестр без ручной пометки тегом console.command.
Что ломается на практике
- Команда не видна в
bin/console list. Чаще всего забыт атрибут#[AsCommand]или отключён autoconfigure. Проверьте, что класс лежит в просканированном пути изservices.yaml. - Invokable-команда не собирается на 6.4. Атрибутов
#[Argument]и#[Option]в этой ветке нет. Смешивать нельзя: либо вся команда на invokable под 7.3, либо configure(). - Опция
--limitприходит строкой в старом подходе. Console не знает целевой тип, приводите сами:(int) $input->getOption('limit'). В invokable это делает фреймворк по типу параметра. - Смешение имён: в сигнатуре параметр
$dryRunпревращается в опцию--dry-run(camelCase → kebab-case). Если ожидаете--dryRun, команда не примет флаг.
Что делать дальше
Определитесь с минимальной версией Symfony в проекте — от неё зависит, доступен ли вам invokable-подход. Если вы на 7.3 и заводите новые команды, переводите на invokable по мере появления, но не переписывайте рабочие configure()-команды массово ради стиля. Инвалидация выбора между подходами упирается в одно: нужна ли динамическая конфигурация. Для команд с фиксированным набором аргументов invokable выигрывает по всем метрикам, кроме требований к версии. Дальше стоит посмотреть раздел документации Console про формат имён аргументов и про тестирование команд через CommandTester — тесты не зависят от выбранного способа объявления.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Symfony Console — Console Commands, Symfony Console Component — CHANGELOG (GitHub)
Частые вопросы
А если у меня Symfony 6.4 LTS, invokable-команды точно недоступны?
Нужно ли вручную регистрировать команду тегом console.command?
Что делать со старым $defaultName?
Можно ли в invokable-команде получить доступ к сервисам?
Команда не появляется в списке — с чего начать?
Стоит ли переписывать все старые команды на invokable?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.