<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · MIDDLE

Події, слухачі й обсервери: що коли обирати і як не загубити подію в транзакції?

Обсервер - це набір слухачів, підвішених до подій збереження конкретної моделі, тож він мовчить на будь-якій масовій операції через білдер і за замовчуванням виконується всередині транзакції, ще до коміту. Подія зі слухачем описує бізнес-факт, диспатчиться явно з одного місця й дочекається коміту, якщо реалізує `ShouldDispatchAfterCommit`.

Створення замовлення загорнуте в `DB::transaction`, обсервер шле лист - чому клієнт іноді отримує лист про замовлення, якого немає в базі?
Написали `Order::whereIn('id', $ids)->update(['status' => 'paid'])`, і жоден обсервер не спрацював. Чому?
Коли ви робите обсервер, а коли подію зі слухачем? Наведіть приклад, де обсервер - помилка.
Тест перевіряв, що при створенні користувача генерується `slug`. Додали в тест `Event::fake()`, і тест впав. Що сталося?
Eloquent обсервери події транзакції тестування ShouldQueue

Обсервер не має власного механізму. Model::observe(OrderObserver::class) у сервіс-провайдері або атрибут #[ObservedBy(OrderObserver::class)] на моделі (Laravel 11+) реєструє його публічні методи як слухачів подій з іменами на кшталт eloquent.created: App\Models\Order, і далі все йде через той самий Illuminate\Events\Dispatcher, що й звичайні події. Порядок для нового запису: saving, creating, INSERT, created, saved; для оновлення: saving, updating, UPDATE, updated, saved. Плюс deleting/deleted, а з SoftDeletes ще trashed, restoring/restored, forceDeleting/forceDeleted. Дві деталі, на яких регулярно ловлять. Якщо викликати save() на моделі без жодного брудного атрибута, updating і updated не спрацюють, а saving і saved спрацюють. І методи на -ing викликаються через until(): return false з creating чи deleting скасовує саму операцію, а save() поверне false, не кинувши винятку.

Звідси й критерій вибору. Обсервер прибитий до персистенції одного рядка, тому в нього добре лягає те, що має бути правдою завжди й не залежить від сценарію: згенерувати uuid чи slug, нормалізувати телефон, скинути тег кешу, записати рядок в аудит. Подія зі слухачами описує факт предметної області, диспатчиться явно з одного місця застосункового шару й розрахована на кількох незалежних споживачів, кожен зі своєю чергою, своїми tries і своїм падінням. Ціна обсервера - невидимість: він відпрацює й у сідері, і у фабриці всередині тесту, і в нічному імпорті, тому виклик платіжного API або відправка листа звідти рано чи пізно вистрілять там, де про обсервер ніхто не думав. Ланцюжки обсерверів (обсервер Order чіпає Invoice, обсервер Invoice чіпає Order) дають порядок, який неможливо відтворити з коду, бо порядок слухачів визначається лише порядком реєстрації.

Транзакції ламають наївне використання обох варіантів однаково. Model event фіриться всередині save(), а save() найчастіше стоїть усередині DB::transaction(), тож синхронний слухач працює на незакомічених даних. Локально це виглядає нормально: те саме зʼєднання бачить власні зміни. Проблеми починаються за межами транзакції. Воркер черги читає з іншого конекшена, бере job раніше, ніж коміт відбувся, і падає з ModelNotFoundException, або, гірше, спрацьовує на старому стані й тихо робить неправильну роботу. А якщо транзакція відкотиться, лист уже пішов і платіж уже створено. Лікується це трьома різними інструментами, які плутають між собою: ShouldDispatchAfterCommit на класі події відкладає весь диспатч, разом із синхронними слухачами; ShouldHandleEventsAfterCommit на слухачі або на обсервері відкладає обробку саме цим класом; after_commit => true в конекшені config/queue.php (чи public $afterCommit = true на job) відкладає лише момент push у чергу. Усе це рахує DatabaseTransactionsManager, і спрацьовує воно на коміті найзовнішньої транзакції, а не вкладеного savepoint. Памʼятайте також про DB::transaction($fn, 3): при deadlock замикання виконається повторно, тому подія може піти двічі, і слухач мусить бути ідемпотентним.

