Blog
Symfony Messenger — When the Same Message Is Processed Twice
A worker may complete the business operation and still see the message delivered again. The fix is not pretending duplicates cannot happen, but making repeated execution safe.
The order was processed. Why did the message return?
Imagine an application that handles OrderCreated by sending a confirmation, creating an invoice and updating the order. The mail provider accepts the email, but the worker stops before acknowledging completion to the transport. The message may then return even though part of the work succeeded.
This is an illustrative scenario, not a reported GiSoft incident. It exposes a practical difficulty: the provider, application database and message transport can each hold a different part of the outcome. A missing acknowledgement does not undo an email or a committed invoice.
For an asynchronous transport that redelivers unacknowledged work, the gap might look like this:
Moment Business effect Transport
A Provider accepts email Processing
B Worker stops No acknowledgement
C Effect still exists Redelivery possibleThe mechanism depends on the transport and configuration. A lost connection, expired visibility period or redelivery timeout can have different consequences. Messenger does not give every transport, including synchronous handling, one universal delivery guarantee.
A retry after a handler exception and transport redelivery after a worker disappears are also different mechanisms. Both can invoke the same business operation again.
Partial success: email and invoice export
The following excerpts use hypothetical project services. Constructors, property declarations, message definitions and Messenger registration are omitted. Methods such as sendOrderConfirmation() and transaction->run() are application contracts, not Symfony or provider SDK APIs.
This confirmation handler has no duplicate protection:
final class SendOrderConfirmationHandler
{
public function __invoke(SendOrderConfirmation $message): void
{
$order = $this->orders->get($message->orderId);
$this->mailer->sendOrderConfirmation($order);
}
}Assume orders->get() returns an existing order or throws, and the mail adapter submits directly to the provider. If it merely queues another message, the uncertainty moves to that later consumer.
A local “sent” flag cannot close the gap on its own. Recording it before submission risks skipping an email that was never submitted; recording it afterwards leaves room for a duplicate. A provider's idempotency mechanism may deduplicate submission requests under its documented conditions. That is not a universal guarantee that exactly one email reaches the recipient.
Invoice export has a similar failure window:
public function __invoke(CreateInvoice $message): void
{
$invoice = $this->invoiceService->create($message->orderId);
$this->externalAccounting->send($invoice);
$this->repository->markAsExported($invoice);
}The accounting system may accept the invoice before markAsExported() fails. If the configured retry policy retries this execution, the handler starts again, not at its final line. Finding an existing local invoice can avoid another local row, but does not by itself prevent another accounting record. Export needs its own stable identity and a way to check the remote outcome.
Identify the operation, not the retry
There are four separate identities to keep in mind: the transport message, the intended business operation, an execution attempt and the resulting business effect. Two messages can request the same operation. Conversely, the same order can legitimately require several different operations.
Idempotency means that repeating the same operation does not produce additional unintended business effects. It does not require PHP to execute physically once.
Consider an incomplete payment call:
$paymentGateway->charge($order->getAmount());The method belongs to a hypothetical adapter; it does not show currency, authorisation or duplicate handling. A useful key for an authorised payment operation could be charge-OP-2026-00045. Persist that identity when creating the operation, then reuse it for its retries, redeliveries and manual replay. The transport retry counter must not become part of a freshly generated key.
A genuinely new authorised payment attempt needs a different operation ID. An uncertain outcome is not, by itself, a reason to create one. Likewise, invoice-order-123 only identifies the intended invoice if the business allows one such invoice per order; reservation-928 can identify a particular stock reservation.
The provider's key scope, retention period, parameter checks and lookup facilities matter. A random ID can work if it is saved once for the operation and reused; generating a new one on every dispatch defeats deduplication. Local records also need an appropriate retention policy for the period in which replay remains possible. Bind an operation ID to its purpose, entity and relevant parameters; receiving the same ID with a different request is a conflict, not a successful duplicate.
Local transactions need a duplicate boundary
Even a database-only operation can be applied twice:
$stock->decrease(1);
$entityManager->flush();Here $stock is a managed stock entity and decrease(1) changes its quantity. Starting at ten, two committed executions can leave eight rather than nine. Each transaction may be valid in isolation. flush() sends the changes to the database; with an enclosing transaction, its final commit is still separate.
The following GenerateInvoiceHandler shows a sequential duplicate check. It is not a complete concurrency-safe implementation:
final class GenerateInvoiceHandler
{
public function __invoke(GenerateInvoice $message): void
{
$this->transaction->run(function () use ($message): void {
$operationId = $message->operationId;
if ($this->processedOperations->exists($operationId)) {
return;
}
$this->invoiceService->generate($message->orderId);
$this->processedOperations->markProcessed($operationId);
});
}
}For this example, generate() must only make local database changes. Invoice persistence and the completed-operation record must commit or roll back together, using the same transactional connection. The omitted wrapper must actually provide that boundary and flush pending ORM changes before committing.
Two workers can both find no operation record and both enter generate(). The initial exists() check is therefore an optimisation, not the guarantee. One possible implementation enforces a non-null, unique operation key in the database. If inserting the completion record conflicts, the losing transaction must roll back its invoice changes too. Duplicate handling belongs outside that failed transaction, with a usable persistence context and verification of the already committed operation. Unrelated constraint errors must not be treated as successful duplicates.
An atomic claim or suitable locking can be another approach. Neither is supplied by the excerpt. Nor would rolling back local work retract an external request made inside generate().
Where the business really requires one invoice per order, a constraint such as invoice.order_id UNIQUE provides a second, distinct defence. The column must be non-null, and the uniqueness scope must match the model. Partial invoices or credit notes may require a different rule. A database constraint protects local rows, not an email or payment already accepted elsewhere.
Concurrent payment attempts and uncertain results
An in-memory check does not coordinate workers:
if ($order->isPaid()) {
return;
}
$order->markAsPaid();This fragment changes local state; it neither charges a customer nor proves payment. If used as a guard around a payment call, two workers could both read “unpaid” before either writes back and both request a charge.
An atomic conditional update can let one worker claim the work:
UPDATE orders
SET payment_started = true
WHERE id = :id
AND payment_started = false;This parameterised SQL assumes a unique order ID and a non-null Boolean flag initially set to false. Only a worker whose update affects one row may proceed under this claim protocol. Zero rows does not mean “payment succeeded”. The claim must be committed before it is relied on; the other payment rules remain necessary.
The flag does not record an external result or recover a crashed worker. Recovery must distinguish a crash before submission from a crash after the provider accepted the payment. Simply clearing the flag and charging again recreates the original risk. A durable operation record, the same provider key and a defined reconciliation procedure are still needed.
An HTTP timeout likewise means that the outcome may be unknown. The provider might have completed the operation before its response was lost. Check the remote reference or operation ID where the API supports it, and follow that provider's retry contract. If the result cannot be established safely, stop automatic attempts and escalate for investigation.
A normal Doctrine transaction covers participating database changes. It does not make a payment API, mail server or broker roll back with them. Holding a database lock during an HTTP request does not create that guarantee either.
Publication: after commit or through an outbox?
An independent asynchronous transport can expose a message before the associated database transaction commits. For example, the producer persists and flushes an Order inside an open transaction, then dispatches OrderCreated. Another worker queries through a separate connection and cannot yet see the order; the producer commits afterwards. Visibility depends on the transport, isolation and actual transaction participation.
Deferring dispatch until after commit addresses that ordering problem, but an in-memory deferred message can still be lost if the process stops between commit and publication. “After the current bus” is not automatically “after any database transaction”: middleware ordering and transaction ownership matter. Symfony documents the required ordering for dispatch_after_current_bus and doctrine_transaction.
A transactional outbox records the intended publication durably with the business change. This is a conceptual boundary, not executable SQL:
One local database transaction
Save Order
Save OutboxMessage(OrderCreated)
COMMIT
--------------------------------------------
After commit
Publisher -> transport -> consumer
Publication or delivery may repeat
Consumer protects its business operationBoth records must participate in the same suitable database transaction. A separate publisher then reads committed entries and records publication progress. If it publishes successfully but stops before recording that success, it may publish again. The outbox closes the local change/publication-intent gap; it still needs a monitored publisher and duplicate-safe consumers, not an assumption of exactly-once execution.
Keep failure boundaries understandable
A handler with several effects is difficult to recover when the last step fails:
public function __invoke(OrderCompleted $message): void
{
$this->stock->decrease($message->orderId);
$this->invoice->create($message->orderId);
$this->mailer->sendConfirmation($message->orderId);
$this->crm->notify($message->orderId);
}Here $this->stock is an order-level service, unlike the stock entity above: its argument is an order ID. If the CRM call fails after stock, invoice and email operations succeed, a retry may revisit all four steps.
One option is to commit the required local changes with records of follow-up operations, then process those operations independently. This can stay within one application. Each step still needs its own operation identity, duplicate handling and explicit dependencies where ordering matters. Splitting classes alone does not make the work safe.
Retries should be bounded and matched to the error. Temporary unavailability may justify another attempt; invalid input or rejected authorisation usually needs a correction. An uncertain external outcome needs reconciliation. Check the installed Messenger version and exception handling: not every retry mechanism necessarily respects the same attempt limit.
If configured, a failure transport retains failed messages according to the failure policy. Do not assume such a destination exists automatically. Manual replay is another handler execution, not a continuation from the failed line. Before replaying, establish which earlier effects succeeded and keep the existing business operation ID.
Diagnose an operation across all systems
Start with the operation ID, not just the exception. Correlate executions with committed database state, provider references and available transport history. Establish what completed before deciding whether to retry.
Useful structured fields include message type, business operation ID, relevant entity ID, available retry or attempt metadata, worker/process identity, start and finish, transaction outcome and external reference. A Messenger retry counter need not include every broker redelivery or manual execution. Commit, rollback and acknowledgement details require appropriate instrumentation; they are not automatically present in every log.
Check worker restarts, memory limits, deployment overlap and transport timeouts. A redelivery interval shorter than processing time can allow overlapping executions in transports that use it. Graceful shutdown and supported keepalive settings can reduce this risk, but do not replace duplicate handling. Verify options against the installed transport and version.
Do not log complete entities, message payloads, credentials, full payment details or unnecessary personal data.
Test repeated execution and partial success
The following Pest excerpt checks one operation twice in sequence. Its omitted setup must define $orderId, $operationId, $handler and $invoiceRepository inside the test callback, using a known order and an isolated database. The message class and repository method are project-specific, not Pest helpers. Named arguments require PHP 8 or later; the installed Pest version has its own requirements.
it(
'does not create another invoice on retry',
function (): void {
// Test data and dependency setup omitted.
$message = new GenerateInvoice(
orderId: $orderId,
operationId: $operationId,
);
$handler($message);
$handler($message);
$invoices = $invoiceRepository->findByOrderId($orderId);
expect($invoices)->toHaveCount(1);
},
);This verifies one resulting invoice in the tested sequential case. It does not exercise transport acknowledgement, two simultaneous transactions or real provider behaviour. The test's persistence setup must flush as intended and read the database, not merely inspect an in-memory collection.
Additional focused cases should cover:
- Two workers using separate database connections and the same operation ID, including the unique-constraint conflict and rollback.
- A fake provider accepting the operation, followed by a local failure. Retrying must reuse the key and follow the documented lookup or deduplication path.
- An ambiguous timeout and manual replay with the original operation identity.
- The actual business invariant: one invoice, one intended stock decrement or one authorised payment operation.
A fake verifies the application's protocol, not a provider's guarantees. Contract tests against a suitable provider test environment can check integration assumptions without treating them as proof of exactly-once delivery.
The useful target is a safe business result even when processing repeats. Identify the operation, enforce local invariants and make uncertain external outcomes recoverable. A message being handled twice need not become a customer being charged twice.
Technical references
- Symfony Messenger 7.4: transports, retries and transactional middleware — https://symfony.com/doc/7.4/messenger.html
- Doctrine ORM: transactions and concurrency — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/reference/transactions-and-concurrency.html
- AWS: transactional outbox, atomic recording and duplicate publication — https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html
- Stripe: an example of provider-specific idempotency rules, not a universal API contract — https://docs.stripe.com/api/idempotent_requests
