A browsable e-commerce application showing the ecotone/tempest integration in
real life — because code snippets are not enough: run it, click through it, break
it, and play with the messaging behind it (see the
Tempest module documentation):
Tempest owns HTTP, DI, views and database models; Ecotone owns the messaging.
There is no ecotone.config.php required — composer require ecotone/tempest
was the entire messaging setup (the config file in this app only names the service).
What runs here, end to end:
- CQRS —
CommandBus/QueryBusinjected into controllers; checkout is$commandBus->send(new PlaceOrder(...)). - Aggregate on a Tempest model —
Orderis#[Aggregate]+use IsDatabaseModel; Ecotone loads/saves it through Tempest's own persistence. - Event-driven read models —
OrderWasPlacedfans out to a sync stock decrementer and async notification recorder. - Async processing — a DBAL-backed
notificationschannel on Postgres; a separate worker container consumes it with Ecotone's own CLI entrypoint (./tempest ecotone:run notifications). - Event sourcing —
Shipmentis an#[EventSourcingAggregate]; its stream lives in the Postgres event store;ShipmentListProjectionderives theshipment_viewsread model shown in the UI (with a Dispatch button driving an instance command handler). - Stateless workflow + header enrichment + email — event handlers compose only the content (
EmailNotification);AccountDetailsEnricher(changingHeaders: true) adds the recipient's account details as message headers; a shared#[InternalHandler]send block turns payload + headers into a real SMTP email in Mailpit. Both the confirmation and the delayed review request flow through the same chain. - Metadata propagation — the checkout form's "simulate an email delivery failure" checkbox is sent as metadata on the command; Ecotone carries it to the recorded event and on to the asynchronous handlers, where the mail step reads it as a
#[Header]parameter. No step in between passes it along. - Retries, dead letter, isolated failures — a failing handler is retried with backoff (1s, 3s) and then parked in the database-backed dead letter; other messages on the same channel keep flowing and the worker never dies. Inspect and replay from the CLI (
./tempest ecotone:deadletter:list | show | replay) or from the Alerts page in the UI, which reads the sameDeadLetterGateway. - Delayed messages —
#[Delayed(new TimeSpan(seconds: 30))]sends a review-request email 30 s after a shipment is dispatched. Not a scheduled task: one specific message, due once, waiting inside the durable channel. - Outbox by construction —
OrderWasPlacedis committed in the same Postgres transaction as the order row (the channel is database-backed), so "order saved but event lost" cannot happen. - Deduplication out of the box — redelivered messages are skipped via the
ecotone_deduplicationtable; no code involved.
The storefront — plain Tempest: models, views, a session cart. Adding to cart and browsing never touch Ecotone.
Checkout — the moment messaging starts: the controller sends PlaceOrder through the CommandBus, and everything below (stock, notifications, shipment, email) follows from the recorded OrderWasPlaced event. The "simulate an email delivery failure" checkbox rides along as message metadata, all the way to the mail step.
The orders dashboard — every panel is a different read model: summary tiles from the QueryBus, shipments derived by the projection from the event stream (the Dispatch button sends an instance command to the event-sourced Shipment), notifications filled in asynchronously by the worker.
Order details — the Order aggregate read back through the QueryBus; Cancel routes an instance command to the same Tempest model that is the aggregate.
The inbox — where the workflow ends: confirmation emails sent through Tempest's Mailer from the background worker, and the delayed review request arriving 30 s after dispatch.
Alerts — the dead letter as an operations page: what failed, why, when, and where it was thrown, with the full stacktrace and a button to replay or delete. It reads Ecotone's DeadLetterGateway from a plain Tempest controller, so the page and the ./tempest ecotone:deadletter:* commands work on exactly the same messages.
docker compose up --build -dMigrations and seeding run automatically: a one-shot migrate service waits for
Postgres to be healthy, runs migrate:up and database:seed, and only then are
shop and worker started. Both are idempotent, so every docker compose up
brings the schema up to date without touching existing data.
- Shop UI: http://localhost:8000 — browse, add to cart, check out, watch the orders dashboard.
- Alerts: http://localhost:8000/alerts — parked messages with failure details, replay and delete.
- Mailbox: http://localhost:8025 — order confirmation emails land here (Mailpit).
Place an order, then open the orders page: the summary tiles come from the QueryBus, the shipment appears once the worker prepares it from the event stream, notifications fill in asynchronously, and the confirmation email shows up in Mailpit.
After changing handler code, refresh the compiled messaging system:
docker compose exec shop php tempest ecotone:cache:clear
docker compose restart workerThe checkout form has a "Simulate an email delivery failure" checkbox — tick it to make the confirmation email for that order fail on send:
# 1. Check out twice: once with the box ticked, once without.
# The normal order's email arrives; the other retries (1s, 3s) and parks:
docker compose exec shop php tempest ecotone:deadletter:list
docker compose exec shop php tempest ecotone:deadletter:show <message-id>
# 2. Replay it — the message runs the whole flow again and lands in Mailpit:
docker compose exec shop php tempest ecotone:deadletter:replay <message-id>Or do all of that in the browser: http://localhost:8000/alerts lists the same
parked messages with their failure details and replays or deletes them on a
click — the page injects Ecotone's DeadLetterGateway into a Tempest
controller, which is the same gateway behind the console commands.
Both halves of this are message headers, and neither is passed by hand. The
checkbox is sent as metadata on the PlaceOrder command, and Ecotone propagates
it to the recorded event and on to the asynchronous handlers it reaches — the
mail step reads it as a #[Header] parameter, while nothing in between knows it
exists. The replay is the same trick in reverse: Ecotone marks a replayed
message with ecotone.dlq.message_replied, and the mail step reads that to mean
the outage is over, so a replay always delivers. A handler can see how a message
got to it, not only what it carries.
The point: one poison message never blocks the channel, nothing is lost, and the
paper trail plus the replay tooling appear natively in ./tempest.
docker compose stop worker
# ...place an order in the UI, then look at the pending event messages —
# committed in the SAME transaction as the order row:
docker compose exec database psql -U shop -d shop \
-c "select count(*) from enqueue where queue='notifications';"
docker compose start worker # messages deliver, the table drains| Piece | File |
|---|---|
| Order aggregate (= Tempest model) | app/Shop/Order/Order.php |
| PlaceOrder command + OrderLine DTO | app/Shop/Order/PlaceOrder.php, OrderLine.php |
| Domain events | app/Shop/Order/OrderWasPlaced.php, OrderWasCancelled.php |
| Stock read model update (sync) | app/Shop/Inventory/StockLevelUpdater.php |
| Notifications read model (async) | app/Shop/Notifications/NotificationRecorder.php |
| Email pipeline (content → enrich headers → send) | app/Shop/Notifications/OrderConfirmationWorkflow.php, AccountDetailsEnricher.php, EmailNotification.php |
| Retries + dead letter config | app/Shop/Messaging/MessagingConfiguration.php (errorHandling()) |
| Alerts page (dead letter UI) | app/Shop/Monitoring/AlertsController.php, alerts.view.php |
| Event-sourced shipment | app/Shop/Fulfillment/Shipment.php |
| Shipment projection → read model | app/Shop/Fulfillment/ShipmentListProjection.php, ShipmentView.php |
| Channel + connection config | app/Shop/Messaging/MessagingConfiguration.php |
| Worker entrypoint | built-in ecotone:run (see docker-compose.yml) — no app code needed |
| Dashboard query | app/Shop/Order/OrderSummaryService.php |
docker compose exec shop composer test # Tempest-kernel test + EcotoneLite flow tests
docker compose exec shop composer qa # lint, format, static analysis, teststests/EcotoneLite/— pure messaging tests, no kernel, no database, no broker: the event-sourced shipment flow, async handlers tested synchronously (publishEvent→ assert nothing yet →run('notifications')→ assert), the delayed review request tested by time-travel (releaseAwaitingMessagesAndRunConsumer(..., new TimeSpan(seconds: 31))— no sleeps), and the failure path tested end to end: a poison message is retried, parked in the dead letter, and the healthy message on the same channel still delivers (OrderConfirmationRetryTest,ReviewRequestTest).tests/OrderFlowTest.php— boots a real Tempest kernel (in-memory SQLite) and covers the model-as-aggregate slice through the buses.
The app was created with the standard Tempest skeleton, then Ecotone was added:
composer create-project tempest/app .
composer require ecotone/tempest ecotone/dbal ecotone/pdo-event-sourcing ecotone/jms-converterEverything else is application code under app/Shop.






