How Ecotone Hits 157,266 Confirmed Messages per second in PHP
Ecotone publishes 10,000 broker-confirmed messages from one PHP process in 64ms — enabled by one configuration call, with handlers left untouched.
Updated on 2026-08-07
PHP has a reputation on the publishing side: a handful of messages is fine, but once a system needs to push thousands, the usual instinct is to reach for another language. So today I want to show you how Ecotone changes that math: the same #[CommandHandler] that published events one call at a time now moves 10,000 broker-confirmed messages in 64 milliseconds (157,266 msg/s to Kafka from a single PHP process). The speed comes from the broker's own high-throughput features, enabled in configuration, while your handler stays simple and out of those concerns.
TL;DR: Ecotone's high-throughput publishing is enabled with one builder call per channel — withHighThroughputPublishing(). Handlers keep publishing events exactly as before; Ecotone gathers them, sends one provider-native batch, and collects every broker confirmation before the transaction commits. Measured: Kafka 157,266 msg/s, RabbitMQ 67,143, from one PHP process.Table of contents
- Publishing message by message
- What brokers offer instead: two mechanisms
- Batching: gather and send together
- Non-blocking confirmation: stop waiting between messages
- Both together: where the headline numbers come from
- How Ecotone wires it in: the handler stays as it is
- What Ecotone does between publish and commit
- Taking explicit control with publishDeferred
- Consumers never see the batch
- Why people accept lost messages
- Batching the outbox relay
- Wiring the relay into your stack
- Trade-offs and limits of high-throughput publishing
- Common questions about the Ecotone approach
Publishing message by message
The default on every publishing path is one broker call per message. Each publish() serializes the message, sends it, and waits for the broker's confirmation before your code continues:
foreach ($orders as $order) {
$eventBus->publish(new OrderWasPlaced($order->id));
}
Each iteration pays a full broker round-trip before the next one starts.
Notice what this API cannot express: send everything, keep working, and await the confirmations once all other work is completed. Every publish is a full stop. A handler that emits 10,000 events pays that stop 10,000 times, sequentially, inside the request, while the database transaction holds its locks. The broker is rarely the limit — any broker in this article can ingest far more than one PHP process sends. The cost is the sequencing of the round-trips, and that sequencing is why the publishing side is where PHP systems feel slow first.
What brokers offer instead: two mechanisms
The brokers themselves solved this long ago; the per-message API just cannot reach the solution. Two independent mechanisms exist, and they attack different parts of the cost:
- Batching gathers the messages an operation produces and hands the broker one write instead of N. The round-trips collapse from N to one.
- Non-blocking confirmation keeps sending without stopping for each acknowledgement, then collects all confirmations in a single pass at the end. The sending never stops; the waiting happens once.
Compared to message-by-message publishing, every message is still confirmed by the broker; what changes is when the waiting happens and how many round trips there are. A provider only gets the mechanism its protocol actually has:
| Provider | Batching | Non-blocking confirmation |
| Kafka | native batch produce | delivery reports collected at the end |
| RabbitMQ | one publisher-confirms round trip | confirms collected at the end |
| SQS | native batch send requests, sent concurrently | responses collected at the end | | Postgres | one multi-row INSERT | not offered — the INSERT confirms itself |
| Redis | one scripted round trip | not offered — the reply is the confirmation |
Redis and Postgres confirm the write in the reply to the write. There is no separate acknowledgement arriving later, so there is nothing to defer: batching is their whole feature. Kafka, RabbitMQ and SQS have both mechanisms — and because the two are independent, the public demo measures each on its own before combining them.
Batching: gather and send together
Batching means the messages produced during an operation are collected and travel as one write at the end. Each provider maps that write onto its own primitive: a native batch produce on Kafka, a batch send request on SQS, a multi-row INSERT on Postgres, one scripted round trip on Redis, a single publisher-confirms round trip on RabbitMQ. The number of broker round-trips drops from N to one.
The result. Redis and Postgres are the honest place to read this mechanism on its own, because batching is the only mechanism they have:
| Provider | Per-message publishing | Batched |
| Redis | 32,184 msg/s | 115,638 msg/s |
| Postgres | 16,421 msg/s | 53,711 msg/s |
Redis lands second in the whole demo, behind only Kafka, without any deferred-confirmation mode at all. The less forgiving your infrastructure, the more collapsing the round-trips is worth.
Non-blocking confirmation: stop waiting between messages
The second mechanism leaves every message as its own write — no batch anywhere — but the process no longer stops for each confirmation before writing the next. Deliveries are registered as pending and awaited in a single pass at the end of the scope. What is deferred is the waiting, not the sending. It also composes with batching: several batches can be on the wire back to back, with one wait at the end covering all of them.
What that means underneath differs per broker:
- Kafka produces each message without flushing, and drains delivery reports while it keeps producing. The flush and the wait happen once, at the end.
- RabbitMQ writes each message to the socket as before, but the publisher confirms are coalesced — one wait for all of them instead of a stop after every publish.
- SQS dispatches its requests concurrently (with a cap on how many are in flight at once) and collects the responses together, instead of one HTTP round-trip at a time.
The result. This is the mechanism with the widest spread, and it is a good illustration of why the transport matters:
| Provider | Per-message publishing | Non-blocking confirm |
| Kafka | 9,053 msg/s | 23,421 msg/s — 2.6x |
| RabbitMQ (amqp-ext) | 13,754 msg/s | 20,261 msg/s — 1.5x |
| RabbitMQ (amqp-lib) | 9,776 msg/s | 15,252 msg/s — 1.6x |
| SQS (LocalStack) | 660 msg/s | 1,143 msg/s — 1.7x |
Read down that column and you can see where each transport spends its time. Kafka gains the most at 2.6x, because producing without flushing lets it keep working while delivery reports drain. The AMQP transports and SQS gain 1.5x to 1.7x: each message is still its own write, so only the confirmation wait is coalesced, never the write itself. That is exactly why this is a separate mechanism from batching.
Both together: where the headline numbers come from

The mechanisms compose cleanly: the batch removes the round-trips, the deferred confirmation removes the waiting between what is left, and every confirmation is still collected before the operation completes. No guarantee is traded away for the combination — a failed delivery still fails the operation, or routes only failed messages to the error channel.
The result, one PHP process, 10,000 messages per run, every delivery confirmed before the clock stops:
| Provider | Batched + non-blocking |
| Kafka | 157,266 msg/s — 10,000 confirmed in 64ms |
| RabbitMQ (amqp-ext) | 67,143 msg/s — 10,000 confirmed in 149ms |
| RabbitMQ (amqp-lib) | 36,294 msg/s |
| SQS (LocalStack) | 7,549 msg/s |
Redis and Postgres have no row here, and that is not an omission: with no confirmation to defer, their batched numbers above already are their best case.
The numbers leave one question open: what these mechanisms cost inside your codebase — batch objects to build, futures to thread through the domain layer, a flush to remember. That price is what usually keeps them out of business code.
How Ecotone wires it in: the handler stays as it is
Here is the handler after high-throughput publishing is enabled:
#[CommandHandler]
public function place(PlaceOrder $command, EventBus $eventBus): void
{
$eventBus->publish(new OrderWasPlaced($command->orderId));
}
There is no diff against the message-by-message version. That is the point.
No batch objects in the business code, no futures to thread through your domain layer, no "collect these and flush later" service. The loop from the first section keeps looping exactly as written; Ecotone gathers everything published during the operation and applies batching and non-blocking confirmation underneath.
Enabling it is one builder call on the channel or publisher configuration:
final class MessagingConfiguration
{
// Message Channels — events published from handlers are gathered,
// sent as one native batch, and confirmed before the operation completes
#[ServiceContext]
public function ordersChannel(): KafkaMessageChannelBuilder
{
return KafkaMessageChannelBuilder::create('orders')
->withHighThroughputPublishing();
}
// Message Publisher — enables publishDeferred() with a Future
#[ServiceContext]
public function orderPublisher(): AmqpMessagePublisherConfiguration
{
return AmqpMessagePublisherConfiguration::create()
->withHighThroughputPublishing();
}
}
Configuration is the only place batching exists. Business code stays broker-agnostic and batch-agnostic.
The same call is available across the supported channels — Kafka, RabbitMQ, SQS, Redis, and the Postgres-backed channel:
KafkaMessageChannelBuilder::create('orders')->withHighThroughputPublishing();
AmqpBackedMessageChannelBuilder::create('orders')->withHighThroughputPublishing();
SqsBackedMessageChannelBuilder::create('orders')->withHighThroughputPublishing();
RedisBackedMessageChannelBuilder::create('orders')->withHighThroughputPublishing();
DbalBackedMessageChannelBuilder::create('orders')->withHighThroughputPublishing();
Per-channel opt-in. Channels without the call keep the standard synchronous path.
The opt-in being per-channel rather than global is what lets throughput stay a targeted decision: the order events that fan out by the thousand get the batched path, while a low-volume channel keeps the simplest possible flow. And the decision lives next to the channel definition, where an architecture reviewer will actually look for it.
The two mechanisms stay independently switchable, and the signature tells you what the provider can do:
KafkaMessageChannelBuilder::create('orders')
->withHighThroughputPublishing(
batchPublishing: true,
nonBlockingConfirmation: true,
confirmationTimeoutInMilliseconds: 5000,
);
Redis and Postgres take no arguments — there is nothing to choose.
What Ecotone does between publish and commit
Since the handler code carries no hints, the interesting question is what happens underneath. The flow:

The one-sentence version: the waiting happens once, right before the commit. Messages fire to the broker the instant the handler emits them and travel while it keeps working, and Ecotone collects all broker confirmations in a single pass at the transaction boundary.
Logging a handler run shows the order plainly:
transaction started
command handler executed
published batch of 2 messages to broker
delivery confirmations awaited
transaction committed
The confirmation wait happens once, after the handler and before the commit.
Two properties fall out of this design that matter more than the speed:
No message leaves unconfirmed. The transaction cannot commit until every message is confirmed by the broker. You never end up with committed state whose events silently failed to reach the outside world.
Failure stays per-message. If message 437 of 10,000 is rejected, either the operation fails (no error channel configured, transaction rolls back) or exactly that message routes to the error channel and into retries or the dead letter store — individually. The other 9,999 are unaffected. Hand-rolled batching schemes almost always collapse this into batch-level success or failure, which means duplicates on re-publish or silent loss.
Ecotone also finds the outermost transaction boundary on its own. Nested handlers and buses publishing within the same operation do not each await; one collection pass covers everything published inside.
There is one honest challenge to this design: messages reach the broker before the commit, so a transaction that fails at its very last step has already published events about a change that never happened. Collecting confirmations cannot close that window — only writing the message and the state change in the same transaction can, and that is where the outbox comes in a bit later in the article.
Taking explicit control with publishDeferred
The configuration-only path covers handlers. When you want to drive batching yourself (imports, migrations, the benchmark itself), the publisher exposes the explicit form:
$future = $messagePublisher->publishDeferred(
BatchMessage::fromEntries([
['payload' => $firstOrder],
['payload' => $secondOrder, 'headers' => ['priority' => 5]],
])
);
// ... do other work — messages are already travelling ...
$future->resolve(); // throws PublishingFailedException listing only the messages that failed
You choose the batch contents and the moment to await. Per-message metadata still works inside a batch.
publishDeferred returns a Future. The messages are on the wire immediately; resolve() is where you pay the wait, and on failure the exception names exactly which messages failed rather than waving at the batch. This is the API the public benchmark uses to hit 157,266 confirmed msg/s on Kafka and 67,143 on RabbitMQ from a single process. Redis and Postgres do not offer publishDeferred(): with no separate confirmation to defer, there is no Future to resolve — their batched path is the configuration one.
Consumers never see the batch
One API, but no lowest-common-denominator transport: each provider gets its own fast path, and none of it leaks past the wire. Nothing about batching survives the wire: the consumer side receives individual messages, retries individual messages, and dead-letters individual messages. No consumer, projection, or subscriber changes when a publisher turns this on.
Why people accept lost messages
Everything so far kept one guarantee fixed: the operation does not complete until the broker confirmed every message. Plenty of systems give that guarantee up, and speed is usually the justification. One version publishes to the broker only after the response is already sent — the request feels fast, and a process that dies between the response and the publish loses those messages with no error and no log entry. Another publishes directly to the broker mid-transaction and hopes the commit follows: when it rolls back instead, the system has announced an order that never existed. Both are the same trade — robustness sold for throughput, silently, at the worst possible place.
The pattern that refuses that trade is the outbox: write the messages into the same database transaction as the business change, and let a separate process relay them to the broker. The message and the state change commit or roll back together, so nothing can be lost, nothing false can be announced — and the window from earlier, a commit failing after the messages already reached the broker, is gone: the messages only leave once the commit succeeded. The reason people still avoid it is what happens after the commit: the relay usually consumes the outbox like any other channel, one message per poll cycle, deserializing and republishing each one on its own. The robust option earned a reputation for being slow, and that reputation is what pushes teams into the shortcuts above.
Batching the outbox relay
The relay's problem is the first section's problem one level down, so the same two mechanisms apply. Instead of polling per message, a dedicated publishing endpoint claims a block of rows straight from the database — FOR UPDATE SKIP LOCKED on PostgreSQL — groups them by target and hands the target whole batches. The rows travel in wire format, so nothing is deserialized on the way through, and with high-throughput publishing on the target channel, a claimed block becomes one native broker batch.

Delivery stays at-least-once, and the outbox is its own retry store: a failed delivery is released for redelivery rather than duplicating what already went out, and a connection failure rolls the whole cycle back so a restarted process retries cleanly.
The result, 10,000 messages sitting in the outbox waiting to be relayed:
| Relay (Kafka target) | Time to drain 10,000 |
| Message by message | 34.49s — 290 msg/s |
| Batched, 100 rows per cycle | 0.318s — 31,461 msg/s |
| Batched, one claim of 10,000 | 0.200s — 50,052 msg/s |
Two things worth taking from that. The relay is what costs you, not the broker: roughly 3ms per message goes on poll cycles, per-message transactions and the per-message publish, and every target in the demo (RabbitMQ, Kafka, Redis) receives the same 10,000 rows in under a third of a second once the relay claims them in one go. Batch size is the smaller knob: a hundred cycles of a hundred rows cost 1.3x to 1.6x what a single claim does, depending on the target, the trade being that one claim holds the whole batch in memory inside one transaction.
SQS is the exception, at 1.472s for the single-batch relay: its API caps a batch request at 10 entries, so 10,000 messages are still 1,000 HTTP round trips no matter how many rows you claim at once.
Wiring the relay into your stack
On the Ecotone side the relay is a channel definition rather than a new moving part:
#[ServiceContext]
public function channels(): array
{
return [
OutboxForwardingMessageChannel::create(
referenceName: 'orders',
sourceChannelName: 'outbox',
targetChannelName: 'orderProcessing',
)->withMaxForwardingBatchSize(100),
KafkaMessageChannelBuilder::create('orderProcessing')
->withHighThroughputPublishing(),
];
}
One outbox, one target. The publishing endpoint replaces the outbox channel's consumer.
Business code does not participate: handlers keep publishing to the orders channel exactly as before, the rows land in the outbox inside the business transaction, and the forwarding channel drains them in blocks. Moving a direct-to-broker setup onto this path is configuration work — the code that publishes never learns the outbox exists.
Trade-offs and limits of high-throughput publishing
Where I would not reach for this, and what it does not solve:
- It is an Enterprise capability. The standard synchronous path is free and remains the default on RabbitMQ, Redis, Postgres and SQS; the Kafka module itself is part of Ecotone Enterprise. High-throughput publishing belongs to the paid tier everywhere, with trial licences available.
- Low-volume channels gain little. A handler publishing one event per request saves one round-trip's ordering at most. The feature earns its keep where handlers fan out many messages or where publishing sits inside hot request paths.
- The commit waits for confirmations. That is the guarantee doing its job, but it means a slow broker still surfaces as latency at the transaction boundary rather than disappearing. You are moving the wait, not deleting it.
- The transport still matters. On RabbitMQ the
enqueue/amqp-extconnection reached 67,143 msg/s againstenqueue/amqp-lib's 36,294, because php-amqplib encodes the protocol in pure PHP. One builder call enables the feature; it cannot make up a 1.8× difference in the transport underneath it.
Common questions about the Ecotone approach
What happens if the process dies with unresolved futures?Confirmations are still collected on shutdown, and unresolved deliveries are logged. Nothing is silently dropped on the way down.What if I publish outside any transaction or handler scope?The publish simply awaits synchronously, as it always did. The batched path does not depend on the happy path holding; it degrades to standard behavior.Do I need to change consumers, retries, or dead letter handling?No. The batch exists only on the wire. Consumers receive individual messages, and all failure tooling keeps operating per message.
Wrapping up
The fastest way to judge this is to run it: the demo is public, runs on Docker Compose against five brokers in six configurations, and every number in this article comes from it. High-throughput publishing is a paid Ecotone Enterprise capability, and trial licences are available at ecotone.tech/pricing#trial — so the next step I would suggest is enabling it inside your own stack and measuring against your own brokers. The handlers you already have qualify unchanged; the wiring is the builder call from this article.
About the author: Dariusz Gafka is a Software Architect and author of the Ecotone Framework. He writes about event sourcing, CQRS, and PHP architecture patterns.