<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dariusz Gafka</title>
    <description>The latest articles on DEV Community by Dariusz Gafka (@dgafka).</description>
    <link>https://dev.to/dgafka</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3679749%2F21b8189c-9297-4ece-ba6b-ad1591cf221a.jpeg</url>
      <title>DEV Community: Dariusz Gafka</title>
      <link>https://dev.to/dgafka</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dgafka"/>
    <language>en</language>
    <item>
      <title>How Ecotone Confirms 10,000 Messages in 64ms</title>
      <dc:creator>Dariusz Gafka</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:51:14 +0000</pubDate>
      <link>https://dev.to/dgafka/how-ecotone-confirms-10000-messages-in-64ms-8k8</link>
      <guid>https://dev.to/dgafka/how-ecotone-confirms-10000-messages-in-64ms-8k8</guid>
      <description>&lt;p&gt;One PHP process published 10,000 messages to Kafka, waited for the broker to confirm every single one, and stopped the clock at 64 milliseconds. That is 157,266 confirmed messages per second, from PHP, with no delivery guarantee traded away. This post is the receipts: what I measured, how, and what surprised me along the way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Ecotone's high-throughput publishing sends messages as provider-native batches and collects all broker confirmations before the business operation completes. Measured from one PHP process: Kafka 157,266 msg/s, Redis 115,638, RabbitMQ 67,143 (amqp-ext), Postgres 53,711, SQS 7,549 — every message individually confirmed before the operation completes.&lt;/p&gt;

&lt;p&gt;ℹ️ &lt;strong&gt;Note:&lt;/strong&gt; High-throughput publishing is a paid Ecotone Enterprise capability. A trial licence is available at &lt;a href="https://ecotone.tech/pricing#trial" rel="noopener noreferrer"&gt;ecotone.tech/pricing#trial&lt;/a&gt;, so every number below is reproducible on your machine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;How I measured&lt;/li&gt;
&lt;li&gt;What each provider can actually do&lt;/li&gt;
&lt;li&gt;The numbers&lt;/li&gt;
&lt;li&gt;Where each number comes from: the two mechanisms&lt;/li&gt;
&lt;li&gt;Draining an outbox: the other half of the path&lt;/li&gt;
&lt;li&gt;What actually happens to a published message&lt;/li&gt;
&lt;li&gt;The code behind the benchmark&lt;/li&gt;
&lt;li&gt;Common questions&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I measured
&lt;/h2&gt;

&lt;p&gt;I did not want to claim a number I could not hand to a skeptic. So the benchmark is a runnable demo — public at &lt;a href="https://github.com/SimplyCodedSoftware/ecotone-publishing-throughput-demo" rel="noopener noreferrer"&gt;github.com/SimplyCodedSoftware/ecotone-publishing-throughput-demo&lt;/a&gt; — a Docker Compose setup with Kafka, RabbitMQ (on both Enqueue AMQP transports), LocalStack SQS, Redis and Postgres, plus one PHP process that publishes 10,000 messages and awaits every broker confirmation before stopping the clock, reporting the median of five iterations.&lt;/p&gt;

&lt;p&gt;The rules I held myself to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The guarantee is constant.&lt;/strong&gt; Every scenario waits for per-message broker confirmation. Comparing confirmed publishing against unconfirmed publishing would make the numbers look better and mean nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm, isolated runs.&lt;/strong&gt; Each provider is measured on its own, best warm run reported, with the stable band across repeated runs recorded next to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One PHP process.&lt;/strong&gt; No worker pools, no parallel producers. The point is what a single process can do.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ &lt;strong&gt;Note:&lt;/strong&gt; Prerequisites for running it yourself — Docker Compose and an Ecotone Enterprise trial licence from &lt;a href="https://ecotone.tech/pricing#trial" rel="noopener noreferrer"&gt;ecotone.tech/pricing#trial&lt;/a&gt;. PHP itself runs inside the demo's containers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What each provider can actually do
&lt;/h2&gt;

&lt;p&gt;The numbers below only make sense with one thing said up front: high-throughput publishing is &lt;strong&gt;two independent mechanisms&lt;/strong&gt;, not one, and a provider only gets the mechanism its protocol has.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Batching&lt;/th&gt;
&lt;th&gt;Non-blocking confirmation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kafka&lt;/td&gt;
&lt;td&gt;native batch produce&lt;/td&gt;
&lt;td&gt;delivery reports collected at the end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RabbitMQ&lt;/td&gt;
&lt;td&gt;one publisher-confirms round trip&lt;/td&gt;
&lt;td&gt;confirms collected at the end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQS&lt;/td&gt;
&lt;td&gt;native batch send requests, sent concurrently&lt;/td&gt;
&lt;td&gt;responses collected at the end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres&lt;/td&gt;
&lt;td&gt;one multi-row INSERT&lt;/td&gt;
&lt;td&gt;not offered — the INSERT confirms itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;one scripted round trip&lt;/td&gt;
&lt;td&gt;not offered — the reply is the confirmation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Redis and Postgres confirm the write in the reply to the write, so there is nothing to defer — batching is their entire feature, and their configuration call takes no parameters. That is why they show one fast scenario in the results and the other three providers show two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Best isolated warm runs, one PHP process, 10,000 messages per run, median of five, brokers running locally:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Batched publishing (absolute)&lt;/th&gt;
&lt;th&gt;Band across isolated runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kafka&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;157,266 msg/s — 10,000 confirmed in 64ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;98,962–157,266&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;115,638 msg/s — 10,000 confirmed in 86ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;111,374–115,638&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RabbitMQ (amqp-ext)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;67,143 msg/s — 10,000 confirmed in 149ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;62,854–67,143&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53,711 msg/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;48,887–53,711&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RabbitMQ (amqp-lib)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;36,294 msg/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;31,222–36,294&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQS (LocalStack)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7,549 msg/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7,190–7,549&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things in this table surprised me.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;Redis lands second overall&lt;/strong&gt; at 115,638 msg/s, behind only Kafka — despite having no deferred-confirmation mode at all. Redis acknowledges each command in its reply, so there is nothing to defer and batching is the entire feature; the same is true of Postgres at 53,711. The "boring" providers benefit as much as the streaming ones.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;RabbitMQ appears twice&lt;/strong&gt;, because the two Enqueue AMQP transports are genuinely different code paths: &lt;code&gt;amqp-lib&lt;/code&gt; hands the whole batch to the socket in a single write, &lt;code&gt;amqp-ext&lt;/code&gt; writes per message — and &lt;code&gt;amqp-ext&lt;/code&gt; still wins by roughly 1.8×, because php-amqplib encodes the AMQP protocol in pure PHP and that overhead costs more than the single socket write saves. It stays the recommended transport, and it is the one the RabbitMQ numbers here quote; both transports support the full feature set.&lt;/p&gt;

&lt;p&gt;For context on why this matters: publishing one message at a time is round-trip-bound. Each publish serializes, sends, waits for the broker's confirmation, and only then starts the next one. Ten thousand of those waits, one after another, is what makes the per-message path take seconds. The sequencing is the cost. That is the cap being removed here — the common expectation for confirmed publishing from PHP is a few hundred messages per second.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where each number comes from: the two mechanisms
&lt;/h2&gt;

