Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Події

Питання з реальних співбесід з відповідями: 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 → слухачі «надіслати лист», «нарахувати бонус», «оновити статистику».

Докладніше в документації: Events

Подія повідомляє, що щось сталося, не знаючи, хто на це зреагує. Це розвʼязує звʼязок між тим, хто діє, і тим, хто відповідає.

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().

Правило: модельні події - для інваріантів самої моделі (слаг, нормалізація поля, службові звʼязки). Бізнес-процес - оформлення замовлення, розсилка, інтеграції - належить явному сервісу чи доменній події, які видно в місці виклику.

Докладніше в документації: Eloquent

Типовий випадок: імпорт оновлює товар 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 гарантує, що слухачі в черзі побачать закомічені дані.

Докладніше в документації: Відкладення подій