While working on IndexNow in ICanUp, I needed to run notifications and related asynchronous operations after changing Posts, Categories, and other models.
The database state itself changes inside DB::transaction(). But an external side effect should not start just because the code inside the transaction has already executed save().
I needed a stronger guarantee - run the next operation only after the transaction has actually committed successfully.

The Real ICanUp Case
Post creation in ICanUp already has a clear transaction boundary.
the service saves the Post
synchronizes translations and series membership
resolves IndexNow URLs
and tracks media changes
Operations that go beyond the database transaction are delayed until the 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); });});The important part for me is not the DB::afterCommit() syntax itself. It is the responsibility boundary: database state becomes durable first, and only then can other parts of the system react to it.
What Can Go Wrong When You Dispatch Inside a Transaction
At first, it seems reasonable to call a notification service or dispatch a job immediately after save.
The problem is that executing PHP code inside a transaction does not mean another process can already see those changes.
This becomes especially visible with a queue worker that runs independently from the HTTP request.
| Situation | What can happen | Why it is a problem |
|---|---|---|
| Worker starts very quickly | The job reads old state or cannot find the new record | The transaction has not committed yet |
| Transaction rolls back | The job was already dispatched | Async work reacts to state that does not exist in the database |
| External API already started | The API call cannot be rolled back with the database | Database state and the external system diverge |
The Worker Can Be Faster Than the Commit
A queue worker does not have to wait for the current HTTP request to finish. Once a job reaches the queue, a worker may pick it up while the database transaction is still open.
DB::transaction(function (): void { $post = Post::query()->create([ // ... ]); ProcessPost::dispatch($post->id);});Depending on the connection, isolation level, and what the job reads, the result can be a missing record, an older value, or incomplete state.
A Rollback Cannot Undo an External Side Effect
The more important case is a transaction that performs several operations and then fails near the end.
The database can roll back. But if a notification, webhook, or external API call has already started, the database rollback cannot automatically cancel that external action.
What DB::afterCommit() Changes
DB::afterCommit() lets me register a callback during the transaction but execute it only after a successful 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); });});In the second version, the external reaction is no longer part of the optimistic execution path of the transaction.
Laravel must complete the database transaction successfully first. Only then does the callback become relevant.
ICanUp Runs Several Reactions After the Same Commit
DB::afterCommit(function () use ( $post, $changedMediaIds, $indexNowUrls,): void { $this->dispatcher->dispatch($changedMediaIds); $this->indexNowNotificationService ->notify($indexNowUrls); $this->indexNowPublicationScheduler ->schedulePost($post);});- Media usage changes can notify other parts of the system.
- The IndexNow notification can prepare submission for the current public URLs.
- The publication scheduler can schedule a future SEO operation.
- All of these reactions receive committed database state.
This is especially useful when one transaction changes not only the main model but also translations, relationships, or media references.
The Same Principle Applies to Content Blocks
Content blocks in ICanUp are also synchronized transactionally.
Several media usages may change during one sync. The related events are dispatched only after the commit.
DB::afterCommit(static function () use ( $changedMediaIds): void { foreach ( array_unique($changedMediaIds) as $mediaId ) { event( new MediaUsageChangedEvent( (int) $mediaId ) ); }});For me, this is a useful sign that afterCommit should not be treated as a random local fix. It is a system pattern for moving from transactional state to asynchronous or external reactions.
DB::afterCommit() and Queue after_commit Are Related but Different
Laravel also has queue-level mechanisms for dispatching work after a commit.
DB::afterCommit() is broader than a queue job. Inside the callback I can trigger a notification service, event dispatcher, scheduler, or another operation.
That is why I use it when I want the transaction boundary to be explicit in the application service.
| Mechanism | Level | When it is useful |
|---|---|---|
| DB::afterCommit() | Database transaction callback | When several different operations should run after commit |
| Queue after commit | Queue dispatch behavior | When the main concern is the job dispatch moment |
| Direct dispatch | Immediate operation | When the work does not depend on transactional state |
Not Every Job Needs afterCommit
I do not wrap every dispatch in afterCommit automatically.
If an operation does not depend on data from the current transaction, or it already runs outside a transaction, an additional boundary may add no value.
The important part is not the helper itself. It is the causal relationship between committed state and the next action.
What I Verify in Tests
For this type of code, I care about more than whether a service method was eventually called. I also want the transaction semantics to remain correct.
- The side effect does not run before a successful commit.
- After commit, the operation uses the current state.
- On rollback, an external reaction should not behave as if the change was persisted.
- An update flow preserves both old and new URLs when an external service needs both.
- Duplicate URLs are removed before dispatch.
Why This Matters for IndexNow
IndexNow is an external indexing signal. Once a URL has been submitted, the search engine knows nothing about my database transaction.
Sending a URL before a successful commit would therefore be conceptually wrong: I could announce state that is later rolled back.
AfterCommit lets me place the external SEO side effect after the database source of truth.
The database becomes the truth first. Only then should other systems be told about that truth.
What I Kept for Next Time
- Do not dispatch a job only because a model has already executed save inside a transaction.
- Separate transactional database changes from external side effects.
- Remember that queue workers can start processing faster than the request finishes.
- Do not expect a database rollback to cancel an HTTP request or notification that already happened.
- Use DB::afterCommit() when an application service has a clear post-commit phase.
- Test the rollback path separately from the happy path.
Conclusion
DB::afterCommit() turned out to be more than a convenient Laravel helper for me.
It creates a clear boundary between changes that can still be rolled back and operations that already leave the database.
In ICanUp, that is especially important for IndexNow, media events, and publication scheduling. The transaction becomes committed state first. Only then do I run the code that reacts to that state.
With this model, queue jobs and external integrations become much more predictable, and a rollback is less likely to leave side effects for changes that never actually happened.