&lt;p&gt;The headline row is both mechanisms at once. Measured separately, they buy different things — worth knowing before you decide which switch you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batching — gather and send together.&lt;/strong&gt; Everything the handler published during the operation is collected and handed to the channel as one message instead of N sends; the provider maps it onto its own batch primitive. N round-trips become one, and the application never builds a batch. Redis and Postgres isolate this mechanism perfectly, since it is the only one they have:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Per-message&lt;/th&gt;
&lt;th&gt;Batched&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;32,184 msg/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;115,638 msg/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres&lt;/td&gt;
&lt;td&gt;16,421 msg/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53,711 msg/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Collapsing round-trips is worth the most exactly where the infrastructure is least forgiving: the less headroom the storage has, the more of the per-message path is pure waiting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-blocking confirmation — stop waiting between messages.&lt;/strong&gt; Every message is still written on its own, but the process no longer waits for each confirmation before writing the next: Kafka produces without flushing and drains delivery reports as it goes, RabbitMQ coalesces its publisher confirms, SQS dispatches requests concurrently. The waiting happens once, at the end of the scope, before the transaction commits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Per-message&lt;/th&gt;
&lt;th&gt;Non-blocking confirm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kafka&lt;/td&gt;
&lt;td&gt;9,053 msg/s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;23,421 msg/s&lt;/strong&gt; — 2.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RabbitMQ (amqp-ext)&lt;/td&gt;
&lt;td&gt;13,754 msg/s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;20,261 msg/s&lt;/strong&gt; — 1.5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RabbitMQ (amqp-lib)&lt;/td&gt;
&lt;td&gt;9,776 msg/s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;15,252 msg/s&lt;/strong&gt; — 1.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQS (LocalStack)&lt;/td&gt;
&lt;td&gt;660 msg/s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1,143 msg/s&lt;/strong&gt; — 1.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every transport gains here, and the size of the gain tells you where it was spending its time. Kafka's 2.6x is the outlier: producing without flushing lets the client keep working while delivery reports drain behind it. The AMQP transports and SQS land between 1.5x and 1.7x, because each message is still its own write and only the confirmation wait gets coalesced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both, which is the default.&lt;/strong&gt; The batch removes the round-trips, the deferred confirmation removes the waiting between what is left, and every confirmation is still collected before the transaction commits — Kafka 157,266 msg/s, RabbitMQ (amqp-ext) 67,143, RabbitMQ (amqp-lib) 36,294, SQS 7,549. Those are the rows in the table above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draining an outbox: the other half of the path
&lt;/h2&gt;

&lt;p&gt;Every number above measures the write into the broker. Systems that use the outbox pattern have a second leg to pay for: the message is committed into the database with the business change, and a separate process relays it onward. That relay normally consumes the outbox like any other channel — one message per poll cycle, deserialized and republished on its own.&lt;/p&gt;

&lt;p&gt;The same demo times that leg (&lt;code&gt;./run.sh outbox&lt;/code&gt;). Batched forwarding replaces the outbox consumer with a publishing endpoint that claims rows straight from the database, groups them by target and hands over whole batches in wire format; the target has high-throughput publishing on, so a claim becomes one native broker batch. 10,000 messages waiting in the outbox:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Message by message&lt;/th&gt;
&lt;th&gt;Batched, 100 rows per cycle&lt;/th&gt;
&lt;th&gt;One claim of 10,000&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kafka&lt;/td&gt;
&lt;td&gt;34.49s — 290 msg/s&lt;/td&gt;
&lt;td&gt;0.318s — 31,461 msg/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.200s — 50,052 msg/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RabbitMQ&lt;/td&gt;
&lt;td&gt;29.75s — 336 msg/s&lt;/td&gt;
&lt;td&gt;0.393s — 25,418 msg/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.264s — 37,808 msg/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;29.35s — 341 msg/s&lt;/td&gt;
&lt;td&gt;0.293s — 34,093 msg/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.232s — 43,063 msg/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQS (LocalStack)&lt;/td&gt;
&lt;td&gt;43.67s — 229 msg/s&lt;/td&gt;
&lt;td&gt;1.889s — 5,293 msg/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.472s — 6,794 msg/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Message by message, every row pays a poll cycle, a deserialize, its own transaction and its own publish — around 3ms each, more on SQS where each publish is an HTTP round trip. Batched forwarding claims rows in blocks and hands the target whole batches, which is the entire difference.&lt;/p&gt;

&lt;p&gt;Which is the finding I did not expect: the relay was the bottleneck, not the broker. Every target lands within a tenth of a second of the others once the rows are claimed in one batch — except SQS, whose API caps a batch request at 10 entries, so 10,000 messages are still 1,000 HTTP round trips.&lt;/p&gt;

&lt;p&gt;Batch size is a smaller knob than the batching itself: a hundred cycles of a hundred rows cost 1.3x to 1.6x what a single claim of 10,000 does, depending on the target, and the single claim buys that by holding the whole batch in memory inside one transaction. Delivery guarantees do not move either way. A failed delivery is released for redelivery rather than duplicating what already went out, and a connection failure rolls the cycle back for a clean retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens to a published message
&lt;/h2&gt;

&lt;p&gt;The mechanism in one sentence: &lt;strong&gt;the waiting happens once, at the end&lt;/strong&gt;. Messages fire to the broker the instant your handler emits them and travel while it keeps working; Ecotone collects every confirmation once, right before the transaction commits.&lt;/p&gt;

&lt;p&gt;The business operation cannot complete until every confirmation is in hand. If a delivery fails, the operation fails, or the specific failed message routes to the error channel — individually, not as a whole batch. The consumer side never knows batching happened: it sees individual messages, retries individual messages, dead-letters individual messages.&lt;/p&gt;

&lt;p&gt;What you observe when logging is exactly the order the test suite asserts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;transaction started
command handler executed
published batch of 2 messages to broker
delivery confirmations awaited
transaction committed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Confirmations are awaited once, after the handler finishes and before the commit — not per message.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The code behind the benchmark
&lt;/h2&gt;

&lt;p&gt;The scenario behind the headline number is two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// From the runnable throughput demo&lt;/span&gt;
&lt;span class="nv"&gt;$publisher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;publishDeferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildBatch&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TEXT_PLAIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// Kafka: 10,000 confirmed in 64ms — 157,266 msg/sec&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;&lt;code&gt;publishDeferred&lt;/code&gt; returns a Future; &lt;code&gt;resolve()&lt;/code&gt; blocks until every broker confirmation arrives.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is the explicit form. The form most applications will use requires no publishing code at all — a handler that publishes events stays exactly as it always was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[CommandHandler]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PlaceOrder&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;EventBus&lt;/span&gt; &lt;span class="nv"&gt;$eventBus&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$eventBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderWasPlaced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nv"&gt;$eventBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderConfirmationRequested&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;No batch objects, no futures. The handler publishes events one call at a time, as before.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;High-throughput publishing is switched on in configuration, per channel or publisher:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MessagingConfiguration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Events published from handlers are gathered, sent as one&lt;/span&gt;
    &lt;span class="c1"&gt;// native batch, and confirmed before the operation completes&lt;/span&gt;
    &lt;span class="na"&gt;#[ServiceContext]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;ordersChannel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;KafkaMessageChannelBuilder&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;KafkaMessageChannelBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withHighThroughputPublishing&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Message Publisher — enables publishDeferred() with a Future&lt;/span&gt;
    &lt;span class="na"&gt;#[ServiceContext]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;orderPublisher&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;AmqpMessagePublisherConfiguration&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AmqpMessagePublisherConfiguration&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withHighThroughputPublishing&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;One builder call per channel. The same call maps to each broker's native batching underneath.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Each provider gets what it does best under that one API: Kafka produces without flushing per message, RabbitMQ defers confirms, SQS sends native batch requests concurrently, Postgres writes one multi-row INSERT, Redis pipelines commands. The handler code never sees the difference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;p&gt;&lt;/p&gt;
  Were these runs against remote brokers?
  &lt;br&gt;
No — brokers ran on the same machine, which is the right setup for measuring the publishing path itself. Over a real network the round-trip per message gets more expensive, not less, so per-message publishing degrades further while the batched path pays that latency once per batch.&lt;br&gt;


&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Do any of these numbers skip broker confirmations?
  &lt;br&gt;
No. Every scenario awaits per-message broker confirmation before the clock stops. A failed delivery fails the operation or routes that specific message to the error channel. That constraint is held constant across every broker and transport in the table.&lt;br&gt;


&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Why is SQS so much slower than the others?
  &lt;br&gt;
