0% прочитано

Laravel DB::afterCommit() і Queue Jobs: чому job не варто запускати до commit

У ICanUp я запускаю IndexNow notifications і пов’язані queue operations тільки після успішного DB commit. На реальному Laravel-коді показую, чому dispatch усередині transaction може побачити ще незафіксовані дані або пережити rollback, і як DB::afterCommit() робить межу між database state та зовнішніми side effects явною.

8 вересня 2026 р. 9 хв читанняЛаравель

Під час роботи над IndexNow у ICanUp мені потрібно було запускати notifications і пов’язані asynchronous operations після зміни Post, Category та інших моделей.

Сама зміна database state відбувається всередині DB::transaction(). Але зовнішній side effect не повинен стартувати лише тому, що код усередині transaction уже виконав save().

Мені потрібна була сильніша гарантія - запускати наступну операцію тільки після того, як transaction справді успішно committed.

Коротке правило: якщо job, notification або зовнішній API call залежать від даних, які зараз змінюються в transaction, я не запускаю їх до commit. Для цього в Laravel використовую DB::afterCommit().
Laravel DB afterCommit transaction and queue job flow
Database transaction спочатку має успішно завершитися, і лише після цього запускаються queue jobs та зовнішні side effects.

Реальний кейс у ICanUp

У PostService створення статті вже має чітку transaction boundary.

  • спочатку зберігається Post

  • синхронізуються translations і series membership

  • визначаються IndexNow URLs

  • та media changes

А операції, які виходять за межі самої database transaction, відкладаються до commit.

PHP - PostService: transaction і afterCommit
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 або не знаходить новий recordTransaction ще не committed
Transaction завершилась rollbackJob уже dispatchedAsync operation реагує на state, якого в БД не існує
Запущений зовнішній APIAPI call уже неможливо rollback разом із databaseDatabase і зовнішня система розходяться

Worker може бути швидшим за commit

Queue worker не зобов’язаний чекати завершення поточного HTTP request. Якщо job уже потрапив у queue, worker може забрати його тоді, коли database transaction ще відкрита.

PHP - Потенційно небезпечний dispatch
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 не може автоматично скасувати цю зовнішню дію.

Database transaction контролює database state. Вона не може rollback email, HTTP request, IndexNow submission або job, який інший worker уже почав виконувати.

Що змінює DB::afterCommit()

DB::afterCommit() дозволяє зареєструвати callback під час transaction, але виконати його лише після успішного commit.

До
php
DB::transaction(function (): void {    $post->save();     $this->indexNowNotificationService        ->notify($urls);});
Після
php
DB::transaction(function (): void {    $post->save();     DB::afterCommit(function () use ($urls): void {        $this->indexNowNotificationService            ->notify($urls);    });});

У другому варіанті зовнішня реакція вже не є частиною optimistic execution path transaction.

Спочатку Laravel має успішно завершити database transaction. Лише після цього callback стає актуальним.

Якщо transaction не committed, afterCommit callback не повинен запускати side effect для незбереженого state.

У ICanUp після commit запускається одразу кілька різних реакцій

Side effects після 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.

PHP - Content block media 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 commitQueue dispatch behaviorКоли головна проблема саме в моменті dispatch job
Direct dispatchНегайна operationКоли робота не залежить від transactional state

Не кожен job потрібно обгортати в afterCommit

Я не використовую afterCommit автоматично для будь-якого dispatch.

Якщо operation не залежить від даних поточної transaction або взагалі виконується поза transaction, додаткова boundary нічого не дає.

Важливо не саме використання DB::afterCommit(), а причинний зв’язок між committed state і наступною дією.

Моє практичне питання просте: чи має ця operation право відбутися, якщо database transaction через секунду буде rolled back? Якщо відповідь "ні", це сильний кандидат для afterCommit.

Що я перевіряю в тестах

Для такого коду мені важливо тестувати не лише те, що 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 від змін, яких фактично не сталося.