Разработка · 7 мин чтения

Планировщик задач Laravel: schedule и запуск по cron на сервере

Планировщик задач Laravel я подключаю почти в каждом бэкенд-проекте, где нужна рассылка отчётов по ночам, синхронизация остатков или чистка временных файлов. Работает связка из двух частей: сам Laravel с фасадом Schedule, который знает, что и когда запускать, и один cron на сервере, который раз в минуту дёргает artisan schedule:run. Ниже - как это настроить с нуля и на что я натыкался на реальных проектах.

Как устроен планировщик задач в Laravel

До Laravel 11 расписание задач жило в методе schedule() класса app/Console/Kernel.php. С одиннадцатой версии Kernel-классы убрали, и задачи регистрируются прямо в routes/console.php через фасад Schedule. Логика та же самая, поменялось только место, где я это пишу:

// routes/console.php, Laravel 11+
use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:generate')->dailyAt('01:00');

Schedule::call(function () {
    // например, синхронизация остатков с сайтом
})->everyFiveMinutes()->withoutOverlapping();

Для проектов на более ранних версиях тот же код пишется в Kernel:

// app/Console/Kernel.php, Laravel до 11 версии
protected function schedule(Schedule $schedule)
{
    $schedule->command('reports:generate')->dailyAt('01:00');
}

При апгрейде старого проекта на Laravel 11 переписывать ничего не обязательно - Kernel-подход продолжает работать, просто новые задачи я уже добавляю в routes/console.php, чтобы не плодить два места с расписанием. Смысл планировщика в том, что вместо десятка строк в crontab с разными командами и временем я держу одну точку входа - artisan schedule:run. Она запускается каждую минуту и сама решает, какие задачи из списка пора выполнять прямо сейчас, а какие ещё рано. Это удобно, потому что расписание версионируется в git вместе с кодом, а не живёт отдельно на сервере, где его легко забыть или потерять при переезде на новый хостинг.

Если под задачей понимается не разовый скрипт, а часть архитектуры проекта, скажем, ночная выгрузка заказов во внешнюю систему или пересчёт остатков по расписанию, такие вещи я закладываю сразу на этапе разработки бэкенда на Laravel, а не добавляю потом поверх готового кода.

Настройка cron для запуска Laravel-планировщика на сервере

На сервере нужна ровно одна строка в crontab. Открываю редактор для пользователя, от имени которого крутится сайт (обычно www-data или deploy, не root):

crontab -e

И добавляю строку с путём до проекта:

* * * * * cd /var/www/example.com && php artisan schedule:run >> /dev/null 2>&1

Cron физически не умеет запускать что-то чаще раза в минуту, поэтому строка всегда одна и та же вне зависимости от того, сколько задач реально стоит в расписании внутри Laravel: хоть одна, хоть пятьдесят. Три момента, на которые смотрю в первую очередь при настройке:

  • Путь до php должен указывать на нужную версию, если на сервере их несколько - лучше прописать полный путь вроде /usr/bin/php8.3, а не полагаться на php по умолчанию в PATH.
  • Команда идёт из директории проекта (cd /var/www/example.com &&), иначе artisan не найдёт .env и конфиги.
  • Проверяю, что crontab настроен именно у того пользователя, который реально владеет процессом веб-сервера, а не задвоен ещё и у root.

После добавления строки я запускаю php artisan schedule:list, чтобы увидеть таблицу зарегистрированных задач и время их следующего запуска - так сразу видно, подхватил ли Laravel список правильно.

Методы расписания: интервалы, cron-выражения и часовые пояса

У каждой задачи в цепочке методов задаётся периодичность. Использую как готовые хелперы, так и сырые cron-выражения, когда стандартных вариантов не хватает:

Schedule::command('backup:run')->daily();
Schedule::command('reports:weekly')->weeklyOn(1, '08:00');
Schedule::command('cleanup:temp')->hourly();
Schedule::command('stock:sync')->everyFiveMinutes();
Schedule::command('digest:send')->cron('0 9 * * 1-5')->timezone('Europe/Moscow');

Часовой пояс отдельная тема: сервер часто живёт в UTC, а бизнес ждёт письмо в девять утра по Москве. Не полагаюсь на глобальную настройку timezone в config/app.php - для конкретных задач ставлю метод timezone() явно, иначе после переезда на другой сервер расписание внезапно съезжает на несколько часов.

Бесплатный материал

🎁 Полезный скрипт в подарок

Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.

Без спама. Отписка в 1 клик.

withoutOverlapping и onOneServer: защита от дублей

Две вещи, без которых планировщик рано или поздно устраивает сюрприз в проде.

withoutOverlapping

Если задача выполняется дольше минуты, а cron всё равно дёргает schedule:run каждую минуту, есть риск запустить вторую копию той же задачи поверх первой. Метод withoutOverlapping() ставит блокировку через атомарный лок в кеше, и вторая попытка просто пропускается, пока первая не закончится. Для этого нужен cache-драйвер с поддержкой атомарных блокировок, на практике беру redis или database - файловый кеш для такой задачи не подходит, если приложение крутится на нескольких воркерах или в контейнерах.

