Очередь нужна не только большим проектам. Отправка письма, генерация PDF, обращение к чужому API — всё это может длиться секунды и держать запрос пользователя. Проще положить задачу в очередь и ответить браузеру сразу. Для этого не обязателен Redis: Laravel умеет хранить задачи в обычной таблице MySQL.

Зачем очередь, если сайт маленький

Типичный сценарий: форма отправляет письмо через SMTP. Без очереди пользователь ждёт, пока почтовый сервер ответит, а при таймауте видит ошибку, хотя данные уже сохранены. С очередью запись в базу занимает миллисекунды, письмо уходит в фоне, а сбой можно повторить.

Второй сценарий — тяжёлые задачи: импорт CSV, обход внешнего API с лимитами, пересчёт статистики. Их нельзя выполнять внутри HTTP-запроса: PHP-процесс ограничен по времени, а при обрыве соединения работа теряется.

Таблицы jobs и failed_jobs

Драйвер database хранит задания в двух таблицах. Создаются они штатными командами:

php8.3 artisan queue:table
php8.3 artisan queue:failed-table
php8.3 artisan migrate

В .env укажите соединение:

QUEUE_CONNECTION=database

После правки .env на сервере нужен php8.3 artisan config:clear, иначе Laravel продолжит читать закешированный конфиг. Таблица failed_jobs появляется не всегда: без неё упавшее задание просто исчезнет, и вы не узнаете причину.

Как поставить задачу в очередь

Задание — обычный класс с трейтом ShouldQueue. Внутри — то, что раньше выполнялось в контроллере:

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class SendOrderMail implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $timeout = 60;

    public function __construct(public Order $order) {}

    public function handle(): void
    {
        // отправка письма
    }
}

Постановка в очередь выглядит короче, чем кажется:

SendOrderMail::dispatch($order);
SendOrderMail::dispatch($order)->delay(now()->addMinutes(10));

Модель в конструкторе сериализуется по идентификатору. Если запись удалят до запуска, Laravel выбросит исключение — это нормальное поведение, а не ошибка очереди.

Запуск воркера на shared-хостинге

На VPS воркер держит Supervisor: процесс живёт постоянно и перезапускается при падении. На виртуальном хостинге Supervisor обычно недоступен, зато есть cron. Рабочая схема — запускать воркер раз в минуту с ограничением по времени:

* * * * * php8.3 /home/USER/site.ru/artisan queue:work --stop-when-empty --max-time=50 >> /home/USER/queue.log 2>&1

--stop-when-empty завершает процесс, когда задания кончились, --max-time не даёт ему работать дольше минуты и накладываться на следующий запуск. Пути должны быть абсолютными: cron не наследует рабочий каталог проекта.

Есть и второй вариант — queue:listen, который перезапускает обработчик после каждого задания. Он удобнее при разработке, потому что подхватывает изменения в коде, но нагружает сервер сильнее. Для cron-схемы берите queue:work.

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

retry_after, timeout и дубли задач

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

Правило простое: retry_after должен быть заметно больше максимального времени выполнения. Значения задаются в config/queue.php и в переменных окружения. Заодно проверьте, что задача идемпотентна: повторная отправка письма или повторное списание денег — плохая новость.

Что делать с упавшими задачами

Посмотреть список и причину:

php8.3 artisan queue:failed
php8.3 artisan queue:retry all
php8.3 artisan queue:flush

queue:retry all вернёт задания в работу, queue:flush удалит их навсегда. Перед массовым повтором убедитесь, что причина устранена: иначе получите тот же сбой десять раз подряд. Для критичных задач добавьте уведомление в Telegram при событии JobFailed.

Когда всё-таки нужен Redis

Database-драйвер блокирует строку таблицы при выборке задания. При десятках воркеров и тысячах задач в минуту это становится узким местом. Redis решает вопрос скорости и умеет отложенные задачи эффективнее. Но если у вас один сервер и несколько сотен заданий в сутки, разница незаметна, а лишняя зависимость только усложняет деплой.

Коротко

  • QUEUE_CONNECTION=database и две миграции — минимальный набор;
  • воркер запускается из cron с --stop-when-empty --max-time;
  • retry_after больше timeout, иначе будут дубли;
  • failed_jobs нужна, чтобы видеть причины сбоев;
  • Redis оправдан при высокой нагрузке, а не «на всякий случай».