Une application peut enregistrer correctement une commande sans pour autant en informer le système de paiement, le service de gestion des stocks ou une file de tâches. Elle peut aussi publier la notification, puis échouer avant de valider la commande. Dans les deux cas, les systèmes se retrouvent désynchronisés. Le pattern transactional outbox en PHP résout ce problème de double écriture en faisant valider ensemble la modification métier et l’enregistrement de l’événement, au sein de la même transaction de base de données.
L’outbox ne rend pas la livraison instantanée et ne garantit pas, à elle seule, qu’un consommateur ne traite un événement qu’une seule fois. Son objectif est qu’un événement validé ne dépende pas d’une seconde opération d’écriture susceptible d’échouer indépendamment. Pour bien l’appliquer, il faut également concevoir la lecture concurrente, les nouvelles tentatives, l’idempotence et l’exploitation au quotidien.
Le problème de l’écriture en base de données et de la publication d’un événement

Supposons qu’une opération valide un achat, puis publie OrderPlaced dans une file d’attente. Si le processus PHP se termine entre ces deux actions, l’achat existe, mais personne ne reçoit l’événement. Si l’événement est publié en premier et que la transaction de base de données est annulée, le consommateur risque d’agir sur un achat inexistant.
Une transaction SQL n’englobe généralement pas aussi un appel à une file d’attente, à une API externe ou à un broker. Tenter de coordonner manuellement ces deux opérations ne supprime pas la fenêtre de défaillance : cela ne fait que modifier l’ordre dans lequel elle survient. Le pattern transactional outbox évite la double écriture non atomique en intégrant, dans une même transaction, la modification métier et une ligne représentant l’événement à publier.
Conception minimale de la table outbox
La table doit stocker suffisamment d’informations pour qu’un autre processus puisse identifier, livrer et suivre l’événement. Une première conception peut inclure :
- Identifiant unique : stable et utilisable comme clé d’idempotence, par exemple un UUID.
- Type d’événement : comme
OrderPlaced, idéalement avec une version de schéma si le format évolue. - Charge utile : données sérialisées nécessaires au consommateur, sans inclure d’informations sensibles superflues.
- Date de création : utile pour ordonner les événements, mesurer leur ancienneté et diagnostiquer les retards.
- État et suivi : par exemple, en attente, réservé ou publié ; nombre de tentatives, dernière erreur et date de la dernière tentative.
- Données de réservation : identifiant du worker et expiration du bail, si un lease est utilisé pour coordonner les consommateurs.
Le schéma concret dépend du moteur de base de données et du volume. Ajoutez des index adaptés à la recherche des événements en attente et évitez de charger toute la table pour trouver du travail. Définissez également combien de temps conserver les lignes publiées et en échec ; ne transformez pas l’outbox par défaut en archive permanente.
Enregistrer le métier et l’événement dans une seule transaction
En PHP, le code qui modifie l’état métier et ajoute l’événement doit partager la même connexion et la même transaction. Cet exemple illustre la séquence, et non un schéma SQL universel :
$pdo->beginTransaction();
try {
// Insérer ou mettre à jour la commande en utilisant cette connexion.
$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 transaction est validée, les deux enregistrements sont conservés ; si elle échoue, aucun des deux ne doit être considéré comme validé. Ne publiez pas le message auprès du broker à l’intérieur de la transaction en espérant rendre ainsi l’opération atomique : l’appel externe ne participe pas au commit SQL.
L’UUID de l’exemple identifie l’événement créé dans cette transaction, mais n’empêche pas à lui seul les doublons si la requête métier complète est réessayée. Si ce risque est important — par exemple, si une requête peut être répétée après un timeout —, utilisez une clé d’idempotence pour la requête ou une contrainte d’unicité sur l’opération métier. En cas de nouvelle tentative, récupérez le résultat et l’événement déjà associés à l’opération d’origine, au lieu de créer un autre événement avec un UUID différent.
Traiter les événements en attente avec concurrence et récupération
Un worker interroge un lot limité d’événements éligibles, les réserve et les publie vers leur destination. La réservation doit empêcher plusieurs workers de traiter simultanément le même événement, ou au moins permettre de gérer ce doublon de manière sûre. Les options dépendent du moteur : verrouillage des lignes, mise à jour conditionnelle de l’état ou leases avec expiration. Il n’existe pas de requête portable qui résolve correctement tous les cas ; vérifiez la sémantique de verrouillage et d’isolation de la base utilisée.
Une stratégie courante consiste à réserver des lignes pour une durée limitée, à publier en dehors d’une longue transaction, puis à les marquer comme publiées après réception de la confirmation de la destination. Si le processus s’arrête après la publication, mais avant la mise à jour de l’état, le lease expire et un autre worker peut republier l’événement. Si l’événement est marqué comme publié avant sa publication et que le processus s’arrête, il risque d’être perdu. Les tentatives de publication peuvent donc offrir une garantie de livraison au moins une fois, et non exactement une fois : elles peuvent produire des doublons, sans toutefois garantir que la destination recevra effectivement l’événement. La livraison effective dépend de la politique de nouvelles tentatives et du rétablissement de la disponibilité de la destination avant l’épuisement de ces tentatives, ou d’une intervention pour récupérer l’événement.
Utilisez des lots de petite taille ou configurables et limitez la durée de chaque exécution. Maintenir des verrous SQL pendant l’attente d’un service réseau externe peut dégrader les performances de la base de données. La taille des lots, la durée du lease et la fréquence d’interrogation doivent être ajustées à partir de mesures : un lot volumineux n’améliore pas toujours les performances et peut augmenter la latence ou compliquer la récupération.
Idempotence, nouvelles tentatives et événements bloqués
Le consommateur doit tolérer la réception d’un même événement plusieurs fois. Pour un effet métier dans la même base de données, il peut enregistrer l’identifiant de l’événement dans une table des événements traités dotée d’une contrainte d’unicité, puis appliquer l’effet dans la même transaction que l’enregistrement de cet identifiant. Ainsi, si la transaction est validée, l’identifiant et l’effet sont enregistrés ensemble ; si elle échoue, l’opération peut être réessayée sans que l’un soit validé sans l’autre.
Ce mécanisme ne rend pas automatiquement idempotents les effets externes non transactionnels. Si le consommateur appelle une API, envoie un e-mail ou exécute une autre action en dehors de sa base de données, un arrêt entre l’effet externe et l’enregistrement local peut entraîner une répétition. Utilisez une clé d’idempotence prise en charge par la destination, une coordination spécifique ou un processus de compensation adapté ; si aucune de ces options n’existe, reconnaissez et gérez explicitement le risque de doublon.
Appliquez des nouvelles tentatives avec un délai croissant et des limites raisonnables, en distinguant les erreurs transitoires — par exemple un timeout — des erreurs permanentes, comme une charge utile invalide. Enregistrez la dernière erreur de façon à faciliter l’investigation, mais sans déverser de secrets ni de données personnelles inutiles dans les journaux. Une fois le nombre maximal de tentatives atteint, conservez l’événement dans un état d’échec visible ; ne le supprimez pas silencieusement.
La récupération doit être explicite : identifiez la cause, corrigez-la et décidez s’il faut réessayer le même événement ou intervenir. Avant de reprendre le traitement, vérifiez que le consommateur est idempotent. Un bouton « retraiter » sans contrôles peut répéter des effets externes ou modifier l’ordre attendu. Documentez qui est autorisé à le faire et ce qui est consigné.
Supervision, rétention et protection des données
Surveillez le nombre d’événements en attente, l’ancienneté de l’événement en attente le plus ancien, les tentatives échouées, le taux de publication et le délai entre la création et la livraison. Une file d’attente qui s’allonge peut indiquer une panne de la destination, l’arrêt des consommateurs, des erreurs persistantes ou une capacité insuffisante. Une alerte fondée uniquement sur le nombre total peut masquer les retards ; l’ancienneté permet de déterminer si le traitement progresse.
La table grandira avec chaque événement. Définissez une politique de rétention pour les événements publiés et en échec, et effectuez les suppressions par lots pour éviter des opérations de maintenance trop lourdes. Avant toute purge, conservez les éléments nécessaires à l’audit, au support ou aux investigations, selon les exigences métier. Limitez l’accès à la charge utile, chiffrez ou minimisez les données sensibles lorsque cela s’applique, et définissez une politique de suppression cohérente avec la rétention.
Quand utiliser une outbox et quand éviter sa complexité

L’outbox est utile lorsqu’une validation métier doit déclencher une action asynchrone fiable, que des défaillances entre systèmes sont possibles et que la perte de la notification aurait des conséquences. Elle est particulièrement adaptée si l’application dispose déjà d’une base de données transactionnelle et doit s’intégrer à une file d’attente ou à des services externes sans que leur disponibilité conditionne chaque requête.
Elle peut être superflue pour des tâches facultatives ou reconstructibles, lorsqu’il suffit d’interroger périodiquement l’état métier, ou lorsque la file d’attente et le stockage offrent une transaction intégrée adaptée à ce flux. Évaluez cette solution en tenant compte de ses limites réelles : ne supposez pas que deux services sont atomiques simplement parce qu’ils font partie du même produit.
Ce pattern a aussi un coût : une table supplémentaire, un worker, des nouvelles tentatives, la gestion des états, la rétention et le support opérationnel. Évitez d’ajouter un système d’états complexe avant d’en connaître les besoins. Commencez par des identifiants, un état, des dates, un nombre de tentatives et un mécanisme de réservation sûr ; ne faites évoluer la conception qu’en réponse à des exigences concrètes en matière d’ordre, de volume, d’audit ou de récupération. Le bon choix est la solution la plus simple qui préserve la cohérence nécessaire et permette de détecter et de résoudre les défaillances.



