Главная/Статьи/События и слушатели в Laravel — асинхронная архитектура

События и слушатели в Laravel — асинхронная архитектура

Events отвязывают бизнес-логику от побочных эффектов. Создаём события, слушатели, subscribers. Асинхронная обработка через очереди.

ДМ
Дмитрий Мещеряков
📅 6 июля 2026 г.📖 7 мин чтения

События — механизм, который делает код чище ровно до того момента, когда что-то перестаёт срабатывать. И тогда выясняется, что найти причину сложнее, чем в «грязном» варианте: явного вызова нет, стек обрывается, а слушатель может быть зарегистрирован в трёх разных местах.

Разберём механику и, отдельно, четыре ситуации, в которых события не срабатывают или срабатывают не тогда, когда вы ожидаете.

Зачем события

События позволяют отвязать основную логику от побочных эффектов:

php
// ❌ Без событий — всё в одном месте
class OrderService
{
    public function complete(Order $order): void
    {
        $order->update(['status' => 'completed']);
        // Побочные эффекты смешаны с логикой
        Mail::send(new OrderCompleted($order));
        $this->crm->syncOrder($order);
        $this->analytics->track('order_completed', $order);
        $this->warehouse->releaseStock($order);
        $this->loyalty->addPoints($order->user, $order->total);
    }
}
php
// ✅ С событиями — чистая логика
class OrderService
{
    public function complete(Order $order): void
    {
        $order->update(['status' => 'completed']);
        // Генерируем событие — слушатели сами разберутся
        event(new OrderCompleted($order));
    }
}
💡 Совет

Преимущества событий:

  • Слабая связанность кода
  • Легко добавлять новые реакции
  • Асинхронная обработка через очереди
  • Проще тестировать

Создание события

bash
php artisan make:event OrderCompleted
php
// app/Events/OrderCompleted.php
namespace App\Events;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\InteractsWithSockets;
class OrderCompleted
{
    use Dispatchable, InteractsWithSockets, SerializesModels;
    public function __construct(
        public Order $order
    ) {}
}

Создание слушателя

bash
php artisan make:listener SendOrderCompletedNotification --event=OrderCompleted
php
// app/Listeners/SendOrderCompletedNotification.php
namespace App\Listeners;
use App\Events\OrderCompleted;
use App\Mail\OrderCompletedMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
class SendOrderCompletedNotification implements ShouldQueue
{
    public string $queue = 'emails';
    public int $tries = 3;
    public function handle(OrderCompleted $event): void
    {
        Mail::to($event->order->user)
            ->send(new OrderCompletedMail($event->order));
    }
    /**
     * Определяем, нужно ли обрабатывать событие
     */
    public function shouldQueue(OrderCompleted $event): bool
    {
        return $event->order->total > 0;
    }
    /**
     * Обработка ошибки
     */
    public function failed(OrderCompleted $event, \Throwable $exception): void
    {
        logger()->error('Failed to send order notification', [
            'order_id' => $event->order->id,
            'error' => $exception->getMessage(),
        ]);
    }
}

Регистрация

В EventServiceProvider

php
// app/Providers/EventServiceProvider.php
namespace App\Providers;
use App\Events\OrderCompleted;
use App\Events\OrderCancelled;
use App\Events\UserRegistered;
use App\Listeners\SendOrderCompletedNotification;
use App\Listeners\SyncOrderToCrm;
use App\Listeners\AddLoyaltyPoints;
use App\Listeners\SendWelcomeEmail;
use App\Listeners\CreateUserInCrm;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        OrderCompleted::class => [
            SendOrderCompletedNotification::class,
            SyncOrderToCrm::class,
            AddLoyaltyPoints::class,
        ],
        OrderCancelled::class => [
            SendOrderCancelledNotification::class,
            RefundPayment::class,
            RestoreStock::class,
        ],
        UserRegistered::class => [
            SendWelcomeEmail::class,
            CreateUserInCrm::class,
        ],
    ];
}

Автоматическое обнаружение

php
// app/Providers/EventServiceProvider.php
public function shouldDiscoverEvents(): bool
{
    return true;
}
protected function discoverEventsWithin(): array
{
    return [
        $this->app->path('Listeners'),
    ];
}

Dispatch события

php
// Через хелпер
event(new OrderCompleted($order));
// Через статический метод
OrderCompleted::dispatch($order);
// Синхронно (без очереди)
OrderCompleted::dispatchSync($order);
// С условием
OrderCompleted::dispatchIf($order->total > 0, $order);
OrderCompleted::dispatchUnless($order->is_test, $order);

