Una aplicación puede guardar correctamente un pedido y, aun así, no notificar al sistema de pagos, al servicio de inventario o a una cola de trabajos. También puede publicar el aviso y fallar antes de confirmar el pedido. En ambos casos, los sistemas quedan en desacuerdo. El patrón transactional outbox en PHP resuelve esta doble escritura haciendo que el cambio de negocio y el registro del evento se confirmen juntos en la misma transacción de base de datos.
La bandeja de salida no hace que la entrega sea instantánea ni garantiza por sí sola que un consumidor procese un evento una única vez. Su objetivo es que un evento confirmado no dependa de una segunda operación de escritura que podría fallar por separado. Para aplicarlo bien, hay que diseñar también la lectura concurrente, los reintentos, la idempotencia y la operación diaria.
El problema de escribir en la base de datos y publicar un evento

Supongamos que una operación confirma una compra y después publica OrderPlaced en una cola. Si el proceso PHP termina entre ambas acciones, la compra existe, pero nadie recibe el evento. Si se publica primero y la transacción de base de datos se revierte, el consumidor puede actuar sobre una compra inexistente.
Una transacción SQL no suele abarcar también una llamada a una cola, una API externa o un broker. Intentar coordinar ambas operaciones manualmente no elimina la ventana de fallo: solo cambia el orden en que aparece. El patrón transactional outbox evita la doble escritura no atómica al incluir en una única transacción el cambio de negocio y una fila que representa el evento por publicar.
Diseño mínimo de la tabla outbox
La tabla debe guardar suficiente información para que otro proceso pueda identificar, entregar y rastrear el evento. Un diseño inicial puede incluir:
- Identificador único: estable y utilizable como clave de idempotencia, por ejemplo un UUID.
- Tipo de evento: como
OrderPlaced, idealmente con una versión de esquema si el formato evoluciona. - Carga útil: datos serializados necesarios para el consumidor, sin incluir información sensible que no haga falta.
- Fecha de creación: útil para ordenar, medir antigüedad y diagnosticar retrasos.
- Estado y seguimiento: por ejemplo, pendiente, reclamado o publicado; intentos, último error y fecha del último intento.
- Datos de reclamación: identificador del trabajador y vencimiento de la concesión, si se usa un lease para coordinar consumidores.
El esquema concreto depende del motor de base de datos y del volumen. Añade índices que apoyen la consulta de eventos pendientes y evita cargar toda la tabla para encontrar trabajo. Define también cuánto tiempo conservar filas publicadas y errores; no conviertas la outbox en un archivo indefinido por omisión.
Guardar el negocio y el evento en una sola transacción
En PHP, el código que modifica el estado de negocio y añade el evento debe compartir la misma conexión y transacción. Este ejemplo muestra la secuencia, no un esquema SQL universal:
$pdo->beginTransaction();
try {
// Insertar o actualizar el pedido usando esta conexión.
$orderId = saveOrder($pdo, $orderData);
$eventId = generateUuid();
$payload = json_encode([
'event_id' => $eventId,
'order_id' => $orderId,
], JSON_THROW_ON_ERROR);
$statement = $pdo->prepare(
'INSERT INTO outbox_events (id, event_type, payload, status, created_at)
VALUES (:id, :type, :payload, :status, CURRENT_TIMESTAMP)'
);
$statement->execute([
'id' => $eventId,
'type' => 'OrderPlaced',
'payload' => $payload,
'status' => 'pending',
]);
$pdo->commit();
} catch (Throwable $error) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
Si la transacción se confirma, quedan guardados ambos registros; si falla, ninguno debe considerarse confirmado. No publiques el mensaje al broker dentro de la transacción esperando que eso haga atómica la operación: la llamada externa no participa en el commit de SQL.
El UUID del ejemplo identifica el evento creado en esa transacción, pero no evita por sí solo duplicados si se reintenta la solicitud de negocio completa. Si ese riesgo es relevante —por ejemplo, una petición puede repetirse tras un timeout—, usa una clave de idempotencia de la solicitud o una restricción de unicidad sobre la operación de negocio. Al reconocer la repetición, recupera el resultado y el evento ya asociados a la operación original, en lugar de crear otro evento con un UUID distinto.
Procesar eventos pendientes con concurrencia y recuperación
Un trabajador consulta un lote acotado de eventos elegibles, los reclama y los publica en el destino. La reclamación debe impedir que varios trabajadores procesen simultáneamente el mismo evento, o al menos permitir que esa duplicación se gestione de forma segura. Las opciones dependen del motor: bloqueo de filas, actualización condicional de estado o leases con vencimiento. No hay una consulta portátil que resuelva correctamente todos los casos; verifica la semántica de bloqueo y aislamiento de la base utilizada.
Una estrategia habitual es reclamar filas durante un periodo limitado, publicar fuera de una transacción larga y marcar como publicadas tras recibir confirmación del destino. Si el proceso cae después de publicar pero antes de actualizar el estado, el lease vence y otro trabajador puede volver a publicar el evento. Si se marca como publicado antes de publicar y el proceso cae, el evento podría perderse. Por eso los intentos de publicación pueden ser al menos una vez, no exactamente una vez: pueden producir duplicados, pero no garantizan que el destino llegue a recibir el evento. La entrega efectiva depende de la política de reintentos y de que el destino vuelva a estar disponible antes de agotar los intentos, o de que se intervenga para recuperar el evento.
Usa lotes pequeños o configurables y limita el tiempo de cada ejecución. Mantener bloqueos SQL mientras se espera una red externa puede degradar la base de datos. El tamaño de lote, la duración del lease y la frecuencia de sondeo deben ajustarse con mediciones: un lote grande no siempre mejora el rendimiento y puede aumentar la latencia o dificultar la recuperación.
Idempotencia, reintentos y eventos atascados
El consumidor debe tolerar que el mismo evento llegue más de una vez. Para un efecto de negocio en la misma base de datos, puede registrar el identificador del evento en una tabla de eventos procesados con una restricción única y aplicar el efecto en la misma transacción que registra ese identificador. Así, si la transacción se confirma, quedan registrados conjuntamente el identificador y el efecto; si falla, puede reintentarse sin dejar uno confirmado sin el otro.
Ese mecanismo no vuelve idempotentes automáticamente los efectos externos no transaccionales. Si el consumidor llama a una API, envía un correo o ejecuta otra acción fuera de su base de datos, una caída entre el efecto externo y el registro local puede provocar una repetición. Usa una clave de idempotencia admitida por el destino, una coordinación específica o un flujo compensatorio adecuado; si ninguna opción existe, reconoce y gestiona explícitamente el riesgo de duplicación.
Aplica reintentos con espera creciente y límites razonables, distinguiendo errores transitorios —por ejemplo, un timeout— de errores permanentes, como una carga inválida. Guarda el último error de forma útil para investigar, pero sin volcar secretos ni datos personales innecesarios en los registros. Tras agotar los intentos, conserva el evento en un estado visible de fallo; no lo borres silenciosamente.
La recuperación debe ser explícita: identificar la causa, corregirla y decidir si se reintenta el mismo evento o se necesita una intervención. Antes de reanudar, confirma que el consumidor es idempotente. Un botón de “reprocesar” sin controles puede repetir efectos externos o alterar el orden esperado. Documenta quién puede hacerlo y qué queda registrado.
Supervisión, retención y protección de datos
Supervisa el número de eventos pendientes, la edad del evento pendiente más antiguo, los intentos fallidos, la tasa de publicación y el tiempo entre creación y entrega. Una cola que crece puede indicar una caída del destino, consumidores detenidos, errores persistentes o capacidad insuficiente. Una alarma basada solo en el número total puede ocultar retrasos; la antigüedad ayuda a entender si el trabajo está progresando.
La tabla crecerá con cada evento. Define una política de retención para los publicados y fallidos, y elimina por lotes para evitar operaciones de mantenimiento demasiado pesadas. Antes de purgar, conserva lo necesario para auditoría, soporte o investigación, según los requisitos del negocio. Limita el acceso a la carga útil, cifra o minimiza los datos sensibles cuando corresponda y fija una política de borrado coherente con la retención.
Cuándo usar outbox y cuándo evitar su complejidad

La outbox aporta valor cuando una confirmación de negocio debe iniciar una acción asíncrona fiable, existen fallos posibles entre sistemas y perder la notificación tendría consecuencias. Es especialmente útil si la aplicación ya tiene una base de datos transaccional y necesita integrarse con una cola o con servicios externos sin acoplar la disponibilidad de estos a cada petición.
Puede ser innecesaria para tareas prescindibles o reconstruibles, cuando basta con sondear periódicamente el estado de negocio, o cuando la cola y la persistencia ofrecen una transacción integrada adecuada para ese flujo. Evalúa esa alternativa con sus límites reales: no asumas que dos servicios son atómicos solo porque estén dentro del mismo producto.
El patrón también tiene costes: una tabla adicional, un trabajador, reintentos, gestión de estados, retención y soporte operativo. Evita añadir un sistema de estados complejo antes de conocer las necesidades. Empieza con identificadores, estado, fechas, intentos y una reclamación segura; amplía el diseño solo ante requisitos concretos de orden, volumen, auditoría o recuperación. La decisión correcta es la opción más sencilla que preserve la consistencia necesaria y permita detectar y resolver fallos.



