~/blog/when-symfony-messenger-is-the-wrong-tool
When Symfony Messenger Is the Wrong Tool
Symfony Messenger is powerful, but not every slow operation needs a queue. I explore six situations where asynchronous processing creates more problems than it solves, covering transactions, retries, idempotency, and simpler alternatives with practical PHP examples.
- Published
- Reading time
- 15 min
Whenever someone suggests moving a slow operation to Symfony Messenger, I usually ask:
What can the application truthfully tell the user before that operation finishes?
If we can't answer that, we probably haven't thought through the queue yet.
Symfony Messenger is a useful component. I use it for more than background jobs: synchronous dispatching, middleware, and separating messages from their handlers can all make sense on their own.
The trouble starts when using a message bus and making an operation asynchronous become the same decision. They're not.
Before adding a queue, there are two things worth establishing:
- Does a message bus give us a useful boundary?
- Can this particular operation safely finish later, in a different process?
Moving work out of an HTTP request can improve response times and give us better control over processing capacity. But it also changes when failures happen, what the caller can assume, and how the application keeps its state consistent.
Those are fairly significant trade-offs for something that sometimes begins with "this endpoint is a bit slow."
Here are six situations where I'd question the decision to use Messenger asynchronously.
The PHP examples are deliberately simplified. I've omitted application-specific dependencies and configuration so the underlying design decisions are easier to see.
1. The caller needs a decision before continuing
Take an endpoint that approves a refund.
Before approval, the application has to determine whether the refund is eligible, whether the user has permission, and whether the requested state change is allowed.
Now consider this:
Queueing the decision and immediately reporting success:
// ApproveRefund is routed to an asynchronous transport.
$bus->dispatch(new ApproveRefund(
refundId: $refundId,
actorId: $actorId,
));
return new JsonResponse([
'status' => 'approved',
]);We've returned approved, but the handler may not even have started.
The refund could turn out to be ineligible. The user might not have permission. Or the message could sit in the queue because the workers are unavailable.
None of that matches what the API just told the client.
In this case, I'd keep the decision synchronous:
$result = $approveRefund->execute(
refundId: $refundId,
actorId: $actorId,
);
return new JsonResponse([
'refund_id' => $result->refundId(),
'status' => $result->status(),
]);Whether $approveRefund is an application service or a synchronous Messenger handler doesn't matter much to this particular problem. The response now describes a decision the application has actually made.
Even then, it's important to distinguish internal refund approval from completion by the payment provider. Those are separate outcomes and may need separate states.
There are cases where the product genuinely requires delayed processing. That's fine, but the API should represent it properly.
For example:
$operation = $refundRequests->accept(
refundId: $refundId,
actorId: $actorId,
);
return new JsonResponse(
[
'operation_id' => $operation->id(),
'status' => $operation->status(),
],
202,
[
'Location' => '/refund-operations/' . $operation->id(),
],
);Here, accept() needs to persist the request durably and arrange for reliable processing. Returning 202 Accepted doesn't help much if the request can disappear before a worker sees it.
The operation also needs a proper lifecycle. Depending on the domain, that might include pending, completed, failed, rejected, or awaiting external confirmation.
Clients need some way to discover the final outcome, whether through polling, notifications, or another mechanism.
It's also possible for processing to finish before the client receives the response. So there's no reason to hardcode pending when the recorded operation state is already available.
Notice how much more we're designing now: an operation resource, status transitions, recovery behavior, and a way for clients to check what happened.
That's justified when asynchronous behavior is part of the product. It feels excessive when the original goal was simply to shave some time off an HTTP response.
2. The real problem is inefficient work
"This operation is slow. Let's put it in Messenger."
I've heard variations of that argument, and sometimes it's the right answer. But the first thing I'd want to know is why the operation is slow.
Consider this report generator:
#[AsMessageHandler]
final class GenerateCustomerReportHandler
{
public function __invoke(
GenerateCustomerReport $message,
): void {
$customers = $this->customers->findAll();
foreach ($customers as $customer) {
$orders = $this->orders->findForCustomer(
$customer->id(),
);
$this->report->append($customer, $orders);
}
}
}Moving this handler to a worker gets the work out of the HTTP request, but it hasn't improved the work itself.
We're still loading every customer and querying orders repeatedly. There's a potential N+1 problem, and depending on the data volume, memory consumption could become a problem too.
Now imagine increasing the worker count. Several workers may start running that same expensive query pattern against the database.
Congratulations, we've scaled the problem.
Before adding concurrency, I'd investigate:
- Query plans, indexes, and repeated database access
- Data volume and memory usage
- Whether aggregation can happen in SQL
- Batch sizes and downstream capacity
- Whether the report needs a consistent snapshot
For this kind of report, aggregating data in the database and fetching bounded batches may be a better starting point than loading entire entity graphs.
Something along these lines:
$afterId = 0;
do {
$rows = $reportRows->fetchBatch(
tenantId: $message->tenantId,
afterCustomerId: $afterId,
limit: 500,
);
foreach ($rows as $row) {
$writer->append($row);
}
if ($rows !== []) {
$lastRow = $rows[array_key_last($rows)];
$afterId = $lastRow->customerId;
}
} while (count($rows) === 500);This uses keyset pagination. It assumes positive integer customer IDs, a single result row per customer, and stable ascending ordering.
The repository needs to enforce the cursor and limit, with appropriate indexes. If multiple rows can share a customer ID, the cursor will need another unique field.
There's another consideration if the underlying data changes while the report is running. Pagination alone doesn't guarantee a consistent view. Depending on the requirements, the report might need a fixed cutoff, snapshot isolation, or a different consistency strategy.
And batching shouldn't be confused with resumability.
Suppose the worker writes 400 rows and crashes. On retry, do we start again? Can the writer safely handle duplicate output? Could we rebuild the report from scratch?
A persisted processing checkpoint helps, but it must never advance beyond output that has been committed safely.
Once those issues are addressed, Messenger may still be the right choice. Some reports take too long to run inside an HTTP request even after their queries are optimized.
The important distinction is that a queue changes when and where work executes. It doesn't improve the underlying query or make the work reliable by itself.
It also has capacity limits. A queue can absorb temporary traffic spikes, but if messages arrive faster than workers can process them for long enough, the backlog keeps growing.
The delay hasn't disappeared. We've moved it somewhere else.
3. A database transaction is being stretched across a broker
Consider this code:
$order->confirm();
$entityManager->flush();
$bus->dispatch(
new SendOrderConfirmation($order->id()),
);At first glance, there isn't much to object to. We confirm an order, save it, and dispatch a message.
What worries me is what happens between the database change and publication to the message broker.
Suppose the order commits successfully, but the broker is unavailable. The order is confirmed, yet the confirmation message never gets published.
Reversing the order doesn't solve it. The worker might process the message before the database transaction commits, see stale data, or react to a change that eventually gets rolled back.
There's a Doctrine detail worth mentioning here. flush() doesn't always mean the transaction has committed.
When Doctrine ORM manages an implicit transaction, flush() ordinarily executes and commits the pending writes. Inside an explicitly managed transaction, it can execute while that transaction remains open.
So the transaction boundaries need to be understood from the actual configuration and surrounding code, not inferred from a call to flush().
Transaction middleware doesn't automatically solve the atomicity problem between a database and an independent broker either.
If message publication is important enough that we can't afford to lose it, I'd rather persist the intent to publish alongside the business change.
A transactional outbox is one way to do that:
$entityManager->wrapInTransaction(
function () use ($order, $orders, $outbox): void {
$order->confirm();
$orders->save($order);
$outbox->append(
eventId: $order->confirmationEventId(),
type: 'OrderConfirmed',
payload: [
'order_id' => $order->id(),
],
);
},
);This assumes the order repository and outbox share the same EntityManager and participate in the same transaction. Neither should independently commit or publish to an external broker.
Doctrine ORM's wrapInTransaction() flushes before committing and rolls back if the transaction fails.
With the relevant writes participating in that transaction, the order change and outbox record commit together.
A separate relay can later publish OrderConfirmed through Messenger, where a notification consumer handles it.
The important improvement is that the application no longer has to rely on successfully publishing to an external broker immediately after committing business state. The publication intent has already been stored durably.
Of course, this doesn't give us exactly-once delivery.
The relay might publish an event and then crash before marking the outbox record as delivered. When it restarts, it could publish the same event again.
Consumers still need to tolerate duplicates.
There are operational costs as well. The outbox relay needs monitoring, retries, cleanup, and supervision. We've reduced one failure risk by introducing another component that must be maintained.
Symfony 8.2 introduces transactional outbox support, allowing messages to be stored in a database-backed outbox before forwarding to another transport. For the atomicity guarantee to hold, the outbox and the business change must participate in the same database transaction.
Similarly, a Doctrine Messenger transport can participate in an application transaction when the connection and configuration support it.
Sharing Doctrine doesn't automatically mean sharing a transaction. That part needs verifying.
Would I use an outbox for every event? No.
Some analytics events and disposable notifications can tolerate weaker delivery guarantees. And when an operation really belongs inside one immediate database transaction, keeping it there may be simpler.
An outbox is useful when the reliability requirements justify the extra moving parts.
4. Retrying the handler can repeat an irreversible effect
Payments make retry problems much more interesting, and not necessarily in a good way.
Here's a handler that looks reasonable until you start thinking about failures:
public function __invoke(ChargeOrder $message): void
{
$order = $this->orders->get($message->orderId);
$this->gateway->charge(
$order->amountMinor(),
$order->currency(),
);
$order->markPaid();
$this->orders->save($order);
}Now suppose the payment provider successfully charges the customer, but the worker crashes before saving the order.
When Messenger delivers the message again, the handler could issue another charge.
The same risk exists if the handler finishes but the worker stops before acknowledging the message. Messenger supports retries, but messages can be delivered more than once, including after successful execution.
For operations with irreversible effects, that matters quite a lot.
A safer approach starts with a durable payment attempt identity that stays the same across retries.
public function __invoke(ChargeOrder $message): void
{
$attempt = $this->attempts->get(
$message->attemptId,
);
if ($attempt->hasSucceeded()) {
return;
}
$receipt = $this->gateway->charge(
amountMinor: $attempt->amountMinor(),
currency: $attempt->currency(),
idempotencyKey: $attempt->id(),
);
$this->attempts->recordSuccessIdempotently(
$attempt->id(),
$receipt,
);
}Several assumptions are hidden behind this example.
The payment attempt must already exist before dispatch. Its identity must remain stable across retries. The payment provider must support the required idempotency behavior, and duplicate submissions must not create separate payment attempts for the same logical operation.
That last point is easy to miss.
If every incoming request generates a fresh attempt ID, the provider receives different idempotency keys and has no reason to treat the requests as duplicates.
The application needs a business-level rule that distinguishes retrying an existing payment from intentionally creating a new one. Persistent operation identities, uniqueness constraints, and explicit states can help enforce that rule.
Even this check doesn't guarantee exclusive execution:
if ($attempt->hasSucceeded()) {
return;
}Two workers could load the same pending attempt at the same time. Both would pass the check.
The overall safety depends on the provider's idempotency guarantees, stable request parameters, key-retention limits, and correct local state transitions.
Also, recordSuccessIdempotently() doesn't become atomic because we gave it a reassuring name. Its implementation has to enforce the necessary behavior.
Then there's the unpleasant case where the payment provider succeeds but our application times out before receiving a response.
Did the customer get charged?
We don't know yet.
A timeout tells us the application didn't receive an answer. It doesn't establish that the remote operation failed.
Creating a new attempt with a different idempotency key could result in another charge. The application may need to query the provider or reconcile the original outcome before taking further action.
There's no ordinary local database transaction that can atomically commit both our database changes and an external payment API call. We have to account for partial failures and uncertain results.
None of this is exclusive to Messenger. Synchronous calls to external systems face the same problems.
But moving the work into a queue means retries and duplicate delivery become part of the execution model, so they need deliberate handling.
When someone says, "Messenger handles retries, so this is reliable," I want to know what repeated execution actually does.
Retries are useful only when we know what can safely be repeated.
5. The bus adds indirection without a useful boundary
Not every operation needs to become a message.
For example:
$bus->dispatch(
new NormalizeCustomerName($customerId),
);
$bus->dispatch(
new RecalculateCustomerScore($customerId),
);
$bus->dispatch(
new SaveCustomerProfile($customerId),
);Let's assume these are three closely related steps in a single profile update.
If the messages are asynchronous, dispatching them in this order doesn't guarantee they'll finish in that order. Different workers, retries, and failures can change how execution unfolds.
If they're synchronous, I'd want to understand what the bus contributes to an operation this small.
A straightforward application service might express the intention more clearly:
public function updateProfile(
int $customerId,
ProfileInput $input,
): void {
$customer = $this->customers->get($customerId);
$customer->rename(
$this->names->normalize($input->name),
);
$customer->recalculateScore($this->scoring);
$this->customers->save($customer);
}Here, we can see the order of operations and which service owns the update.
That doesn't remove the need for a database transaction if multiple writes must commit atomically. A direct application-service call isn't automatically transactional.
There are perfectly good reasons to use Messenger synchronously. Middleware, validation, tracing, transaction handling, and consistency with an established architecture can all justify it.
But I don't think creating a message class and handler for every small operation is automatically better design.
Sometimes it just means opening six files to understand something that previously fitted into one method.
There's also a distinction between sending several messages and building a reliable workflow.
When step B depends on A, and C depends on B, the application must define how those dependencies are enforced.
With asynchronous execution, that may involve persistent workflow state, explicit transitions, timeouts, compensation, and recovery mechanisms.
Simply dispatching messages in sequence doesn't provide any of those guarantees.
I'm happy to use a bus when it gives the application a useful boundary. When it doesn't, an ordinary application service is often easier to follow.
6. The operational cost is being underestimated
This is the part I think teams sometimes underestimate when deciding to move work into the background.
Who notices when the HTTP request succeeds but the work never finishes?
With synchronous processing, the caller often knows immediately when something fails.
A background job can fail long after the original request has returned. Without suitable monitoring, the application may leave an operation unfinished for hours without anybody realizing.
Having logs is useful, but someone still needs to know when to look at them.
For asynchronous processing, I'd want visibility into a few things.
Queue depth shows how many messages are waiting, but it doesn't tell us how long they'll take. Ten messages could represent a few milliseconds or several hours of work.
Oldest pending message age is often more useful for spotting a backlog that's getting worse.
Queue latency tells us how long messages wait before workers start processing them.
Processing duration helps identify handlers that are unusually slow or consuming more capacity than expected.
Retry and permanent-failure counts reveal work that isn't completing normally.
A growing queue during a short traffic spike may be perfectly acceptable. A queue that keeps growing while its oldest messages get older usually deserves attention.
Retry configuration needs similar care.
A temporary network outage may be worth retrying. An invalid business state probably isn't. An uncertain payment outcome might need reconciliation rather than another attempt.
Treating every exception the same way makes configuration easier, but it doesn't make the behavior correct.
Symfony supports configurable retry strategies and unrecoverable exceptions. It also supports failure transports, which can preserve messages after their retries are exhausted.
But a failure transport has to be configured deliberately. Without one, messages that exhaust their retries are discarded.
Then there's the operational side of workers themselves: supervision, memory limits, controlled restarts, and handling deployments.
Long-running PHP processes can expose problems that don't appear in ordinary HTTP requests. Services may retain mutable state, database connections may become stale, and memory usage can creep upward over time.
Another issue is message compatibility across deployments.
Queued messages may have been serialized by an earlier application version. Renaming message classes or changing constructor arguments can make those existing payloads impossible to deserialize.
That's not a particularly enjoyable deployment surprise.
Finally, somebody has to deal with operations that fail permanently.
A failure transport can preserve a failed message, but it doesn't decide what happens next.
A delayed notification may be tolerable. A failed refund or payment might require reconciliation, customer communication, or manual intervention.
Those responsibilities belong in the architecture discussion, not in a support ticket discovered three weeks later.
The checklist I use before introducing a queue
Before deciding on asynchronous Messenger handling, I'd want reasonable answers to five questions.
1. Can the operation complete later?
Can the user and the application tolerate the operation remaining pending after the HTTP response?
If the caller needs a definitive decision immediately, that decision generally belongs in synchronous execution.
2. Is repeated execution safe?
Messages can be retried and delivered more than once.
What happens if a handler executes twice, especially when dealing with payments, external APIs, or other irreversible operations?
3. Is the work durably recorded?
When the API reports that a request was accepted, can the application reliably preserve it and arrange processing?
If database changes and message publication need to remain consistent, the transaction boundaries must support that guarantee.
4. Can completion and failure be observed?
Can users, operators, and support staff tell whether the work is pending, completed, or failed?
Successfully dispatching a message doesn't mean it was successfully processed.
5. Who owns recovery?
What happens when retries run out, a remote result is ambiguous, or a job remains stuck?
Someone needs to own the answer.
If those questions haven't been considered, adding a queue probably won't improve the design. It may just move the difficult parts somewhere they're harder to notice.
Final thought
Symfony Messenger is a good tool, and background processing is often exactly what an application needs. Long-running reports, independent workloads, and operations designed around delayed completion are all reasonable candidates.
A message bus can also be useful synchronously when it provides meaningful application boundaries or middleware.
The mistake is treating a queue as an automatic improvement whenever an operation becomes slow or complicated.
Running work asynchronously changes the application's failure behavior, consistency guarantees, and operational responsibilities. Sometimes that trade-off is worthwhile. Other times, a direct application-service call, a clear transaction boundary, or fixing an inefficient query is the better choice.
Before introducing a queue, I want to understand what we're gaining and, just as importantly, what "accepted" means to the person waiting for the result.