Event Subscriber

Когда один класс слушает несколько событий:

php
// app/Listeners/OrderEventSubscriber.php
namespace App\Listeners;
use App\Events\OrderCreated;
use App\Events\OrderCompleted;
use App\Events\OrderCancelled;
use Illuminate\Events\Dispatcher;
class OrderEventSubscriber
{
    public function handleOrderCreated(OrderCreated $event): void
    {
        logger()->info('Order created', ['id' => $event->order->id]);
    }
    public function handleOrderCompleted(OrderCompleted $event): void
    {
        logger()->info('Order completed', ['id' => $event->order->id]);
    }
    public function handleOrderCancelled(OrderCancelled $event): void
    {
        logger()->info('Order cancelled', ['id' => $event->order->id]);
    }
    /**
     * Регистрация слушателей
     */
    public function subscribe(Dispatcher $events): array
    {
        return [
            OrderCreated::class => 'handleOrderCreated',
            OrderCompleted::class => 'handleOrderCompleted',
            OrderCancelled::class => 'handleOrderCancelled',
        ];
    }
}
php
// app/Providers/EventServiceProvider.php
protected $subscribe = [
    OrderEventSubscriber::class,
];

Model Events

⚠️ Важно

События модели срабатывают только при работе через Eloquent-объект. Это самая частая причина «почему обсервер не вызвался», и она не выглядит как ошибка.

Не вызовут событий:

php
User::where('active', 0)->update(['status' => 'archived']);  // массовое обновление
User::where('id', '>', 100)->delete();                        // массовое удаление
DB::table('users')->insert($rows);                            // запросы через Query Builder
User::insert($rows);                                          // и insert у модели тоже

Вызовут:

php
$user->update(['status' => 'archived']);   // объект загружен
$user->delete();
User::create($attributes);

Логика простая: события — свойство модели как объекта, а массовые операции выполняются одним SQL-запросом без загрузки моделей. Это сделано намеренно (иначе update() на миллионе строк загрузил бы миллион объектов), но узнают об этом обычно постфактум — когда обнаруживается, что после массового импорта не отправились уведомления и не обновился поисковый индекс.

Если события нужны — либо обрабатывайте порциями через chunkById() с сохранением каждой модели, либо диспатчите событие явно после массовой операции.

Eloquent автоматически генерирует события:

php
// app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
    protected static function booted(): void
    {
        // При создании
        static::created(function (Order $order) {
            event(new OrderCreated($order));
        });
        // При обновлении
        static::updated(function (Order $order) {
            if ($order->wasChanged('status')) {
                match ($order->status) {
                    'completed' => event(new OrderCompleted($order)),
                    'cancelled' => event(new OrderCancelled($order)),
                    default => null,
                };
            }
        });
        // Перед удалением
        static::deleting(function (Order $order) {
            // Отменяем удаление оплаченных заказов
            if ($order->status === 'paid') {
                return false;
            }
        });
    }
}

Через Observer

bash
php artisan make:observer OrderObserver --model=Order
php
// app/Observers/OrderObserver.php
namespace App\Observers;
use App\Models\Order;
use App\Events\OrderCreated;
class OrderObserver
{
    public function created(Order $order): void
    {
        event(new OrderCreated($order));
    }
    public function updated(Order $order): void
    {
        if ($order->wasChanged('status') && $order->status === 'completed') {
            event(new OrderCompleted($order));
        }
    }
    public function deleting(Order $order): bool
    {
        // Запрещаем удаление оплаченных
        return $order->status !== 'paid';
    }
}
php
// app/Providers/AppServiceProvider.php
use App\Models\Order;
use App\Observers\OrderObserver;
public function boot(): void
{
    Order::observe(OrderObserver::class);
}

Асинхронные слушатели

php
// Слушатель в очереди
class SyncOrderToCrm implements ShouldQueue
{
    use InteractsWithQueue;
    public string $queue = 'integrations';
    public int $tries = 3;
    public int $backoff = 60;
    public function handle(OrderCompleted $event): void
    {
        $this->crm->sync($event->order);
    }
}
// Слушатель синхронный (без ShouldQueue)
class UpdateOrderStats
{
    public function handle(OrderCompleted $event): void
    {
        Cache::forget('order_stats');
    }
}
⚠️ Важно

