Питання на співбесіді: Події
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
8 питань
Подія - простий клас з даними про те, що сталося. Слухач - клас, що реагує.
php artisan make:event OrderShipped
php artisan make:listener SendShipmentNotification --event=OrderShipped
class OrderShipped
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}
class SendShipmentNotification
{
public function handle(OrderShipped $event): void
{
$event->order->customer->notify(new ShipmentSent($event->order));
}
}
Реєстрація не потрібна: Laravel сам знаходить слухачів у app/Listeners за типом параметра handle(). Вручну - через Event::listen() у провайдері.
Диспетчеризація:
OrderShipped::dispatch($order);
// або
event(new OrderShipped($order));
Навіщо це: код, що відправляє замовлення, не знає, хто реагує - лист, аналітика, склад. Нову реакцію додають новим слухачем, не чіпаючи відправника.
Повільних слухачів (листи, HTTP) роблять ShouldQueue - тоді вони виконуються у воркері, а не в запиті.
Події дають слабке зв'язування: одна частина застосунку «оголошує», що щось сталося, інші - реагують, нічого не знаючи одна про одну.
event(new OrderShipped($order)); // диспатч
// слухач
class SendShipmentNotification
{
public function handle(OrderShipped $event): void
{
// ...
}
}
- Слухача, що реалізує
ShouldQueue, обробляють асинхронно в черзі. - У сучасному Laravel слухачі автоматично виявляються за type-hint у методі
handle- ручна реєстрація не обов'язкова.
Приклад: подія UserRegistered → слухачі «надіслати лист», «нарахувати бонус», «оновити статистику».
Подія повідомляє, що щось сталося, не знаючи, хто на це зреагує. Це розвʼязує звʼязок між тим, хто діє, і тим, хто відповідає.
OrderPlaced::dispatch($order);
Слухачі - лист, нарахування бонусів, повідомлення на склад - підписані окремо, і додати ще одного можна, не чіпаючи оформлення замовлення.
Коли події доречні:
- На одну дію треба кілька незалежних реакцій, і їхній перелік з часом росте.
- Реакції належать іншим доменам - розсилка, аналітика, інтеграції.
- Реакція має піти у фонову роботу (
ShouldQueueна слухачі). - Реагувати мусить пакет чи модуль, який про ваш код не знає.
Коли краще виклик напряму:
- Реакція єдина й завжди та сама.
OrderPlacedз одним слухачем - це виклик методу, записаний у два файли. - Порядок і результат важливі: подія не повертає значення й не гарантує послідовності.
- Дія - частина тієї самої операції, яка мусить відкотитися разом із нею.
Головна пастка - невидимість. Через рік ніхто не скаже, що саме відбувається під час оформлення замовлення: у коді видно один рядок, а реально виконуються сім слухачів у різних файлах. Налагодження зводиться до пошуку по всьому проєкту.
Друга пастка - транзакції. Подія, відправлена всередині DB::transaction(), може дійти до черги раніше, ніж транзакція завершиться, - і воркер не знайде запису. Для цього є ShouldDispatchAfterCommit на події або afterCommit на завданні.
Правило: починайте з прямого виклику. Переходьте на подію, коли реакцій стало більше однієї або вони належать іншій частині системи.
Бо подію диспетчеризували всередині транзакції, а воркер узяв завдання раніше, ніж транзакція закомітилася.
DB::transaction(function () use ($data) {
$order = Order::create($data);
OrderPlaced::dispatch($order); // слухач у черзі вже може стартувати
$order->items()->createMany($data['items']);
});
Воркер читає замовлення з бази - а там його ще немає, або немає позицій. Якщо транзакція відкотиться, слухач узагалі обробить те, чого не існує.
Рішення:
ShouldQueueAfterCommitу слухачі - стати в чергу лише після коміту;ShouldDispatchAfterCommitу події - диспетчеризувати саму подію після коміту (не дістануть і синхронні слухачі);after_commit => trueу конфігурації підключення черги - для всіх завдань;- для спостерігачів -
ShouldHandleEventsAfterCommit.
Якщо транзакції немає, усе це спрацьовує одразу, тож поведінка поза транзакцією не змінюється.
Те саме стосується листів і сповіщень у черзі - у них є afterCommit().
Два різні питання - «чи відправлено подію» і «чи правильно працює слухач» - тестують окремо.
Чи відправлено:
Event::fake([OrderShipped::class]);
$this->post("/orders/{$order->id}/ship")->assertOk();
Event::assertDispatched(OrderShipped::class, fn ($e) => $e->order->is($order));
Передавати список подій важливо: Event::fake() без аргументів підмінить усі події, зокрема події моделей. Тоді зламається все, що тримається на creating - генерація UUID, slug, спостерігачі - і тест впаде з дивною помилкою.
Інші інструменти:
Event::fakeExcept([...])- підмінити все, крім переліченого;Event::fakeFor(fn () => ...)- лише для частини тесту;Event::assertListening(OrderShipped::class, SendShipmentNotification::class)- що слухач підписаний.
Чи правильно працює слухач - окремий тест, де слухач викликають напряму:
(new SendShipmentNotification)->handle(new OrderShipped($order));
Notification::assertSentTo($order->customer, ShipmentSent::class);
Варто мати й кілька тестів без підмін, де ланцюжок «дія → подія → слухач» проходить цілком: саме там ламаються інтеграції, які фейки приховують.
Eloquent сам розсилає події життєвого циклу: creating, created, updating, updated, saving, saved, deleting, deleted, restored, replicating.
Зручно для дрібниць, які мають виконуватися завжди:
class Post extends Model
{
protected static function booted(): void
{
static::creating(function (Post $post) {
$post->slug ??= Str::slug($post->title);
});
}
}
Для повного набору краще окремий observer - інакше booted() розростається:
#[ObservedBy(PostObserver::class)]
class Post extends Model
{
}
Чим небезпечні:
- Масові операції їх не викликають.
Post::where(...)->update([...]),->delete()таinsert()йдуть повз Eloquent, тож жоден слухач не спрацює. Дані тихо розходяться з тим, що гарантував observer. - Логіка стає невидимою.
$post->save()у контролері виглядає простим рядком, а насправді тягне за собою запис в інші таблиці й виклик API. - Тести сповільнюються. Кожна фабрика в кожному тесті тягне весь ланцюжок подій, включно з тими, що ходять у мережу.
- Помилка в слухачі ламає збереження. Виняток у
creatingскасовує операцію - зокрема там, де про це ніхто не думав. - Рекурсія. Слухач
saved, який оновлює ту саму модель, викликає сам себе; рятуєsaveQuietly().
Правило: модельні події - для інваріантів самої моделі (слаг, нормалізація поля, службові звʼязки). Бізнес-процес - оформлення замовлення, розсилка, інтеграції - належить явному сервісу чи доменній події, які видно в місці виклику.
Типовий випадок: імпорт оновлює товар 20 разів за хвилину, і кожне ProductUpdated перебудовує пошуковий індекс.
Дебаунс (Laravel 13) - обробити лише останню подію за проміжок:
#[DebounceFor(30)]
class UpdateProductSearchIndex implements ShouldQueue
{
public function debounceId(ProductUpdated $event): string
{
return (string) $event->product->getKey();
}
public function handle(ProductUpdated $event): void { /* ... */ }
}
Кожна нова подія з тим самим debounceId скидає таймер; виконується лише остання, через 30 секунд тиші.
Унікальний слухач - не ставити в чергу дубль, поки попередній не оброблений:
class AcquireProductKey implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(LicenseSaved $event): string
{
return (string) $event->license->id;
}
}
Різниця: дебаунс чекає, доки події вщухнуть, і обробляє останню. Унікальність обробляє першу, а повтори відкидає, поки вона в черзі.
Інші підходи:
- подію на рівні пачки:
ProductsImportedодин раз замість тисячіProductUpdated; Event::defer()чиModel::withoutEvents()навколо масових операцій.
Обидва механізми потребують драйвера кешу з атомарними блокуваннями.
Event::defer() відкладає всі події, що виникли в замиканні, до його завершення.
Event::defer(function () {
$user = User::create(['name' => 'Victoria']);
$user->posts()->create(['title' => 'Перший допис']);
});
Без defer слухач created користувача спрацює одразу після User::create() - ще до створення поста. Якщо слухач розраховує на пов'язані записи (надіслати вітання з посиланням на перший допис), він їх не знайде.
Особливості:
- якщо в замиканні виняток, відкладені події не диспетчеризуються зовсім;
- другим аргументом можна відкласти лише певні події:
['eloquent.created: '.User::class].
Чим відрізняється від after-commit:
Event::defer() |
ShouldDispatchAfterCommit |
|
|---|---|---|
| Чекає | кінця замикання | коміту транзакції бази |
| Транзакція потрібна | ні | так (без неї - одразу) |
| Мета | цілісність набору записів у коді | щоб слухачі не бачили незакомічених даних |
Їх можна поєднувати: defer усередині DB::transaction() збирає події, а after-commit гарантує, що слухачі в черзі побачать закомічені дані.