SQS itself caps how many entries fit in one native batch request, so more round-trips remain per 10,000 messages. It still lands at 7,549 msg/s confirmed — well above what a per-message loop achieves against the same endpoint.&lt;br&gt;


&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Do my consumers need to understand batches?
  &lt;br&gt;
No. Batching never survives the wire. Consumers receive individual messages; retries and dead-lettering operate on individual messages. Nothing downstream changes.&lt;br&gt;


&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;Numbers you cannot reproduce are just claims. The demo is public at &lt;a href="https://github.com/SimplyCodedSoftware/ecotone-publishing-throughput-demo" rel="noopener noreferrer"&gt;github.com/SimplyCodedSoftware/ecotone-publishing-throughput-demo&lt;/a&gt;: Docker Compose, five brokers, six configurations, one PHP process, and the clock only stops when the last confirmation arrives. Request a trial licence, run it on your laptop, and see where your band lands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.ecotone.tech" rel="noopener noreferrer"&gt;Explore the Ecotone docs&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Trial licences are available at &lt;a href="https://ecotone.tech/pricing#trial" rel="noopener noreferrer"&gt;ecotone.tech/pricing#trial&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What throughput do you actually get out of PHP publishing in your own stack? The demo runs on any machine with Docker — I would genuinely like to see numbers that disagree with mine.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.ecotone.tech/how-ecotone-hits-over-150k-confirmed-messages-in-one-second-in-php/" rel="noopener noreferrer"&gt;blog.ecotone.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>kafka</category>
      <category>performance</category>
      <category>ecotone</category>
    </item>
    <item>
      <title>Tempest + Ecotone: One Declarative Foundation</title>
      <dc:creator>Dariusz Gafka</dc:creator>
      <pubDate>Thu, 30 Jul 2026 20:08:57 +0000</pubDate>
      <link>https://dev.to/dgafka/tempest-ecotone-one-declarative-foundation-1c3b</link>
      <guid>https://dev.to/dgafka/tempest-ecotone-one-declarative-foundation-1c3b</guid>
      <description>&lt;p&gt;&lt;em&gt;Updated on 2026-07-22&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tempest describes itself as a framework designed to get out of your way — you write application code, and discovery finds it. Ecotone sits one layer higher and makes the matching promise: full business focus — you write business logic, and the architecture wiring is handled. Those are two phrasings of one foundation idea, declarative configuration: you declare intent in code, and the framework derives everything else. This article is about what happens when the two meet — &lt;code&gt;composer require ecotone/tempest&lt;/code&gt; does not add a dependency so much as install an architecture layer into the foundation. I know how this architecture behaves — I did not build a shop to find out. I built it because code snippets stop being enough at exactly this point: you have to see the layers in a real application, click through it, and break it yourself. So this article walks a working e-commerce shop, receipts included, down to the class count. One thing before we start: Ecotone is my framework, and it has a paid Enterprise tier, so audit the claims below with that in mind.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Tempest and Ecotone are built on the same foundation — declarative configuration. Tempest applies it to the application layer and gets out of your way; Ecotone applies it to the architecture layer and enables full business focus. &lt;code&gt;composer require ecotone/tempest&lt;/code&gt; installs that layer into Tempest: CQRS, async processing with retries and a dead letter, event sourcing, projections, workflows, per-message delayed delivery, and an outbox with deduplication for free — and a runnable e-commerce demo shows what that looks like in practice: the entire messaging layer is roughly a dozen application classes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One foundation: declarative configuration&lt;/li&gt;
&lt;li&gt;Installing the architecture layer&lt;/li&gt;
&lt;li&gt;The shop, concept by concept&lt;/li&gt;
&lt;li&gt;The count&lt;/li&gt;
&lt;li&gt;What this does not solve&lt;/li&gt;
&lt;li&gt;Transferable lessons&lt;/li&gt;
&lt;li&gt;Common gotchas&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One foundation: declarative configuration
&lt;/h2&gt;

&lt;p&gt;Ecotone now has a first-class integration for &lt;a href="https://tempestphp.com" rel="noopener noreferrer"&gt;Tempest&lt;/a&gt; — a new package, &lt;code&gt;ecotone/tempest&lt;/code&gt; (&lt;a href="https://docs.ecotone.tech/modules/tempest" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;). What makes this integration different from a typical framework adapter is that both sides already work the same way. In Tempest you write a controller, a model, a console command — discovery finds it, no registration. In Ecotone you write a class with &lt;code&gt;#[CommandHandler]&lt;/code&gt; on a method — the framework finds it, builds the bus, routes the message, manages the transaction. Neither side asks you to describe your application to it; both derive the wiring from what you declared in code.&lt;/p&gt;

&lt;p&gt;That shared foundation is why the composition looks like layers rather than glue:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe55ecmhkrn3s9eqokgq3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe55ecmhkrn3s9eqokgq3.png" alt="The two layers: Tempest as the foundation, Ecotone as the architecture layer installed by one composer require" width="800" height="600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A pedestal, built bottom-up: Tempest is the foundation layer, one composer require installs the Ecotone architecture layer on top of it, and everything above the two is yours — business logic, and only business logic.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The result is checkable as a size claim: an application with enterprise-level messaging architecture where the application code holds business logic and almost nothing else. There is no configuration layer to minimize; it simply isn't there.&lt;/p&gt;

&lt;p&gt;The entire setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require ecotone/tempest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;One command. No service provider, no bundle registration, no YAML.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A config file exists, but it is optional — the composer require alone gives you a fully working integration. Claims like that are cheap on a slide, which is why everything below comes from a browsable shop rather than snippets: product grid, cart, checkout, orders dashboard, shipment tracking, real emails landing in a local inbox. The whole application is public — &lt;a href="https://github.com/ecotoneframework/tempest-ecotone-demo" rel="noopener noreferrer"&gt;ecotoneframework/tempest-ecotone-demo&lt;/a&gt; — so you can clone it, run &lt;code&gt;docker compose up&lt;/code&gt;, play with it, and count the classes yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ &lt;strong&gt;Note:&lt;/strong&gt; Prerequisites — PHP 8.5, Tempest, &lt;code&gt;ecotone/tempest&lt;/code&gt;. The demo additionally uses &lt;code&gt;ecotone/dbal&lt;/code&gt;, &lt;code&gt;ecotone/pdo-event-sourcing&lt;/code&gt; and &lt;code&gt;ecotone/jms-converter&lt;/code&gt; on Postgres.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Installing the architecture layer
&lt;/h2&gt;

&lt;p&gt;The diagram's middle layer is what the composer require actually delivers. Ecotone brings its whole platform — durable channels, retries with backoff, dead-lettering, per-message delayed delivery, a query bus, event sourcing — and every piece of it is declared the same way the foundation is: attributes on plain classes, discovered, never registered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Ecotone\Modelling\Attribute\CommandHandler&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PlaceOrderHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[CommandHandler('order.place')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// store the order in the database&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;A handler is a plain class with one attribute — discovered, routed and wrapped in a transaction without any registration.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is the full path a request takes, from the browser down to the stored order — and who owns each step:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhmxvazvjniv1gpgjqqhp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhmxvazvjniv1gpgjqqhp.png" alt="Request flow from the browser through Tempest routing and the Ecotone command bus down to the database" width="800" height="251"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Tempest carries the request to your controller; Ecotone carries the command to your handler, inside a transaction; the only code you wrote is the controller call and the handler body.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Where does discovery get its scan paths? From what you already wrote. When no namespaces are configured, Ecotone derives them from Tempest's &lt;code&gt;Composer&lt;/code&gt; object — the PSR-4 roots of your composer.json. The database connection comes from Tempest's own &lt;code&gt;DatabaseConfig&lt;/code&gt; through &lt;code&gt;TempestConnectionReference::defaultConnection()&lt;/code&gt;, so Ecotone's transactions wrap Tempest ORM writes on one shared PDO connection. You declared both things once; the integration reads them instead of asking again.&lt;/p&gt;

