Під час роботи над IndexNow у ICanUp мені потрібно було запускати notifications і пов’язані asynchronous operations після зміни Post, Category та інших моделей.
Сама зміна database state відбувається всередині DB::transaction(). Але зовнішній side effect не повинен стартувати лише тому, що код усередині transaction уже виконав save().
Мені потрібна була сильніша гарантія - запускати наступну операцію тільки після того, як transaction справді успішно committed.

Реальний кейс у ICanUp
У PostService створення статті вже має чітку transaction boundary.
спочатку зберігається Post
синхронізуються translations і series membership
визначаються IndexNow URLs
та media changes
А операції, які виходять за межі самої database transaction, відкладаються до commit.
DB::transaction(function () use ($request): void { $post = new Post; $post->author_id = (int) auth()->id(); $post = $this->savePost($post, $request); $post->syncTranslations($request->validated()); $this->syncSeriesMembership($post, $request); $indexNowUrls = $this->indexNowUrlService ->postTargets($post); $changedMediaIds = $this->mediaUsageService ->syncSingleForContext( $post, null, $post->image_media_id, MediaUsageContextEnum::IMAGE, ); DB::afterCommit(function () use ( $post, $changedMediaIds, $indexNowUrls, ): void { $this->dispatcher->dispatch($changedMediaIds); $this->indexNowNotificationService ->notify($indexNowUrls); $this->indexNowPublicationScheduler ->schedulePost($post); });});Для мене тут найважливіша не сама syntax DB::afterCommit(), а межа відповідальності: database state спочатку стає durable, і тільки після цього інші частини системи отримують право реагувати.
Що може піти не так, якщо dispatch зробити всередині transaction
На перший погляд логічно одразу після save викликати notification service або dispatch job.
Проблема в тому, що виконання PHP-коду всередині transaction ще не означає, що інший process уже бачить ці зміни.
Особливо помітно це стає з queue worker, який працює незалежно від HTTP request.
| Ситуація | Що може статися | Чому це проблема |
|---|---|---|
| Worker стартував дуже швидко | Job читає старий state або не знаходить новий record | Transaction ще не committed |
| Transaction завершилась rollback | Job уже dispatched | Async operation реагує на state, якого в БД не існує |
| Запущений зовнішній API | API call уже неможливо rollback разом із database | Database і зовнішня система розходяться |
Worker може бути швидшим за commit
Queue worker не зобов’язаний чекати завершення поточного HTTP request. Якщо job уже потрапив у queue, worker може забрати його тоді, коли database transaction ще відкрита.
DB::transaction(function (): void { $post = Post::query()->create([ // ... ]); ProcessPost::dispatch($post->id);});Залежно від connection, isolation level і того, що саме читає job, результатом може бути missing record, старе значення або неповний state.
Rollback не може повернути вже запущений side effect
Ще важливіший випадок - transaction виконує кілька операцій, після чого одна з них падає.
Database зробить rollback. Але якщо notification, webhook або зовнішній API call уже був запущений, database rollback не може автоматично скасувати цю зовнішню дію.
Що змінює DB::afterCommit()
DB::afterCommit() дозволяє зареєструвати callback під час transaction, але виконати його лише після успішного commit.
DB::transaction(function (): void { $post->save(); $this->indexNowNotificationService ->notify($urls);});DB::transaction(function (): void { $post->save(); DB::afterCommit(function () use ($urls): void { $this->indexNowNotificationService ->notify($urls); });});У другому варіанті зовнішня реакція вже не є частиною optimistic execution path transaction.
Спочатку Laravel має успішно завершити database transaction. Лише після цього callback стає актуальним.
У ICanUp після commit запускається одразу кілька різних реакцій
DB::afterCommit(function () use ( $post, $changedMediaIds, $indexNowUrls,): void { $this->dispatcher->dispatch($changedMediaIds); $this->indexNowNotificationService ->notify($indexNowUrls); $this->indexNowPublicationScheduler ->schedulePost($post);});- Media usage changes можуть повідомити інші частини системи.
- IndexNow notification може підготувати submission для актуальних public URLs.
- Publication scheduler може запланувати майбутню SEO operation.
- Усі ці реакції отримують уже committed database state.
Це особливо зручно, коли одна transaction змінює не тільки main model, а ще translations, relations або media references.
Той самий принцип працює і для Content Blocks
Content blocks у ICanUp також синхронізуються transactionally.
Під час sync можуть змінитися media usages у кількох blocks. Але відповідні events відправляються тільки після commit.
DB::afterCommit(static function () use ( $changedMediaIds): void { foreach ( array_unique($changedMediaIds) as $mediaId ) { event( new MediaUsageChangedEvent( (int) $mediaId ) ); }});Для мене це хороший сигнал, що afterCommit не повинен бути випадковим локальним fix. Це системний pattern для переходу від transactional state до asynchronous або зовнішніх reactions.
DB::afterCommit() і queue after_commit - це пов’язані, але не однакові речі
Laravel також має queue-level механізми для dispatch після commit.
Але DB::afterCommit() ширший за queue job. Усередині callback я можу запустити notification service, event dispatcher, scheduler або будь-яку іншу operation.
Тому в ICanUp я використовую його там, де хочу явно показати transaction boundary у application service.
| Механізм | Рівень | Коли зручний |
|---|---|---|
| DB::afterCommit() | Database transaction callback | Коли після commit треба виконати одну або кілька різних operations |
| Queue after commit | Queue dispatch behavior | Коли головна проблема саме в моменті dispatch job |
| Direct dispatch | Негайна operation | Коли робота не залежить від transactional state |
Не кожен job потрібно обгортати в afterCommit
Я не використовую afterCommit автоматично для будь-якого dispatch.
Якщо operation не залежить від даних поточної transaction або взагалі виконується поза transaction, додаткова boundary нічого не дає.
Важливо не саме використання DB::afterCommit(), а причинний зв’язок між committed state і наступною дією.
Що я перевіряю в тестах
Для такого коду мені важливо тестувати не лише те, що service був викликаний, а й семантику транзакції.
- Side effect не запускається до успішного commit.
- Після commit потрібна operation виконується з актуальними даними.
- При rollback зовнішня reaction не повинна поводитися так, ніби зміна була збережена.
- Update сценарій враховує old і new URLs, якщо зовнішній сервіс повинен знати про обидва.
- Повторні URLs дедуплікуються до dispatch.
Чому це важливо саме для IndexNow
IndexNow - зовнішній indexing signal. Після submission search engine не знає нічого про мою database transaction.
Тому надсилати URL до успішного commit було б концептуально неправильно: я могла б повідомити search engine про state, який потім не зберігся.
AfterCommit дозволив поставити зовнішній SEO side effect після database source of truth.
Спочатку database стає правдою. Потім про цю правду можна повідомляти інші системи.
Що я залишила собі на майбутнє
- Не dispatch job лише тому, що model вже виконав save всередині transaction.
- Відділяти transactional database changes від зовнішніх side effects.
- Для queue workers пам’ятати, що вони можуть почати роботу швидше, ніж завершиться request.
- Не очікувати, що rollback database скасує вже відправлений HTTP request або notification.
- Використовувати DB::afterCommit() там, де application service має явну post-commit phase.
- Тестувати rollback path окремо від happy path.
Висновок
DB::afterCommit() виявився для мене не просто зручним Laravel helper.
Він допомагає провести чітку межу між змінами, які ще можуть бути rolled back, і operations, які вже виходять за межі database.
У ICanUp це особливо важливо для IndexNow, media events і publication scheduling. Спочатку transaction має стати committed state. Тільки після цього я запускаю те, що реагує на цей state.
З такою моделлю queue jobs і зовнішні integrations стають значно передбачуванішими, а rollback перестає залишати за собою side effects від змін, яких фактично не сталося.



