Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Laravel After-Commit Jobs and Outbox Recovery

Last updated: 5 Oct 20268 min read
tutorial
IntermediateBy AITrove Editorial

A queued job can begin before the transaction that produced its input commits unless dispatch is coordinated with commit. Laravel can delay a dispatch until after commit, which stops a worker from reading a row that is still invisible or will be rolled back. That timing guarantee is not the same as durable intent. A process can fail after the database commits but before the queue receives the job. For a notification promised by a permit approval, write an outbox row in the same transaction as the case change. An after-commit job can wake a dispatcher, and a scheduled sweep can find events whose wake-up never arrived. The worker must handle retry and ambiguous provider outcomes by event identity.

Working case

Case 62 is approved and a job is dispatched inside the transaction. A fast worker reads before commit and cannot find the new approval. Moving dispatch to after commit fixes the visibility race. Then the API process crashes immediately after commit, before its queued wake-up reaches a broker. The notification still disappears if dispatch was the only record of work. The repaired command stores event 93 in an outbox table with the approval. The queued job carries only that event ID and can be lost without losing the intent. A worker or periodic sweep claims the row, sends with a stable provider key when possible, and records the outcome.

Implementation boundary

php
<?php
use App\Jobs\WakePermitOutbox;
use App\Models\ApprovalOutbox;
use App\Models\PermitCase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;

$eventId = DB::transaction(function () use ($caseId, $reviewer) {
    $permit = PermitCase::query()->whereKey($caseId)->lockForUpdate()->firstOrFail();
    Gate::forUser($reviewer)->authorize('approve', $permit);
    $permit->status = 'approved';
    $permit->save();

    $event = ApprovalOutbox::query()->create([
        'case_id' => $caseId,
        'reviewer_id' => $reviewer->id,
        'kind' => 'permit_approved',
        'state' => 'pending',
    ]);

    WakePermitOutbox::dispatch($event->id)->afterCommit();
    return $event->id;
});

Write the outbox event, recipient policy, and case state in one database transaction. Dispatch a short wake-up job with afterCommit, or configure the queue connection to defer all relevant jobs; make that choice explicit in deployment settings. Give the job only stable identifiers, not a model snapshot whose authorization or recipient state may become stale. The dispatcher claims pending events under a lease or atomic state transition, uses a provider idempotency key if available, and records delivery or retry state. Give workers finite attempt and timeout budgets and a dead-letter path for permanent failures. Scan pending events independently of the wake-up path so a post-commit process crash is recoverable.

Cost and boundaries

An outbox insert adds transaction work and storage, while a sweep and worker pool consume database reads and queue capacity. After-commit dispatch avoids uncommitted-row races but may add a small delay before workers start. Retry can duplicate external effects when the provider accepted a message but the acknowledgment was lost; an event identity or provider key narrows that ambiguity. Unique-job locks prevent some duplicate dispatches but are not permanent exactly-once delivery guarantees. Monitor oldest pending event, lease expirations, retry count, provider response class, and queue lag. Do not measure success only by the API response or by a job being enqueued.

Failure trace

Roll back approval and verify neither the outbox event nor after-commit wake-up is visible. Commit approval, kill the API process before dispatch, restart the sweep, and confirm the event is found. Process one event twice and require one durable delivered state; inspect whether the provider itself can deduplicate a repeated send. Crash a worker after claiming but before acknowledging and confirm the lease expires. Force a permanent invalid recipient and move the event to a reviewable failure state instead of retrying forever. Deploy without queue workers and verify monitoring exposes the growing pending age promptly.

Verification

  • The approval and outbox row commit or roll back together.
  • Wake-up dispatch waits until commit.
  • A sweep can recover a pending row when dispatch is lost.

Practice drill

Implement an approval outbox row and a WakePermitOutbox job. Have the controller dispatch after commit, while a scheduled worker scans at most 47 pending rows per batch. Use event 93 as a stable identity distinct from the HTTP request ID. Test rollback, post-commit crash, queue delay, provider timeout after possible acceptance, lease expiry, and permanent failure. Record enough state to answer whether a reviewer notification was pending, attempted, delivered, or needs intervention. Verify the job acquires its own database resources and cannot reuse a request transaction after the response.

Decision note

Use after-commit dispatch for ordering and a transactional outbox for recovery when dispatch itself is lost.

Common Mistakes

  • Treating afterCommit as a durable outbox.
  • Assuming queue uniqueness means external delivery happened exactly once.
  • Retrying provider failures without a stable event identity.

Related lessons

Laravel Policy, Query, and Queue Boundaries; Laravel Route Binding, Policy, and Private Resource Scope; Laravel Eloquent Loading and Cursor Page Cost; Laravel Form Requests, Transactions, and Approval Replay; Background Workflow Reliability; FastAPI Lifespan, Background Work, and Delivery State.

Apply and check

Build Project: Laravel permit review workflow and review Web Development: Laravel policy, query, and queue quiz.

web-tech
web-development
Storage details