Порядок выполнения: Синхронные слушатели выполняются сразу в порядке регистрации. Асинхронные — когда воркер их обработает.

⚠️ Важно

Очередь и транзакции: самая коварная связка в этой теме.

Типичный код: внутри DB::transaction() создаётся заказ и диспатчится событие, слушатель которого стоит в очереди. Воркер — отдельный процесс, он забирает задачу немедленно и может начать её выполнять до того, как транзакция закоммитилась. В базе заказа ещё нет, и слушатель падает с «модель не найдена».

Воспроизводится это плохо: на локальной машине с драйвером sync очередь выполняется синхронно и проблемы нет; на проде она проявляется под нагрузкой, случайным образом, у части заказов.

Лечится явно: свойство public $afterCommit = true; в классе задачи или слушателя, либо 'after_commit' => true в конфигурации соединения очереди. Тогда задача отправляется в очередь только после успешного коммита.

Тестирование

php
// tests/Feature/OrderTest.php
use App\Events\OrderCompleted;
use App\Listeners\SendOrderCompletedNotification;
use Illuminate\Support\Facades\Event;
public function test_order_completed_event_is_dispatched(): void
{
    Event::fake();
    $order = Order::factory()->create();
    $order->update(['status' => 'completed']);
    Event::assertDispatched(OrderCompleted::class, function ($event) use ($order) {
        return $event->order->id === $order->id;
    });
}
public function test_listener_handles_event(): void
{
    Event::fake();
    $order = Order::factory()->create();
    // Вызываем слушатель напрямую
    $listener = new SendOrderCompletedNotification();
    $listener->handle(new OrderCompleted($order));
    // Проверяем результат
}
public function test_specific_listeners_are_attached(): void
{
    Event::fake();
    Event::assertListening(
        OrderCompleted::class,
        SendOrderCompletedNotification::class
    );
}

Практический пример: система уведомлений

php
// События
class OrderCreated {}
class OrderPaid {}
class OrderShipped {}
class OrderDelivered {}
// Слушатели
// app/Listeners/Order/NotifyCustomer.php
class NotifyCustomer implements ShouldQueue
{
    public function handle(object $event): void
    {
        $notification = match (get_class($event)) {
            OrderCreated::class => new OrderCreatedNotification($event->order),
            OrderPaid::class => new OrderPaidNotification($event->order),
            OrderShipped::class => new OrderShippedNotification($event->order),
            OrderDelivered::class => new OrderDeliveredNotification($event->order),
        };
        $event->order->user->notify($notification);
    }
}
// EventServiceProvider
protected $listen = [
    OrderCreated::class => [NotifyCustomer::class, CreateInvoice::class],
    OrderPaid::class => [NotifyCustomer::class, SyncToCrm::class],
    OrderShipped::class => [NotifyCustomer::class, UpdateTracking::class],
    OrderDelivered::class => [NotifyCustomer::class, RequestReview::class],
];

Итоги

КонцепцияОписание
EventЧто произошло (факт)
ListenerРеакция на событие
SubscriberКласс с несколькими реакциями
ObserverСлушатель событий модели

Правила, к которым я пришёл на практике.

Событие — это факт, а не команда. OrderCompleted, а не SendOrderEmail. Если имя события содержит глагол в повелительном наклонении, у вас не событие, а замаскированный вызов метода, и слушатель к нему будет ровно один — то есть вся конструкция не нужна.

Один слушатель — одно действие. Не ради красоты: слушатели выполняются независимо, и упавший SyncToCrm не должен мешать отправке письма клиенту. Слушатель, делающий пять вещей подряд, теряет это свойство целиком.

Тяжёлое — в очередь, но с afterCommit. Оба условия обязательны.

Проверяйте, что события вообще доходят. Event::fake() в тестах проверяет диспатч, но не проверяет регистрацию слушателя в реальном приложении. Один интеграционный тест на цепочку целиком стоит десяти изолированных.

Помните про массовые операции. Все update() и delete() через построитель запросов проходят мимо событий модели.

💡 Совет

И вопрос, который стоит задать перед введением события. Побочный эффект, который обязан выполниться (списание товара со склада, изменение баланса), — это не побочный эффект, а часть операции, и ему место в транзакции, а не в слушателе. События хорошо описывают то, что «хорошо бы сделать по случаю»: уведомить, залогировать, синхронизировать. Как только от слушателя начинает зависеть корректность данных, вы получаете распределённую транзакцию без гарантий — и разбирать её последствия будете при первом же сбое очереди.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.