De transactional outbox in Laravel zonder de exactly-once-mythe
Een concrete Laravel-outbox met atomische writes, veilige claims, retries, idempotente consumers, ordering, contractversies en beheer.
Wanneer een ordercommit slaagt maar publiceren van OrderPlaced mislukt, hoort de rest van het systeem nooit van een echte order. Wanneer publicatie slaagt en de worker crasht vóór succes is opgeslagen, kan hetzelfde event tweemaal komen. De transactional outbox sluit het eerste gat en maakt het tweede beheersbaar. Exactly-once-delivery ontstaat er niet door.
Schrijf businessstate en intentie atomisch
Binnen één databasetransactie schrijf ik de aggregatewijziging en een outboxrij. Die bevat een uniek event-ID, aggregate-type en ID, eventtype, schemaversie, tijdstip en JSON-payload met een stabiel publiek contract. Een Eloquent-model serialiseer ik niet; dat koppelt consumers aan interne kolommen.
Het event beschrijft iets dat is gebeurd, zoals OrderPlaced, en geen opdracht zoals SendOrderEmail. Consumers bepalen hun eigen actie. De producer blijft zo verantwoordelijk voor domeinwaarheid en meerdere consumers kunnen reageren zonder de ordertransactie te veranderen.
DB::transaction(function () use ($command) {
$order = Order::place($command);
$order->save();
OutboxMessage::create([
'id' => (string) Str::uuid(),
'aggregate_type' => 'order',
'aggregate_id' => (string) $order->id,
'event_type' => 'order.placed',
'schema_version' => 1,
'payload' => OrderPlacedPayload::from($order),
'occurred_at' => now(),
]);
});Claim berichten zonder de tabel te blokkeren
Publishers claimen kleine batches met row locks en slaan rijen over die een andere worker al heeft. Een claim heeft eigenaar en vervaltijd, zodat een gecrashte worker niets permanent vasthoudt. Publicatie gebeurt buiten een lange transactie; daarna registreert de worker succes of een nieuwe retrytijd.
Er blijft een crashwindow tussen acceptatie door de broker en het opslaan van succes. Dan wordt hetzelfde event opnieuw verstuurd. Daarom staat het event-ID in de publieke envelope en moet iedere consumer idempotent zijn.
- Alarmeer op leeftijd van het oudste ongepubliceerde bericht
- Gebruik exponentiële backoff met maximum
- Zet poison messages in een zichtbare foutstatus
- Laat één slecht event latere events niet eindeloos blokkeren
Maak consumers idempotent aan hun eigen grens
Een consumer bewaart verwerkte event-ID’s in dezelfde transactie als zijn lokale effect. Bij een projection committen de projection en het processed-ID samen. Voor een externe provider gebruik ik het event- of operation-ID als idempotency key wanneer dat wordt ondersteund en reconcilieer ik onzekere uitkomsten.
Een cachecheck is niet genoeg: twee workers kunnen racen en cache-items verlopen. Idempotency hoort in duurzame state binnen de consumer. Iedere consumer kan een eigen bewaartermijn hebben op basis van retry- en replaybeleid.
Beloof alleen de ordering die nodig is
Globale eventvolgorde is duur en meestal betekenisloos. Waar nodig bewaar ik volgorde per aggregate met versienummers en een partition key. Consumers weigeren of parkeren een gat in plaats van stil versie 5 vóór versie 4 toe te passen.
Veel consumers hebben geen strikte volgorde nodig wanneer hun operatie vanzelf idempotent is of actuele bronstate leest. Ik schrijf die keuze op. Verborgen aannames over ordering veroorzaken de lastigste event-driven bugs.
Evolueer en beheer het contract
Schemawijzigingen zijn standaard additief. Consumers negeren onbekende velden en kunnen onbekende enumwaarden verwerken. Contractvoorbeelden draaien in CI tegen de producer-serializer. Wanneer betekenis verandert, publiceer ik een nieuwe eventversie en ondersteun ik beide tijdens migratie.
Operationeel volg ik throughput, retries, foutreden, oudste leeftijd, tabelgrootte, cleanup-lag en consumerdelay. Voltooide rijen worden na de replay- en auditperiode verwijderd of gearchiveerd. Cleanup is onderdeel van het ontwerp.
Praktische checklist
- Schrijf state en outbox in één transactie
- Publiceer een stabiele payload en geen Eloquent-model
- Gebruik leases en veilige retries
- Eis duurzame idempotency van iedere consumer
- Definieer ordering alleen waar nodig
- Alarmeer op oudste ongepubliceerde leeftijd en ruim op
