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

Классическая ситуация: контроллер регистрации разросся до сорока строк. Сохранили пользователя, отправили приветственное письмо, дёрнули CRM по HTTP, записали событие в аналитику. Тест на регистрацию теперь падает, если недоступен внешний API. Laravel предлагает вынести побочные действия в события и слушателей — но у этого решения есть цена, и не в каждой ситуации она оправдана.
Разбираем три подхода к одной задаче: синхронные слушатели, слушатели в очереди и прямой вызов сервисов без событий вообще. Материал на Laravel 11.x; принципиально механизм не менялся с версии 8, но с 11.x автоматическое обнаружение слушателей включено по умолчанию и EventServiceProvider для простой регистрации больше не обязателен.
Что такое событие и слушатель
Событие — это класс-сообщение: «случилось вот это». Слушатель — класс, который реагирует. Один UserRegistered может слушать три разных слушателя, и класс регистрации о них ничего не знает. В этом весь смысл: тот, кто диспатчит событие, развязан с теми, кто на него реагирует.
Минимальный набор: класс события с публичными свойствами и слушатель с методом handle.
// app/Events/UserRegistered.php
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class UserRegistered
{
use Dispatchable;
public function __construct(public User $user)
{
}
}// app/Listeners/SendWelcomeEmail.php
namespace App\Listeners;
use App\Events\UserRegistered;
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeMail;
class SendWelcomeEmail
{
public function handle(UserRegistered $event): void
{
// Тип-хинт в handle связывает слушателя с событием
Mail::to($event->user->email)->send(new WelcomeMail($event->user));
}
}Диспатч из сервиса или контроллера — одной строкой:
use App\Events\UserRegistered;
UserRegistered::dispatch($user);В Laravel 11.x слушатель подхватывается автоматически по тип-хинту аргумента handle — ручная регистрация в провайдере не нужна, пока вам хватает соглашения об именах и расположении в app/Listeners.
Три подхода к одной задаче
Подход 1: синхронный слушатель
Событие диспатчится, слушатели выполняются прямо в том же процессе, по очереди, до возврата ответа пользователю. Контроллер разгружен, но по времени ничего не выиграли: пользователь ждёт, пока отправится письмо и ответит CRM.
Что выигрываете: развязка кода, лёгкое добавление новых реакций, тестируемость через Event::fake(). Чем платите: время ответа складывается из всех слушателей, а падение любого из них по умолчанию роняет весь запрос.
Подход 2: слушатель в очереди
Тот же слушатель, но реализует ShouldQueue. Теперь при диспатче событие сериализуется и уходит в очередь, а обработка идёт в фоне воркером. Пользователь получает ответ сразу после сохранения в БД.
namespace App\Listeners;
use App\Events\UserRegistered;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendWelcomeEmail implements ShouldQueue
{
// Можно задать очередь и число попыток
public int $tries = 3;
public int $backoff = 30;
public function handle(UserRegistered $event): void
{
// тот же код, что и в синхронном варианте
}
}Важная деталь: событие в очереди сериализуется. Если в свойстве события лежит Eloquent-модель, Laravel через трейт SerializesModels сохранит только идентификатор и заново достанет модель из БД при обработке. Значит, к моменту выполнения слушателя данные могут отличаться от тех, что были при диспатче. Если пользователь удалён между диспатчем и обработкой — воркер упадёт на восстановлении модели.
Синхронный слушатель развязывает код. Очередь развязывает ещё и время. Это разные вещи, и путать их — частая ошибка: очередь нужна не для чистоты, а чтобы пользователь не ждал.
Подход 3: без событий, прямой вызов
Иногда события — лишняя абстракция. Если реакция ровно одна, она всегда выполняется вместе с действием и в другом месте кода никогда не понадобится — событие только добавляет два файла и слой косвенности. Прямой вызов сервиса читается легче:
// В сервисе регистрации
$user = User::create($data);
$this->welcomeMailer->send($user); // явно и трассируемо
return $user;Здесь видно, что произойдёт, без прыжка в провайдер и поиска слушателей. Для фоновой отправки при этом подходе диспатчат job напрямую: SendWelcomeEmail::dispatch($user) без промежуточного события.
Сравнение
| Критерий | Синхронный слушатель | Слушатель в очереди | Без событий |
|---|---|---|---|
| Время ответа пользователю | Ждёт все реакции | Не ждёт | Ждёт (или job в фон) |
| Развязка кода | Есть | Есть | Нет |
| Несколько реакций на событие | Просто добавить | Просто добавить | Правишь вызывающий код |
| Нужна инфраструктура очереди | Нет | Да (воркер, драйвер) | Только если job в фон |
| Трассируемость «что за чем» | Средняя | Низкая | Высокая |
| Устойчивость к падению реакции | Роняет запрос | Ретраи в очереди | Роняет запрос |
Когда что выбирать
- Синхронный слушатель — когда реакций несколько и они разные по смыслу, но все быстрые и локальные (записать в лог, обновить счётчик, инвалидировать кеш). Очередь тут избыточна, а развязка полезна.
- Слушатель в очереди — когда реакция долгая или ходит во внешний сервис: письмо, HTTP к CRM, генерация PDF. Всё, что не должно задерживать ответ и что не страшно повторить при сбое.
- Без событий — когда реакция одна, жёстко связана с действием и не будет переиспользована. Не создавайте событие «на будущее»: развязку легко ввести потом, а лишний слой сразу мешает читать код.
Смешанный случай
Частая реальная конфигурация: одно событие UserRegistered, три слушателя. Обновление счётчика в кеше — синхронный, письмо и запрос в CRM — в очереди. Laravel это позволяет: очередность определяется каждым слушателем отдельно через ShouldQueue, событие о ней ничего не знает.
Что ломается
- Слушатель в очереди без запущенного воркера. Событие уходит в очередь, но никто её не разбирает. Письма «отправлены» в коде, но не дошли. Проверьте, что
php artisan queue:workзапущен и драйвер очереди неsyncв проде. - Драйвер
syncв конфиге. ПриQUEUE_CONNECTION=syncслушатель сShouldQueueвыполняется синхронно, несмотря на интерфейс. На локальной машине письмо «в очереди» блокирует запрос, и вы удивляетесь, почему нет выигрыша по времени. - Модель удалена до обработки. Из-за
SerializesModelsворкер достаёт модель заново и падает сModelNotFoundException, если запись исчезла. Передавайте в событие идентификатор и проверяйте существование, если такое возможно. - Тихое проглатывание ошибок. Упавший слушатель в очереди после исчерпания попыток уходит в
failed_jobsи молчит. Настройте мониторинг таблицы неудачных задач, иначе часть писем просто не дойдёт, и вы узнаете об этом от пользователей. - Event::fake() маскирует интеграцию. В тесте с
Event::fake()слушатели не выполняются вовсе — вы проверяете только факт диспатча. Логику слушателя тестируйте отдельно, вызываяhandleнапрямую.
Что делать дальше
Начните с самого простого варианта, который решает задачу: если реакция одна — прямой вызов, если несколько и быстрые — синхронные слушатели. К очереди переходите, когда замерили время ответа и увидели, что его тянут именно побочные действия. Настройте драйвер очереди (Redis или база), запустите queue:work под супервизором и заведите алерт на рост failed_jobs. Прогрев и приоритеты очередей — отдельная тема; здесь важно было выбрать, что вообще выносить в события, а что нет.
Мотивационное письмо и эссе: Поступление, стипендии, гранты, обмен
Спросить за 25 ₽Источники: Laravel 11.x — Events, Laravel 11.x — Queues
Частые вопросы
У меня Laravel 8 или 9, автообнаружение слушателей работает?
Слушатель в очереди не срабатывает, что проверить первым делом?
Можно ли смешивать синхронные и очередные слушатели на одно событие?
Зачем событие, если реакция всего одна?
Как тестировать логику слушателя, если Event::fake() его не выполняет?
Почему воркер падает с ModelNotFoundException?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.