Symfony Messenger ack is not a payment fence
Redis transport redelivers after a crash. Horizon has the same rule. Ack after the provider call is how you charge twice.
A card was charged twice on a Symfony host that already ran Horizon on Laravel. Laravel Horizon and Symfony Messenger both sit on Redis in this stack. Delivery is at least once. A php-fpm worker in the queue container died after the provider answered 200 and before the bus acked the message. Redis gave it to the next consumer. The handler had no fence. The card was charged again.
Users saw two statements. The worker log showed one success and one retry. Ack after the HTTP call is how the second charge waits. I do not treat unique middleware as the fence. It covers overlap of two running workers. It does not cover a retry after SIGKILL.
The fence is a table with a unique key of order id plus step name. Insert before the HTTP call. If the insert hits unique, return and ack.
Ack last, claim first
The local insert sits in a transaction. The provider call sits after the key is claimed. The provider reference is stored next to the key. A replay reuses that id. Messenger retries stay bounded. Three attempts, then failure transport and an alert. The job timeout is shorter than the Redis reservation.
The same pattern is the Horizon note on this site. Symfony is not a different product problem. It is the same Redis list with a different worker binary.
I compared auto-ack on the Redis transport with ack after the handler. Auto-ack before the HTTP call loses the message if the process dies mid-call. Ack after the call without a fence duplicates the call. Claim in MySQL first, HTTP second, store the reference, ack third. The failure transport must not re-run the HTTP call without hitting unique.
What stays off the bus
Login, CSRF, and /api/user stay synchronous. A message that must finish before HTML is a slow request with extra failure modes. Contact mail can wait. A cache rebuild can wait. A payment cannot wait without the fence.
Queue, cache, and sessions share Redis. Prefixes stay split so an eviction of a listing slab does not drop a reserved message. That prefix is part of the handler, not a line in the worker log after the incident.
Messenger failed messages looked healthy while the payment table had two rows for one step. I count fence inserts versus provider 200s. If 200s are higher, ack ran after a call that had no unique claim.
What I took from this
I measure provider 200s against unique rows per order step, not Messenger throughput. Redis will redeliver. The second handler run must ack without charging.
I do not trust unique middleware or ack-after-HTTP as a fence. Insert the key first. Store the provider id. Ack last.
The rule I keep: same fence as Horizon, prefixes split from cache, login off the bus. Timeout shorter than the Redis reservation. Three tries, then failure transport.
