Свой artisan-команда с нуля: чек-лист перед продакшеном
Кастомная команда в Laravel запускается локально за пять минут, но в продакшене начинает падать по таймауту, дублировать запуски и молчать при ошибках. Разбираем, что проверить перед деплоем.

Команда php artisan report:generate прекрасно работает на вашей машине: отрабатывает за секунду, пишет в консоль, возвращается к приглашению. В продакшене та же команда по крону начинает вести себя иначе — два экземпляра запускаются внахлёст, при падении никто не узнаёт, память течёт на большой выборке, а код возврата всегда 0, поэтому мониторинг считает всё зелёным. Разбираем чек-лист для Laravel 11.x/12.x: что дописать в команду до того, как она уйдёт в крон и в очередь.
Статья про уже написанную команду и её подготовку к бою. Как сгенерировать заготовку через make:command и заполнить $signature — отдельная база, здесь считаем, что команда работает и делает то, что нужно.
Код возврата: молчаливое падение — худшее, что может быть
Метод handle(), который ничего не возвращает, отдаёт код 0 — «успех». Даже если внутри было исключение, которое вы поймали и залогировали. Крон, systemd, Kubernetes CronJob и любой внешний планировщик смотрят именно на код возврата. 0 значит «не трогай, всё хорошо».
В Laravel есть константы Command::SUCCESS, Command::FAILURE и Command::INVALID (0, 1 и 2 соответственно). Возвращайте их явно.
public function handle(): int
{
try {
$count = $this->generateReports();
} catch (\Throwable $e) {
// логируем с трейсом, чтобы было что читать в проде
report($e);
$this->error('Не удалось сгенерировать отчёты: ' . $e->getMessage());
return self::FAILURE; // крон увидит код 1
}
$this->info("Готово, отчётов: {$count}");
return self::SUCCESS;
}Проверка: запустите команду с заведомо неверными данными и посмотрите echo $? в bash. Должна быть 1, а не 0.
Команда, которая падает молча с кодом 0, опаснее команды, которая не запускается вообще. Второе вы заметите сразу, первое — через неделю по отсутствию отчётов.
Защита от перекрытия запусков
Крон запускает команду по расписанию, не проверяя, закончился ли предыдущий запуск. Если генерация отчёта раз в минуту начала занимать три минуты, к обеду у вас десяток параллельных процессов, конкурирующих за одну таблицу.
В Laravel для этого есть трейт Illuminate\Console\ConcurrencyDisabled? Нет — правильный механизм зависит от того, как вы запускаете. Два рабочих варианта:
Через планировщик Laravel
Если команда висит в routes/console.php или в расписании, используйте withoutOverlapping(). Он ставит атомарную блокировку в кеше на время выполнения.
use Illuminate\Support\Facades\Schedule;
Schedule::command('report:generate')
->everyMinute()
->withoutOverlapping(10); // блокировка живёт максимум 10 минутАргумент — время жизни блокировки в минутах. Если процесс умрёт, не сняв блокировку, она протухнет через это время, и команда снова сможет запуститься. Ставьте с запасом относительно реального времени работы.
Внутри команды через атомарный лок
Если команду дёргают снаружи (системный крон, ручной запуск, деплой-хук), планировщик Laravel не участвует. Тогда берите блокировку сами через Cache::lock():
use Illuminate\Support\Facades\Cache;
public function handle(): int
{
$lock = Cache::lock('report-generate', 600); // 600 секунд
if (! $lock->get()) {
$this->warn('Другой экземпляр уже выполняется, выходим.');
return self::SUCCESS; // это не ошибка, просто нечего делать
}
try {
$this->generateReports();
} finally {
$lock->release();
}
return self::SUCCESS;
}Атомарные блокировки работают на драйверах кеша redis, memcached, database и некоторых других. На драйвере array (по умолчанию в тестах) блокировка не переживёт процесс — это нормально, но помните об этом. Точный список поддерживаемых драйверов сверяйте в разделе Cache официальной документации вашей версии.
Память и большие выборки
Локально в базе сто строк, в проде — миллион. Команда, которая делает Model::all() и гоняет коллекцию в цикле, на большой таблице съест всю память и упадёт с Allowed memory size exhausted.
Гоняйте выборку порциями. У Eloquent для этого есть chunk() и chunkById():
use App\Models\Order;
Order::where('status', 'pending')
->chunkById(1000, function ($orders) {
foreach ($orders as $order) {
$this->process($order);
}
});chunkById() надёжнее обычного chunk(), когда внутри цикла вы меняете те самые строки, по которым идёт выборка: он листает по первичному ключу, а не по OFFSET, и не пропускает записи из-за сдвига страниц. Если строки не меняются — хватит и chunk().
Прогресс, вывод и тишина
Команда, которая молчит десять минут, выглядит как зависшая. При ручном запуске покажите прогресс-бар, при запуске из крона — не засоряйте почту рута лишним выводом.
| Метод вывода | Когда использовать |
|---|---|
$this->info() | Итог, важное сообщение об успехе |
$this->error() | Ошибка, идёт в STDERR |
$this->warn() | Предупреждение, не блокирующее |
$this->withProgressBar() | Долгий цикл при интерактивном запуске |
report() / Log:: | Всё, что нужно найти потом в проде |
Ключевое правило: вывод в консоль — для человека, который смотрит прямо сейчас. Для истории пишите в лог. Не полагайтесь на то, что кто-то читает почту от крона.
Идемпотентность и повторный запуск
Спросите себя: что будет, если команда запустится дважды подряд? Если она начисляет бонусы, отправляет письма или списывает деньги — двойной запуск обойдётся дорого. Планировщик может рестартовать задачу, деплой может дёрнуть её вручную, кто-то нажмёт Enter не подумав.
- Помечайте обработанное. Ставьте
processed_atи фильтруйте по нему, чтобы повторный проход не трогал уже сделанное. - Оборачивайте в транзакцию набор изменений, которые должны примениться целиком или никак —
DB::transaction(). - Не удаляйте до подтверждения. Сначала успешная отправка, потом отметка «отправлено», а не наоборот.
Таймаут и долгие команды
CLI-скрипты в PHP по умолчанию не ограничены по времени выполнения (max_execution_time в CLI обычно 0), в отличие от веба. Но у вас есть внешние таймауты: у планировщика, у соединения с базой, у HTTP-клиента внутри команды. Долгий запрос к базе может оборваться по wait_timeout MySQL, а внешний API — по таймауту Guzzle.
Проставляйте таймауты явно на всех внешних вызовах внутри команды. HTTP-клиент Laravel по умолчанию не ограничивает время ответа — задайте его:
use Illuminate\Support\Facades\Http;
$response = Http::timeout(30) // максимум 30 секунд на ответ
->retry(3, 200) // 3 попытки с паузой 200 мс
->get('https://api.example.com/data');Чек-лист перед деплоем
- Метод
handle()возвращаетSUCCESS/FAILUREявно, при ошибке — код 1. - Исключения ловятся и уходят в
report(), а не просто в консоль. - Есть защита от параллельного запуска —
withoutOverlapping()илиCache::lock(). - Большие выборки идут через
chunkById(), а неall(). - Повторный запуск не портит данные: есть отметка обработки или транзакция.
- У всех внешних вызовов проставлен таймаут.
- Итог пишется в лог, а не только в STDOUT.
- Команда протестирована на данных, близких к продовому объёму, а не на десяти строках.
Что дальше
После деплоя настройте мониторинг именно на код возврата и на факт запуска. Полезный приём — «мёртвый человек»: команда при успехе дёргает внешний heartbeat-сервис (например, healthchecks-подобный), и если пинга не было в срок, приходит алерт. Это ловит случай, когда крон вообще не запустил команду — сама команда об этом сообщить не может.
Следующий шаг для тяжёлых задач — вынести работу из синхронной команды в очередь через задания. Тогда команда только ставит задачи, а воркер их разбирает с ретраями и контролем памяти. Это отдельная тема, но именно туда стоит смотреть, когда одна команда перестаёт укладываться в окно крона.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Laravel Documentation — Artisan Console, Laravel Documentation — Task Scheduling
Частые вопросы
У меня Laravel 10, а не 11 — код подойдёт?
Команда всё равно запускается в двух экземплярах, хотя я поставил withoutOverlapping — почему?
Что будет, если команда упадёт по таймауту и не снимет блокировку?
Нужно ли ставить set_time_limit в CLI-команде?
Как понять, что команда съедает слишком много памяти, до того как она упадёт в проде?
Стоит ли ловить исключение внутри handle или дать ему всплыть?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.