Масові операції обсервер просто не бачить. Order::query()->update([...]), ->delete(), insert(), upsert(), increment() на білдері й truncate() формують SQL напряму, без екземпляра моделі, а отже без жодного model event. Те саме з видаленням дочірніх рядків через ON DELETE CASCADE: база чистить їх сама, і обсервер OrderItem мовчить. Порівняйте два видалення: Model::destroy($ids) завантажує моделі й фірить події для кожної, а Order::whereIn('id', $ids)->delete() не фірить нічого. Якщо події справді потрібні на кожен рядок, ітеруйтеся chunkById() або cursor() і зберігайте поштучно, свідомо приймаючи N запитів і N наборів слухачів (таке місце майже завжди виноситься в чергу батчами). Якщо не потрібні, вимикайте їх явно: saveQuietly(), updateQuietly(), deleteQuietly(), Model::withoutEvents(fn () => ...), трейт WithoutModelEvents у сідері. Інваріант, який мусить триматися для кожного рядка незалежно від способу запису, живе в схемі бази (not null, default, унікальний індекс, зовнішній ключ) або в єдиній точці входу застосунку, а обсервер тут лише зручність.

У тестах головна пастка - Event::fake() без аргументів. Він підміняє фасад фейком і додатково викликає Model::setEventDispatcher(), тобто вимикає й обсервери; тест, який перевіряв генерацію slug при створенні, після такого рядка починає падати або, що гірше, проходити на порожньому місці. Тому фейкають вибірково: Event::fake([OrderPaid::class]) пропускає eloquent.* до справжнього диспатчера. Далі йдуть assertDispatched() із замиканням по payload, assertDispatchedTimes(), assertNotDispatched() і Event::assertListening(OrderPaid::class, SendReceipt::class), який єдиний дешево ловить слухача, загубленого при рефакторингу реєстрації. Model event можна асертити за рядком: Event::assertDispatched('eloquent.created: '.Order::class). Обсервер перевіряють за наслідком (зберегли модель, подивилися колонку), слухача - як звичайний клас із викликом handle(). Черга фейкається окремо, і сумістити фейки не вийде: під Event::fake() queued listener не потрапить у чергу взагалі, тож Queue::assertPushed() там нічого не знайде. Останнє: RefreshDatabase тримає весь тест у транзакції, але каже менеджеру транзакцій ігнорувати цю обгортку, тому afterCommit-логіка в сучасних версіях тестується нормально; якщо подія в тесті не приходить, дивіться саме на цей шар, а не на слухача.

// Обсервер відкладає свою обробку до коміту найзовнішньої транзакції
final class OrderObserver implements ShouldHandleEventsAfterCommit
{
    public function creating(Order $order): void
    {
        // creating іде до INSERT, тому атрибути ще можна дописати
        if ($order->number === null) {
            $order->number = OrderNumber::next();
        }
    }

    public function updated(Order $order): void
    {
        // wasChanged() читає вже збережені зміни, isDirty() - ще незбережені
        if ($order->wasChanged('status') && $order->status === Status::Paid) {
            OrderPaid::dispatch($order->id);
        }
    }
}

// Подія знає лише факт. Контракт тримає диспатч до коміту й тоді,
// коли її кидає не обсервер, а сервіс усередині DB::transaction.
final class OrderPaid implements ShouldDispatchAfterCommit
{
    public function __construct(public readonly int $orderId) {}
}

// Масовий апдейт через білдер: один запит, нуль model events, обсервер мовчить
Order::whereIn('id', $ids)->update(['status' => Status::Paid]);

// Той самий намір через моделі: N запитів і N наборів подій
Order::whereIn('id', $ids)->cursor()->each(
    fn (Order $order) => $order->update(['status' => Status::Paid])
);

