skip to content

In Laravel, when does a DB::afterCommit() callback registered during a loyalty-points transfer run, and what happens to it if the transaction rolls back?

level: seniorimportance: should knowfreq 32%

answer

  1. outermost COMMIT, not the inner one
  2. no open transaction: runs now
  3. discarded on rollback
  4. savepoint rollback drops its callbacks
  5. DB::afterRollBack counterpart

basics

~10 s

It runs right after the outermost transaction commits, or immediately if no transaction is open. If the transaction, or the savepoint it was registered in, rolls back, the callback is discarded and never runs.

solid answer

~40 s

`DB::afterCommit($callback)` hands the callback to Laravel's transactions manager. If a transaction is open, the callback is attached to the **current** transaction level and runs only when the **outermost** transaction commits; committing an inner level merely stages it. If that level rolls back, whether the whole transaction or just an inner savepoint, its callbacks are discarded. With **no** transaction open, the callback runs immediately. It still runs inside the same PHP request, after `COMMIT` and before `DB::transaction()` returns, so an exception it throws reaches the caller even though the data is already committed. Its counterpart `DB::afterRollBack()` runs callbacks when the transaction they were registered in rolls back.

code

php · 20 lines
php
<?php

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($from, $to, $points) {
    DB::table('members')->where('id', $from)->decrement('points', $points);
    DB::table('members')->where('id', $to)->increment('points', $points);

    DB::afterCommit(fn () => logger()->info('points moved', compact('from', 'to', 'points')));

    try {
        DB::transaction(function () use ($to) {
            DB::afterCommit(fn () => logger()->info('bonus granted'));
            throw new RuntimeException('bonus service down');
        });
    } catch (RuntimeException) {
        // savepoint rolled back: 'bonus granted' is discarded
    }
});
// after COMMIT only 'points moved' is logged

go deeper

for a junior

Recall that DB::afterCommit() delays a closure until the transaction commits and drops it on rollback.

for a middle

Explain the outermost-commit rule, immediate execution outside transactions and how a savepoint rollback discards callbacks.

for a senior

Account for callbacks being synchronous and in memory: exceptions after commit, lost callbacks on crashes, and when an outbox is needed.

for a principal

Choose between in-process after-commit hooks and a transactional outbox for each class of side effect the system has.

## Why the hook exists After a loyalty transfer, the app wants to tell the receiver "you got 200 points". If that message goes out **inside** the transaction and the transaction later rolls back, the member is told about points they never received. `DB::afterCommit()` lets code register work that should happen only once the data is durable. ## What happens when you call it `DB::afterCommit($callback)` forwards to the connection, which passes the callback to the application's `DatabaseTransactionsManager`: 1. **No transaction open**: the manager finds no pending transaction and calls the callback **immediately**. 2. **Transaction open**: the callback is attached to the record for the current, innermost transaction level. 3. **Inner level commits**: its record is staged, callbacks and all, as part of the parent; nothing runs yet. 4. **Outermost level commits**: after the real `COMMIT`, every staged callback for that connection runs. 5. **Rollback**: the rolled-back level's record and any committed children of it are dropped, so their callbacks never run. ## The transfer, done correctly ```php use Illuminate\Support\Facades\DB; DB::transaction(function () use ($from, $to, $points) { // debit, credit, ledger insert ... DB::afterCommit(function () use ($to, $points) { Member::find($to)->notify(new PointsReceived($points)); }); }); ``` If the debit fails with `InsufficientPoints`, the transaction rolls back and the notification is never sent. If a caller wraps this in its own `DB::transaction()`, the notification waits for that outer commit too. ## Behaviour worth knowing | Situation | Callback runs? | |---|---| | Registered with no transaction open | yes, immediately | | Registered, outermost transaction commits | yes, after `COMMIT` | | Registered in an inner level that commits, outer commits | yes, after outer `COMMIT` | | Registered in an inner level that rolls back, outer commits | no | | Registered, outermost transaction rolls back | no | Two consequences follow from the implementation: - **It is synchronous.** The callbacks run inside `DB::transaction()` after the commit and before it returns. A slow callback delays the response, and an exception from it propagates to the caller even though the data is already committed. Wrap risky work, or push it to a queue. - **It is not durable.** The callback lives in PHP memory. If the process dies between `COMMIT` and the callback, the callback is lost. When the side effect must never be lost, record it in the same transaction, for example as an outbox row, and process that separately. ## The rollback counterpart `DB::afterRollBack($callback)` registers work for the opposite outcome: it runs when the transaction level it was registered in rolls back, which suits cleanup such as deleting an uploaded file that belonged to the failed operation. Unlike `afterCommit()`, it does nothing when no transaction is open. ## Where neighbouring features fit Queued jobs, broadcast events and listeners have their own after-commit switches that rely on the same manager; those belong to the queue and event topics. `DB::afterCommit()` is the general tool for any closure. ## Interview checklist - Names the **outermost** commit as the trigger. - Knows it runs **immediately** outside a transaction, which surprises people calling it from code that is not always transactional. - Knows rollback, including a savepoint rollback, **discards** it. - Mentions that it is **synchronous and in memory**, so it is not a delivery guarantee.

  • A helper calls DB::afterCommit() but is sometimes invoked outside any transaction. What happens then?
    The callback runs immediately, at the point of registration, because the transactions manager finds no pending transaction to attach it to. That is usually the desired behaviour, but it means the helper's side effect can happen before later code in the same request fails; wrap the caller in `DB::transaction()` if the effect must depend on that later work.
  • Why is DB::afterCommit() not enough to guarantee the member is always notified?
    The callback lives only in PHP memory and runs after the commit. If the process crashes, times out or throws between `COMMIT` and the callback, the data is committed but the notification is lost. For a guarantee, write an outbox row inside the transaction and have a job deliver it.

saying these in an interview costs you the question

  • Believes afterCommit callbacks run when an inner transaction commits
  • Thinks afterCommit throws when no transaction is open
  • Expects callbacks from a rolled-back savepoint to still run
  • Says afterCommit callbacks run on a queue asynchronously
  • Treats afterCommit as a guaranteed-delivery mechanism