&lt;p&gt;The buses arrive the same way. Every Ecotone gateway is injectable from Tempest's container with zero registration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Ecotone\Modelling\CommandBus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Tempest\Router\Post&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;CommandBus&lt;/span&gt; &lt;span class="nv"&gt;$commandBus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="na"&gt;#[Post('/orders')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Redirect&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;commandBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendWithRouting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'order.place'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/orders'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Tempest does the dependency injection; Ecotone provides the gateways. &lt;code&gt;QueryBus&lt;/code&gt; and &lt;code&gt;EventBus&lt;/code&gt; inject the same way.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The split is clean. Tempest solves HTTP, DI, forms, database models, console. Ecotone adds, on top of the same models and the same container, the messaging layer an application needs when it grows.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shop, concept by concept
&lt;/h2&gt;

&lt;p&gt;What we are building: a shop where a customer browses products, fills a cart and checks out. The checkout records an order; stock drops immediately; a notification appears on the dashboard; a confirmation email arrives in the inbox; the warehouse prepares a shipment whose history is an event stream feeding the read model on screen; and thirty seconds after the shipment is dispatched, the customer gets a review request.&lt;/p&gt;

&lt;p&gt;It is an ordinary-looking application, which is the point — here is the shop a user actually sees:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdedmhzxipuv0l2io7c2i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdedmhzxipuv0l2io7c2i.png" alt="The demo shop storefront with products, cart and checkout" width="800" height="606"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every panel on that page comes from a different mechanism explained below: the tiles are a query, the shipments are a projection over an event stream, the notifications were written by a background worker. None of that is visible to the person clicking. One business flow, end to end:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb8lzsm3gszdqj20n1n80.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb8lzsm3gszdqj20n1n80.png" alt="The whole shop flow: checkout, order aggregate, notifications channel, workflow, shipment stream and projection" width="799" height="191"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The whole flow. Each section below delivers one highlighted piece of it.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;