// Тест: частковий фейк, інакше Event::fake() глушить і eloquent.* події
Event::fake([OrderPaid::class]);
$order->update(['status' => Status::Paid]);
Event::assertDispatched(OrderPaid::class, fn (OrderPaid $e) => $e->orderId === $order->id);
Що обсервер не має окремого механізму: його методи реєструються як слухачі подій `eloquent.created: App\Models\Order` у тому самому диспатчері, тому все сказане про слухачів діє й на обсервери.
Що model event диспатчиться всередині `save()`, тобто всередині відкритої транзакції: синхронний слухач бачить незакомічені дані, а воркер черги на іншому конекшені їх не бачить і падає з `ModelNotFoundException`.
Різницю між `ShouldDispatchAfterCommit` на події (відкладає сам диспатч, зокрема для синхронних слухачів), `ShouldHandleEventsAfterCommit` на обсервері чи слухачі й `$afterCommit`/`after_commit` для черги (відкладає лише push у чергу).
Що `->update()`, `->delete()`, `insert()`, `upsert()`, `truncate()` на білдері й каскад `ON DELETE CASCADE` у БД не породжують model events узагалі, а `Model::destroy($ids)` породжує, бо вантажить моделі поштучно.
Що `Event::fake()` без аргументів підміняє диспатчер разом із `Model::setEventDispatcher()`, тобто вимикає й обсервери; для перевірки однієї події беруть `Event::fake([OrderPaid::class])`.
Критерій вибору: інваріант, привʼязаний до збереження рядка (uuid, slug, інвалідація кешу, аудит) - обсервер; бізнес-факт з кількома незалежними споживачами - подія зі слухачами.
Класти в обсервер відправлення листа чи виклик платіжного API: транзакція відкотиться, а лист уже пішов, бо обсервер відпрацював до коміту.
Вважати, що `Order::query()->update([...])` теж пройде через обсервер, бо це «той самий Eloquent»: model events фіксуються лише на екземплярі моделі.
Ставити `$afterCommit = true` на queued listener і думати, що синхронні слухачі теж почекають коміту. Ця властивість керує тільки моментом push у чергу.
Тримати в обсервері бізнес-логіку, яку не можна виконувати в сідерах, фабриках і імпортах: `factory()->create()` теж фіритиме `created` і піде дзвонити в зовнішній сервіс.
Писати `Event::fake()` без списку класів у тесті, де перевіряється робота обсервера, а потім довго шукати, чому `slug` порожній.
Розраховувати, що `updated` спрацює на кожен `save()`: якщо жоден атрибут не брудний, спрацюють лише `saving` і `saved`.
Диспатчити подію з `updating` замість `updated` і передавати в неї модель: слухач бачить стан, який ще може не зберегтися.
ПОРАДА

Скажіть, що обсервер - це слухач, привʼязаний не до бізнес-факту, а до рядка в таблиці, і що з цього випливають рівно дві його вади: він не бачить масових операцій і виконується до коміту. Далі назвіть три контракти й що саме кожен відкладає: `ShouldDispatchAfterCommit` (диспатч події), `ShouldHandleEventsAfterCommit` (обробку синхронним слухачем або обсервером), `after_commit` у конфігу черги (push job у чергу).

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Model events фіксуються на екземплярі моделі всередині `save()`/`delete()`, тож `update()` і `delete()` на білдері, `insert()`, `upsert()` і `truncate()` проходять повз обсервер. `Event::fake()` підміняє диспатчер і викликає `Model::setEventDispatcher()`, тому без списку класів вимикає й обсервери; правильний спосіб - `Event::fake([OrderPaid::class])`. `$afterCommit` та `after_commit` у конфігу черги відкладають лише момент push job у чергу, синхронних слухачів вони не стосуються: для них потрібен `ShouldHandleEventsAfterCommit` на слухачі або `ShouldDispatchAfterCommit` на самій події.