OrderPlaced dispatcht, 14:02:11. Doctrine-Rollback, 14:02:11. Zwei Logzeilen mit demselben Zeitstempel, und dazwischen ein Kunde, der eine Bestätigungsmail für eine Bestellung bekommen hat, die es gar nicht gibt. Die meisten von uns hatten schon mal eine Version dieser Nacht. Du speicherst eine Zeile und schickst eine Nachricht an AMQP oder SQS, die beiden Operationen sind keine atomare Einheit, und früher oder später tut dir die Lücke dazwischen weh. Meine Haltung zu Symfony 8.2 ist einfach. Von allem in diesem Release ist die Transactional Outbox für Teams im Alltag am wichtigsten, und ich finde, sie sollte der Standardweg werden, Messenger an einen Broker anzubinden. Sie sollte nicht der clevere Kniff sein, zu dem du nach dem dritten Incident greifst.

terminal
php bin/console messenger:consume db_outbox
# handles the messages sent to "orders" as usual
php bin/console messenger:consume orders

Erst die Fakten. 8.2 soll Ende November 2026 erscheinen, braucht PHP 8.4.0 oder neuer und wird bis Juli 2027 unterstützt. Der Branch bewegt sich noch, also deploy bitte noch nichts davon. Die Outbox stammt von Nicolas Grekas und ist eine Option an einem Transport. Du benennst einen Doctrine-Transport als Outbox, Nachrichten werden dort innerhalb deiner Datenbanktransaktion eingefügt, und ein Relay-Worker leitet sie später an den eigentlichen Broker weiter. Deine Consumer lesen genau wie vorher aus RabbitMQ oder SQS. Ein Rollback reißt die Nachricht zusammen mit den Daten mit. Ist der Broker down, wartet die Nachricht in einer Tabelle, bis er wieder da ist.

Das meiste davon ging auch schon vorher. Wer alles auf den Doctrine-Transport geroutet hat, bekam transaktionale Inserts, und viele Teams haben genau das still und leise gemacht. Der Preis war, dass deine Datenbank zur Queue wurde und jeder Worker sie gepollt hat. Ich habe gesehen, wie eine bescheidene Postgres-Kiste einen ordentlichen Teil ihrer CPU damit verbracht hat, einem Dutzend untätiger Consumer immer wieder dasselbe leere SELECT zu beantworten. Die Outbox behält den nützlichen Teil dieses Tricks, also den Insert, der zusammen mit deinen Daten committet oder zurückgerollt wird, und gibt das eigentliche Queueing an Software zurück, die dafür gebaut ist.

Hier der stärkste Einwand, und ich halte ihn für berechtigt. Du bekommst ein weiteres bewegliches Teil. Das Relay ist ein Prozess, den du beaufsichtigen, überwachen und skalieren musst, und wenn es hängt, stapeln sich deine Nachrichten in einer Tabelle, in die niemand schaut. Auch die Reihenfolge geht verloren: Laut Doku können weitergeleitete Nachrichten in anderer Reihenfolge ankommen. Retries werden ebenfalls komplizierter. Scheitert das Weiterleiten, greifen Retry-Strategie und Failure-Transport des Outbox-Transports, scheitert dagegen der Handler, wird auf dem Ziel-Transport wiederholt. Damit musst du zwei Fehlerpfade verstehen statt einem, und wer schon mal einen Retry-Sturm debuggt hat, weiß, dass jeder zusätzliche Pfad etwas kostet.

Ich lande trotzdem bei der Outbox, denn die Alternative fühlt sich nur deshalb einfacher an, weil ihre Fehler unsichtbar sind. Eine vor dem Commit dispatchte Nachricht liefert dir keine Metrik, wenn etwas schiefgeht. Du erfährst es eine Woche später aus einem Support-Ticket. Ein hängendes Relay liefert dir eine wachsende Zeilenzahl, auf die du einen Alert setzen kannst. Wenn ich die Wahl habe, nehme ich den Fehler, den ich sehen kann. Zur Reihenfolge: Wenn deine Handler über einen verteilten Broker hinweg von der Ankunftsreihenfolge abhängen, waren sie schon vorher fragil, und die Outbox zwingt dich nur, das zuzugeben. Delays funktionieren übrigens weiter. Eine verzögerte Nachricht wartet in der Outbox und geht an den Broker, sobald sie fällig ist.

Im selben Release steckt ein Begleit-Feature, das aus derselben Ehrlichkeit in Sachen Zustellung kommt. Yanick Witschi hat eine claim_check-Option beigesteuert: Alles über einem Byte-Limit, das du pro Transport festlegst, landet in einem PSR-6-Cache-Pool, und der Broker transportiert nur noch eine Referenz mit Prüfsumme. Wieder tauschst du einen impliziten Fehler gegen einen expliziten. Dein Cache-Pool liegt jetzt auf dem Zustellpfad, seine default_lifetime muss Delays, Retries und die Zeit im Failure-Transport abdecken, und wenn ein Claim abläuft, bekommst du eine ClaimCheckNotFoundException, verpackt in eine MessageDecodingFailedException, die durch das normale Retry-Handling läuft. Das ist besser, als wenn ein Broker eine fette Payload stillschweigend ablehnt, solange niemand cache:pool:clear auf dem falschen Pool ausführt.

In der Praxis bedeutet die Umstellung zwei Worker statt einem. Das sind die gesamten Betriebskosten, und ich erkläre im Code-Review lieber einen zusätzlichen Supervisor-Eintrag, als einem Product Manager eine Phantom-Bestellmail zu erklären.

Deshalb würde ich gern von dir hören, vor allem wenn du Messenger mit hohem Volumen betreibst: Würdest du die Outbox für jeden Transport einschalten, der neben einem Datenbank-Write sitzt, oder gibt es Workloads, bei denen du bewusst weiter direkt aus der Transaktion heraus sendest und das Risiko in Kauf nimmst? Falls du welche behalten würdest, möchte ich wissen, welche und warum.