Index

A Queued Command Changes the Contract

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 204

The 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 succeeded

RFC 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 charge

The 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 locally

The 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 B

The 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:

TransitionApplication attemptQueue attempt
pending0
running11
running22
running33
failed33
running after queue:retry41
succeeded41

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.