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

Регистрация пользователя занимала 40 миллисекунд, пока в слушатель события Registered не добавили отправку приветственного письма через внешний SMTP. После этого ответ контроллера стал приходить за 2–3 секунды: пользователь ждал, пока Laravel синхронно достучится до почтового сервера. Событие никуда не делось, а вот слушатель начал держать HTTP-запрос. Разберём приёмы, которые убирают такие проблемы, и границы, где событийная модель начинает мешать.
Материал для Laravel 11.x (базовый механизм не менялся с 8.x, но с 11.x автодискавери слушателей включён по умолчанию — об этом ниже). Формат — практика: разбираем конкретные развилки, а не устройство диспетчера изнутри.
Синхронный слушатель против очереди
По умолчанию слушатель выполняется в том же процессе, что и код, отправивший событие. Пока слушатель лёгкий — заполнить поле, сбросить кеш — это нормально. Как только внутри появляется сетевой вызов (почта, вебхук, внешний API), синхронное выполнение растягивает ответ пользователю.
Решение — вынести слушатель в очередь, реализовав ShouldQueue:
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class SendOrderConfirmation implements ShouldQueue
{
use InteractsWithQueue;
// очередь, в которую попадёт задача
public $queue = 'mail';
// сколько раз пробовать перед провалом
public $tries = 3;
public function handle(OrderPlaced $event): void
{
// здесь тяжёлый сетевой вызов — почта
// выполнится в воркере, а не в HTTP-запросе
}
}После этого event(new OrderPlaced($order)) положит задачу в очередь и мгновенно вернёт управление. Но задача выполнится, только если запущен воркер: php artisan queue:work. Забытый воркер — самая частая причина, по которой «письма не приходят, хотя код верный».
Событие в очереди не выполняется само по себе. Если после деплоя вы не перезапустили queue:work, воркер продолжает крутить старый код из памяти, а новые слушатели молчат. Перезапуск воркера после каждого деплоя — не рекомендация, а обязательная часть релиза.Событие внутри транзакции: гонка, которую не видно на локали
Типичная ошибка: событие отправляют внутри DB::transaction, а слушатель в очереди пытается прочитать запись, которая ещё не закоммичена.
DB::transaction(function () use ($order) {
$order->save();
// слушатель в очереди может стартовать
// раньше, чем транзакция закоммитится
OrderPlaced::dispatch($order);
});На локальной машине воркер медленный, транзакция успевает закоммититься — и всё «работает». В проде воркер подхватывает задачу за миллисекунды и получает ModelNotFoundException, потому что запись ещё не видна другому соединению.
Правильный приём — интерфейс ShouldQueueAfterCommit (в Laravel 11.x) либо свойство public $afterCommit = true; в слушателе. Тогда задача попадёт в очередь только после успешного коммита:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
class SendOrderConfirmation implements ShouldQueue, ShouldQueueAfterCommit
{
// задача уйдёт в очередь после COMMIT транзакции
}Альтернатива — включить after_commit глобально в конфиге очереди (config/queue.php, ключ after_commit у нужного соединения). Точное поведение флага сверяйте с разделом Queues официальной документации вашей версии — детали менялись между релизами.
Событие или прямой вызов сервиса
Событие развязывает отправителя и обработчиков: тот, кто разместил заказ, не знает, что на это письмо, начисление бонусов и запись в аналитику. Это плюс, пока обработчиков несколько и они независимы. Минус в том, что по коду контроллера больше не видно, что произойдёт после dispatch — логика размазана по слушателям.
| Ситуация | Событие + слушатели | Прямой вызов сервиса |
|---|---|---|
| Несколько независимых реакций на одно действие | Подходит: добавляете слушатель, не трогая отправителя | Отправитель обрастает вызовами |
| Результат обработчика нужен прямо сейчас в ответе | Плохо: слушатель ничего не возвращает вызвавшему коду | Подходит: получаете значение сразу |
| Одна обязательная операция в цепочке | Скрывает зависимость, тяжело отлаживать | Явно и читаемо |
| Реакции пишут разные команды/модули | Подходит: слабая связанность | Конфликты в одном методе |
Критерий простой: если код после действия обязателен, влияет на возвращаемое значение и его всего один — вызывайте сервис напрямую. Событие оправдано, когда реакций несколько, они опциональны и отправитель не должен о них знать.
Дубли и идемпотентность
Слушатель в очереди с $tries = 3 при сбое повторяется. Если внутри — отправка письма или списание, повтор приведёт к двойному письму или двойному списанию. Очередь не гарантирует ровно одну доставку, она гарантирует хотя бы одну.
Что помогает:
- Ключ идемпотентности. Перед действием проверяйте по уникальному ключу (id заказа + тип операции), не выполнено ли оно уже. Запись флага и само действие по возможности в одной транзакции.
- Middleware
WithoutOverlapping. Не даёт двум задачам с одним ключом выполняться одновременно — спасает от гонки при быстрых повторах. - Разумный
$triesиbackoff. Для операций с внешними эффектами лучше меньше повторов и явная обработка провала через методfailed(), чем слепые ретраи.
Автодискавери слушателей: где ломается тишиной
В Laravel 11.x слушатели по умолчанию находятся автоматически по типу аргумента в методе handle — регистрировать их в EventServiceProvider вручную больше не обязательно. Это удобно, пока не приводит к молчаливым сбоям:
- Слушатель не срабатывает, а ошибки нет. Проверьте тип аргумента
handle: автодискавери привязывается именно к нему, опечатка в импорте события отвяжет слушатель без предупреждения. - Кеш событий в проде. Если выполнена команда кеширования событий, новые слушатели не подхватятся, пока кеш не пересобран. Пересобирайте кеш при деплое.
- Дублирование: слушатель и найден автоматически, и прописан вручную — событие обработается дважды. При переезде на 11.x уберите ручные регистрации, которые теперь дублируют автодискавери.
Что делать дальше
Пройдитесь по своим слушателям с этим чек-листом: у тяжёлых стоит ShouldQueue; у тех, что читают только что созданные записи, — ShouldQueueAfterCommit; у тех, что шлют письма и списывают деньги, есть защита от повтора. Настройте мониторинг очереди (Horizon для Redis-очередей покажет проваленные задачи и время в очереди) и метод failed() у критичных слушателей, чтобы провал не растворялся в логах. Следите за двумя показателями в проде: время задачи в очереди до старта и доля попаданий в failed_jobs — рост любого из них сигнализирует раньше, чем начнут жаловаться пользователи.
Эмпатичный слушатель: Поддержка, снятие тревожности, дыхательные практики.
Спросить за 8 ₽Источники: Laravel — Events (официальная документация), Laravel — Queues (официальная документация)
Частые вопросы
Слушатель реализует ShouldQueue, но не выполняется — в чём дело?
Отличается ли механизм в Laravel 8–10 от 11.x?
Слушатель ловит ModelNotFoundException в проде, но локально всё работает — почему?
Как избежать двойных писем при повторе задачи из очереди?
Когда лучше вызвать сервис напрямую, а не через событие?
Слушатель в очереди упал и просто пропал — как отследить?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.