Patrón Inbox/Outbox en Laravel: Mensajería Confiable en Sistemas Distribuidos
8 Jan 2026
13 min read
Introducción
Cuando trabajas con microservicios o sistemas distribuidos, uno de los mayores desafíos es garantizar que los mensajes entre servicios se envíen y procesen de manera confiable. ¿Qué pasa si tu aplicación falla justo después de guardar datos en la base de datos pero antes de enviar el evento? ¿Cómo evitas procesar el mismo mensaje dos veces?
Los patrones Inbox y Outbox resuelven estos problemas de forma elegante, garantizando consistencia eventual y procesamiento idempotente de mensajes.
En este artículo, aprenderás a implementar estos patrones en Laravel con ejemplos prácticos que puedes aplicar inmediatamente en tus proyectos.
El Problema: Inconsistencia en Sistemas Distribuidos
Imagina que tienes un e-commerce con microservicios separados:
class OrderController extends Controller
{
public function store(Request $request)
{
// 1. Guardar el pedido en la base de datos
$order = Order::create([
'user_id' => $request->user()->id,
'total' => $request->total,
'status' => 'pending'
]);
// 2. Enviar evento al servicio de inventario
Http::post('https://inventory-service/api/reserve', [
'order_id' => $order->id,
'items' => $request->items
]);
// 3. Enviar evento al servicio de notificaciones
Http::post('https://notification-service/api/send', [
'user_id' => $order->user_id,
'type' => 'order_confirmation'
]);
return response()->json($order);
}
}
Problemas con este código:
✗ Pérdida de mensajes: Si la aplicación falla después del paso 1, los servicios nunca reciben los eventos
✗ Inconsistencia: El pedido está guardado pero el inventario no se reservó
✗ Mensajes duplicados: Si reintentamos, podríamos enviar el evento dos veces
✗ Acoplamiento temporal: El controlador debe esperar las respuestas HTTP
¿Qué es el Patrón Outbox?
El patrón Outbox garantiza que los mensajes se publiquen de manera confiable utilizando la misma transacción de base de datos que el cambio de estado.
Cómo Funciona
- En lugar de publicar eventos directamente, los guardas en una tabla “outbox” dentro de la misma transacción
- Un proceso separado lee la tabla outbox y publica los eventos a tu sistema de mensajería (RabbitMQ, Kafka, SQS, etc.)
- Una vez publicados, los mensajes se marcan como procesados o se eliminan
Beneficio clave: Si la transacción falla, tanto el cambio de estado como los eventos se revierten. Si tiene éxito, los eventos eventualmente se publicarán.
¿Qué es el Patrón Inbox?
El patrón Inbox garantiza que los mensajes se procesen de manera idempotente, evitando duplicados.
Cómo Funciona
- Cuando recibes un mensaje, primero lo guardas en una tabla “inbox” con su ID único
- Verificas si ya fue procesado anteriormente
- Si es nuevo, lo procesas y marcas como procesado
- Si ya fue procesado, lo ignoras
Beneficio clave: Puedes recibir el mismo mensaje múltiples veces sin efectos secundarios duplicados.
Implementando el Patrón Outbox en Laravel
Vamos a implementar el patrón Outbox paso a paso.
Paso 1: Crear la Tabla Outbox
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('outbox_messages', function (Blueprint $table) {
$table->id();
$table->string('aggregate_type'); // Ej: 'order', 'payment'
$table->string('aggregate_id'); // ID del recurso
$table->string('event_type'); // Ej: 'OrderCreated'
$table->json('payload'); // Datos del evento
$table->timestamp('occurred_at');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->index(['processed_at', 'occurred_at']);
});
}
public function down()
{
Schema::dropIfExists('outbox_messages');
}
};
Paso 2: Crear el Modelo Outbox
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class OutboxMessage extends Model
{
protected $fillable = [
'aggregate_type',
'aggregate_id',
'event_type',
'payload',
'occurred_at',
'processed_at',
];
protected $casts = [
'payload' => 'array',
'occurred_at' => 'datetime',
'processed_at' => 'datetime',
];
public function scopePending($query)
{
return $query->whereNull('processed_at')
->orderBy('occurred_at');
}
public function markAsProcessed(): void
{
$this->update(['processed_at' => now()]);
}
}
Paso 3: Crear un Servicio para Registrar Eventos
<?php
namespace App\Services;
use App\Models\OutboxMessage;
use Illuminate\Support\Facades\DB;
class OutboxService
{
/**
* Registrar un evento en el outbox.
*/
public function record(
string $aggregateType,
string $aggregateId,
string $eventType,
array $payload
): OutboxMessage {
return OutboxMessage::create([
'aggregate_type' => $aggregateType,
'aggregate_id' => $aggregateId,
'event_type' => $eventType,
'payload' => $payload,
'occurred_at' => now(),
]);
}
/**
* Registrar múltiples eventos en una transacción.
*/
public function recordMany(array $events): void
{
DB::transaction(function () use ($events) {
foreach ($events as $event) {
$this->record(
$event['aggregate_type'],
$event['aggregate_id'],
$event['event_type'],
$event['payload']
);
}
});
}
}
Paso 4: Usar el Outbox en tu Lógica de Negocio
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Services\OutboxService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class OrderController extends Controller
{
public function __construct(
private OutboxService $outboxService
) {}
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'total' => 'required|numeric',
]);
// Todo en una transacción atómica
$order = DB::transaction(function () use ($validated, $request) {
// 1. Crear el pedido
$order = Order::create([
'user_id' => $request->user()->id,
'total' => $validated['total'],
'status' => 'pending',
]);
// 2. Registrar eventos en el outbox (misma transacción)
$this->outboxService->record(
aggregateType: 'order',
aggregateId: (string) $order->id,
eventType: 'OrderCreated',
payload: [
'order_id' => $order->id,
'user_id' => $order->user_id,
'items' => $validated['items'],
'total' => $validated['total'],
]
);
$this->outboxService->record(
aggregateType: 'order',
aggregateId: (string) $order->id,
eventType: 'InventoryReservationRequested',
payload: [
'order_id' => $order->id,
'items' => $validated['items'],
]
);
return $order;
});
return response()->json($order);
}
}
Ventajas de esta implementación:
✓ Atomicidad garantizada: Si falla algo, todo se revierte
✓ Respuesta rápida: El controlador no espera publicar eventos
✓ Sin pérdida de mensajes: Los eventos están persistidos
Paso 5: Crear el Procesador de Outbox
<?php
namespace App\Console\Commands;
use App\Models\OutboxMessage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
class ProcessOutboxMessages extends Command
{
protected $signature = 'outbox:process';
protected $description = 'Procesar mensajes pendientes del outbox';
public function handle()
{
OutboxMessage::pending()
->chunk(100, function ($messages) {
foreach ($messages as $message) {
try {
// Publicar el evento
$this->publishEvent($message);
// Marcar como procesado
$message->markAsProcessed();
$this->info("Procesado: {$message->event_type} #{$message->id}");
} catch (\Exception $e) {
$this->error("Error procesando mensaje #{$message->id}: {$e->getMessage()}");
// Opcionalmente: registrar error, implementar reintento, etc.
}
}
});
}
private function publishEvent(OutboxMessage $message): void
{
// Publicar a tu sistema de mensajería
match ($message->event_type) {
'OrderCreated' => Event::dispatch(new \App\Events\OrderCreated($message->payload)),
'InventoryReservationRequested' => Event::dispatch(new \App\Events\InventoryReservationRequested($message->payload)),
default => throw new \Exception("Tipo de evento desconocido: {$message->event_type}")
};
// O publicar a RabbitMQ, Kafka, SQS, etc.
// RabbitMQ::publish($message->event_type, $message->payload);
}
}
Paso 6: Programar el Procesador
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule)
{
// Procesar outbox cada minuto
$schedule->command('outbox:process')
->everyMinute()
->withoutOverlapping();
}
}
Implementando el Patrón Inbox en Laravel
Ahora implementemos el patrón Inbox para procesar mensajes de forma idempotente.
Paso 1: Crear la Tabla Inbox
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('inbox_messages', function (Blueprint $table) {
$table->id();
$table->string('message_id')->unique(); // ID único del mensaje
$table->string('event_type');
$table->json('payload');
$table->timestamp('received_at');
$table->timestamp('processed_at')->nullable();
$table->text('error')->nullable();
$table->timestamps();
$table->index('processed_at');
});
}
public function down()
{
Schema::dropIfExists('inbox_messages');
}
};
Paso 2: Crear el Modelo Inbox
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InboxMessage extends Model
{
protected $fillable = [
'message_id',
'event_type',
'payload',
'received_at',
'processed_at',
'error',
];
protected $casts = [
'payload' => 'array',
'received_at' => 'datetime',
'processed_at' => 'datetime',
];
public function isProcessed(): bool
{
return $this->processed_at !== null;
}
public function markAsProcessed(): void
{
$this->update(['processed_at' => now()]);
}
public function markAsFailed(string $error): void
{
$this->update([
'error' => $error,
'processed_at' => now(),
]);
}
}
Paso 3: Crear el Servicio Inbox
<?php
namespace App\Services;
use App\Models\InboxMessage;
use Illuminate\Support\Facades\DB;
class InboxService
{
/**
* Procesar un mensaje de forma idempotente.
*/
public function processMessage(
string $messageId,
string $eventType,
array $payload,
callable $handler
): bool {
return DB::transaction(function () use ($messageId, $eventType, $payload, $handler) {
// Intentar crear el mensaje en el inbox
$message = InboxMessage::firstOrCreate(
['message_id' => $messageId],
[
'event_type' => $eventType,
'payload' => $payload,
'received_at' => now(),
]
);
// Si ya fue procesado, ignorar (idempotencia)
if ($message->isProcessed()) {
return true;
}
try {
// Ejecutar el handler
$handler($payload);
// Marcar como procesado
$message->markAsProcessed();
return true;
} catch (\Exception $e) {
$message->markAsFailed($e->getMessage());
throw $e;
}
});
}
}
Paso 4: Usar el Inbox para Procesar Eventos
<?php
namespace App\Http\Controllers;
use App\Services\InboxService;
use App\Services\InventoryService;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function __construct(
private InboxService $inboxService,
private InventoryService $inventoryService
) {}
/**
* Webhook para recibir eventos de otros servicios.
*/
public function handle(Request $request)
{
$validated = $request->validate([
'message_id' => 'required|string',
'event_type' => 'required|string',
'payload' => 'required|array',
]);
try {
$this->inboxService->processMessage(
messageId: $validated['message_id'],
eventType: $validated['event_type'],
payload: $validated['payload'],
handler: function ($payload) use ($validated) {
// Procesar según el tipo de evento
match ($validated['event_type']) {
'InventoryReserved' => $this->handleInventoryReserved($payload),
'PaymentCompleted' => $this->handlePaymentCompleted($payload),
default => throw new \Exception("Tipo de evento desconocido")
};
}
);
return response()->json(['status' => 'processed']);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
private function handleInventoryReserved(array $payload): void
{
// Actualizar el estado del pedido
$order = Order::findOrFail($payload['order_id']);
$order->update(['inventory_status' => 'reserved']);
}
private function handlePaymentCompleted(array $payload): void
{
$order = Order::findOrFail($payload['order_id']);
$order->update(['status' => 'paid']);
}
}
Caso Práctico Completo: E-commerce con Microservicios
Veamos un ejemplo completo de cómo los patrones Inbox/Outbox trabajan juntos:
Servicio de Pedidos (Order Service)
<?php
namespace App\Services;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
class OrderService
{
public function __construct(
private OutboxService $outboxService
) {}
public function createOrder(array $data): Order
{
return DB::transaction(function () use ($data) {
// 1. Crear pedido
$order = Order::create([
'user_id' => $data['user_id'],
'total' => $data['total'],
'status' => 'pending',
]);
// 2. Registrar eventos en outbox
$this->outboxService->record(
'order',
(string) $order->id,
'OrderCreated',
[
'order_id' => $order->id,
'user_id' => $order->user_id,
'items' => $data['items'],
'total' => $data['total'],
]
);
return $order;
});
}
public function confirmOrder(int $orderId): void
{
DB::transaction(function () use ($orderId) {
$order = Order::findOrFail($orderId);
$order->update(['status' => 'confirmed']);
$this->outboxService->record(
'order',
(string) $order->id,
'OrderConfirmed',
[
'order_id' => $order->id,
'user_id' => $order->user_id,
]
);
});
}
}
Servicio de Inventario (Inventory Service)
<?php
namespace App\Services;
use App\Models\InventoryReservation;
use Illuminate\Support\Facades\DB;
class InventoryService
{
public function __construct(
private OutboxService $outboxService,
private InboxService $inboxService
) {}
/**
* Procesar solicitud de reserva (desde OrderCreated).
*/
public function handleReservationRequest(string $messageId, array $payload): void
{
$this->inboxService->processMessage(
$messageId,
'OrderCreated',
$payload,
function ($data) {
DB::transaction(function () use ($data) {
// Reservar inventario
$reservation = InventoryReservation::create([
'order_id' => $data['order_id'],
'items' => $data['items'],
'status' => 'reserved',
]);
// Publicar evento de confirmación
$this->outboxService->record(
'inventory',
(string) $reservation->id,
'InventoryReserved',
[
'order_id' => $data['order_id'],
'reservation_id' => $reservation->id,
]
);
});
}
);
}
}
Optimizaciones y Mejores Prácticas
1. Limpieza de Mensajes Procesados
<?php
namespace App\Console\Commands;
use App\Models\OutboxMessage;
use App\Models\InboxMessage;
use Illuminate\Console\Command;
class CleanupMessages extends Command
{
protected $signature = 'messages:cleanup {--days=7}';
protected $description = 'Limpiar mensajes procesados antiguos';
public function handle()
{
$days = $this->option('days');
// Limpiar outbox
$deletedOutbox = OutboxMessage::whereNotNull('processed_at')
->where('processed_at', '<', now()->subDays($days))
->delete();
// Limpiar inbox
$deletedInbox = InboxMessage::whereNotNull('processed_at')
->where('processed_at', '<', now()->subDays($days))
->delete();
$this->info("Limpiados {$deletedOutbox} mensajes del outbox y {$deletedInbox} del inbox");
}
}
2. Reintentos con Backoff Exponencial
<?php
namespace App\Services;
use App\Models\OutboxMessage;
class OutboxProcessor
{
private const MAX_RETRIES = 5;
public function processMessage(OutboxMessage $message): void
{
$attempt = $message->retry_count ?? 0;
try {
$this->publish($message);
$message->markAsProcessed();
} catch (\Exception $e) {
if ($attempt >= self::MAX_RETRIES) {
$message->update([
'error' => $e->getMessage(),
'failed_at' => now(),
]);
return;
}
// Backoff exponencial: 1s, 2s, 4s, 8s, 16s
$delay = pow(2, $attempt);
$message->update([
'retry_count' => $attempt + 1,
'next_retry_at' => now()->addSeconds($delay),
]);
}
}
}
3. Monitoreo y Alertas
<?php
namespace App\Console\Commands;
use App\Models\OutboxMessage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class MonitorOutbox extends Command
{
protected $signature = 'outbox:monitor';
protected $description = 'Monitorear mensajes atascados en el outbox';
public function handle()
{
// Alertar si hay mensajes muy antiguos sin procesar
$stuckMessages = OutboxMessage::whereNull('processed_at')
->where('occurred_at', '<', now()->subHours(1))
->count();
if ($stuckMessages > 0) {
Log::warning("Hay {$stuckMessages} mensajes atascados en el outbox");
// Enviar alerta a Slack, email, etc.
}
$this->info("Monitoreo completado: {$stuckMessages} mensajes atascados");
}
}
4. Particionamiento de Tablas
Para aplicaciones de alto volumen, considera particionar las tablas por fecha:
// En tu migración
Schema::create('outbox_messages', function (Blueprint $table) {
$table->id();
// ... otros campos
$table->timestamp('occurred_at');
// Índice para particionamiento
$table->index('occurred_at');
});
// Crear particiones mensuales (depende de tu DBMS)
// PostgreSQL ejemplo:
DB::statement("
CREATE TABLE outbox_messages_2026_01 PARTITION OF outbox_messages
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
");
Cuándo Usar los Patrones Inbox/Outbox
✓ Usa Inbox/Outbox cuando:
- Trabajas con arquitectura de microservicios
- Necesitas consistencia eventual entre servicios
- Requieres mensajería confiable sin pérdida de datos
- Implementas Event Sourcing o CQRS
- Tienes comunicación asíncrona entre servicios
- Necesitas garantizar idempotencia en el procesamiento
✗ No uses Inbox/Outbox cuando:
- Tienes una aplicación monolítica simple
- No necesitas consistencia eventual
- La comunicación síncrona es suficiente
- El volumen de mensajes es muy bajo
- La complejidad adicional no aporta valor
Ventajas del Patrón Inbox/Outbox
✓ Atomicidad: Cambios de estado y eventos en la misma transacción
✓ Confiabilidad: Sin pérdida de mensajes
✓ Idempotencia: Procesamiento seguro de duplicados
✓ Desacoplamiento: Servicios independientes
✓ Resiliencia: Tolerancia a fallos temporales
✓ Auditabilidad: Registro completo de eventos
Desventajas y Consideraciones
✗ Complejidad adicional: Más tablas y lógica que mantener
✗ Latencia: Los eventos no se publican instantáneamente
✗ Almacenamiento: Crecimiento de tablas que requiere limpieza
⚠️ Cuidado con: Mensajes atascados, falta de monitoreo, falta de limpieza
Alternativas y Herramientas
Paquetes Laravel
- Laravel Transactional Events: Eventos solo después de commits exitosos
- Spatie Event Sourcing: Event Sourcing completo
Conclusión
Los patrones Inbox y Outbox son herramientas esenciales para construir sistemas distribuidos confiables. Aunque añaden complejidad, los beneficios en términos de consistencia, confiabilidad e idempotencia son invaluables en arquitecturas de microservicios.
Recapitulación:
✓ Outbox garantiza que los eventos se publiquen de manera confiable
✓ Inbox garantiza procesamiento idempotente sin duplicados
✓ Ambos patrones trabajan juntos para consistencia eventual
✓ Se implementan fácilmente en Laravel con tablas y servicios
✓ Requieren monitoreo, limpieza y manejo de reintentos
Si estás construyendo microservicios o sistemas distribuidos con Laravel, estos patrones te ayudarán a crear una arquitectura más robusta y confiable.
Artículos Relacionados
Si te gustó este artículo, te recomiendo leer:
- Patrón Factory en Laravel - Crea objetos complejos de forma elegante
- Guía Completa de Principios SOLID - Aprende los 5 principios fundamentales
- Principio de Inversión de Dependencias - Desacopla tu código con abstracciones
Happy coding!
C O M E N T A R I O S
Deja un comentario
Tu email no será publicado. Los campos marcados con * son obligatorios.
Cargando comentarios...
☕ ¿Te ha sido útil este artículo?
Apóyame con un café mientras sigo creando contenido técnico
☕ Invítame un café