&lt;p&gt;Rolling out Tempest was one command: &lt;code&gt;composer create-project tempest/app app&lt;/code&gt; — a working skeleton with routing, views, console. Adding Ecotone was one more: &lt;code&gt;composer require ecotone/tempest&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  The aggregate is the Tempest model
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Focus: checkout → the Order aggregate.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Checkout needs somewhere to send &lt;code&gt;PlaceOrder&lt;/code&gt; — and this is the strongest single moment in the integration, so look closely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Ecotone\Modelling\Attribute\Aggregate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Ecotone\Modelling\Attribute\CommandHandler&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Ecotone\Modelling\Attribute\IdentifierMethod&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Ecotone\Modelling\Attribute\QueryHandler&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Tempest\Database\IsDatabaseModel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Tempest\Database\PrimaryKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Aggregate]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;IsDatabaseModel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;PrimaryKey&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$total_price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$is_cancelled&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[CommandHandler]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PlaceOrder&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total_price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;totalPrice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;is_cancelled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[IdentifierMethod('id')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[CommandHandler(routingKey: 'cancel_order')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;is_cancelled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[QueryHandler('is_cancelled')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isCancelled&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;is_cancelled&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;One final class where both frameworks meet: &lt;code&gt;#[Aggregate]&lt;/code&gt; from Ecotone, &lt;code&gt;IsDatabaseModel&lt;/code&gt; from Tempest. The static &lt;code&gt;place()&lt;/code&gt; factory is the creation handler — a command creates the aggregate, and &lt;code&gt;save()&lt;/code&gt; goes through Tempest's own persistence.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Look at &lt;code&gt;cancel()&lt;/code&gt;. It mutates state. That is all it does. No fetch, no save, no repository injection. Sending a command to it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$orderId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$commandBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PlaceOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'user-1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;totalPrice&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nv"&gt;$commandBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendWithRouting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cancel_order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'aggregate.id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$queryBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendWithRouting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'is_cancelled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'aggregate.id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Ecotone loads the model by id, calls the handler, saves it back — through Tempest's own persistence.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is no repository class to write. &lt;code&gt;TempestRepository&lt;/code&gt; — Ecotone's built-in repository, shipped with the integration — makes any model using &lt;code&gt;IsDatabaseModel&lt;/code&gt; usable as an aggregate directly. Checkout in the controller is three lines: build &lt;code&gt;PlaceOrder&lt;/code&gt; from the cart, send it, redirect with the returned order id.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event handler: subscribing to what happened
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Focus: the synchronous stock decrement.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The aggregate records &lt;code&gt;OrderWasPlaced&lt;/code&gt;. Subscribing to it is one attribute on a plain class — this synchronous handler decrements stock in the same transaction as the order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StockLevelUpdater&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[EventHandler]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;whenOrderWasPlaced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OrderWasPlaced&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;No subscription config, no event map — the parameter type is the subscription.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Asynchronous event handler
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Focus: the notifications channel and its async read model.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same event also drives handlers that should not block checkout. Adding &lt;code&gt;#[Asynchronous('notifications')]&lt;/code&gt; moves a handler to a background worker consuming a database-backed channel — the consistency model is chosen per handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationRecorder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Asynchronous('notifications')]&lt;/span&gt;
    &lt;span class="na"&gt;#[EventHandler(endpointId: 'notification.order_placed')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;whenOrderWasPlaced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OrderWasPlaced&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Order #%d placed by %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customerName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'order_placed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;One attribute picks the consistency model per handler.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And this is the moment the integration gets registered. The &lt;code&gt;notifications&lt;/code&gt; channel the attribute refers to is declared in the application's only Ecotone configuration — one small class, two one-line methods: bridge Tempest's Postgres config into Ecotone, and declare the durable channel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Ecotone\Dbal\DbalBackedMessageChannelBuilder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Ecotone\Messaging\Attribute\ServiceContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Ecotone\Tempest\Config\TempestConnectionReference&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MessagingConfiguration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[ServiceContext]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;TempestConnectionReference&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;TempestConnectionReference&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaultConnection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[ServiceContext]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;notificationsChannel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;DbalBackedMessageChannelBuilder&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DbalBackedMessageChannelBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'notifications'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;&lt;code&gt;#[ServiceContext]&lt;/code&gt; methods are discovered like everything else. The channel runs on the same Postgres connection Tempest's models use.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three async handlers hang off the same event: this one writes the notifications read model, one starts the confirmation-email workflow, one starts shipment preparation. The worker is Ecotone's own CLI entrypoint — &lt;code&gt;./tempest ecotone:run notifications&lt;/code&gt; — with no app code behind it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event sourcing and a projection
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Focus: the shipment event stream and its projection.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Shipment&lt;/code&gt; is an &lt;code&gt;#[EventSourcingAggregate]&lt;/code&gt;: its state is not a table row but the stream of events in the Postgres event store. Command handlers return events; &lt;code&gt;#[EventSourcingHandler]&lt;/code&gt; methods rebuild the state from them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[EventSourcingAggregate]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Shipment&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;WithAggregateVersioning&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[Identifier]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$customerName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$customerEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$dispatched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[CommandHandler]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PrepareShipment&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ShipmentWasPrepared&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;customerName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;packageCount&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;packageCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;customerEmail&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customerEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[CommandHandler(routingKey: 'shipment.dispatch')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;dispatched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ShipmentWasDispatched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;customerName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;customerEmail&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customerEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[EventSourcingHandler]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;applyPrepared&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ShipmentWasPrepared&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customerName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customerName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customerEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customerEmail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[EventSourcingHandler]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;applyDispatched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ShipmentWasDispatched&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;dispatched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Handlers return events; state is rebuilt from the stream. The &lt;code&gt;dispatched&lt;/code&gt; guard makes the Dispatch button idempotent — clicking twice records nothing twice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The UI never reads the stream directly. A &lt;code&gt;#[Projection]&lt;/code&gt; derives the read model — and the read model is a plain Tempest model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Projection('shipment_list', Shipment::class)]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ShipmentListProjection&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[EventHandler]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;whenShipmentWasPrepared&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ShipmentWasPrepared&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ShipmentView&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;package_count&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;packageCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'prepared'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[EventHandler]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;whenShipmentWasDispatched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ShipmentWasDispatched&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ShipmentView&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'dispatched'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The projection writes &lt;code&gt;ShipmentView&lt;/code&gt; — a Tempest &lt;code&gt;IsDatabaseModel&lt;/code&gt; — so the dashboard queries it like any other table.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Workflow: async emails end to end
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Focus: the email workflow, ending in the inbox.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The email pipeline is three small steps connected by channel names, and each step owns exactly one concern. The event handler composes the CONTENT — and only the content. It does not know the recipient; it passes the notification forward with the id needed to find out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Asynchronous('notifications')]&lt;/span&gt;
&lt;span class="na"&gt;#[EventHandler(endpointId: 'order_confirmation.start', outputChannelName: 'notification.enrich')]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OrderWasPlaced&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;EmailNotification&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EmailNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Order #%d confirmed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="cm"&gt;/* ...items and total rendered to HTML */&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Content and an id. No recipient, no mailer, no bus.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The next step enriches the message HEADERS with the account details. With &lt;code&gt;changingHeaders: true&lt;/code&gt;, the returned array is merged into the headers while the payload passes through untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AccountDetailsEnricher&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;InternalHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;inputChannelName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'notification.enrich'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;outputChannelName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'email.send'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;changingHeaders&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;enrich&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;EmailNotification&lt;/span&gt; &lt;span class="nv"&gt;$notification&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$notification&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'customerEmail'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer_email&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'customerName'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;A pipeline step that only adds knowledge. The payload flows on unchanged.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And the last step is a prepared building block: it reads the payload plus the enriched headers, builds the &lt;code&gt;GenericEmail&lt;/code&gt;, and sends through Tempest's own &lt;code&gt;Mailer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[InternalHandler(inputChannelName: 'email.send')]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;EmailNotification&lt;/span&gt; &lt;span class="nv"&gt;$notification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customerEmail'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$customerEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customerName'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$customerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Mailer&lt;/span&gt; &lt;span class="nv"&gt;$mailer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$customerEmail&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$customerEmail&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$mailer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GenericEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$notification&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$customerEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;p&amp;gt;Hi %s!&amp;lt;/p&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$customerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$notification&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The send block never changes — any notification in the application can flow through it. The delayed review request in the next section reuses this exact chain.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And the end of the chain is a real email, sent by the background worker through Tempest's &lt;code&gt;Mailer&lt;/code&gt; — here caught by Mailpit, next to the delayed review request from the section below:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqeukrfacyq4sdwxkggpw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqeukrfacyq4sdwxkggpw.jpg" alt="Mailpit inbox with order confirmation and review request emails" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Delayed messages
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Focus: the delayed review request.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Thirty seconds after a shipment is dispatched, the customer gets a review request. This is not a recurring job for a scheduler: it is one specific message, due once, thirty seconds after its own trigger. It waits inside the durable channel — surviving worker restarts — and is released when due:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Delayed(new TimeSpan(seconds: 30))]&lt;/span&gt;
&lt;span class="na"&gt;#[Asynchronous('notifications')]&lt;/span&gt;
&lt;span class="na"&gt;#[EventHandler(endpointId: 'review_request.on_shipment_dispatched', outputChannelName: 'notification.enrich')]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;requestReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ShipmentWasDispatched&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;EmailNotification&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EmailNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'How was order #%s?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;p&amp;gt;Your package is on its way. When it arrives, tell us how it went.&amp;lt;/p&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;One attribute replaces the scheduler — and the whole delayed email is eight lines of content, because enrichment and sending come from the pipeline it flows through.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  When the mail step fails: retries, dead letter, alerts page
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Focus: the mail send — the one step in this flow that talks to the outside world, and therefore the one that fails.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The checkout form has a "simulate an email delivery failure" checkbox:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35lxl80s5t3vaexuo342.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35lxl80s5t3vaexuo342.png" alt="Cart and checkout form with the simulate email delivery failure checkbox" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is sent as metadata on the command, and that metadata propagates: to the event the aggregate records, into the durable channel, out to the background worker, and finally to the send step, which reads it as a &lt;code&gt;#[Header]&lt;/code&gt; parameter. Nothing between checkout and the mailer mentions it.&lt;/p&gt;

&lt;p&gt;Tick it and the send throws. The message is retried after one second, retried again after three, and then parked in a database-backed dead letter with its stacktrace. The order placed right behind it, without the box ticked, gets its email immediately — same channel, no blockage, and the worker keeps running. That behavior is a third &lt;code&gt;#[ServiceContext]&lt;/code&gt; method on the same config class from earlier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[ServiceContext]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;errorHandling&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;ErrorHandlerConfiguration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ErrorHandlerConfiguration&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;createWithDeadLetterChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'errorChannel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;RetryTemplateBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;exponentialBackoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;initialDelay&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;multiplier&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;maxRetryAttempts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'dbal_dead_letter'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;No retry loops in handlers, no dead-letter migration, no supervisor watching the worker.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Recovery is a console command, because Ecotone's commands are discovered by Tempest like any other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./tempest ecotone:deadletter:list       &lt;span class="c"&gt;# what is parked, when it failed, why&lt;/span&gt;
./tempest ecotone:deadletter:show &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;  &lt;span class="c"&gt;# full payload and stacktrace&lt;/span&gt;
./tempest ecotone:deadletter:replay &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But operations work is not always CLI work, and this is where the layering pays off again: the dead letter is not a private mechanism, it is a service. &lt;code&gt;DeadLetterGateway&lt;/code&gt; injects into any Tempest controller, so the demo turns it into a page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AlertsController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;DeadLetterGateway&lt;/span&gt; &lt;span class="nv"&gt;$deadLetter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="na"&gt;#[Get('/alerts')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'./alerts.view.php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;deadLetter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[Post('/alerts/{messageId}/replay')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;replay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$messageId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Redirect&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;deadLetter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$messageId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/alerts'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Two routes and a view. Each entry is an &lt;code&gt;ErrorContext&lt;/code&gt; — message id, failure time, exception class and message, file, line, stacktrace — so the template has everything an operations screen needs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmyfnquzhfykf8e05zagv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmyfnquzhfykf8e05zagv.jpg" alt="Alerts page listing parked messages with failure details, stacktrace and replay buttons" width="800" height="312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One detail worth stealing even if you never use this page: a replayed message carries the header &lt;code&gt;ecotone.dlq.message_replied&lt;/code&gt;, readable as a normal &lt;code&gt;#[Header]&lt;/code&gt; parameter. Recovery can therefore behave differently from the first attempt — skip a step that already succeeded, relax a guard, tag the result. A handler gets to see how a message reached it, not only what it carries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Outbox and deduplication
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Focus: the order transaction and the channel — one commit carries both.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Stop the worker and place an order. The order row and its event messages are committed in the same Postgres transaction, because the channel is database-backed — &lt;code&gt;select count(*) from enqueue where queue='notifications'&lt;/code&gt; shows them waiting. Start the worker; the table drains and the emails go out. The dual-write problem ("order saved, event lost") cannot happen here, and nothing was configured to make that true. Redelivered messages are skipped through the &lt;code&gt;ecotone_deduplication&lt;/code&gt; table — also zero code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-tenancy
&lt;/h3&gt;

&lt;p&gt;Beyond this flow, the package also covers tenant separation. When you need it, a single message header routes commands, queries, transactions and business-interface SQL to the right tenant database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$commandBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegisterCustomer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'John Doe'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tenant'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tenant_a'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$commandBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegisterCustomer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'John Doe'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tenant'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tenant_b'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$queryBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendWithRouting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer.getAllRegistered'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tenant'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tenant_a'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// [1]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Tenant routing decided by one metadata header.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everything shown in this article, this header-based multi-tenancy included, runs on Ecotone's free Apache-2.0 tier; only custom tenant resolvers sit behind the paid Enterprise licence.&lt;/p&gt;

&lt;p&gt;The whole flow — browse, checkout, stock −1, async notification, shipment prepared from the event stream, dispatch, email in the inbox — was exercised through the running web UI. The messaging logic is additionally covered without any framework boot by &lt;code&gt;EcotoneLite&lt;/code&gt; tests, including async-tested-synchronously and the email workflow with a stubbed mailer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The count
&lt;/h2&gt;

&lt;p&gt;The application's entire messaging layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one state-stored aggregate (&lt;code&gt;Order&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;one event-sourced aggregate (&lt;code&gt;Shipment&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;commands and events as plain readonly objects&lt;/li&gt;
&lt;li&gt;four event handlers&lt;/li&gt;
&lt;li&gt;one projection&lt;/li&gt;
&lt;li&gt;one query service&lt;/li&gt;
&lt;li&gt;one workflow, plus the enrichment step it flows through&lt;/li&gt;
&lt;li&gt;one config class: connection, channel, error handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is about a dozen small classes. Everything else in the codebase is Tempest UI: controllers, views, a session cart — including the alerts page, which is two routes on top of &lt;code&gt;DeadLetterGateway&lt;/code&gt;. There is no custom worker command, no repository, no serializer configuration, no queue wiring. The architecture list reads like a system that needs a platform team; the diff reads like a weekend project.&lt;/p&gt;

&lt;p&gt;The count also survived the resiliency additions. Retries with dead-lettering came in as one &lt;code&gt;#[ServiceContext]&lt;/code&gt; method on the existing config class; the delayed review email is one method on the existing workflow class; the outbox and deduplication needed no code at all. The feature list grew, the class list did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;p&gt;Honest edges, so the claim stays checkable.&lt;/p&gt;

&lt;p&gt;A production-grade messaging layer reveals its complexity one production incident at a time — retry storms, poison messages, dual writes, replay against changed code. Hardening one takes years of absorbing other people's failures, and that is the layer Ecotone contributes here with the incident bill already paid. Tempest contributes HTTP, DI and models, which it does well. That is exactly why composition works: each side brings the layer it has spent its years on.&lt;/p&gt;

&lt;p&gt;Multi-tenant messaging also has open edge cases in Ecotone's own tracker. The header-based routing shown above is solid for the command/query/transaction path, but I would not claim every corner is covered yet.&lt;/p&gt;

&lt;p&gt;And the integration is young. Building the demo surfaced four integration bugs in ecotone 1.321 — each got a red-first test and a fix released in 1.322, and every workaround the demo carried was then deleted: the concrete &lt;code&gt;OrderMailer&lt;/code&gt; wrapper, the custom &lt;code&gt;shop:consume&lt;/code&gt; command, &lt;code&gt;persistent: true&lt;/code&gt; in the database config, the &lt;code&gt;APP_ENV&lt;/code&gt; override in phpunit.xml. The demo today runs workaround-free. That is what week one of a new integration honestly looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transferable lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Declarative + declarative composes; imperative + declarative fights.&lt;/strong&gt; The integration is thin (about 20 source files) because neither side adapts to the other's philosophy. When picking tools to combine, alignment of principles matters more than feature lists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero config means reading what the app already declares.&lt;/strong&gt; PSR-4 roots, database config — the developer wrote them once; the integration derives from them instead of asking twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A framework choice does not have to mean an ecosystem ceiling.&lt;/strong&gt; The "good fit, once more mature" objection assumes every capability must come from the framework's own ecosystem. A portable architecture layer breaks that assumption: the same Ecotone code runs on Laravel, Symfony, and now Tempest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise-grade is a property of the architecture, not the amount of code.&lt;/strong&gt; Transactions shared with the ORM connection, dead-letter support, tenant routing — none of it written by the application.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;/p&gt;
  Tempest persists every model property — including Ecotone's recorded events
  &lt;br&gt;
Using Ecotone's &lt;code&gt;WithEvents&lt;/code&gt; trait on a Tempest model maps the trait's private &lt;code&gt;$recordedEvents&lt;/code&gt; property to a database column, and checkout fails with "no such column: recordedEvents". The fix is Tempest's own escape hatch: declare your own recorded-events property marked &lt;code&gt;#[Virtual]&lt;/code&gt; with a &lt;code&gt;recordThat()&lt;/code&gt; method and an &lt;code&gt;#[AggregateEvents]&lt;/code&gt; release method. Two conventions colliding on one class, one-attribute fix.&lt;br&gt;


&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  JMS cannot serialize array-shape docblocks
  &lt;br&gt;
&lt;code&gt;array&amp;lt;array{product_id: int, ...}&amp;gt;&lt;/code&gt; fails with "Can't use unsupported type array." Define a proper readonly DTO (&lt;code&gt;OrderLine&lt;/code&gt;) and use &lt;code&gt;@param array&amp;lt;OrderLine&amp;gt; $items&lt;/code&gt; — JMS reads that natively. The serializer pushed the model toward the better shape.&lt;br&gt;


&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  In integration tests, clear Ecotone's static state BEFORE the kernel boots
  &lt;br&gt;
Discovery may compile the messaging system during boot. Clearing statics afterwards leaves a container singleton without its gateway ids, and failures get confusing fast.&lt;br&gt;


&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Tempest gaining CQRS is the small news. The bigger news is what two discovery-based frameworks make possible when composed: an enterprise-level feature list carried by application code that holds only business logic. If you have been telling yourself that this architecture is for teams with a platform group, count the classes again.&lt;/p&gt;

&lt;p&gt;How many classes does the messaging layer hold in your current project — and how many of them are business logic? Tell me in the comments.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.ecotone.tech/modules/tempest" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Explore Ecotone for Tempest&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;em&gt;About the author: Dariusz Gafka is a Software Architect and author of the &lt;a href="https://github.com/ecotoneframework/ecotone" rel="noopener noreferrer"&gt;Ecotone Framework&lt;/a&gt;. He writes about event sourcing, CQRS, and PHP architecture patterns.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.ecotone.tech/tempest-ecotone-one-declarative-foundation/" rel="noopener noreferrer"&gt;blog.ecotone.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>architecture</category>
      <category>cqrs</category>
      <category>tempest</category>
    </item>
    <item>
      <title>Your Legacy PHP Codebase Isn't Hopeless</title>
      <dc:creator>Dariusz Gafka</dc:creator>
      <pubDate>Mon, 26 Jan 2026 22:05:16 +0000</pubDate>
      <link>https://dev.to/dgafka/your-legacy-php-codebase-isnt-hopeless-16l0</link>
      <guid>https://dev.to/dgafka/your-legacy-php-codebase-isnt-hopeless-16l0</guid>
      <description>&lt;p&gt;You ship a small bug fix. Suddenly, two other features break. Every deployment feels like gambling. The business depends on this app — it brings in revenue, customers use it daily — but nobody feels confident working on it.&lt;/p&gt;

&lt;p&gt;You open a file that should be a simple list of functions and find a 2,000‑line monolith of nested loops and if‑statements. Comments like &lt;code&gt;// Temporary fix&lt;/code&gt; from years before. Presentation, database queries, and business logic all mixed together in what one developer described as a “glorious spaghetti mashup.”&lt;/p&gt;

&lt;p&gt;You're not alone. And your codebase isn't hopeless.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Industry’s Dirty Secret
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;~13% of Composer installs still run end‑of‑life PHP versions
&lt;/li&gt;
&lt;li&gt;~27% are on EOL or security‑only versions
&lt;/li&gt;
&lt;li&gt;Over 50% of popular PHP packages support unsupported PHP versions
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Legacy systems are the norm, not the exception.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Trap: Rewrite or Suffer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Rewrites have ~23% success rate
&lt;/li&gt;
&lt;li&gt;Incremental modernization has ~53% success rate
&lt;/li&gt;
&lt;li&gt;Small changes fail only ~4% of the time
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rewrites discard years of encoded business knowledge.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Third Path: Incremental Transformation
&lt;/h2&gt;

&lt;p&gt;Instead of rewriting everything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identify a painful area
&lt;/li&gt;
&lt;li&gt;Extract logic into a message handler
&lt;/li&gt;
&lt;li&gt;Test it in isolation
&lt;/li&gt;
&lt;li&gt;Make it async if needed
&lt;/li&gt;
&lt;li&gt;Repeat
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is exactly what &lt;strong&gt;Ecotone&lt;/strong&gt; enables.&lt;/p&gt;




&lt;h2&gt;
  
  
  Example: The 800‑Line Controller
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;placeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// validation, persistence, payments, emails, analytics...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 1: Extract an Event
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderWasPlaced&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderNotificationHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[EventHandler]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sendConfirmation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OrderWasPlaced&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: Publish From Legacy Code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;eventBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderWasPlaced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$customer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: Test in Isolation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderNotificationHandlerTest&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;TestCase&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 4: Make It Async
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Asynchronous('notifications')]&lt;/span&gt;
&lt;span class="na"&gt;#[EventHandler]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sendConfirmation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OrderWasPlaced&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5: Repeat
&lt;/h2&gt;

