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