События — механизм, который делает код чище ровно до того момента, когда что-то перестаёт срабатывать. И тогда выясняется, что найти причину сложнее, чем в «грязном» варианте: явного вызова нет, стек обрывается, а слушатель может быть зарегистрирован в трёх разных местах.
Разберём механику и, отдельно, четыре ситуации, в которых события не срабатывают или срабатывают не тогда, когда вы ожидаете.
Зачем события
События позволяют отвязать основную логику от побочных эффектов:
// ❌ Без событий — всё в одном месте
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);
}
}// ✅ С событиями — чистая логика
class OrderService
{
public function complete(Order $order): void
{
$order->update(['status' => 'completed']);
// Генерируем событие — слушатели сами разберутся
event(new OrderCompleted($order));
}
}Преимущества событий:
- Слабая связанность кода
- Легко добавлять новые реакции
- Асинхронная обработка через очереди
- Проще тестировать
Создание события
php artisan make:event OrderCompleted// 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
) {}
}Создание слушателя
php artisan make:listener SendOrderCompletedNotification --event=OrderCompleted// 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
// 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,
],
];
}Автоматическое обнаружение
// app/Providers/EventServiceProvider.php
public function shouldDiscoverEvents(): bool
{
return true;
}
protected function discoverEventsWithin(): array
{
return [
$this->app->path('Listeners'),
];
}Dispatch события
// Через хелпер
event(new OrderCompleted($order));
// Через статический метод
OrderCompleted::dispatch($order);
// Синхронно (без очереди)
OrderCompleted::dispatchSync($order);
// С условием
OrderCompleted::dispatchIf($order->total > 0, $order);
OrderCompleted::dispatchUnless($order->is_test, $order);Event Subscriber
Когда один класс слушает несколько событий:
// 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',
];
}
}// app/Providers/EventServiceProvider.php
protected $subscribe = [
OrderEventSubscriber::class,
];Model Events
События модели срабатывают только при работе через Eloquent-объект. Это самая частая причина «почему обсервер не вызвался», и она не выглядит как ошибка.
Не вызовут событий:
User::where('active', 0)->update(['status' => 'archived']); // массовое обновление
User::where('id', '>', 100)->delete(); // массовое удаление
DB::table('users')->insert($rows); // запросы через Query Builder
User::insert($rows); // и insert у модели тожеВызовут:
$user->update(['status' => 'archived']); // объект загружен
$user->delete();
User::create($attributes);Логика простая: события — свойство модели как объекта, а массовые операции выполняются одним SQL-запросом без загрузки моделей. Это сделано намеренно (иначе update() на миллионе строк загрузил бы миллион объектов), но узнают об этом обычно постфактум — когда обнаруживается, что после массового импорта не отправились уведомления и не обновился поисковый индекс.
Если события нужны — либо обрабатывайте порциями через chunkById() с сохранением каждой модели, либо диспатчите событие явно после массовой операции.
Eloquent автоматически генерирует события:
// 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
php artisan make:observer OrderObserver --model=Order// 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';
}
}// app/Providers/AppServiceProvider.php
use App\Models\Order;
use App\Observers\OrderObserver;
public function boot(): void
{
Order::observe(OrderObserver::class);
}Асинхронные слушатели
// Слушатель в очереди
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 в конфигурации соединения очереди. Тогда задача отправляется в очередь только после успешного коммита.
Тестирование
// 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
);
}Практический пример: система уведомлений
// События
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.