&lt;p&gt;Analytics, loyalty points, reporting — each becomes isolated and testable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Command Handlers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PlaceOrder&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderHandler&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;commandBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PlaceOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Database Queries
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderQueries&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[DbalQuery(...)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getPendingOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$customerId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Resilience &amp;amp; Retries
&lt;/h2&gt;

&lt;p&gt;Retries, DLQs, and error handling are declarative and consistent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Idempotency
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Deduplicated('orderId')]&lt;/span&gt;
&lt;span class="na"&gt;#[CommandHandler]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;placeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PlaceOrder&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why This Works
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;System keeps running
&lt;/li&gt;
&lt;li&gt;Small PRs
&lt;/li&gt;
&lt;li&gt;Fast tests
&lt;/li&gt;
&lt;li&gt;Compounding improvements
&lt;/li&gt;
&lt;li&gt;Attracts senior engineers
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require ecotone/ecotone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel and Symfony integrations available.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Legacy codebases are not failures — they are assets.&lt;br&gt;
Incremental modernization gives them a future.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>codequality</category>
      <category>php</category>
    </item>
    <item>
      <title>Implementing Event-Driven Architecture in PHP</title>
      <dc:creator>Dariusz Gafka</dc:creator>
      <pubDate>Fri, 26 Dec 2025 16:42:28 +0000</pubDate>
      <link>https://dev.to/dgafka/implementing-event-driven-architecture-in-php-27ho</link>
      <guid>https://dev.to/dgafka/implementing-event-driven-architecture-in-php-27ho</guid>
      <description>&lt;p&gt;Traditional service integration moves routing logic outside the application’s code.&lt;br&gt;
