Планировщик задач в Laravel: рабочие приёмы из практики
Как правильно готовить schedule в Laravel 11: защита от наложения задач, блокировка на нескольких серверах, вывод в лог и защита от тихого падения. Разбор приёмов, которые спасают в проде.

Крон запустил вашу задачу отправки писем в 3:00. Задача повисла на медленном SMTP и не закончилась к 3:05, когда крон запустил её снова. К утру у части клиентов по три одинаковых письма, а вы разбираете жалобы. Планировщик Laravel умеет это предотвращать одним методом, но по умолчанию он ничего такого не делает — всё нужно включить руками.
Разберём приёмы, которые отделяют работающий schedule от того, что тихо ломается в проде: защита от наложения, блокировка при нескольких серверах, вменяемое логирование и контроль того, что задача вообще запустилась. Всё для Laravel 11.x, где расписание переехало в routes/console.php; для 10.x и раньше те же методы живут в app/Console/Kernel.php.
С чего начинается наложение задач
Планировщик Laravel — это не отдельный демон. Это одна крон-запись, которая раз в минуту дёргает php artisan schedule:run, а Laravel сам решает, что из расписания пора выполнять. Базовая строка в crontab выглядит так:
* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1Проблема в том, что schedule:run запускает задачи и не ждёт завершения предыдущего запуска. Если задача выполняется дольше своего интервала, следующая минута запустит вторую копию поверх первой. Для отчёта, который читает базу, это лишняя нагрузка. Для задачи, которая пишет или отправляет — дубли и порча данных.
Приём первый: withoutOverlapping
Метод withoutOverlapping() ставит блокировку в кеше на время выполнения задачи. Пока предыдущий запуск не закончился, новый просто пропускается.
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:daily')
->everyMinute()
->withoutOverlapping();Здесь кроется грабля. Если процесс упадёт по-жёсткому (kill -9, OOM, перезагрузка сервера), блокировка не снимется, и задача перестанет запускаться навсегда. По умолчанию срок жизни блокировки — 24 часа, после чего она протухает сама. Если задача короткая, а простой в сутки для вас неприемлем — задайте свой таймаут в минутах:
Schedule::command('reports:daily')
->everyMinute()
->withoutOverlapping(10); // блокировка протухнет через 10 минутСтавьте таймаут с запасом к реальному времени выполнения. Слишком маленький — и медленный запуск разблокируется сам, снова получите наложение.
Несколько серверов: onOneServer
Как только приложение крутится на двух и более серверах приложения, крон стоит на каждом из них. Значит, schedule:run запускается параллельно везде, и одна и та же задача из расписания выполнится столько раз, сколько у вас серверов.
onOneServer() решает это через общую блокировку: первый сервер, дошедший до задачи, берёт лок, остальные пропускают.
Schedule::command('reports:daily')
->daily()
->onOneServer();
onOneServerработает только с общим для всех серверов драйвером кеша — Redis, Memcached, DynamoDB или база. С драйверомfileилиarrayкаждый сервер имеет свою локальную блокировку, и защита не сработает вообще.
Ещё одна деталь: onOneServer различает задачи по имени команды и её расписанию. Две задачи с одинаковой командой и одинаковым временем Laravel посчитает за одну. Если у вас несколько таких — задайте им явные имена через name(), иначе одна из них молча не запустится.
Разница между двумя блокировками
| Метод | От чего защищает | Область блокировки |
|---|---|---|
withoutOverlapping() | Наложение запусков одной задачи во времени | В пределах экземпляра планировщика |
onOneServer() | Дублирование задачи на разных серверах | Общий кеш всех серверов |
Это не альтернативы, а разные проблемы. На кластере из нескольких серверов, где задача может ещё и затянуться, вешают оба метода сразу.
Задача упала — а вы не узнали
Самая частая беда в проде — планировщик тихо работает, а задача внутри падает, и никто об этом не знает неделю. По умолчанию вывод schedule:run уходит в /dev/null из-за >> /dev/null в crontab. Значит, всё, что задача напечатала, включая трассировку исключения, пропало.
Первое — не глушите вывод крона наглухо. Направьте его в файл:
* * * * * cd /path-to-project && php artisan schedule:run >> storage/logs/schedule.log 2>&1Второе — навесьте хуки на конкретные задачи. Laravel даёт onSuccess, onFailure, emailOutputTo и appendOutputTo:
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:daily')
->daily()
->onOneServer()
->withoutOverlapping()
->appendOutputTo(storage_path('logs/reports.log'))
->onFailure(function () {
// сработает при ненулевом коде выхода команды
Log::error('Задача reports:daily завершилась с ошибкой');
});onFailure ориентируется на код выхода процесса. Если ваша artisan-команда ловит исключение сама и завершается с кодом 0, хук не сработает — планировщик считает такой запуск успешным. Либо пробрасывайте исключение наружу, либо возвращайте ненулевой код через return self::FAILURE;.
Контроль того, что задача вообще запускалась
Хуки ловят падение, но не ловят ситуацию, когда крон вообще перестал ходить — например, кто-то почистил crontab при деплое. Здесь помогает мониторинг «мёртвой руки» (dead man's switch): задача при успехе дёргает внешний пинг (Healthchecks, Cronitor, свой эндпоинт), а сервис поднимает тревогу, если пинга не было в срок.
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:daily')
->daily()
->thenPing('https://hc-ping.com/YOUR_CHECK_UUID');thenPing отправляет GET-запрос после успешного выполнения. Есть также pingBefore, pingOnSuccess и pingOnFailure для более точного контроля. Это единственный способ узнать, что планировщик умер целиком, — изнутри приложения такую ситуацию не заметить.
Частые ошибки, которые видно на ревью
- Тяжёлая логика прямо в замыкании расписания.
Schedule::call(fn () => ...)с бизнес-логикой на 50 строк выполняется в том же процессе, что иschedule:run. Одна медленная задача задерживает диспетчеризацию остальных. Выносите в artisan-команду или в очередь. - Отсутствие таймаута выполнения.
schedule:runсам по себе не ограничивает время задачи. Если команда может зависнуть на внешнем вызове — ставьте таймаут внутри неё, а не надейтесь на крон. - Тяжёлые задачи в лоб вместо очереди. Рассылка тысячи писем прямо в задаче расписания блокирует процесс на всё время. Задача расписания должна поставить job в очередь и завершиться за миллисекунды:
SendReports::dispatch(). - Часовой пояс не задан. Расписание считает время по
app.timezone. Для конкретной задачи задавайте->timezone('Europe/Moscow')явно, если ждёте запуск по местному времени, а не по UTC.
Если планировщик не запускается
- Задачи не выполняются вовсе. Проверьте, что крон-запись реально есть в crontab того пользователя, от которого работает PHP:
crontab -l. Частая ошибка — запись добавили под root, а приложение работает под www-data. The command "..." does not exist.Крон запускается не из корня проекта. Убедитесь, что в строке естьcd /path-to-project &&перед вызовом artisan.- Задача с
withoutOverlappingперестала запускаться после падения. Зависшая блокировка. Снимите её вручную командойphp artisan schedule:clear-cache(в текущих версиях) или дождитесь протухания по таймауту. onOneServerне работает. Драйвер кешаfileвместо общего. ПереключитеCACHE_STOREнаredisилиdatabase.
Что делать дальше
Соберите базовую защиту в привычку: на каждую задачу, которая пишет или отправляет, вешайте withoutOverlapping(), на кластере добавляйте onOneServer(), а вывод крона пишите в файл, а не в /dev/null. Для отладки самого расписания есть php artisan schedule:list — покажет все задачи и время следующего запуска, и php artisan schedule:test — прогонит конкретную задачу вручную. Следующий шаг — вынести тяжёлые задачи в очереди и настроить один внешний мониторинг пинга на критичные из них, чтобы узнавать о молчании планировщика раньше, чем это заметят пользователи.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Task Scheduling — Laravel Documentation
Частые вопросы
У меня Laravel 10, расписание в Kernel.php — приёмы подойдут?
Почему withoutOverlapping не помогло — задача всё равно запустилась дважды?
onFailure не срабатывает, хотя задача явно падает. В чём дело?
Можно ли обойтись без крона и запускать планировщик как демон?
Как проверить расписание, не дожидаясь реального времени запуска?
Задача расписания тормозит остальные — что не так?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.