Планировщик задач в Laravel: чек-лист перед проливом в прод
Планировщик Laravel запускается одной cron-строкой, но ломается на дублях, перекрытиях и молчаливо пропущенных задачах. Чек-лист того, что проверить до деплоя.

Планировщик Laravel живёт на одной строке в crontab: система дёргает php artisan schedule:run раз в минуту, а внутри фреймворк сам решает, какие задачи пора выполнять. Проблема в том, что базовая настройка работает ровно до первого инцидента: задача на импорт выполняется на двух серверах разом и создаёт дубли, длинная выгрузка накладывается сама на себя и вешает базу, а упавший по ночам джоб никто не замечает три недели. Ниже — чек-лист, который стоит пройти по каждой задаче перед проливом в прод. Актуально для Laravel 10.x и 11.x; в 11.x расписание переехало из app/Console/Kernel.php в файл routes/console.php, на логику это не влияет.
Что нужно проверить до деплоя
- На сервере есть ровно одна cron-запись, дёргающая планировщик раз в минуту, и она реально запущена.
- Каждая задача защищена от перекрытий и от запуска на нескольких инстансах.
- У длинных задач стоит таймаут, а результат и падения куда-то пишутся.
- Время задач согласовано с таймзоной приложения.
- Есть внешний контроль: если планировщик встал, вы узнаете не от клиента.
Одна cron-строка, а не десять
Ошибка новичка — заводить отдельную cron-запись под каждую задачу. Смысл планировщика Laravel в обратном: cron знает про фреймворк одну строку, а расписание живёт в коде под версионным контролем.
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
Проверьте, что путь абсолютный и что crontab принадлежит пользователю, от которого работает приложение (обычно www-data или deploy-пользователь), а не root. Расхождение прав — частая причина, по которой задачи «не запускаются», хотя строка на месте.
Перекрытия и дубли: главный источник инцидентов
Планировщик проверяет расписание раз в минуту. Если задача выполняется дольше минуты, следующий тик запустит её второй экземпляр — и так далее, пока сервер не ляжет под весом параллельных выгрузок.
withoutOverlapping для длинных задач
Метод withoutOverlapping() ставит блокировку через кеш: пока предыдущий запуск не завершился, новый не стартует.
// routes/console.php (Laravel 11.x)
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:build')
->hourly()
->withoutOverlapping(30); // блокировка снимается через 30 минут принудительно
Аргумент в минутах — страховка на случай, если процесс упал, не сняв блокировку. Ставьте его чуть больше реального максимального времени задачи, иначе повисшая блокировка остановит расписание навсегда, и вы будете гадать, почему отчёты перестали собираться.
Важно: блокировка хранится в кеше. Если у вас драйвер кеша array или отдельный кеш на каждый воркер, защита не сработает между процессами. Для withoutOverlapping нужен общий кеш — Redis или Memcached.
onOneServer для нескольких инстансов
Если приложение раскатано на несколько серверов и cron стоит на каждом, задача выполнится столько раз, сколько у вас машин. Метод onOneServer() разрешает запуск только на одном инстансе за тик.
Schedule::command('newsletter:send')
->dailyAt('08:00')
->onOneServer();
Если рассылка ушла клиентам дважды, дело почти всегда не в бизнес-логике, а в том, что забыли onOneServer() на кластере из двух машин.
onOneServer() тоже опирается на общий кеш-локер (Redis/Memcached/DB). На драйвере file или array он бесполезен, потому что у каждого сервера свой кеш. Это единственная настройка, где неправильный драйвер приводит к тихому дублированию без единой ошибки в логе.
Что делать с результатом задачи
По умолчанию вывод команды уходит в никуда. Для боевой задачи это плохо: когда она упадёт, вы не найдёте причину.
->sendOutputTo($path)— писать stdout в файл (перезаписывает).->appendOutputTo($path)— дописывать в конец файла.->emailOutputOnFailure($address)— письмо только при ненулевом коде выхода.->onFailure(fn () => ...)и->onSuccess(...)— колбэки для метрик или алертов.
Schedule::command('orders:sync')
->everyFifteenMinutes()
->withoutOverlapping()
->appendOutputTo(storage_path('logs/orders-sync.log'))
->onFailure(function () {
// отправить событие в мониторинг
logger()->error('orders:sync упала');
});
Таймзона: тихий сдвиг на несколько часов
Планировщик берёт время из настройки приложения (config/app.php, ключ timezone). Если там UTC, а вы ждёте задачу в 09:00 по Москве, она выполнится в 12:00. Задать таймзону конкретной задаче можно точечно:
Schedule::command('reports:daily')
->dailyAt('09:00')
->timezone('Europe/Moscow');
Проверьте согласованность до деплоя: расхождение таймзоны сервера, БД и приложения даёт задачи, которые «выполняются не тогда», а на отладку уходит полдня.
Как проверить расписание, не дожидаясь ночи
Две команды, которые нужны при каждой правке расписания:
php artisan schedule:list— показывает все задачи, их выражение cron и следующее время запуска. Первое, чем проверять, что задача вообще зарегистрирована.php artisan schedule:test— запускает выбранную задачу вручную, минуя расписание. Удобно, чтобы убедиться, что команда рабочая, до того как доверить её крону.
schedule:list появился в Laravel 9.x, schedule:test — примерно в тех же версиях; если команды нет, сверьтесь с документацией вашей версии.
Сравнение методов защиты
| Метод | От чего защищает | Требует общий кеш | Когда ставить |
|---|---|---|---|
withoutOverlapping() | Наложение задачи саму на себя на одном сервере | Да (Redis/Memcached) | Задача может длиться дольше интервала |
onOneServer() | Дубли на нескольких инстансах кластера | Да | Cron стоит больше чем на одной машине |
runInBackground() | Блокировку всей минуты одной долгой задачей | Нет | Много независимых задач в одну минуту |
runInBackground() не про дубли: он позволяет планировщику не ждать завершения одной задачи, прежде чем запустить остальные из этой же минуты. Без него десять задач в 00:00 выполнятся строго по очереди.
Внешний контроль: heartbeat
Самая опасная поломка — тихая: cron удалили при переезде, сервер перезагрузился без cron-демона, деплой сломал crontab. Планировщик просто перестаёт работать, ошибок нет. Спасает внешний heartbeat: задача пингует сервис вроде Healthchecks или Dead Man's Snitch, и если пинг не пришёл вовремя, приходит алерт.
Schedule::command('heartbeat:ping')
->everyFiveMinutes()
->pingOnSuccess('https://hc-ping.com/YOUR_CHECK_UUID');
Метод pingOnSuccess() (и парный pingOnFailure()) шлёт HTTP-запрос на указанный URL. Если внешний сервис не увидел пинг в срок — вы узнаёте о мёртвом планировщике сами, а не от разгневанного клиента.
Что делать дальше
Пройдите чек-лист по каждой задаче в расписании: перекрытие, кластер, вывод, таймзона, внешний пинг. После деплоя посмотрите schedule:list прямо на боевом сервере — списки в разных ветках расходятся чаще, чем кажется. Настройте алерт на heartbeat и проверьте драйвер кеша: если там не Redis или Memcached, withoutOverlapping и onOneServer — просто декорации. Отдельная большая тема — куда выносить тяжёлую работу из задач через очереди вместо синхронного выполнения в планировщике; здесь мы её не разворачиваем.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Laravel Documentation — Task Scheduling
Частые вопросы
У меня Laravel 10, а расписание в статье в routes/console.php. Где писать мне?
onOneServer стоит, но задача всё равно выполняется дважды. Почему?
Задача повисла, и теперь ничего не запускается. Что случилось?
Обязательно ли ставить withoutOverlapping на все задачи?
Как проверить расписание локально, не дожидаясь наступления времени?
Планировщик молчит и не даёт ошибок. Как понять, что он вообще работает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.