Message brokers, cloud messaging services, and stream-processing topologies become the place where business-critical flows are defined.&lt;/p&gt;

&lt;p&gt;However, this comes at a cost of making our Endpoints - Dumb.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dumb Endpoints
&lt;/h2&gt;

&lt;p&gt;We often agree to move routing logic outside the application, because it promises simplicity or speed. It looks simpler because it looks like we no longer need to handle routing ourselves. From a developer’s perspective, we just receive and process a message, while routing happens “somewhere else” — outside the code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When important logic is pushed outside the application, the code becomes unaware of the integrations it depends on. This makes changes harder to test and verify. It also lowers confidence when making changes, because modifying something outside the application is always riskier than changing code we fully own and can easily cover with tests.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When we follow the dumb endpoints approach, where the application is unaware of routing logic, we eventually end up in a situation where:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Knowledge becomes fragmented&lt;/strong&gt; — Only a few people truly understand the full setup and configuration that lives outside the applications being integrated.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Testing becomes painful&lt;/strong&gt; — It is no longer easy to test behavior using automated application-level tests. Changes often require modifying external configurations, where testing and verifying correctness is much harder.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Changes become risky&lt;/strong&gt; — When changes cannot be easily verified, confidence drops. This slows development and often leads to more bugs and production issues.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The state of the architecture is often accepted as it is, and the problems created by dumb endpoints are pushed onto developers. This often leads to situations where more “control” is introduced to prevent further issues — for example, by adding gatekeepers who must review and approve every change.&lt;br&gt;
Ironically, this all starts with the promise of speed and simplicity, offered as a trade-off for moving integration logic outside the application.&lt;/p&gt;

&lt;p&gt;However, we can achieve both speed and simplicity while keeping integrations under the control of the application itself. There is no trade-off required. To do this, we need to follow a different approach — one where &lt;strong&gt;endpoints are no longer dumb, but become smart.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Smart Endpoints - Dumb Pipes
&lt;/h2&gt;

&lt;p&gt;This leads us to the &lt;strong&gt;Smart Endpoints, Dumb Pipes&lt;/strong&gt; approach.&lt;br&gt;
It reverses the direction of responsibility — instead of moving logic outward, we move it back inward. Applications are no longer dumb. They become smart and decide where messages should go and where they should be consumed from. In this model, the application itself fully controls the integration.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;To achieve smart endpoints we need to build the logic of routing inside our Applications. This means using clear abstractions that allow us to orchestrate message flow within the code, rather than external configurations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To make this possible, messaging needs to be a first-class citizen in our applications.&lt;br&gt;
The messaging abstraction should provide routing capabilities that we can configure as needed and fully test from within the application. Ideally, this abstraction should be decoupled, meaning we are not forced to implement or extend any framework-specific classes.&lt;/p&gt;

&lt;p&gt;Enterprise Integration Patterns is a great book that defines a set of abstractions for building messaging systems at the programming-language level. I brought these patterns to life in the &lt;strong&gt;Ecotone Framework for PHP&lt;/strong&gt;.&lt;br&gt;
In the next section, we will explore how to build integrations between applications using a higher-level abstraction built on top of these patterns — the &lt;strong&gt;Service Map.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Service Map
&lt;/h2&gt;

&lt;p&gt;Now that we’ve established that smart endpoints keep routing logic inside the application and provide messaging capabilities directly within the programming language, let’s explore how applications can actually be integrated. &lt;br&gt;
To do this, we will look at one of Ecotone’s features — the Service Map.  &lt;/p&gt;

&lt;p&gt;Service Map is exactly what it sounds like—a map of integrated applications (services) and the pipes (channels) which they communicate through. Here's how to set it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[ServiceContext]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;serviceMap&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;DistributedServiceMap&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DistributedServiceMap&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withCommandMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;targetServiceName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ticketService"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;channelName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ticket_commands"&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;This configuration says:&lt;/strong&gt; "When sending Commands to Ticket Service, use ticket_commands channel (pipe)"&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The routing is done at the Application level, not the Message Broker level. This means that we control the process from within the codebase we own, and can easily cover that with tests.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fizg2r468slvzj1jymyg5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fizg2r468slvzj1jymyg5.png" alt="Command routing" width="800" height="51"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This configuration is for sending Commands, for Event we will be using Event Mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[ServiceContext]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;serviceMap&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;DistributedServiceMap&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DistributedServiceMap&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withEventMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;channelName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ticket_events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;subscriptionKeys&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"user.*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This configuration says: "When publishing Events, when routing key start with user then ticket_events channel (pipe)"&lt;br&gt;&lt;br&gt;
Event Mapping allows us to publish Events to specific Channel (Pipe) based on subscription keys. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvyuddlhagufzolzwetxq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvyuddlhagufzolzwetxq.png" alt="Event routing" width="800" height="54"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We can of course have multiple subscription to broadcast events to different Services. &lt;/p&gt;
&lt;h2&gt;
  
  
  Application Code
&lt;/h2&gt;

&lt;p&gt;With the map configured, publishing is straightforward. &lt;/p&gt;
&lt;h3&gt;
  
  
  Sending side
