Свои команды в Symfony: чек-лист перед продакшеном
Консольная команда, которая нормально работает в разработке, под cron ломается тихо: съедает память, не отдаёт код возврата, дублирует запуски. Что проверить перед деплоем.

Команда app:import:orders три недели прогоняла импорт по 500 заказов и всех устраивала. На проде за ночь прилетело 40 000 строк, процесс сожрал 2 ГБ памяти и упал с Allowed memory size exhausted на середине. Половина заказов импортировалась, половина нет, cron записал в лог невнятное PHP Fatal error, и никто не заметил до утра. Разбираем чек-лист, который отделяет команду «работает у меня» от команды, которую не страшно поставить в расписание.
Материал по Symfony 6.4 и 7.x, компонент symfony/console. Атрибуты #[AsCommand] и типизированные аргументы работают начиная с 5.3, но примеры ниже — под актуальный синтаксис 6.4+.
Что проверить: короткий список
Разложим по пунктам, дальше каждый разберём с кодом и ценой ошибки.
- Команда возвращает осмысленный код выхода, а не всегда
0. - Память не растёт линейно от объёма данных.
- Долгие операции не запускаются параллельно сами с собой.
- Вывод в лог, а не только в
stdout, который cron выбросит. - Сигнал завершения обрабатывается, команда не падает посреди транзакции.
- Аргументы и опции валидируются до начала работы.
Код возврата: cron судит по нему
Самая частая тихая ошибка. Метод execute() должен вернуть int. Если вы забыли return или вернули null, PHP приведёт это к 0 — «успех». Cron, CI и любой оркестратор считают команду отработавшей, даже если внутри всё сгорело.
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'app:import:orders', description: 'Импорт заказов из внешнего API')]
class ImportOrdersCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
$imported = $this->importer->run();
} catch (\Throwable $e) {
// Логируем и явно сообщаем об ошибке наверх
$this->logger->error('Импорт упал', ['exception' => $e]);
$output->writeln('<error>' . $e->getMessage() . '</error>');
return Command::FAILURE; // это код 1, cron его увидит
}
$output->writeln(sprintf('Импортировано: %d', $imported));
return Command::SUCCESS; // код 0
}
}
Константы Command::SUCCESS (0), Command::FAILURE (1) и Command::INVALID (2) читаются лучше голых цифр. В расписании оборачивайте запуск так, чтобы ненулевой код приводил к уведомлению — через флаг MAILTO в crontab или healthcheck-пинг после успешного завершения.
Память: батчи вместо «загрузить всё»
Doctrine держит в UnitOfWork ссылки на все загруженные и созданные сущности. Цикл на 40 000 persist() без промежуточной очистки — это гарантированный рост памяти, пока не упрётесь в memory_limit. Лечится двумя вещами: пачками и clear().
$batchSize = 500;
$i = 0;
foreach ($this->orderReader->iterate() as $row) {
$order = new Order();
$order->fill($row);
$this->em->persist($order);
if (++$i % $batchSize === 0) {
$this->em->flush();
$this->em->clear(); // отпускаем сущности из памяти
}
}
$this->em->flush(); // хвост, который не добрал до батча
$this->em->clear();
Источник данных тоже читайте потоково. Если это база — используйте toIterable() у Doctrine Query вместо getResult(), который тянет весь набор в массив. Если внешний API — постранично, не собирая все страницы в один массив перед обработкой.
Команда, которая работает на тестовых 500 записях, ничего не говорит о том, как она поведёт себя на проде. Прогоните её на объёме, близком к боевому, и посмотрите на memory_get_peak_usage(true) в конце.
После clear() все ранее полученные сущности становятся detached. Если вам нужна ссылка на объект, который переживает очистку (например, справочник-родитель), получите его заново через find() после clear() — иначе flush() либо проигнорирует объект, либо кинет ошибку про managed entity.
Блокировка: не дать команде обогнать себя
Импорт запускается по cron раз в пять минут, но однажды отработал за семь. Теперь два процесса пишут в одну таблицу одновременно — дубли, гонки, иногда дедлоки. Компонент symfony/lock решает это через трейт.
use Symfony\Component\Console\Command\LockableTrait;
class ImportOrdersCommand 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;
}
}
Оговорка: LockableTrait по умолчанию использует локальный store (файловая блокировка на этой машине). На нескольких серверах за балансировщиком локальный лок не спасёт — там нужен общий store, например на Redis или в базе. Настройка store для lock — отдельная тема, здесь важно, что дефолт защищает только от параллельного запуска на одном хосте.
Логи: stdout под cron уходит в никуда
Всё, что вы вывели через $output->writeln(), под cron не попадёт никуда, если вы не перенаправили вывод в файл. Для диагностики после факта пишите ключевые события через инжектированный LoggerInterface, а не только в консоль.
Symfony умеет связать уровень многословности консоли с логгером через ConsoleLogger, но для продакшена надёжнее прокинуть в команду обычный monolog-логгер и писать в него важное: старт, число обработанных записей, ошибки. Тогда даже при тихом падении в логе останется, докуда дошли.
Сигналы: не рвать процесс посреди транзакции
При деплое или systemctl stop процессу прилетает SIGTERM. По умолчанию PHP завершится немедленно — если это случилось между persist() и flush(), часть работы потеряется. С Symfony 5.2 есть интерфейс SignalableCommandInterface: перехватываете сигнал, дорабатываете текущий батч, выходите чисто.
use Symfony\Component\Console\Command\SignalableCommandInterface;
class ImportOrdersCommand extends Command implements SignalableCommandInterface
{
private bool $shouldStop = false;
public function getSubscribedSignals(): array
{
return [\SIGTERM, \SIGINT];
}
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
$this->shouldStop = true; // выставляем флаг, не убиваем процесс сразу
return false; // false = не завершать немедленно, дать команде выйти самой
}
// в основном цикле проверяем флаг между батчами:
// if ($this->shouldStop) { $this->em->flush(); break; }
}
Сигнатура handleSignal изменилась: параметр $previousExitCode и возможность вернуть код появились в Symfony 6.3. На 6.2 и ниже метод возвращает void. Если пишете под 6.4/7.x — используйте вариант выше.
Аргументы: падать сразу, а не на середине
Проверяйте входные данные в начале execute() или в initialize(), до первой записи в базу. Команда, которая обработала 10 000 строк и потом упала на невалидной опции --date, оставляет систему в половинчатом состоянии.
protected function configure(): void
{
$this
->addArgument('source', InputArgument::REQUIRED, 'Идентификатор источника')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Прогон без записи в БД');
}
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$source = $input->getArgument('source');
if (!$this->sourceRegistry->has($source)) {
throw new \InvalidArgumentException(sprintf('Неизвестный источник: %s', $source));
}
}
Опция --dry-run — недооценённая вещь для операций с данными. Перед первым боевым запуском на проде прогоняйте с ней: команда проходит весь путь, считает, что сделала бы, но ничего не пишет.
Финальный прогон перед деплоем
- Запустите на объёме данных, близком к боевому, и снимите пиковую память.
- Проверьте, что
echo $?после падения показывает не ноль. - Запустите две копии одновременно — вторая должна отвалиться по локу.
- Пошлите процессу
SIGTERMв середине — данные не должны остаться битыми. - Найдите записи о старте и итоге команды в боевом логе, а не только в консоли.
Дальше: настройте мониторинг завершения. Cron-задача, которая молча перестала выполняться, опаснее той, что падает с ошибкой. Ставьте healthcheck-пинг в конец успешного прогона (Healthchecks, dead man's switch в вашем алертинге) — тогда пропущенный запуск сам поднимет тревогу.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Symfony — The Console Component, Symfony — Console: Locking, Signals, Verbosity
Частые вопросы
У меня Symfony 5.4, синтаксис из примеров подойдёт?
После em->clear() flush перестал сохранять объект, что не так?
LockableTrait защищает от параллельного запуска на нескольких серверах?
Команда падает по памяти даже с батчами, что ещё съедает?
Как понять код возврата команды после запуска?
Нужен ли SignalableCommandInterface для короткой команды?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.