Outbox Pattern
Guarantee at-least-once event delivery by writing events transactionally to the database before sending to Kafka.
Outbox Pattern
The transactional outbox pattern solves a fundamental problem in event-driven systems: how do you guarantee that a domain change and its corresponding event are always published together?
Without outbox, there is a race condition:
1. Write user to database ← succeeds
2. Publish event to Kafka ← Kafka is down → event is lost foreverOr in the opposite order:
1. Publish event to Kafka ← succeeds
2. Write user to database ← fails → event published for a change that didn't happenThe outbox pattern eliminates this by making the event write part of the same database transaction as the domain write:
BEGIN TRANSACTION
1. Write user to database
2. Write event to vortos_outbox table ← same connection, same transaction
COMMIT (or ROLLBACK — both writes succeed or both fail)
[Separately, OutboxRelayWorker polls vortos_outbox]
3. Read pending outbox rows
4. Publish to Kafka
5. Mark rows as publishedEnabling Outbox
Outbox is enabled by default on all producers. Set outbox(true) explicitly or rely on the default:
#[RegisterProducer]
public function userProducer(): KafkaProducerDefinition
{
return KafkaProducerDefinition::create('user.events')
->transport('user.events')
->publishes(UserRegistered::class)
->outbox(true); // default — can be omitted
}To bypass outbox and produce directly to Kafka (synchronously, no delivery guarantee):
->outbox(false)Direct Production Risk
Disabling outbox means events can be lost if Kafka is unavailable when dispatch() is called. Only disable outbox for non-critical events where occasional loss is acceptable, or in development environments.
Database Table
You must create the outbox table in your database. Run this migration:
CREATE TABLE vortos_outbox (
id UUID PRIMARY KEY,
transport_name VARCHAR(255) NOT NULL,
event_id UUID NOT NULL,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(512) NOT NULL,
aggregate_version INTEGER NOT NULL,
payload_type VARCHAR(512) NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1,
occurred_at TIMESTAMP NOT NULL,
correlation_id VARCHAR(255),
causation_id VARCHAR(255),
trace_id VARCHAR(255),
metadata JSONB,
payload TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP,
next_attempt_at TIMESTAMP,
failure_reason TEXT
);
CREATE INDEX idx_vortos_outbox_status_next
ON vortos_outbox (status, next_attempt_at)
WHERE status = 'pending';
CREATE INDEX idx_vortos_outbox_aggregate_occurred
ON vortos_outbox (aggregate_id, occurred_at);
CREATE INDEX idx_vortos_outbox_type_occurred
ON vortos_outbox (payload_type, occurred_at);Column reference:
| Column | Description |
|---|---|
event_id | UUID of the specific event dispatch |
aggregate_id | ID of the aggregate that raised the event |
aggregate_type | FQCN of the aggregate class |
aggregate_version | Version of the aggregate at time of event |
payload_type | FQCN of the POPO event class |
schema_version | Payload schema version (default 1) |
occurred_at | When the event was recorded by the aggregate |
correlation_id | Request correlation ID (from Metadata) |
causation_id | ID of the command/event that caused this event |
trace_id | Distributed trace ID |
metadata | Full Metadata JSON (tenantId, userId, custom fields) |
payload | JSON-serialized POPO |
FOR UPDATE SKIP LOCKED
Both fetchPending (relay) and fetchFailed (replay) use SELECT ... FOR UPDATE SKIP LOCKED. Running multiple relay or replay workers in parallel is safe — each process locks the rows it is processing and other processes skip those rows entirely. No message is ever processed by two workers simultaneously.
How the Relay Works
The OutboxRelayWorker runs in a loop, polling the outbox table and producing pending messages to Kafka:
Poll for pending rows
SELECT * FROM vortos_outbox
WHERE status = 'pending'
AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())
ORDER BY created_at ASC
LIMIT 100
FOR UPDATE SKIP LOCKEDProduce to Kafka
For each pending row, deserialize the payload and call ProducerInterface::produce() to send to the Kafka topic specified in transport.
Mark as published
On success, update the row:
UPDATE vortos_outbox
SET status = 'published', published_at = NOW()
WHERE id = $idHandle failure
On failure, a single atomic UPDATE increments attempt_count, computes the exponential backoff delay, and sets the status — all in one round-trip with no SELECT preceding it:
UPDATE vortos_outbox
SET
attempt_count = attempt_count + 1,
failure_reason = :reason,
status = CASE
WHEN attempt_count + 1 >= :maxAttempts THEN 'failed'
ELSE 'pending'
END,
next_attempt_at = CASE
WHEN attempt_count + 1 >= :maxAttempts THEN NULL
ELSE NOW() + make_interval(secs => LEAST(:backoff_base * POWER(2, attempt_count + 1)::int, :backoff_cap))
END
WHERE id = :idDelay doubles on each attempt (backoffBase * 2^attempt), capped at backoffCap seconds. Both values are configurable — see Global Configuration below. When attempt_count reaches maxAttempts, the row is marked failed and next_attempt_at is cleared — it will not be picked up again by the relay and requires the vortos:outbox:replay command.
Running the Relay
The outbox relay runs as a separate long-lived process alongside your consumers. Add it to supervisord.conf:
[program:outbox-relay]
command=php /var/www/html/bin/console vortos:outbox:relay
autostart=true
autorestart=true
startsecs=3
stdout_logfile=/var/log/supervisor/outbox-relay.out.log
stderr_logfile=/var/log/supervisor/outbox-relay.err.logOr run manually:
php bin/console vortos:outbox:relay
php bin/console vortos:outbox:relay --batch-size=50
php bin/console vortos:outbox:relay --sleep-ms=100
# dry run — lists pending rows without producing to Kafka
php bin/console vortos:outbox:relay --dry-runGlobal Configuration
Outbox infrastructure settings are configured globally in your bootstrap via VortosMessagingConfig:
use Vortos\Messaging\DependencyInjection\VortosMessagingConfig;
$config = new VortosMessagingConfig();
$config->outbox()
->table('vortos_outbox') // default — must match the table in your migration
->maxAttempts(5) // default — rows failing beyond this are marked 'failed'
->backoffBase(30) // default — initial backoff in seconds, doubles each attempt
->backoffCap(3600); // default — backoff ceiling in seconds (1 hour)These defaults are production-ready for most workloads. Tune maxAttempts and the backoff values to match your Kafka SLA and downstream service recovery times.
Inspecting Outbox State
Use vortos:outbox:list to inspect outbox rows without writing SQL:
# show all rows (pending + published + failed)
php bin/console vortos:outbox:list
# filter by status
php bin/console vortos:outbox:list --status=failed
php bin/console vortos:outbox:list --status=pending
php bin/console vortos:outbox:list --status=published
# filter by transport or event class
php bin/console vortos:outbox:list --transport=user.events
php bin/console vortos:outbox:list --event-class="App\\User\\Domain\\Event\\UserRegistered"
# show most recently created first, limit to 20
php bin/console vortos:outbox:list --latest --limit=20Example output:
Found 2 outbox row(s) (failed).
failed UserRegistered → user.events 2min ago agg: agg-001... attempts: 5
id: 019d4784-7b26-78a6-ba36-3b74ce5ad751
reason: Connection refused
pending OrderPlaced → orders.placed 5s ago agg: agg-002... attempts: 0
id: 019d4785-93cf-78a6-ba36-3b74ce5ad752
Tip: vortos:outbox:show <id> for full row details · vortos:outbox:replay to reset failed rowsUse vortos:outbox:show <id> for the full row — payload, all timestamps, metadata, and correlation IDs:
php bin/console vortos:outbox:show 019d4784-7b26-78a6-ba36-3b74ce5ad751Replaying Failed Outbox Rows
When a relay attempt exhausts maxAttempts, the row is marked status = 'failed' and will not be picked up by the relay again. Use vortos:outbox:replay to reset these rows back to pending so the relay will retry them:
# inspect what will be replayed without making changes
php bin/console vortos:outbox:replay --dry-run
# replay all failed rows (prompts for confirmation)
php bin/console vortos:outbox:replay
# skip the confirmation prompt (safe for CI / cron)
php bin/console vortos:outbox:replay --force
# replay up to 50 rows (default: 50)
php bin/console vortos:outbox:replay --limit=50
# replay only rows for a specific transport
php bin/console vortos:outbox:replay --transport=user.events
# replay only a specific event class
php bin/console vortos:outbox:replay --event-class="App\\User\\Domain\\Event\\UserRegistered"
# replay a single specific row by ID
php bin/console vortos:outbox:replay --id=019d4784-7b26-78a6-ba36-3b74ce5ad751
# process the most recently failed rows first (default: oldest first)
php bin/console vortos:outbox:replay --latest
# replay rows created during a specific timestamp window
php bin/console vortos:outbox:replay --created-from="2026-05-11T10:00:00Z" --created-to="2026-05-11T10:30:00Z"
# combine flags freely
php bin/console vortos:outbox:replay --latest --limit=10 --transport=user.events --created-from="2026-05-11T10:00:00Z" --dry-run--created-from and --created-to filter outbox rows by created_at and require full timestamps, not date-only values. The outbox table does not store a separate permanent-failure timestamp, so this selects rows by when the original outbox message was created.
Found 3 failed outbox row(s).
• 019d4784-7b26-... | UserCreatedEvent → user.events (attempts: 5)
• 019d4784-93cf-... | OrderPlacedEvent → orders.placed (attempts: 5)
• 019d4785-1abc-... | UserCreatedEvent → user.events (attempts: 5)
Reset: 3 Dry run: falseThe command resets status back to pending, clears attempt_count to 0, and nulls failure_reason and next_attempt_at. The relay picks the rows up on the next poll cycle.
FOR UPDATE SKIP LOCKED on Replay
vortos:outbox:replay uses SELECT ... FOR UPDATE SKIP LOCKED when fetching failed rows. Running multiple replay workers in parallel is safe — each locks a non-overlapping batch.
Fix the Root Cause First
Resetting failed rows without fixing the underlying problem — Kafka unreachable, serialization error, misconfigured transport — will cause them to fail again and hit maxAttempts a second time. Investigate failure_reason before replaying.
Transaction Ownership
The outbox writer does not open its own transaction. It writes to the outbox table using the caller's existing database connection — which must already be inside a transaction.
This is correct behavior. The transaction boundary belongs to your application, not the framework:
// The TransactionalMiddleware wraps the entire handler in a transaction.
// Any outbox writes inside the handler are automatically included.
#[AsEventHandler(handlerId: 'order.placed.process', consumer: 'orders')]
final class ProcessOrderHandler
{
public function __construct(
private readonly OrderRepository $orders,
private readonly EventBusInterface $eventBus, // dispatches to outbox
) {}
public function __invoke(OrderPlacedEvent $event): void
{
$order = $this->orders->find($event->orderId);
$order->process();
$this->orders->save($order); // domain write
// This outbox write happens in the SAME transaction as the save above.
// If the transaction rolls back, neither write persists.
$this->eventBus->dispatch(new OrderProcessedEvent($order->id()));
}
}Dispatch Outside a Handler
If you call $eventBus->dispatch() from a controller or service that is NOT wrapped in TransactionalMiddleware, you are responsible for the transaction boundary. Call $connection->beginTransaction() before your domain write and $connection->commit() after. If you dispatch without an active transaction, the outbox write will succeed independently of your domain write — defeating the purpose of the outbox.
Monitoring Outbox Health
Check for stuck or failed outbox rows:
-- Pending rows older than 5 minutes (relay may be down)
SELECT COUNT(*) FROM vortos_outbox
WHERE status = 'pending' AND created_at < NOW() - INTERVAL '5 minutes';
-- Failed rows that need attention
SELECT * FROM vortos_outbox
WHERE status = 'failed'
ORDER BY created_at DESC
LIMIT 20;Set up an alert if pending rows older than your relay's expected processing time accumulate — this indicates the relay worker is down or unable to reach Kafka.