&lt;/h3&gt;

&lt;p&gt;For Commands, we target a specific service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;onUserRegistered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="kt"&gt;DistributedBus&lt;/span&gt; &lt;span class="nv"&gt;$distributedBus&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$distributedBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;convertAndSendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;targetServiceName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ticketService"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;routingKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ticket.create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CreateTicket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Welcome!"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Events that multiple services might care about, we publish without a target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$distributedBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;convertAndPublishEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;routingKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"user.registered"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserRegistered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Ecotone makes this part of the API: Commands are sent to a single service, while Events can be delivered to many services.&lt;br&gt;
The Service Map automatically handles routing based on subscription keys.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Receiving side
&lt;/h3&gt;

&lt;p&gt;On the receiving side, we mark handlers as distributed to accept external messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Distributed]&lt;/span&gt;
&lt;span class="na"&gt;#[CommandHandler("ticket.create")]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;createTicket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;CreateTicket&lt;/span&gt; &lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Create the ticket&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;#[Distributed]&lt;/span&gt;
&lt;span class="na"&gt;#[EventHandler("user.registered")]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;onUserRegistered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserRegistered&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// React to user registration&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The &lt;code&gt;#[Distributed]&lt;/code&gt; attribute makes it explicit that these handlers can receive messages from other services. This clarity prevents accidental breaking changes.  &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I mentioned earlier that this approach does not require sacrificing speed.&lt;br&gt;
We are not building our own integration infrastructure from scratch — instead, we reuse existing systems.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The key idea is to keep the logic inside the application and treat pipes (channels) as simple transport.&lt;br&gt;
The channel’s only responsibility is to move messages, not to act as the “mastermind” of orchestration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We have two message channels (pipes): &lt;strong&gt;ticket_commands&lt;/strong&gt; and &lt;strong&gt;event_commands&lt;/strong&gt;.&lt;br&gt;
With the Service Map approach, we can define their implementations in a way that fits our needs, without being tightly coupled to a specific message broker.  &lt;/p&gt;

&lt;p&gt;This means we can choose — and later switch — the underlying technology if needed.&lt;br&gt;
For example, we might decide to use RabbitMQ or Redis-based channels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[ServiceContext]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;// Amazon SQS Message Channel&lt;/span&gt;
        &lt;span class="nc"&gt;SqsBackedMessageChannelBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ticket_events"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="c1"&gt;// RabbitMQ Message Channel&lt;/span&gt;
        &lt;span class="nc"&gt;AmqpBackedMessageChannelBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ticket_commands"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Defining Channel is enough for Ecotone to automatically register Message Consumer for us. From that point on, we can start consuming messages right away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/console ecotone:run &lt;span class="o"&gt;{&lt;/span&gt;ticket_commands/ticket_events&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Streaming Channels
&lt;/h2&gt;

&lt;p&gt;The Service Map works regardless of whether we use queue-based brokers or streaming platforms under the hood. &lt;br&gt;
When using streaming platforms, we gain additional capabilities thanks to their non-destructive nature, which I described in a &lt;a href="https://blog.ecotone.tech/async-failure-recovery-queue-vs-streaming-channel-strategies/" rel="noopener noreferrer"&gt;previous blog post&lt;/a&gt;.  &lt;/p&gt;

&lt;p&gt;In the case of queue-based solutions, we can push messages to each channel as part of the publishing process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[ServiceContext]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;serviceMap&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;DistributedServiceMap&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DistributedServiceMap&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withEventMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;channelName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ticket_events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;subscriptionKeys&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"user.*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withEventMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;channelName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"order_events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;subscriptionKeys&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"user.*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5j6axwepidysvz5qk4kn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5j6axwepidysvz5qk4kn.png" alt="Queue based Event publishing" width="450" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When using Kafka or RabbitMQ streaming channels, we can push messages to a single channel, from which multiple services can consume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[ServiceContext]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;serviceMap&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;DistributedServiceMap&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DistributedServiceMap&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withEventMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;channelName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"user_events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;subscriptionKeys&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"user.*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ukpqsgbr2314lb6cyq2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ukpqsgbr2314lb6cyq2.png" alt="Streaming based Event Publishing" width="450" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ecotone provides different Message Channels integrations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Streaming Channels: Kafka and RabbitMQ&lt;/li&gt;
&lt;li&gt;Queue Channels: RabbitMQ, Amazon SQS, Redis, Database Channels, Symfony Messenger, Laravel Queues&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Decoupled Data Models
&lt;/h2&gt;

&lt;p&gt;All communication happens through defined routing keys, whether the message is a Command or an Event. This is intentional and helps keep applications decoupled from each other.  &lt;/p&gt;

&lt;p&gt;As a result, each application can use models that fit its own needs and include only the data that is truly meaningful from an integration perspective.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Publisher sends this&lt;/span&gt;
&lt;span class="nv"&gt;$distributedBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;convertAndPublishEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;routingKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"user.billing.changed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BillingDetailsChanged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$newAddress&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Consumer can use different model&lt;/span&gt;
&lt;span class="na"&gt;#[Distributed]&lt;/span&gt;
&lt;span class="na"&gt;#[EventHandler("user.billing.changed")]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserAddressUpdated&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Different class, same routing key&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Whether models are shared or not should be a project-level decision.&lt;br&gt;
Ecotone does not force either approach, allowing teams to choose what works best for their specific context.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Testing Integrations
&lt;/h2&gt;

&lt;p&gt;One of the core ideas I mentioned earlier is making integrations testable at the application level. With Ecotone’s Service Map, we can test integrations using in-memory channels or real integrations, all directly from the application code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$messaging&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EcotoneLite&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;bootstrapFlowTesting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ServiceMapConfig&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;enableAsynchronousProcessing&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;// Define using which Channel you want to test&lt;/span&gt;
        &lt;span class="nc"&gt;SimpleMessageChannelBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;createQueueChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ticket_commands"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$messaging&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;convertAndSendCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;targetServiceName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ticketService"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;routingKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ticket.create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CreateTicket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Welcome!"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Verify command landed in channel&lt;/span&gt;
&lt;span class="nv"&gt;$message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$messaging&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessageChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ticket_commands'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertNotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same way we could test out consumption side of things. It's really easy to test any kind of Service Map and cover that with automated tests to ensure that delivery happens as we expect. &lt;/p&gt;

&lt;h2&gt;
  
  
  Other Supporting Features
&lt;/h2&gt;

&lt;p&gt;We did cover the core part of integration, however together with that Ecotone provides much more features, that ensures that integrations works as expected. For this you may consider exploring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Outbox pattern: For transactional consistency&lt;/li&gt;
&lt;li&gt;Dead letter queues: For failed message handling &lt;/li&gt;
&lt;li&gt;Message priorities: For urgent processing &lt;/li&gt;
&lt;li&gt;Scheduled messages: For delayed delivery&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For this you may take a look on &lt;a href="https://docs.ecotone.tech/" rel="noopener noreferrer"&gt;Ecotone's documentation page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Choosing the &lt;strong&gt;Smart Endpoints, Dumb Pipes&lt;/strong&gt; architecture allows us to take full control of the integration process and keep things simple, testable, and easy to verify for everyone.&lt;br&gt;
The goal is to keep integration logic close to where it is actually used. This helps maintain shared knowledge and a clear understanding of how the system behaves as it evolves.&lt;/p&gt;

&lt;p&gt;You can read more about about Service Map under &lt;a href="https://docs.ecotone.tech/modelling/microservices-php/distributed-bus/distributed-bus-with-service-map" rel="noopener noreferrer"&gt;this link&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Whatever you choose to use Ecotone to deliver this approach or build it yourself, feel free to join &lt;a href="https://discord.gg/GwM2BSuXeg" rel="noopener noreferrer"&gt;Ecotone's community channel&lt;/a&gt; to discuss different approaches and share the experiences. &lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>php</category>
      <category>eventdriven</category>
    </item>
  </channel>
</rss>
