Уведомления в Laravel: рабочие приёмы для очередей и каналов
Как отправлять уведомления в Laravel так, чтобы почта не тормозила запрос, а сбой SMTP не ронял бизнес-логику: очереди, несколько каналов, дедупликация и повторные попытки.

Пользователь оформил заказ, а страница подтверждения крутится три секунды, потому что контроллер ждёт ответа от SMTP-сервера. Знакомо. В Laravel уведомления решают половину этой боли из коробки, но только если их правильно поставить в очередь и не забыть про каналы, повторные попытки и защиту от дублей. Разберём приёмы, которые нужны на проекте с реальным трафиком, а не на демо. Ориентир — Laravel 11.x, но всё описанное работает и в 10.x с оговорками, где они есть.
Проблема синхронной отправки
Базовый вызов $user->notify(new OrderShipped($order)) без очереди выполняется прямо внутри запроса. Пока Laravel устанавливает соединение с почтовым сервисом и ждёт ответа, HTTP-запрос заблокирован. На локальной машине с mailhog это незаметно, в проде с внешним SMTP или API-провайдером — это сотни миллисекунд в лучшем случае, а при таймауте провайдера пользователь получает 500, хотя заказ уже создан.
Решение — реализовать интерфейс ShouldQueue у класса уведомления. Тогда отправка уходит в очередь, а запрос завершается сразу.
<?php
namespace App\Notifications;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class OrderShipped extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Order $order)
{
}
// Каналы доставки для конкретного получателя
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Заказ №' . $this->order->id . ' отправлен')
->line('Ваш заказ передан в доставку.')
->action('Отследить', url('/orders/' . $this->order->id));
}
// Для канала database — что ляжет в JSON-колонку
public function toArray(object $notifiable): array
{
return [
'order_id' => $this->order->id,
'status' => 'shipped',
];
}
}
Как только уведомление реализует ShouldQueue, для его обработки нужен запущенный воркер: php artisan queue:work. Без воркера уведомления будут копиться в таблице jobs и никуда не уйдут — это первая ошибка при переходе на очереди.
Синхронное уведомление — это не «просто медленнее». Это связывание надёжности вашего бизнес-процесса с доступностью чужого SMTP. Очередь разрывает эту связь: заказ создался, а письмо доедет само.
Несколько каналов и условная доставка
Метод via() получает получателя, поэтому набор каналов можно строить по данным пользователя. Не шлите SMS тем, кто отключил его в настройках, — это и деньги, и жалобы.
public function via(object $notifiable): array
{
$channels = ['database'];
if ($notifiable->email_notifications) {
$channels[] = 'mail';
}
if ($notifiable->phone && $notifiable->sms_notifications) {
$channels[] = 'vonage'; // канал SMS из пакета laravel/vonage-notification-channel
}
return $channels;
}
Каналы mail и database встроены. Каналы Slack, Vonage и другие с версии Laravel 9 вынесены в отдельные пакеты — их ставят отдельно через Composer. Точный список поддерживаемых каналов и имена пакетов сверяйте в разделе Notifications официальной документации: они менялись между мажорными версиями.
Ленивая генерация контента по каналу
Каждый метод toMail, toArray, toVonage вызывается только если соответствующий канал попал в via(). Тяжёлую выборку из БД не делайте в конструкторе — она отработает даже для получателя, которому этот канал не нужен. Собирайте данные внутри метода конкретного канала.
Разные очереди для разных уведомлений
Приветственное письмо и алерт о падении оплаты не должны стоять в одной очереди с одинаковым приоритетом. Направьте их в разные очереди и запускайте воркеры с указанием порядка.
use Illuminate\Notifications\Notification;
class PaymentFailed extends Notification implements ShouldQueue
{
use Queueable;
public function __construct()
{
// Уведомление попадёт в очередь alerts
$this->onQueue('alerts');
}
// ... via(), toMail()
}
Запуск воркера с приоритетом очередей — сначала алерты, потом всё остальное:
php artisan queue:work --queue=alerts,default
Воркер разбирает очереди слева направо: пока в alerts что-то есть, до default он не дойдёт. Это дешёвый способ приоритизации без отдельной инфраструктуры.
Повторные попытки и обработка сбоев
Провайдер почты может ответить таймаутом или 503. По умолчанию задача из очереди при исключении вернётся на повтор, но без ограничений это превращается в бесконечный цикл, забивающий воркер. Задайте лимит попыток и backoff — паузу между ними.
class OrderShipped extends Notification implements ShouldQueue
{
use Queueable;
// Максимум попыток
public $tries = 3;
// Пауза в секундах между попытками: 10, затем 30, затем 60
public $backoff = [10, 30, 60];
// Через сколько секунд задача считается зависшей
public $timeout = 30;
// ...
}
Что происходит после исчерпания попыток: задача уходит в таблицу failed_jobs, если запущена миграция для неё (php artisan queue:failed-table и migrate). Оттуда её видно через php artisan queue:failed и можно перезапустить командой php artisan queue:retry. Без этой таблицы упавшие уведомления просто пропадают, и вы узнаете о проблеме от пользователя, а не из мониторинга.
Метод failed для реакции на провал
Добавьте в класс уведомления метод failed() — он сработает, когда попытки закончились. Логируйте туда контекст: кому и что не ушло.
public function failed(\Throwable $exception): void
{
\Log::error('Не удалось отправить OrderShipped', [
'order_id' => $this->order->id,
'error' => $exception->getMessage(),
]);
}
Дедупликация: как не отправить письмо дважды
Классическая ситуация: пользователь дважды нажал кнопку, или вебхук платёжки пришёл повторно. В обоих случаях уведомление отправляется дважды. Решений несколько, у каждого своя цена.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
Флаг в БД (notified_at) | Простота, видно в данных | Нужна колонка и проверка перед каждой отправкой |
ShouldBeUnique у job (косвенно) | Работает на уровне очереди | Уникальность держится на время в кеше, не навсегда |
| Кеш-замок с TTL | Гибко, без миграций | Зависит от драйвера кеша, замок может протухнуть |
Для критичных уведомлений (оплата, доставка) надёжнее флаг в БД: проверяйте его в транзакции перед отправкой. Кеш-замок годится для «мягких» уведомлений, где повтор — неприятность, а не денежная проблема.
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($order, $user) {
// Блокируем строку, чтобы параллельный запрос не проскочил
$fresh = Order::whereKey($order->id)->lockForUpdate()->first();
if ($fresh->shipped_notified_at === null) {
$user->notify(new OrderShipped($fresh));
$fresh->update(['shipped_notified_at' => now()]);
}
});
Что ломается на практике
- Модель в конструкторе, а не её id. Очередь сериализует уведомление. Если передать целую модель со связями, в очередь уедет тяжёлый объект. Трейт
Queueableвместе сSerializesModelsсериализует модель по ключу и подтягивает свежую при обработке — но если запись к моменту обработки удалена, воркер упадёт сModelNotFoundException. Обрабатывайте это вfailed(). - Воркер не перезапущен после деплоя. Воркер держит код в памяти. После выката новой версии старый воркер работает на старом коде, пока его не перезапустят:
php artisan queue:restart. Забыли — и правки в уведомлении не применяются. - Нет таблицы failed_jobs. Упавшие уведомления исчезают бесследно. Заведите таблицу и подключите мониторинг (Horizon для Redis-очередей показывает провалы наглядно).
- on-demand уведомления теряют получателя. Для отправки без модели (
Notification::route('mail', $email)->notify(...)) в очереди сериализуется маршрут, а не пользователь. Проверьте, что адрес доступен на момент обработки.
Что настроить дальше
Переведите очереди с драйвера database на Redis и поставьте Laravel Horizon — он даёт дашборд с метриками очередей, временем обработки и провалами в реальном времени. Настройте supervisor или systemd, чтобы воркеры перезапускались автоматически: без этого один упавший воркер тихо останавливает всю доставку. И заведите алерт на рост таблицы failed_jobs — это первый сигнал, что провайдер уведомлений сломался, а не пользователи перестали получать письма.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Laravel Notifications — официальная документация, Laravel Queues — официальная документация
Частые вопросы
Уведомление реализует ShouldQueue, но письма не приходят. Что проверить первым?
У меня Laravel 10, а не 11. Приёмы подойдут?
Как отправить уведомление без модели User, просто на email?
Что будет, если модель из конструктора уведомления удалят до обработки очереди?
Нужна ли таблица failed_jobs, если у меня всё и так работает?
Как избежать двойной отправки при повторном вебхуке?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.