Schedule::command('stock:sync')
    ->everyFiveMinutes()
    ->withoutOverlapping(10);

Число в скобках - таймаут блокировки в минутах на случай, если задача зависла и не сняла лок сама.

onOneServer

Когда приложение развёрнуто на нескольких серверах за балансировщиком, каждый из них по отдельности гоняет свой cron, и без дополнительной настройки одна и та же задача выполнится параллельно на всех машинах. Метод onOneServer() решает это через тот же механизм блокировок в общем кеше - задача выполняется только на той машине, которая первой захватила лок:

Schedule::command('reports:generate')
    ->dailyAt('01:00')
    ->onOneServer();

Работает это только если redis или другой центральный кеш общий для всех серверов, а не локальный на каждом из них.

Логи, уведомления и проверка расписания

Голый вывод artisan schedule:run в /dev/null удобен, пока всё работает, и бесполезен, когда что-то ломается посреди ночи. Для важных задач добавляю логирование и колбэк на ошибку:

Schedule::command('reports:generate')
    ->dailyAt('01:00')
    ->appendOutputTo(storage_path('logs/schedule.log'))
    ->onFailure(function () {
        Log::error('Не удалось сформировать ночной отчёт');
    });

В onFailure можно повесить что угодно - отправку в Slack, письмо, вызов вебхука для telegram-бота, который у меня и так часто крутится в проекте для уведомлений команды. Для визуального контроля добавляю Laravel Pulse - там видно и долгие job’ы из очередей, и зависшие команды, без того чтобы ковыряться в логах руками.

Отдельно держу в голове разницу между schedule:run и schedule:test: первый вызывает cron раз в минуту и выполняет всё, что подошло по времени, второй - интерактивная команда, которой я вручную прогоняю конкретную задачу из списка, не дожидаясь её реального времени, когда нужно быстро проверить, что она вообще отрабатывает без ошибок.

И ещё один момент, который путают чаще всего: планировщик и очереди решают разные задачи. Schedule по часам и дням запускает команды и колбэки, а queue:work - это долгоживущий процесс, обрабатывающий джобы, которые в очередь кладёт код приложения. Cron планировщика можно смело гонять каждую минуту, а вот queue:work через cron не запускают - для него нужен supervisor или systemd, которые перезапустят процесс, если он упадёт.

Типичные ошибки при настройке планировщика на проде

Большая часть проблем с планировщиком всплывает не на локали, а через пару недель на проде, когда накапливаются логи или подключается второй сервер:

Ошибка Чем оборачивается
Строка cron прописана дважды - вручную и через деплой-скрипт Задачи выполняются по два раза, дублируются письма и записи в базе
Не указан полный путь до нужной версии php Cron берёт системный php по умолчанию, который может отличаться от версии проекта
Тяжёлая логика написана прямо в замыкании Schedule::call Задача блокирует следующий тик schedule:run, если не стоит withoutOverlapping
onOneServer включён без общего кеша между серверами Задача либо не запускается нигде, либо всё равно дублируется

По последнему пункту таблицы: если тяжёлая работа выполняется дольше пары секунд, обычно выношу её не в закрытие внутри schedule, а в отдельный джоб, который планировщик просто диспатчит в очередь, а обрабатывает уже queue:work под supervisor. Так cron остаётся лёгким и предсказуемым, а вся тяжёлая логика уходит туда, где для неё есть ретраи и нормальный контроль зависаний.

Laravel регулярно обновляется, поэтому перед внедрением я сверяюсь с актуальной документацией.

Частые вопросы

Как часто нужно запускать schedule:run через cron?

Раз в минуту, без исключений. Сам Laravel внутри разберётся, какие задачи из расписания реально пора выполнять на этой минуте, а какие ещё ждут своего часа - дополнительных cron-строк под каждую задачу заводить не нужно.

Нужен ли отдельный cron-job на каждую задачу в проекте?

Нет, и это одно из главных удобств планировщика. Вся логика расписания живёт в коде Laravel, на сервере остаётся одна строка cron, которая просто дёргает schedule:run, а список задач и их периодичность меняются через git, а не через ручное редактирование crontab.

Как проверить, что планировщик реально работает на сервере?

Запускаю php artisan schedule:list, чтобы увидеть таблицу задач и время следующего запуска, а для точечной проверки конкретной команды использую schedule:test, который выполняет выбранную задачу сразу, не дожидаясь её расписания.

Чем планировщик задач отличается от очередей в Laravel?

Schedule решает, когда что-то должно произойти - раз в час, раз в сутки, по cron-выражению. Очереди решают, как это выполнить асинхронно и с ретраями, если задача упала. На практике планировщик часто просто ставит джоб в очередь по расписанию, а сама тяжёлая работа выполняется уже воркером queue:work.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно