Send the same payment-capture request through two Laravel routes.
The synchronous route returns 204 after the provider has created charge
ch_001 and the order records that reference. When the provider returns 503,
the HTTP request returns 500 and the order remains unpaid.
The queued route returns 202 while the order is still unpaid and the provider
ledger is empty. Its response contains an operation URL. Cancel the order
before a worker starts and that operation later becomes refused; the
provider is never called.
The command class survived the move to a queue. The caller’s promise did not. A synchronous command says the work finished before the response. A queued command says the application accepted responsibility for reaching and exposing a later outcome. That gap creates contracts for status, transaction visibility, current state, retry identity, ordering, terminal failure, and repair.
A 202 response is a receipt, not a result
The synchronous timeline has one visible call:
HTTP request
-> load order
-> create provider charge
-> record charge on order
-> HTTP 204The fixture’s 204 response arrives with a paid order and one provider charge. There is no queue row and no application operation to poll because the caller already has the outcome.
Queuing splits that timeline:
HTTP request
-> create pending operation
-> dispatch after commit
-> HTTP 202 + /operations/op-1
worker attempt
-> mark operation running
-> load current order
-> call provider
-> record charge
-> mark operation succeededRFC 9110 calls 202 intentionally noncommittal. Processing has not finished and may later be disallowed. It also recommends describing the request’s current status and pointing to a status monitor.
The queued controller therefore returns an application-owned operation:
{
"operation_id": "op-1",
"status": "pending",
"status_url": "/operations/op-1"
}That operation is not the queue row. After a successful worker run, Laravel
deletes the jobs row while /operations/op-1 still reports succeeded.
After exhausted retries, Laravel moves infrastructure evidence to
failed_jobs while the same URL reports an application failure category.
Returning 202 without that durable application status would acknowledge uncertainty and then give the caller no way to resolve it.
The worker wakes up in a different world
The capture command contains identifiers, the amount, and the order version observed by the caller:
final readonly class CapturePayment
{
public function __construct(
public string $operationId,
public string $orderId,
public int $amountCents,
public int $expectedVersion,
) {}
}It does not contain an Order snapshot or a cached canCapture decision.
Those values would describe the world near HTTP acceptance, not the world in
which the worker acts.
The worker reloads the order. If its current status is no longer
awaiting_payment, it refuses the operation before crossing the provider
boundary:
$order = DB::table('orders')
->where('id', $command->orderId)
->firstOrFail();
if ($order->status !== 'awaiting_payment') {
$journal->markTerminal(
$command->operationId,
'refused',
'order_not_payable',
);
return;
}The retained control accepts a capture, changes the order to cancelled, then
starts the worker. The operation history becomes pending -> running -> refused. The provider call list stays empty.
A mutation which ignores the reloaded status marks the same operation
succeeded and charges the cancelled order. The original 202 cannot turn into
a later 409 response; only the operation resource can tell the truth about the
later refusal.
A Valid Command Can Still Be Refused establishes the state boundary in one request. A queue inserts time between command construction and that decision, making the distinction impossible to hide behind input validation.
Commit before making the job visible
The controller creates the operation and dispatches its job inside one database transaction:
DB::transaction(function () use ($command): void {
DB::table('operations')->insert([
'id' => $command->operationId,
'status' => 'pending',
// other application fields
]);
CapturePaymentJob::dispatch($command)->afterCommit();
});Laravel’s transaction documentation explains the
failure window: a fast worker can receive a job before the transaction which
created its data has committed. afterCommit() delays dispatch until commit
and discards it when the transaction rolls back.
The fixture retains both sides. A successful request leaves one pending operation and one available database job. A forced exception after dispatch but before commit returns 500 and leaves neither row.
This control uses Laravel’s database queue and one SQLite transaction. That
setup cannot faithfully expose a worker reading an independently committed
transport before the application commit; an early queue insert rolls back
with the rest of the database transaction. The source analyzer still rejects
beforeCommit(), and the early-visibility claim remains bounded to Laravel’s
documented behavior rather than dressed up as an observed SQLite race.
afterCommit() also does not make a remote broker and the application
database atomic. A transactional outbox is the stronger alternative when the
state change and message publication must share a durable commit. It pays for
that guarantee with a relay, publication lag, cleanup, duplicate delivery,
and its own repair path.
Retry before an effect is not retry after an effect
“The provider failed” hides the question which decides whether a retry is safe: did the provider perform the effect?
The local HTTP provider supports two controlled failures. In the first, it returns 503 before creating a charge:
attempt 1: provider call -> 503 -> zero charges
attempt 2: provider call -> 200 -> one chargeThe application remains unpaid after attempt one. Attempt two creates
ch_001, records it on the order, marks the operation succeeded, and deletes
the queue row. Two calls produced one effect because the first call did
nothing.
The ambiguous failure crosses the boundary in the opposite order:
attempt 1: create ch_001 -> return 503
attempt 2: what should happen now?After attempt one, the provider ledger contains ch_001, but the application
has no provider reference and the operation remains running. Treating the
503 as proof of “no charge” would be fiction. The worker cannot tell whether
the response was lost, the provider failed after committing, or the effect
never happened.
The distinction is operational, not taxonomic. Both failures appear as an exception to the worker. Only one has already changed another system.
Stable identity lets the provider answer the retry
Every capture attempt sends the operation ID as its provider idempotency key:
$charge = $this->provider->capture(
$command->operationId,
$command->amountCents,
);The provider stores the first charge under that key. A repeated request with the same key and amount returns the same charge. This is the bounded behavior described by Stripe’s idempotent request documentation, implemented locally so the result is observable rather than assumed.
The ambiguous sequence now closes:
attempt 1: op-1 -> create ch_001 -> 503
attempt 2: op-1 -> return existing ch_001 -> succeed locallyThe retained ledger has two calls and one charge. Replace the operation ID
with a freshly generated UUID on each attempt and the same two calls create
ch_001 and ch_002. The application still has no result because each new
key makes the provider treat the retry as new work.
Adding Laravel’s ShouldBeUnique to that broken job does not help. Laravel’s
unique-job documentation describes a cache lock which
suppresses another dispatch with the same unique key. The fixture is retrying
one already-reserved job. With a random provider key, its unique-job mutation
still creates two charges.
Queue uniqueness answers “should another copy enter the queue?” Effect
idempotency answers “did this external operation already happen?” A job which
sends email and posts a webhook has at least two more identity decisions. One
stable job ID does not make every effect behind handle() safe.
Ordering needs a business precondition
Laravel’s pinned database queue source selects the oldest available row in this fixture. That doesn’t make order a durable business guarantee. Multiple workers, delayed jobs, retries, priorities, and separate queues can all make a later command run first.
Two accepted address changes make the failure visible:
change A: expected_version=1, address=Address A
change B: expected_version=2, address=Address BThe fixture dispatches A and then B, but delays A so B becomes available first. The handler compares B’s expected version with the current order:
if ($version !== $command->expectedVersion) {
$journal->markTerminal(
$command->operationId,
'refused',
'unexpected_order_version',
);
return;
}B is premature at version one, so the worker refuses it. A then changes the address and advances the order to version two. Explicitly resubmitting B can advance the order to version three.
Remove both version checks and the inverted run becomes quietly wrong. B
succeeds first with Address B; A succeeds later and overwrites it with the
older Address A. Both operations claim success. The final row has version
two and the stale address.
The version is a tripwire, not a sorting algorithm. It detects that the worker’s assumptions are wrong. The application must still decide whether to refuse, reschedule, serialize by key, or reconcile the work.
A failed job is not yet a failed operation
Laravel’s queue documentation notes that an attempt is not
the same as a call to handle(). Exceptions, manual releases, middleware
releases, and timeouts can consume attempts. A product status built from a
guess about handler calls will eventually lie.
The fixture gives CapturePaymentJob three attempts and zero backoff. With a
provider which always fails before the effect, the third attempt creates a
failed_jobs row. The job’s failure hook also closes the application
operation:
public function failed(?Throwable $exception): void
{
app(OperationJournal::class)->markTerminal(
$this->command->operationId,
'failed',
$exception instanceof RequestException
? 'provider_unavailable'
: 'worker_failure',
);
}Without that transition, Laravel correctly retains the failed job while the
caller sees running forever. Infrastructure knows the worker stopped;
product state does not.
After the provider recovers, the fixture runs Laravel’s explicit
queue:retry command. The original failed record disappears, the job returns
to the queue, and the operation eventually succeeds with one charge. Its
history exposes another useful distinction:
| Transition | Application attempt | Queue attempt |
|---|---|---|
| pending | 0 | — |
| running | 1 | 1 |
| running | 2 | 2 |
| running | 3 | 3 |
| failed | 3 | 3 |
running after queue:retry | 4 | 1 |
| succeeded | 4 | 1 |
Laravel resets the retried queue payload’s attempt count. The application journal continues its own history. Neither number is a substitute for the provider call ledger, and none of them proves how many effects occurred.
The fixture proves an explicit repair mechanism. It does not prove an alert, retention policy, on-call response, or acceptable recovery time. Those remain deployment decisions which need their own evidence.
Queue only after naming the later truths
A queue is attractive when the caller can accept a delayed outcome, capacity needs an explicit boundary, or retry should leave the request process. It is the wrong trade when the caller needs a completed result and the application cannot expose progress, classify retry, deduplicate effects, bound attempts, or own terminal repair.
Other mechanisms make different promises. A batch or scheduler fits work which belongs to a periodic set rather than one caller-visible operation. A provider-owned asynchronous API plus webhook may own the authoritative progress. A transactional outbox closes a publication gap. A process manager is appropriate when several events, deadlines, and compensations form one durable workflow.
A Command Bus Must Pay for Its
Indirection deliberately stops
at synchronous dispatch. Replacing dispatchNow() with queued dispatch does
not preserve that contract merely because the same command object reaches the
same handler.
Keep the synchronous call when 204 must mean the effect is complete. Return 202 only when the application can say what was accepted, where its current state lives, which state the worker will trust, how every external effect is identified, what detects wrong order, where exhausted work ends, and who can repair it. The queue begins after the response. So does most of the contract.