События и слушатели в Laravel: где ломается очередь
Событие ушло, слушатель отработал, а нужная запись в базе так и не появилась. Разбираем частые ошибки с events и listeners в Laravel 11 и 12: очереди, сериализация моделей, транзакции и молчаливые сбои.

Пользователь регистрируется, вы диспатчите событие UserRegistered, а письмо не приходит. Слушатель зарегистрирован, очередь запущена, ошибок в логах нет. Проблема почти всегда в одной из четырёх точек: слушатель поставлен в очередь, но воркер не перезапущен; модель сериализовалась и подгрузилась заново уже изменённой; событие ушло внутри транзакции, которая ещё не закоммитилась; или исключение внутри слушателя проглотилось очередью. Разберём каждую на конкретном поведении в Laravel 11.x и 12.x.
Речь про приёмы и типичные ошибки, а не про внутреннее устройство диспетчера. Про то, как писать сами доменные события правильно с точки зрения архитектуры — отдельная тема, здесь только механика events и listeners.
Ошибка 1: слушатель в очереди работает со старой копией кода
Самая обидная. Слушатель реализует ShouldQueue, вы правите его логику, обновляете страницу — поведение старое. Причина не в кеше и не в конфиге: воркер queue:work держит загруженный код в памяти процесса и не перечитывает файлы. Пока процесс жив, он выполняет ту версию слушателя, которая была на момент старта.
После любого изменения кода слушателя, который обрабатывается в очереди, воркер нужно перезапустить:
php artisan queue:restart
Команда не убивает процесс мгновенно — она посылает сигнал, и воркер завершится после текущей задачи. Если у вас supervisor или systemd, процесс поднимется заново уже с новым кодом. В деплое queue:restart должна идти после выкатки файлов, иначе воркеры ещё какое-то время работают на старом коде.
Правило простое: тронул код queued-слушателя или задачи — перезапусти воркер. В локальной разработке это причина 90% «мои правки не применяются».
Ошибка 2: SerializesModels подгружает модель заново
Когда слушатель уходит в очередь, событие сериализуется в JSON и складывается в таблицу jobs. Полную модель Eloquent в JSON не запихнуть, поэтому трейт SerializesModels (который Laravel подмешивает в события по умолчанию через Dispatchable и в задачах) сохраняет только идентификатор. Когда воркер берёт задачу, он делает свежий запрос в базу и подтягивает модель заново.
Отсюда неожиданное поведение: вы передали в событие модель с изменёнными, но не сохранёнными в базу атрибутами — в слушателе их не будет. Слушатель увидит то, что лежит в базе на момент выполнения задачи, а не то, что было в памяти на момент диспатча.
$user->name = 'Новое имя';
// name ещё не сохранён в базу
UserUpdated::dispatch($user);
// В queued-слушателе после десериализации:
// $event->user->name вернёт старое имя из базы, не 'Новое имя'
Что делать:
- сохраняйте модель
$user->save()до диспатча события, если слушателю нужны актуальные данные; - либо передавайте в событие не модель, а нужные значения примитивами — они сериализуются как есть;
- если модель удаляют между диспатчем и обработкой, свежий запрос вернёт
nullили броситModelNotFoundException— обрабатывайте этот случай.
Ошибка 3: событие ушло внутри незакоммиченной транзакции
Классический баг. Внутри DB::transaction() вы создаёте запись и тут же диспатчите событие с queued-слушателем. Воркер — отдельный процесс, он читает свою транзакцию базы. Если он успеет подхватить задачу до коммита вашей транзакции, свежего запроса модель ещё не увидит: для воркера её нет.
Симптом — плавающая ошибка «то ловится, то нет», ModelNotFoundException под нагрузкой и ноль ошибок на пустом стенде, где воркер медленнее приложения.
В Laravel есть штатное решение. Слушатель может реализовать интерфейс ShouldQueueAfterCommit, либо у него задаётся свойство:
class SendWelcomeEmail implements ShouldQueue
{
// Задача попадёт в очередь только после коммита текущей транзакции
public $afterCommit = true;
}
Можно включить это поведение глобально для соединения через ключ after_commit в config/queue.php — тогда все задачи по этому соединению будут диспатчиться после коммита. Точное поведение флага и приоритет над явным dispatch()->afterCommit() сверяйте в разделе документации про очереди вашей версии, детали между минорными релизами уточнялись.
Ошибка 4: исключение в слушателе никто не видит
Синхронный слушатель бросит исключение — оно всплывёт в запрос, и вы его увидите. Queued-слушатель ведёт себя иначе: исключение остаётся внутри задачи. Задача уйдёт в failed_jobs (если настроен драйвер, отличный от sync), запрос пользователя при этом отработает успешно. Разработчик смотрит на зелёный ответ и думает, что всё хорошо.
Что настроить, чтобы не терять сбои:
- Убедитесь, что таблица
failed_jobsсоздана и драйвер failed настроен вconfig/queue.php. Проверить провалившиеся задачи:php artisan queue:failed. - Определите в слушателе метод
failed(Throwable $e)— он вызывается, когда исчерпаны попытки. Логируйте туда контекст. - Задайте
public $triesиpublic $backoffу слушателя, чтобы контролировать число повторов и паузу между ними, а не полагаться на умолчание. - Подключите оповещение о провалах — например, через хук
Queue::failing()в сервис-провайдере или внешний трекер ошибок.
use Throwable;
class SendWelcomeEmail implements ShouldQueue
{
public $tries = 3;
public $backoff = 30; // секунд между попытками
public function handle(UserRegistered $event): void
{
// ...
}
// Вызывается после последней неудачной попытки
public function failed(UserRegistered $event, Throwable $exception): void
{
Log::error('Не удалось отправить письмо', [
'user_id' => $event->user->id,
'error' => $exception->getMessage(),
]);
}
}
Ошибка 5: слушатели «не находятся» после автообнаружения
С Laravel 11 регистрация событий изменилась: массив $listen в EventServiceProvider больше не нужен как основной способ — фреймворк по умолчанию сканирует директорию app/Listeners и сам сопоставляет слушатели с событиями по типу в сигнатуре handle(). Отсюда две частые проблемы при переходе с 10.x:
- слушатель лежит вне
app/Listenersили не типизирует событие вhandle()— автообнаружение его не подхватит; - в проде включён кеш событий
php artisan event:cache, а новый слушатель добавлен без пересборки кеша — его не будет доevent:cacheзаново илиevent:clear.
Проверить, какие слушатели видит фреймворк: php artisan event:list. Если вашего слушателя там нет — дело в одном из двух пунктов выше.
Как выбрать: синхронный слушатель или очередь
| Критерий | Синхронный (без ShouldQueue) | Очередь (ShouldQueue) |
|---|---|---|
| Влияние на время ответа | Задерживает запрос | Не влияет, задача уходит в фон |
| Обработка ошибки | Всплывает в запрос сразу | Уходит в failed_jobs, нужен мониторинг |
| Свежесть данных модели | Работает с объектом из памяти | Перезапрос из базы, риск транзакций |
| Нужен воркер и перезапуск | Нет | Да, queue:work + queue:restart в деплое |
| Когда уместно | Быстрая логика, нужен результат в этом же запросе | Отправка писем, вызовы внешних API, тяжёлые операции |
Критерий простой: если работа слушателя не должна тормозить ответ пользователю и её можно повторить при сбое — в очередь. Если результат нужен прямо сейчас и в этом же запросе — синхронно.
Что настроить дальше
Поднимите мониторинг очереди в проде: смотрите глубину очереди (сколько задач ждёт), долю провалов и время обработки задачи. Для Redis-очередей удобен Laravel Horizon — он показывает эти метрики и позволяет перезапускать провалившиеся задачи из панели. Настройте supervisor так, чтобы воркеры поднимались после падения, и убедитесь, что queue:restart вызывается в конце каждого деплоя. Отдельно проверьте, что failed-задачи не копятся молча: настройте оповещение, иначе баг из ошибки 4 вернётся в новой форме.
Эмпатичный слушатель: Поддержка, снятие тревожности, дыхательные практики.
Спросить за 8 ₽Источники: Laravel Documentation — Events, Laravel Documentation — Queues
Частые вопросы
Я поменял код слушателя, а поведение старое — почему?
Слушатель отработал без ошибок, но действие не выполнилось. Куда смотреть?
У меня Laravel 10, а не 11 — массив $listen ещё работает?
Слушатель то ловит ModelNotFoundException, то нет. Что это?
Могу ли я передать в событие модель с несохранёнными изменениями?
Грозит ли что-то в проде, если я забыл queue:restart в деплое?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.