Skip to content

Outbox

The outbox holds messages to other systems that must not get lost: a mail to a customer, a call to an ERP, a webhook. A message is written in the same transaction as the change. If the change is rolled back, the message is gone as well. If it is saved, the message survives a crash and is delivered later, with retries.

anvil:tick delivers the messages every minute. Schedule it as described in Scheduled commands, there is no extra worker to run.

The simplest way: let a domain event go through the outbox.

app/Config/Anvil.php
public array $outboxHandlers = [
'invoice-mail' => \App\Outbox\SendInvoiceMail::class,
];
public array $durableEventHandlers = [
'invoice.sent' => ['invoice-mail'],
];
use Anvil\Outbox\OutboxException;
use Anvil\Outbox\OutboxHandler;
use Anvil\Outbox\OutboxMessage;
final class SendInvoiceMail implements OutboxHandler
{
public function handle(OutboxMessage $message): ?array
{
// $message->payload is DomainEvent::toArray(): name, subject, subject_uuid, data, changes …
$invoice = (new InvoiceModel())->findByUuid((string) $message->subjectUuid)
?? throw OutboxException::permanent('The invoice has been deleted.');
// ... send the mail
return ['to' => $invoice->email];
}
}

Unlike a listener of $domainEventListeners, the handler runs later, in the next tick, and again after an error.

Push a message where you change the data, inside the same transaction:

Transaction::run(db_connect(), static function () use ($order): void {
(new OrderModel())->update($order->id, ['state' => 'shipped']);
service('outbox')->push('erp-shipment', ['order' => $order->uuid], 'erp-shipment:' . $order->uuid, label: 'Shipment to the ERP');
});

The idempotency key is unique per tenant. Pushing the same key again returns the existing message and queues nothing.

  • At least once. A message can arrive twice, for example when the process ends after the call but before the result is saved. Make handlers idempotent, for example with the message UUID ($message->uuid) as key at the receiver.
  • In the tenant of the message, as system. Audit entries name the system.
  • Errors. OutboxException::permanent() fails the message at once. OutboxException::temporary() and every other exception are retried. The text of an OutboxException is shown to administrators, other errors only say “unexpected error” and go to the log.
  • No order. Messages can overtake each other. Use the time of the event.
  • No secrets in the payload. Payloads are stored in plain text and kept for $outboxDays.
Setting Default Meaning
$outboxMaxAttempts 8 attempts before a message fails
$outboxBackoffMinutes 1, 5, 15, 60, 180, 360, 720 pause before the next attempt; the last value repeats
$outboxBatch 50 messages per tick
$outboxTimeBudget 40 seconds per tick at most
$outboxDays 30 days finished messages are kept (anvil:prune)

Several servers can run anvil:tick at the same time. A message is claimed before it is handled, and a claim that is older than five minutes is taken over.

Terminal window
php spark anvil:outbox # deliver the due messages now
php spark anvil:outbox --failed # failed messages of all tenants
php spark anvil:outbox --pending # messages still waiting
php spark anvil:outbox --retry <uuid> # queue a failed or cancelled message again
php spark anvil:outbox --cancel <uuid> # cancel a pending or failed message

Retries, cancellations and failed messages are recorded in the audit log as outbox.retried, outbox.cancelled and outbox.failed.