← All packages

laravel-outbox

Laravel

Transactional outbox for Laravel: stage messages inside your database transaction and relay them reliably with retries and backoff.

composer require webrek/laravel-outbox
00v1.2.0PHP ^8.2GitHub ↗Packagist ↗

Laravel Outbox

Latest Version on Packagist Total Downloads Tests PHP Version License

Un transactional outbox para Laravel. Coloca un mensaje dentro de la misma transacción de base de datos que tu escritura de negocio, y un relay lo entrega después con reintentos y backoff. La escritura y el mensaje hacen commit juntos —o aterrizan ambos o ninguno— de modo que nunca publicas un evento de un cambio que se revirtió, ni pierdes un evento de un cambio que sí se confirmó.

Esta es la mitad productora del exactly-once. Combínalo con webrek/laravel-idempotency en el consumidor para obtener efectos exactly-once de extremo a extremo sobre una infraestructura at-least-once.

Por qué

Despachar un job en cola, disparar un webhook o publicar en un broker después de guardar un modelo es un dual write: si el proceso muere entre el commit y el despacho, el efecto secundario se pierde. Hacerlo antes del commit es peor: el efecto se dispara incluso si la transacción se revierte. El patrón outbox elimina esa brecha escribiendo la intención en la misma base de datos, dentro de la misma transacción, y entregándola desde ahí.

use Illuminate\Support\Facades\DB;
use Webrek\Outbox\Facades\Outbox;

DB::transaction(function () use ($request) {
    $order = Order::create($request->validated());

    // Hace commit atómicamente con la orden. Sin orden, no hay mensaje — y viceversa.
    Outbox::publish('order.placed', ['order_id' => $order->id]);
});

Instalación

composer require webrek/laravel-outbox

Publica y ejecuta la migración:

php artisan vendor:publish --tag=outbox-migrations
php artisan migrate

Opcionalmente publica la configuración:

php artisan vendor:publish --tag=outbox-config

La tabla del outbox debe vivir en la misma conexión que los datos junto a los que colocas los mensajes —la atomicidad solo abarca la transacción de una sola conexión. Configura outbox.connection en consecuencia (por defecto usa tu conexión predeterminada).

Entrega de mensajes mediante el relay

Ejecuta el relay como un worker de larga duración (como queue:work):

php artisan outbox:work

Reclama los mensajes vencidos con un row lock —es seguro correr varios workers en paralelo—, entrega cada uno a un publisher y lo marca como publicado. Una entrega fallida se reintenta con backoff exponencial hasta max_attempts, tras lo cual el mensaje se descarta. Un mensaje que quedó en processing por un worker que se cayó se reclama una vez que pasa claim_timeout.

Procesa un solo lote y termina (útil para tareas programadas o pruebas):

php artisan outbox:work --once

Recorta los mensajes ya entregados de forma programada:

use Illuminate\Support\Facades\Schedule;

Schedule::command('outbox:prune')->daily();   // conserva las últimas `prune.retention_hours`

Entrega de mensajes al exterior

Cómo llega un mensaje al mundo exterior depende de un publisher. De fábrica el paquete incluye EventPublisher, que convierte cada mensaje en un evento OutboxMessageReady que puedes escuchar:

use Webrek\Outbox\Events\OutboxMessageReady;

Event::listen(OutboxMessageReady::class, function (OutboxMessageReady $event) {
    $message = $event->message;

    Http::post('https://example.test/hooks', $message->payload)->throw();
});

La entrega es síncrona: si el listener lanza una excepción, el mensaje se reprograma; si retorna, el mensaje se marca como publicado.

¿Prefieres una clase dedicada? Implementa el contrato y apunta la configuración hacia ella:

use Webrek\Outbox\Contracts\Publisher;
use Webrek\Outbox\Models\OutboxMessage;

class BrokerPublisher implements Publisher
{
    public function publish(OutboxMessage $message): void
    {
        // empuja a Kafka / RabbitMQ / SNS / un endpoint HTTP…
        // lanza una excepción para reintentar, retorna para confirmar.
    }
}
// config/outbox.php
'publisher' => App\Outbox\BrokerPublisher::class,

Observabilidad

El relay dispara eventos de ciclo de vida a los que puedes engancharte para métricas y alertas:

EventoCuándo
OutboxMessagePublishedUn mensaje se entregó correctamente.
OutboxMessageFailedUn intento falló; el mensaje se reintentará.
OutboxMessageDiscardedSe agotó el presupuesto de reintentos; se abandona el mensaje.

Cada uno lleva el OutboxMessage; los eventos de falla también llevan el Throwable.

Recuperación de mensajes descartados

Un mensaje que agota su presupuesto de reintentos se marca como failed y se deja en la tabla para inspección —nunca se descarta en silencio. Una vez que hayas corregido el sistema downstream, regresa los mensajes a pending para que el relay los intente de nuevo con un presupuesto fresco:

php artisan outbox:retry --all          # todos los mensajes descartados
php artisan outbox:retry <id> <id> …    # mensajes específicos

Para distribuir los reintentos de un gran backlog y que no se disparen todos a la vez, sube retry.jitter (0–1) antes de reprocesar.

Inspección del outbox

Observa de un vistazo cuántos mensajes hay en cada estado —y qué tan rezagado está el más antiguo en pending:

php artisan outbox:status

Simularlo en pruebas

Outbox::fake() intercambia el outbox por un registrador en memoria, de modo que las pruebas de tu aplicación pueden verificar qué se publicaría sin escribir en la base de datos ni ejecutar el relay:

use Webrek\Outbox\Facades\Outbox;

Outbox::fake();

$this->post('/orders', [...]);

Outbox::assertPublished('order.placed', fn ($message) => $message->payload['id'] === $order->id);
Outbox::assertPublishedTimes('order.placed', 1);
Outbox::assertNothingPublished();   // o verifica que nada se haya filtrado

Configuración

return [
    'connection' => env('OUTBOX_CONNECTION'),   // misma conexión que tus datos de negocio
    'table' => 'outbox_messages',
    'publisher' => Webrek\Outbox\Publishers\EventPublisher::class,
    'max_attempts' => 10,                        // intentos antes de descartar
    'batch_size' => 100,                         // mensajes reclamados por pasada del relay
    'claim_timeout' => 300,                       // segundos antes de reclamar un mensaje atorado
    'retry' => [
        'base_seconds' => 10,                     // delay = base * multiplier^(attempt - 1)
        'max_seconds' => 3600,
        'multiplier' => 2,
        'jitter' => 0.0,                          // 0–1: distribuye los reintentos para evitar un thundering herd
    ],
    'prune' => [
        'retention_hours' => 168,
    ],
];

Requisitos

ComponenteVersión
PHP8.2+
Laravel12.x / 13.x
Base de datosCualquiera con transacciones (PostgreSQL, MySQL/MariaDB, SQLite, SQL Server)

Pruebas

composer install
composer test

Contribuir

Consulta CONTRIBUTING.md.

Seguridad

Revisa la política de seguridad antes de reportar una vulnerabilidad.

Licencia

Publicado bajo la licencia MIT.