In Laravel, why can a job dispatched inside DB::transaction during sign-up fail on the worker, and how does afterCommit fix it?
answer
- worker is faster than the commit
- uncommitted row is invisible elsewhere
- ->afterCommit() on the dispatch
- after_commit => true per connection
- rollback discards the pending job
basics
~20 sThe job can reach a worker before the transaction commits, so the worker cannot see the new user and SerializesModels throws ModelNotFoundException. afterCommit holds the push until every open transaction commits, and drops the job on rollback.
solid answer
~40 sA worker runs on its own database connection, and it can pop the job while the request's transaction is still open. The new user row is not visible outside that transaction, so restoring the model with `firstOrFail()` throws `ModelNotFoundException` and the job is failed; if the transaction later rolls back, a job that did run would have emailed a user who does not exist. `SendWelcomeEmail::dispatch($user)->afterCommit()` tells the queue to register the push as a commit callback: it happens only after the outermost open transaction commits, happens immediately if none is open, and is discarded if the transaction rolls back. You can turn this on for a whole connection with `'after_commit' => true` in `config/queue.php` (every skeleton connection ships `false`), implement `ShouldQueueAfterCommit`, and opt a single dispatch back out with `->beforeCommit()`.
code
php · 16 lines<?php
use App\Jobs\SendWelcomeEmail;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\DB;
$user = DB::transaction(function () use ($data) {
$user = User::create($data);
Team::create(['owner_id' => $user->id, 'name' => $user->name]);
// Pushed only after the outermost transaction commits
SendWelcomeEmail::dispatch($user)->afterCommit();
return $user;
});go deeper
Recall that a job dispatched in a transaction may run before the data is saved, and that afterCommit() delays the push.
Explain the worker's separate connection, the ModelNotFoundException symptom, the three ways to opt in, and rollback discarding the job.
Diagnose the intermittent failure, explain why the database driver masked it, and still design the job to survive redelivery.
Decide whether after_commit is a connection-wide default for the codebase, weighing safety against the surprise for code that expects immediate pushes.
## The race Sign-up usually writes several rows at once — the user, a team, a default settings row — so it runs inside `DB::transaction()`. Sending the welcome email is slow, so it is queued. The natural code dispatches `SendWelcomeEmail` inside the transaction closure, right after `User::create()`. The job, however, is handed to the queue backend as soon as the `PendingDispatch` is destroyed, which is before the closure returns and the transaction commits. A worker polling Redis or SQS can pop it within milliseconds. What happens next: 1. The worker unserializes the job, and `SerializesModels` re-queries the user by key on the worker's own connection. 2. Under normal isolation, that connection cannot see a row that another connection has inserted but not committed. 3. `firstOrFail()` throws `ModelNotFoundException`, and the queue handler marks the job **failed** at once. 4. If instead the job runs after an update inside the transaction, it sees the old values. 5. If the transaction rolls back after the job ran, the email went to a user who was never created. The bug is timing-dependent, so it appears under load or after a queue backend change, not on a developer machine. ## The fixes **Per dispatch** — chain `afterCommit()`: - `SendWelcomeEmail::dispatch($user)->afterCommit();` **Per connection** — set the option on the connection in `config/queue.php`: - `'after_commit' => true` makes every job on that connection wait for commits; the Laravel 13 skeleton ships `false` on each connection. - `->beforeCommit()` opts one dispatch out again. **Per class** — implement `Illuminate\Contracts\Queue\ShouldQueueAfterCommit`, which extends `ShouldQueue`. With any of these, the queue checks whether a transaction manager is bound and, if so, registers the push as a callback on the current transaction instead of pushing now. ## Exactly what afterCommit does | Situation at dispatch | Result | |---|---| | No transaction open | pushed immediately | | Inside a transaction, later committed | pushed after the **outermost** transaction commits | | Inside nested transactions | waits for the outermost commit, not the inner savepoint | | Transaction rolled back | the job is discarded, never pushed | | Job also implements `ShouldBeUnique` | its unique lock is released on rollback | The docs note that the connection-level `after_commit` also applies to queued event listeners, mailables, notifications and broadcast events sent on that connection. ## Why it often appears after switching to Redis The skeleton's `database` queue connection sets `'connection' => env('DB_QUEUE_CONNECTION')`, which is empty by default, so jobs are inserted through the **application's own** database connection. Inside a transaction, the `INSERT` into the `jobs` table is part of that transaction: the worker cannot see the job row until it commits, and a rollback removes it. That largely hides the race. Move the queue to Redis, SQS or a separate queue database and the push is no longer transactional, so the race appears. Relying on that side effect is fragile; declare the intent with `afterCommit`. ## Confirming the diagnosis The race leaves a recognisable trail: - the `failed_jobs` table holds `SendWelcomeEmail` entries whose exception reads `No query results for model [App\Models\User]` followed by the new user's id; - retrying those failed jobs later succeeds, because the row now exists; - the failures cluster around traffic peaks or right after the queue backend changed; - the failures disappear when the queue is backed up, because by the time a worker reaches the job the transaction has committed. A quick confirmation in a local environment is to add a short `sleep()` after the dispatch inside the transaction and run a worker on Redis: the job fails every time. Remove the sleep, add `afterCommit()`, and it succeeds every time. ## Related but different tools - `DB::afterCommit()` runs an arbitrary callback after commit; it belongs to the database layer, not the queue API. - `dispatchAfterResponse()` delays until after the HTTP response, not the commit, and has no retries. - Making the job tolerate a missing model (`#[DeleteWhenMissingModels]`) hides the symptom but still loses the email. `afterCommit` removes one duplicate-looking failure mode, but it does not make delivery exactly-once: a job can still run twice after a worker crash, so the email job itself should tolerate a retry.
- With after_commit set to true on the connection, how do you push one urgent Laravel job immediately inside a transaction?Chain `->beforeCommit()` on that dispatch. It sets the job's `afterCommit` flag to false, and the queue pushes it right away even though a transaction is open. Use it only when the job does not depend on data written in that transaction.
- Does afterCommit make the welcome-email job run exactly once?No. It only guarantees the job is not pushed before the data it needs is committed, and not pushed at all on rollback. A worker can still run it twice — for example when a worker dies mid-job and the job is handed out again after `retry_after` — so the job should check whether the email was already sent.
- Why might the race never show up with the skeleton's database queue connection?That connection uses the app's own database connection by default, so the `jobs` row is inserted inside the open transaction and stays invisible to workers until commit. Switching to Redis, SQS or a separate queue connection removes that protection.
saying these in an interview costs you the question
- Queued jobs run inside the request's database transaction
- afterCommit() throws if no database transaction is open
- A rolled-back transaction still pushes jobs dispatched with afterCommit()
- The skeleton's queue connections ship with after_commit enabled
- afterCommit waits only for the innermost nested transaction to commit