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

Команда обрабатывает 400 000 строк из базы, в тестовом окружении отрабатывает за минуту, а на проде через полчаса падает с Allowed memory size exhausted. Cron запускает её каждую ночь, письмо о падении никто не читает, данные тихо перестают обновляться. Разберём, почему консоль Symfony (версии 6.4 LTS и 7.x) регулярно приводит к таким сценариям и как этого избежать: утечка памяти на Doctrine, коды возврата, окружение и логирование, параллельные запуски.
Память: Doctrine держит всё, что вы через него достали
Самая частая грабля. Вы делаете выборку и в цикле что-то с каждой сущностью. Пока EntityManager жив, он хранит в UnitOfWork ссылки на все загруженные объекты — они не собираются сборщиком мусора. На тысяче строк незаметно, на сотнях тысяч процесс упирается в лимит memory_limit.
Второй источник — SQL-логгер. В окружении dev Doctrine по умолчанию накапливает все выполненные запросы в памяти для профайлера. Длинная команда, запущенная с --env=dev, распухает даже без цикла по сущностям.
// src/Command/RecalcCommand.php — Symfony 6.4 / 7.x
protected function execute(InputInterface $input, OutputInterface $output): int
{
$batchSize = 500;
$i = 0;
// iterate() отдаёт строки по одной, а не грузит всю выборку в массив
$query = $this->em->createQuery('SELECT o FROM App\\Entity\\Order o');
foreach ($query->toIterable() as $order) {
$order->recalcTotal();
if (++$i % $batchSize === 0) {
$this->em->flush();
// очищаем UnitOfWork, иначе память растёт линейно
$this->em->clear();
}
}
$this->em->flush();
$this->em->clear();
return Command::SUCCESS;
}
toIterable() (в старых версиях — iterate()) не грузит весь результат в PHP-массив, а clear() после каждого батча отпускает уже обработанные сущности. Одно но: после clear() все ранее полученные объекты становятся detached — не обращайтесь к ним после очистки, получите ошибку или пустые связи.
Если команда живёт дольше пары секунд и трогает Doctrine — считайте, что она течёт по памяти, пока вы не доказали обратное. Мерьте memory_get_usage(true) в начале и в конце, а не надейтесь на глаз.
Код возврата: cron верит числу, а не вашему echo
Метод execute() обязан вернуть int. Если забыть return, в PHP 8 команда упадёт с TypeError, но встречается хуже: команда ловит исключение, пишет в вывод «ошибка» и всё равно возвращает Command::SUCCESS. Cron видит код выхода 0 и считает, что всё хорошо. Мониторинг молчит.
Константы для читаемости:
Command::SUCCESS— 0, штатное завершение;Command::FAILURE— 1, ошибка выполнения;Command::INVALID— 2, неверные входные данные (не то, что передали в аргументах).
Не глотайте исключения молча. Если поймали и обработали — верните Command::FAILURE, чтобы обёртка cron или systemd увидела ненулевой код и подняла тревогу.
Окружение и APP_ENV: dev-команда на проде
Консоль читает APP_ENV так же, как веб. Если в crontab окружение не выставлено, команда запустится в том APP_ENV, что записан в .env, а это часто dev. Последствия: включён профайлер, Doctrine копит запросы, кеш не прогрет и собирается на лету, ошибки сыплются в другой лог.
Передавайте окружение явно флагом или переменной:
# Явный флаг окружения и отключённый вывод отладки
php bin/console app:recalc --env=prod --no-debug
# Или через переменную окружения перед вызовом
APP_ENV=prod APP_DEBUG=0 php bin/console app:recalc
--no-debug отключает сбор отладочной информации, которая в длинных командах тоже ест память. На проде это почти всегда то, что нужно.
Вывод, который никто не видит в cron
В терминале $output->writeln() печатает всё. В cron стандартный вывод уходит в письмо (если настроено) или в никуда. Progress bar, который красиво бежит в консоли, в лог-файле превращается в кашу из управляющих символов.
Разделяйте вывод для человека и логи для системы. Для сообщений об ошибках пишите в stderr, не в stdout:
use Symfony\Component\Console\Output\ConsoleOutputInterface;
// getErrorOutput() доступен у ConsoleOutput — пишет в stderr
if ($output instanceof ConsoleOutputInterface) {
$output->getErrorOutput()->writeln('Не удалось обработать заказ #' . $id);
}
Для аудита и постфактум-разбора подключите штатный логгер (Monolog) и пишите ключевые события туда — файл переживёт сессию терминала, письмо cron нет.
Параллельные запуски: cron наложился сам на себя
Ночная команда обычно отрабатывает за 10 минут, но однажды база тормознула, и она шла 40. Cron тем временем запустил вторую копию. Две команды параллельно правят одни и те же строки — получаете гонку и дубли.
В Symfony есть трейт для блокировки на основе компонента Lock. Он не даёт запуститься второй копии, пока работает первая:
use Symfony\Component\Console\Command\LockableTrait;
class RecalcCommand 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;
}
}
По умолчанию блокировка держится в файловой системе. Если у вас несколько серверов приложений, файловый лок каждого из них локален — настройте общее хранилище (Redis, база) через компонент Lock, иначе защита между машинами не работает.
Ещё три места, где спотыкаются
- Таймауты процессов. Компонент Process по умолчанию ставит таймаут 60 секунд. Долгая внешняя команда, запущенная из вашей, упадёт с
ProcessTimedOutException— задавайтеsetTimeout()явно илиnullдля снятия лимита. - Интерактивные вопросы в неинтерактивном режиме.
QuestionHelperв cron не дождётся ответа. Проверяйте$input->isInteractive()и предусмотрите значение по умолчанию или флаг вроде--force. - Кодировка вывода. Ширина progress bar и обрезка строк считаются по символам; при неверной локали кириллица в таблицах
Tableможет ломать выравнивание. На сервере проверьте локаль окружения.
Как отличить грабли по симптому
| Симптом | Вероятная причина | Что проверить |
|---|---|---|
| Allowed memory size exhausted | Doctrine держит сущности или логирует SQL | clear() в батче, --env=prod --no-debug |
| Cron молчит, данные не обновляются | Команда возвращает 0 при ошибке | return Command::FAILURE, вывод в stderr |
| Дубли в данных ночью | Две копии команды параллельно | LockableTrait, общее хранилище лока |
| Медленно и странно логирует | Окружение dev вместо prod | APP_ENV в crontab |
| Зависает без вывода | Интерактивный вопрос в cron | $input->isInteractive() |
Что настроить после
Оберните запуск в cron минимальным контролем: перенаправьте stderr в отдельный файл (2>>/var/log/app/recalc.err) и настройте алерт на ненулевой код возврата — тогда молчаливое падение перестанет быть молчаливым. Для длинных задач посмотрите на компонент Messenger: тяжёлую обработку логичнее вынести в очередь, а консольную команду оставить тонким запускателем. И заведите привычку прогонять новую команду с --env=prod локально хотя бы раз перед выкаткой — половина граблей вылезает именно на этом шаге.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Symfony Console Component — официальная документация, Doctrine ORM — Batch Processing
Частые вопросы
У меня Symfony 5.4, а не 6.4 — приёмы подойдут?
Команда всё равно течёт по памяти после clear() — что не так?
Как понять, что cron вообще запускает мою команду?
Грозит ли неверный код возврата чем-то, кроме молчания мониторинга?
LockableTrait защитит от параллельного запуска на двух серверах?
Стоит ли выносить долгую обработку из команды в очередь?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.