Свои команды в Symfony: рабочие приёмы из практики
Консольные команды в Symfony обрастают костылями быстрее контроллеров: аргументы валидируются руками, прогресс молчит, а cron запускает их параллельно. Разбираем приёмы, которые убирают эту боль.

Консольная команда в Symfony пишется за пять минут: наследуешь Command, описываешь execute(), регистрируешь. Проблемы начинаются позже — когда команда крутится в cron каждую минуту, принимает аргументы от другого разработчика и должна не упасть на проде посреди импорта 200 тысяч строк. Здесь собраны приёмы для Symfony 6.4 и 7.x, которые снимают частые грабли: атрибуты вместо configure(), валидация ввода, блокировки от параллельного запуска, корректные коды возврата и вывод, который видно в логах.
Атрибуты вместо configure()
С Symfony 6.1 команда описывается атрибутом #[AsCommand], а getDefaultName() и getDefaultDescription() объявлены устаревшими. Если вы всё ещё регистрируете имя внутри configure() — это код из эпохи 4.x. Атрибут даёт lazy-загрузку: контейнер знает имя команды, не инстанцируя весь класс.
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;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:user:import',
description: 'Импорт пользователей из CSV',
)]
final class ImportUsersCommand extends Command
{
protected function configure(): void
{
$this
->addArgument('file', InputArgument::REQUIRED, 'Путь к CSV-файлу')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Только показать, ничего не писать в базу');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Импорт пользователей');
return Command::SUCCESS;
}
}
Зависимости прокидывайте через конструктор — autowiring работает так же, как в сервисах. Не тяните сервис-локатор в команду ради «удобства»: он ломает подсказки IDE и статический анализ.
SymfonyStyle вместо голого OutputInterface
Класс SymfonyStyle — обёртка над $input и $output с готовыми методами: title(), section(), success(), warning(), error(), table(), progressBar(). Он сам обрабатывает переносы и не заставляет вручную клеить теги <info>. Разница видна на длинной команде: с SymfonyStyle вывод читается, с ручным writeln() превращается в кашу.
Валидация ввода: не полагайтесь на execute()
Аргумент, объявленный как REQUIRED, Symfony проверит сам — команда упадёт до execute(), если файл не передан. Но саму осмысленность значения (файл существует, читается, это CSV) консоль не проверяет. Для этого есть initialize() — он вызывается до execute() и до interact():
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$file = $input->getArgument('file');
if (!is_readable($file)) {
throw new \InvalidArgumentException(
sprintf('Файл "%s" не найден или недоступен для чтения', $file)
);
}
}
Исключение, брошенное здесь, консоль перехватит и выведет как ошибку с ненулевым кодом возврата. Не пишите проверки внутри execute() вперемешку с бизнес-логикой — initialize() держит валидацию отдельно.
Если аргумент нужно спросить у пользователя интерактивно, когда он не передан, — это метод interact(), а не execute(). Он не вызывается при --no-interaction и в неинтерактивном окружении (cron), поэтому туда нельзя класть обязательную логику.
Правило простое:initialize()— проверить и подготовить,interact()— спросить недостающее,execute()— только работа. Смешаете — получите команду, которая ведёт себя по-разному в терминале и в cron.
Коды возврата, о которых забывают
Метод execute() обязан вернуть int. Есть три константы: Command::SUCCESS (0), Command::FAILURE (1), Command::INVALID (2). Забытый return в PHP вернёт null, и Symfony 7.x бросит ошибку типа — команда не запустится вовсе. В 6.x с включёнными strict types поведение то же.
Зачем это важно на проде: cron, CI и оркестраторы вроде systemd смотрят именно на код возврата. Команда, которая молча проглотила исключение и вернула SUCCESS, — источник тихих сбоев, которые всплывают через неделю потерянными данными.
SUCCESS— работа выполнена целиком;FAILURE— упали в процессе, часть работы могла не выполниться;INVALID— неверный ввод, работа не начиналась.
Блокировка от параллельного запуска
Классический инцидент: импорт запускается по cron раз в минуту, но обработка одного файла занимает три. Запускаются три копии, каждая читает один файл, пишет дубли. Symfony даёт трейт LockableTrait из компонента Lock:
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
final class ImportUsersCommand extends Command
{
use LockableTrait;
protected function execute(InputInterface $input, OutputInterface $output): int
{
if (!$this->lock()) {
$output->writeln('Команда уже выполняется, выходим');
return Command::SUCCESS;
}
// ... работа ...
$this->release();
return Command::SUCCESS;
}
}
По умолчанию блокировка привязана к имени команды и хранится через FlockStore — файловые блокировки на локальной ФС. Это подводный камень: на нескольких серверах за балансировщиком флоки не видят друг друга, и параллельный запуск на разных нодах не блокируется. Если команда крутится в кластере, настройте общий store — Redis или базу — через сервис lock.factory и передайте свой LockFactory, а не используйте трейт вслепую.
Долгие команды: память, прогресс, батчи
Импорт 200 тысяч строк через Doctrine ORM без предосторожностей съедает память: EntityManager держит все обработанные сущности в UnitOfWork. Признаки — команда падает с Allowed memory size exhausted где-то на 40-й тысяче.
$batchSize = 500;
$i = 0;
$progress = $io->createProgressBar($total);
foreach ($rows as $row) {
$user = new User($row['email']);
$this->entityManager->persist($user);
if (($i % $batchSize) === 0) {
$this->entityManager->flush();
$this->entityManager->clear(); // отцепляем сущности, освобождаем память
$progress->advance($batchSize);
}
$i++;
}
$this->entityManager->flush();
$progress->finish();
$io->newLine(2);
Ключевой вызов — clear() после flush(). Он отсоединяет управляемые сущности. Осторожно: после clear() все ранее полученные сущности становятся detached, обращение к их связям вызовет ошибку. Если внутри цикла нужны справочники — перезапрашивайте их по id после каждой очистки или храните только скаляры.
Прогресс-бар пишет в stderr и постоянно перерисовывает строку через управляющие символы. В логах cron это превращается в мусор. Оборачивайте его в проверку $output->isDecorated() или в неинтерактивном режиме выводите вместо бара периодические строки «обработано N из M».
Таблица: где какой метод команды
| Метод | Когда вызывается | Что класть | Чего не класть |
|---|---|---|---|
| configure() | При регистрации | Аргументы, опции, справку | Обращения к сервисам и базе |
| initialize() | До interact и execute | Валидацию, подготовку | Основную работу |
| interact() | Только в интерактиве | Запрос недостающих значений | Обязательную логику (пропускается в cron) |
| execute() | Всегда | Работу, возврат кода | Валидацию ввода |
Тестирование команды
Команду тестируют через CommandTester — без запуска реального процесса. Проверяйте код возврата и содержимое вывода:
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
final class ImportUsersCommandTest extends KernelTestCase
{
public function testDryRun(): void
{
$kernel = self::bootKernel();
$application = new Application($kernel);
$command = $application->find('app:user:import');
$tester = new CommandTester($command);
$tester->execute([
'file' => __DIR__ . '/fixtures/users.csv',
'--dry-run' => true,
]);
$tester->assertCommandIsSuccessful();
$this->assertStringContainsString('Импорт пользователей', $tester->getDisplay());
}
}
Метод assertCommandIsSuccessful() проверяет, что код возврата — 0. Именно поэтому дисциплина с кодами из предыдущего раздела окупается в тестах.
Что настроить дальше
Когда базовая команда работает, займитесь эксплуатацией. Настройте общий Lock-store, если команда живёт в кластере. Добавьте в команду инъекцию LoggerInterface и пишите старт, завершение и ошибки в лог — вывод в консоль cron съест, а лог останется. Для команд, которые тянут HTTP или очередь, посмотрите в сторону Messenger: тяжёлую работу лучше вынести в асинхронный обработчик, а команду оставить тонкой точкой запуска. И проверьте, что monitoring смотрит на код возврата, а не только на факт запуска процесса.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Symfony Console: Console Commands, Symfony: How to Prevent Multiple Command Executions (Lock)
Частые вопросы
А если у меня Symfony 5.4, атрибут #[AsCommand] работает?
LockableTrait не блокирует запуск на втором сервере, почему?
Команда съедает всю память на большом импорте, что делать?
Куда класть валидацию аргументов — в execute или initialize?
Прогресс-бар в логах cron выглядит как мусор из символов, как убрать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.