Uma aplicação pode salvar corretamente um pedido e, ainda assim, não notificar o sistema de pagamentos, o serviço de estoque ou uma fila de jobs. Também pode publicar o aviso e falhar antes de confirmar o pedido. Em ambos os casos, os sistemas ficam em desacordo. O padrão transactional outbox em PHP resolve essa dupla gravação fazendo com que a alteração de negócio e o registro do evento sejam confirmados juntos na mesma transação do banco de dados.
A outbox não torna a entrega instantânea nem garante, por si só, que um consumidor processe um evento uma única vez. Seu objetivo é evitar que um evento confirmado dependa de uma segunda operação de gravação que poderia falhar separadamente. Para aplicá-la corretamente, também é preciso projetar a leitura concorrente, as novas tentativas, a idempotência e a operação diária.
O problema de gravar no banco de dados e publicar um evento

Suponha que uma operação confirme uma compra e, em seguida, publique OrderPlaced em uma fila. Se o processo PHP terminar entre as duas ações, a compra existe, mas ninguém recebe o evento. Se o evento for publicado primeiro e a transação do banco de dados for revertida, o consumidor poderá agir sobre uma compra inexistente.
Uma transação SQL normalmente também não abrange uma chamada a uma fila, uma API externa ou um broker. Tentar coordenar manualmente as duas operações não elimina a janela de falha: apenas muda a ordem em que ela aparece. O padrão transactional outbox evita a dupla gravação não atômica ao incluir, em uma única transação, a alteração de negócio e uma linha que representa o evento a ser publicado.
Design mínimo da tabela outbox
A tabela deve armazenar informações suficientes para que outro processo possa identificar, entregar e rastrear o evento. Um design inicial pode incluir:
- Identificador único: estável e utilizável como chave de idempotência, por exemplo, um UUID.
- Tipo de evento: como
OrderPlaced, idealmente com uma versão de schema se o formato evoluir. - Payload: dados serializados necessários ao consumidor, sem incluir informações confidenciais desnecessárias.
- Data de criação: útil para ordenar, medir a antiguidade e diagnosticar atrasos.
- Status e acompanhamento: por exemplo, pendente, reservado ou publicado; tentativas, último erro e data da última tentativa.
- Dados da reserva: identificador do worker e vencimento do lease, caso um lease seja usado para coordenar consumidores.
O schema específico depende do mecanismo de banco de dados e do volume. Adicione índices que deem suporte à consulta de eventos pendentes e evite carregar a tabela inteira para encontrar trabalho. Defina também por quanto tempo manter linhas publicadas e erros; não transforme a outbox em um arquivo por tempo indeterminado por padrão.
Gravar o negócio e o evento em uma única transação
Em PHP, o código que modifica o estado de negócio e adiciona o evento deve compartilhar a mesma conexão e transação. Este exemplo mostra a sequência, não um schema SQL universal:
$pdo->beginTransaction();
try {
// Inserir ou atualizar o pedido usando esta conexão.
$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;
}
Se a transação for confirmada, os dois registros serão gravados; se falhar, nenhum dos dois deverá ser considerado confirmado. Não publique a mensagem no broker dentro da transação na expectativa de que isso torne a operação atômica: a chamada externa não participa do commit SQL.
O UUID do exemplo identifica o evento criado nessa transação, mas não evita, por si só, duplicatas se a solicitação de negócio completa for repetida. Se esse risco for relevante — por exemplo, se uma solicitação puder ser repetida após um timeout —, use uma chave de idempotência da solicitação ou uma restrição de unicidade na operação de negócio. Ao reconhecer a repetição, recupere o resultado e o evento já associados à operação original, em vez de criar outro evento com um UUID diferente.
Processar eventos pendentes com concorrência e recuperação
Um worker consulta um lote limitado de eventos elegíveis, reserva-os e os publica no destino. A reserva deve impedir que vários workers processem simultaneamente o mesmo evento ou, pelo menos, permitir que essa duplicação seja tratada com segurança. As opções dependem do mecanismo: bloqueio de linhas, atualização condicional de status ou leases com vencimento. Não há uma consulta portável que resolva corretamente todos os casos; verifique a semântica de bloqueio e isolamento do banco de dados utilizado.
Uma estratégia comum é reservar linhas por um período limitado, publicar fora de uma transação longa e marcá-las como publicadas após receber a confirmação do destino. Se o processo cair depois de publicar, mas antes de atualizar o status, o lease vencerá e outro worker poderá publicar o evento novamente. Se o evento for marcado como publicado antes da publicação e o processo cair, ele poderá ser perdido. Por isso, as tentativas de publicação podem oferecer entrega pelo menos uma vez, não exatamente uma vez: podem gerar duplicatas, mas não garantem que o destino chegue a receber o evento. A entrega efetiva depende da política de novas tentativas e de o destino voltar a ficar disponível antes que elas se esgotem, ou de uma intervenção para recuperar o evento.
Use lotes pequenos ou configuráveis e limite o tempo de cada execução. Manter bloqueios SQL enquanto se aguarda uma rede externa pode degradar o banco de dados. O tamanho do lote, a duração do lease e a frequência de polling devem ser ajustados com base em medições: um lote grande nem sempre melhora o desempenho e pode aumentar a latência ou dificultar a recuperação.
Idempotência, novas tentativas e eventos travados
O consumidor deve tolerar que o mesmo evento chegue mais de uma vez. Para um efeito de negócio no mesmo banco de dados, é possível registrar o identificador do evento em uma tabela de eventos processados com uma restrição única e aplicar o efeito na mesma transação que registra esse identificador. Assim, se a transação for confirmada, o identificador e o efeito serão registrados juntos; se falhar, será possível tentar novamente sem deixar um deles confirmado sem o outro.
Esse mecanismo não torna automaticamente idempotentes os efeitos externos não transacionais. Se o consumidor chamar uma API, enviar um e-mail ou executar outra ação fora do próprio banco de dados, uma falha entre o efeito externo e o registro local poderá provocar uma repetição. Use uma chave de idempotência aceita pelo destino, uma coordenação específica ou um fluxo compensatório adequado; se nenhuma opção existir, reconheça e gerencie explicitamente o risco de duplicação.
Implemente novas tentativas com espera crescente e limites razoáveis, distinguindo erros transitórios — por exemplo, um timeout — de erros permanentes, como um payload inválido. Armazene o último erro de forma útil para investigação, mas sem despejar segredos nem dados pessoais desnecessários nos logs. Depois que as tentativas se esgotarem, mantenha o evento em um status de falha visível; não o exclua silenciosamente.
A recuperação deve ser explícita: identificar a causa, corrigi-la e decidir se o mesmo evento será tentado novamente ou se será necessária uma intervenção. Antes de retomar, confirme que o consumidor é idempotente. Um botão de “reprocessar” sem controles pode repetir efeitos externos ou alterar a ordem esperada. Documente quem pode fazer isso e o que fica registrado.
Monitoramento, retenção e proteção de dados
Monitore o número de eventos pendentes, a idade do evento pendente mais antigo, as tentativas com falha, a taxa de publicação e o tempo entre a criação e a entrega. Uma fila em crescimento pode indicar indisponibilidade do destino, consumidores parados, erros persistentes ou capacidade insuficiente. Um alerta baseado apenas no número total pode ocultar atrasos; a idade ajuda a entender se o trabalho está progredindo.
A tabela crescerá a cada evento. Defina uma política de retenção para os eventos publicados e com falha e exclua-os em lotes para evitar operações de manutenção muito pesadas. Antes de limpar os dados, preserve o que for necessário para auditoria, suporte ou investigação, conforme os requisitos do negócio. Restrinja o acesso ao payload, criptografe ou minimize os dados confidenciais quando apropriado e estabeleça uma política de exclusão coerente com a retenção.
Quando usar outbox e quando evitar sua complexidade

A outbox agrega valor quando uma confirmação de negócio deve iniciar uma ação assíncrona confiável, há possibilidade de falhas entre sistemas e perder a notificação teria consequências. É especialmente útil quando a aplicação já tem um banco de dados transacional e precisa se integrar a uma fila ou a serviços externos sem vincular a disponibilidade deles a cada solicitação.
Ela pode ser desnecessária para tarefas dispensáveis ou que possam ser reconstruídas, quando basta consultar periodicamente o estado do negócio ou quando a fila e a persistência oferecem uma transação integrada adequada para esse fluxo. Avalie essa alternativa considerando seus limites reais: não presuma que dois serviços são atômicos apenas por fazerem parte do mesmo produto.
O padrão também tem custos: uma tabela adicional, um worker, novas tentativas, gerenciamento de estados, retenção e suporte operacional. Evite adicionar um sistema de estados complexo antes de conhecer as necessidades. Comece com identificadores, status, datas, tentativas e uma reserva segura; amplie o design apenas diante de requisitos concretos de ordenação, volume, auditoria ou recuperação. A decisão correta é a opção mais simples que preserve a consistência necessária e permita detectar e resolver falhas.



