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

Классическая ситуация: в контроллере регистрации после создания пользователя вы отправляете приветственное письмо, начисляете бонус, пишете в лог аналитики и дёргаете внешний CRM. Метод store() разрастается до сорока строк, отправка письма блокирует ответ на 800 мс, а падение CRM роняет всю регистрацию. События и слушатели в Laravel — механизм, который развязывает эти зависимости. Но применённый бездумно, он превращает линейный код в квест «найди, где это вообще вызывается». Разбираем, где событие оправдано, как его правильно оформить в Laravel 11.x и чем платит очередь.
Когда событие оправдано, а когда нет
Событие — это факт, который уже произошёл: пользователь зарегистрировался, заказ оплачен, файл загружен. Слушатели — реакции на факт, которые не нужны для формирования ответа прямо сейчас. Ключевой критерий один.
Событие оправдано, когда на один факт есть несколько независимых реакций, и хотя бы одна из них может подождать или упасть без вреда для основного сценария.
Если реакция ровно одна и она нужна синхронно — вызывайте метод сервиса напрямую. Событие с единственным слушателем, который выполняется тут же, — это лишний слой абстракции, который только усложняет чтение кода.
- Хорошо: после оплаты заказа надо уменьшить остаток на складе, отправить чек, уведомить менеджера и записать в очередь доставки — четыре независимые реакции.
- Плохо: после сохранения формы надо обновить одно поле в связанной таблице — это просто вызов метода, а не событие.
Минимальный рабочий пример
Создаём событие и слушатель командами Artisan (Laravel 11.x):
php artisan make:event OrderPaid
php artisan make:listener SendOrderReceipt --event=OrderPaidКласс события — это контейнер данных. Никакой логики, только то, что произошло:
<?php
namespace App\Events;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderPaid
{
use Dispatchable, SerializesModels;
// Модель приедет в слушатель как есть, трейт умеет её сериализовать
public function __construct(public Order $order)
{
}
}Слушатель — реакция. Один слушатель делает одну вещь:
<?php
namespace App\Listeners;
use App\Events\OrderPaid;
use App\Mail\OrderReceipt;
use Illuminate\Support\Facades\Mail;
class SendOrderReceipt
{
public function handle(OrderPaid $event): void
{
Mail::to($event->order->customer_email)
->send(new OrderReceipt($event->order));
}
}Отправка события из сервиса или контроллера:
use App\Events\OrderPaid;
$order->markAsPaid();
OrderPaid::dispatch($order);Автообнаружение против ручной регистрации
Начиная с Laravel 11.x связка событие-слушатель определяется автоматически по тайпхинту в методе handle(): фреймворк сканирует каталог app/Listeners. Отдельно прописывать пару в провайдере не нужно. Ручная регистрация через Event::listen() в AppServiceProvider нужна, только если слушатель лежит вне стандартного каталога или вы подписываете замыкание.
Проверить, что связь установлена, можно командой:
php artisan event:listВ выводе должна появиться строка с App\Events\OrderPaid и всеми привязанными к нему слушателями.
Синхронный слушатель или очередь
Это главная развилка. По умолчанию слушатель выполняется синхронно — в том же запросе, что и dispatch(). Отправка письма на 800 мс задержит ответ пользователю ровно на эти 800 мс. Чтобы вынести работу в фон, слушатель реализует интерфейс ShouldQueue:
<?php
namespace App\Listeners;
use App\Events\OrderPaid;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendOrderReceipt implements ShouldQueue
{
// Число повторов при падении джоба
public int $tries = 3;
public function handle(OrderPaid $event): void
{
// тело не меняется
}
}Теперь при dispatch() Laravel не выполнит слушатель, а положит задачу в очередь. Ответ вернётся мгновенно, письмо уйдёт в фоне через воркер. Но за это вы платите инфраструктурой: нужен запущенный queue:worker, драйвер очереди (Redis, база или SQS) и мониторинг упавших задач.
| Критерий | Синхронный слушатель | ShouldQueue |
|---|---|---|
| Влияние на время ответа | Задерживает ответ на всё время работы | Не влияет, работа уходит в фон |
| Падение слушателя | Ломает основной сценарий (если без try/catch) | Изолировано, попадёт в failed_jobs |
| Нужна инфраструктура | Нет | Драйвер очереди + воркер |
| Отладка | Всё в одном стектрейсе | Логи воркера, таблица failed_jobs |
| Подходит для | Быстрых операций внутри БД | Писем, HTTP-запросов, тяжёлых расчётов |
Когда что выбирать
- Реакция быстрая, только внутри вашей БД, и её результат нужен целостно с основной операцией — синхронный слушатель.
- Реакция ходит во внешний сервис (SMTP, платёжка, чужой API) или считает что-то тяжёлое — ShouldQueue, всегда.
- Реакция может упасть, и вы хотите повторы без влияния на пользователя — ShouldQueue с настроенным
$triesи$backoff.
Что ломается на практике
Три ошибки встречаются чаще всего, и все три тихие — код работает, пока не начнёт.
- Событие внутри транзакции, слушатель в очереди. Вы вызвали
dispatch()внутриDB::transaction(). Воркер подхватил задачу и полез в БД за записью раньше, чем транзакция закоммитилась — запись он не найдёт. Решение: слушатель реализуетShouldQueueAfterCommit, либо в конфиге соединения очереди включён параметрafter_commit. - Толстый слушатель. В один
handle()напихали и письмо, и склад, и CRM. Падение CRM в очереди отправит всю задачу на повтор — и письмо уйдёт трижды. Правило: один слушатель — одна реакция. Четыре реакции — четыре слушателя, каждый со своей судьбой в очереди. - Логика ответа в слушателе. Слушатель не может ничего вернуть в контроллер и повлиять на HTTP-ответ. Если реакция должна изменить то, что видит пользователь прямо сейчас, — это не событие, а обычный вызов в потоке запроса.
Проверка после падения
Упавшие задачи очереди лежат в таблице failed_jobs. Посмотреть и перезапустить:
php artisan queue:failed
php artisan queue:retry allЕсли event:list не показывает ваш слушатель, а автообнаружение включено — проверьте, что в handle() стоит тайпхинт именно класса события, а не интерфейса или лишнего аргумента.
Что настроить дальше
После того как события заработали, займитесь тремя вещами. Поднимите постоянный воркер через Supervisor или контейнер-демон — без него задачи будут копиться и никуда не уйдут. Настройте обработку failed_jobs: алерт при появлении записей и метод failed() в слушателе для компенсации. И задайте $backoff для слушателей, ходящих во внешние API, чтобы повторы не били по упавшему сервису каждую секунду. Инвалидация кеша по событиям и вещание событий через WebSocket (broadcasting) — отдельные темы, здесь мы их не разворачиваем.
Эмпатичный слушатель: Поддержка, снятие тревожности, дыхательные практики.
Спросить за 8 ₽Источники: Laravel Documentation — Events, Laravel Documentation — Queues
Частые вопросы
У меня Laravel 10, автообнаружение слушателей работает?
Слушатель не срабатывает, event:list его не показывает — что делать?
Можно ли остановить основную операцию, если слушатель упал?
Письмо приходит трижды после сбоя — почему?
Обязательно ли выносить слушателей в очередь?
Слушатель в очереди не находит только что созданную запись — в чём дело?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.