
The transactional outbox pattern for AI workflows in Laravel
Build a transactional outbox in Laravel so AI workflow state and queued work survive crashes, retries, duplicate delivery and broker outages.
The transactional outbox pattern for AI workflows in Laravel makes one promise: when a database transition commits, the durable intent to continue the workflow commits with it. Instead of updating workflow state and hoping a queue dispatch also succeeds, the application writes both the state change and an outbox message in one SQL transaction. A separate relay publishes that message later.
This solves a narrow but dangerous failure window. Imagine an agent saves an approved answer, commits the transaction and should enqueue a publishing job. The PHP process dies before Redis or SQS accepts the job. The database says the answer is approved, but nothing will publish it. Reversing the order is worse: a worker can act on data that eventually rolls back.
The outbox closes that gap without a distributed transaction. It does not create exactly-once delivery, make consumers idempotent or decide whether an ambiguous tool call succeeded. Those remain separate contracts. The value of the pattern is that missing work becomes durable, queryable data instead of an invisible timing accident.
The dual-write failure hiding behind a normal dispatch
A typical Laravel service changes a model and dispatches a job:
DB::transaction(function () use ($run, $artifact) {
$run->approve($artifact);
$run->save();
PublishApprovedArtifact::dispatch($run->id);
});
That code touches two independent durability systems: the relational database and the queue backend. Neither system participates in the other's commit. With immediate dispatch, a fast worker may load the workflow before the transaction commits, or the job may survive even though the transaction rolls back. Laravel documents this race and provides after-commit dispatch to prevent it.
Moving the dispatch after the commit fixes the rollback side, but leaves another sequence:
- the workflow row commits successfully;
- the process is interrupted, the queue is unavailable or dispatch throws;
- no durable record says that publication is still required.
This window can be milliseconds wide and still matter. Deployments, container termination, network partitions and broker incidents eventually land in it. AI workflows amplify the damage because the missing job may represent an expensive model result, a human approval or the only command that advances a long-running process.
Laravel afterCommit() is useful, but it is not an outbox
Laravel's queue connection can set after_commit to true, or a dispatch can call afterCommit(). Laravel then waits for open database transactions to commit before pushing the job. If the transaction rolls back, the deferred job is discarded. This is the correct tool when the main concern is a worker observing uncommitted data.
A transactional outbox provides a stronger durability boundary. The workflow update and the message-to-be-published are rows in the same database transaction. Once the commit returns, a relay can retry delivery even after the original request, PHP worker or queue broker disappears. Operators can also inspect the pending intent directly.
| Mechanism | What it guarantees | Remaining gap |
|---|---|---|
| Immediate dispatch | The application attempted to enqueue | Job may run before commit or survive rollback |
afterCommit() | Dispatch happens only after a successful commit | Process or broker can fail after commit, before durable enqueue |
| Transactional outbox | State change and delivery intent commit atomically | Relay may publish more than once |
There is an important special case: Laravel's database queue driver can place jobs in a relational table. If the business write and queue insert truly use the same database connection and transaction, they can share an atomic commit. Verify the exact connection and dispatch behavior instead of assuming it. Teams often move the queue to Redis or SQS later, which restores the dual-write boundary.
A practical outbox table for Laravel
The minimum useful row needs a stable message ID, a type, a versioned payload and publishing state. For production operation, I also want workflow correlation, aggregate ordering, attempt data and a lease that lets another relay recover abandoned work.
Schema::create('outbox_messages', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('workflow_id')->index();
$table->string('aggregate_type', 80);
$table->string('aggregate_id', 120);
$table->unsignedBigInteger('aggregate_version');
$table->string('event_type', 160);
$table->unsignedSmallInteger('schema_version');
$table->json('payload');
$table->timestamp('available_at')->index();
$table->timestamp('locked_at')->nullable()->index();
$table->timestamp('published_at')->nullable()->index();
$table->unsignedInteger('attempts')->default(0);
$table->text('last_error')->nullable();
$table->timestamps();
$table->unique([
'aggregate_type', 'aggregate_id', 'aggregate_version'
]);
});
The unique aggregate version prevents two messages from claiming the same logical transition. It also gives the consumer an ordering signal. A global auto-incrementing ID is not enough when concurrent workflow instances publish independently, and a timestamp is a weak ordering contract.
Do not turn the outbox into permanent event storage by accident. It is a delivery ledger, not necessarily the complete audit history. Keep published rows long enough for diagnosis and replay policy, then archive or prune them deliberately. The workflow's accepted states and artifacts should live in their own durable model.
Write workflow state and intent in one transaction
The application service should construct the event from the transition it just accepted. Both writes happen inside the same DB::transaction() callback:
final class ApproveArtifact
{
public function handle(WorkflowRun $run, Artifact $artifact): void
{
DB::transaction(function () use ($run, $artifact): void {
$run->approve(
artifactId: $artifact->id,
artifactHash: $artifact->sha256,
);
$run->save();
OutboxMessage::query()->create([
'id' => (string) Str::uuid(),
'workflow_id' => $run->id,
'aggregate_type' => 'workflow_run',
'aggregate_id' => $run->id,
'aggregate_version' => $run->version,
'event_type' => 'artifact.approved',
'schema_version' => 1,
'payload' => [
'artifact_id' => $artifact->id,
'artifact_sha256' => $artifact->sha256,
'approved_by' => auth()->id(),
],
'available_at' => now(),
]);
});
}
}
If either insert fails, both changes roll back. If both commit, the relay can find the message later. The HTTP request does not need the queue to be healthy to accept the business transition, although the product must decide whether delayed downstream work is acceptable.
I prefer an explicit application service over relying on a generic model observer. Observers make it easy to emit events for incidental saves, hide transaction ownership and forget which payload version a transition requires. A domain method plus an outbox writer keeps the business decision and its integration consequence visible in one place.
The relay should claim briefly and expect uncertainty
A scheduled Artisan command or long-running process can relay pending rows. Multiple relay instances need a claiming strategy such as FOR UPDATE SKIP LOCKED, supported by modern PostgreSQL and MySQL versions. Claim a small batch in a short transaction, then perform network dispatch outside that transaction. Never hold database locks while waiting for SQS, Redis or an HTTP broker.
The sequence is intentionally at-least-once:
- claim available rows and set a lease timestamp;
- publish each message with its outbox UUID as the message ID;
- mark the row published after the broker accepts it;
- on failure, increment attempts, store a bounded error and calculate backoff;
- release expired leases so another relay can retry abandoned rows.
The relay can crash after step two and before step three. On recovery it will publish the same message again. Trying to delete that possibility usually adds distributed coordination without removing the underlying uncertainty. Design the consumer for duplicates instead.
Relay throughput should be boring and measurable. Track the age of the oldest unpublished message, pending count, publication latency, attempts and exhausted records. A queue dashboard alone cannot show messages that have not reached the queue yet. Alert on outbox age, not only row count: ten messages stuck for an hour can be more urgent than ten thousand messages flowing normally.
The outbox prevents loss, not duplicate effects
The message ID must travel through the queue to the consumer. Before applying a side effect, the consumer records that ID in an inbox or idempotency table under a unique constraint. The effect and processed-message record should share a local transaction when possible.
DB::transaction(function () use ($message): void {
$inserted = ProcessedMessage::query()->insertOrIgnore([
'message_id' => $message->id,
'consumer' => 'publish-approved-artifact',
'processed_at' => now(),
]);
if ($inserted === 0) {
return;
}
WorkflowPublication::query()->create([
'workflow_id' => $message->workflowId,
'artifact_id' => $message->payload['artifact_id'],
'status' => 'ready',
]);
});
For an external effect, local deduplication is only half of the contract. Send a stable idempotency key to providers that support one. If a timeout hides the response, reconcile by that key before repeating the call. The outbox tells you that an action is owed; it cannot tell you whether a remote system completed an attempt whose response was lost.
This is the same boundary described in idempotency for AI automation and partial failure in AI workflows. Outbox, inbox and provider reconciliation solve different failure modes. Treating one as an exactly-once switch is how duplicate emails, publications and payments escape into production.
What an AI workflow message should contain
AI systems tempt developers to put the entire prompt, response and conversation into every message. That creates large payloads, repeats sensitive data and couples consumers to one model format. The outbox should carry the smallest immutable facts needed to locate and verify the accepted artifact.
A useful envelope includes:
- workflow and correlation IDs for tracing the run across jobs and tools;
- event ID and aggregate version for deduplication and ordering;
- event type and schema version so old messages remain readable after deployment;
- artifact reference and cryptographic hash so the consumer processes exactly what was approved;
- policy or approval reference when the next action depends on human authorization;
- operation ID that becomes the downstream idempotency key;
- no secrets and no raw personal data unless the consumer strictly requires it.
Store large prompts, model outputs and evidence in an artifact store or domain table with appropriate retention and access controls. Put references in the event. That keeps the queue transport small and lets security policy evolve independently from message delivery.
Version the meaning, not only the JSON shape. If version 2 changes “approved” from a human decision to an automated policy decision, consumers need more than a nullable field. Publish a new event type or schema version and keep the old handler available until no unpublished or queued version-1 messages remain. Long-running workflows make compatibility an operational requirement, not a cleanup task.
Test the failure boundaries, not only the happy path
An outbox implementation can pass ordinary feature tests while failing at every interesting boundary. I would test these scenarios explicitly:
- Rollback: force the domain write to fail and assert that no outbox row exists.
- Atomic commit: assert that the state transition and outbox row appear together.
- Broker outage: make dispatch fail, then confirm the row remains retryable with backoff.
- Crash after publish: publish without marking success, run the relay again and verify the consumer applies one effect.
- Concurrent relays: run two claimers and verify that leases prevent simultaneous handling while expired claims recover.
- Out-of-order messages: deliver aggregate version 8 before 7 and confirm the consumer rejects, buffers or safely reconciles it.
- Old schema: process a fixture produced by the previous deployed version.
- Poison message: exhaust its retry budget and move it to visible manual intervention without blocking the batch.
These tests complement state-transition tests for AI agents. One suite proves that the domain accepts only valid moves; the other proves that accepted moves are delivered reliably. Production metrics should join them with the same workflow ID.
Set a bounded retry policy for the relay. The correct response to a malformed payload is not an infinite hot loop. Use attempt, elapsed-time and cost limits, then surface exhausted records with enough context for an operator. The principles in retry budgets for AI automation apply even when the retried action is “publish this message.”
Common transactional outbox mistakes
- Marking published before dispatch. A crash in between loses the message. Mark only after broker acceptance.
- Deleting immediately. This removes evidence needed to diagnose duplicates, lag and schema problems. Retain, then prune by policy.
- Using timestamps for identity. Generate a stable UUID and enforce uniqueness in the database.
- Holding locks during network I/O. Claim in a short transaction and publish outside it.
- Ignoring ordering. Include aggregate version when consumers care about transition order.
- Assuming a unique Laravel job is idempotency. Queue uniqueness limits scheduling concurrency; it does not prove an external effect happened once.
- Storing unlimited AI payloads. Reference immutable artifacts rather than copying sensitive context into transport tables.
- No operational SLO. A durable message that sits unpublished for six hours is not lost, but the user experience may be equally broken.
When the pattern is worth the extra table
Use a transactional outbox when a committed database change must eventually cause work in another durability system and silent loss is unacceptable. It is especially valuable for workflow transitions, integration events, approval handoffs, billing commands and agent tool actions that must survive a broker outage or application restart.
Skip it when the action is genuinely best-effort, when rebuilding missing work from source data is cheap and reliable, or when one database transaction already contains both the business record and the queue record. Do not install the pattern merely because the application has background jobs.
The outbox is also not a replacement for a workflow state machine or durable execution platform. It transports committed intent across a boundary. The workflow model still decides which transition is legal, which artifact was approved and what recovery means. If that coordination is becoming the system's dominant complexity, revisit durable execution versus queues.
My practical rule is simple: if losing the dispatch would require a person to notice and repair the workflow, make the dispatch intent queryable data. Commit it beside the state change, relay it at least once, consume it idempotently and measure how long it waits. That small architecture turns a timing gap into an operational queue you can actually reason about.
Sources checked on October 20, 2026: AWS Prescriptive Guidance on the transactional outbox pattern, Chris Richardson's pattern description at Microservices.io, and the Laravel 13.x documentation on queued jobs and database transactions. The schema, relay design and AI message envelope are practical engineering guidance, not framework guarantees. This article reflects experience with PHP backends, queues, workflow automation and AI agents. It was prepared with AI assistance and editorial review. Cover: data center image from Pixabay.
If your Laravel AI workflow can commit state without reliably scheduling its next step, I can help review the transaction, queue and recovery boundaries. See my AI engineering consulting work or contact me.
