Skip to content

Repository files navigation

Ecotone × Tempest demo shop

Tempest and Ecotone connected by message flows

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:

  • CQRSCommandBus/QueryBus injected into controllers; checkout is $commandBus->send(new PlaceOrder(...)).
  • Aggregate on a Tempest modelOrder is #[Aggregate] + use IsDatabaseModel; Ecotone loads/saves it through Tempest's own persistence.
  • Event-driven read modelsOrderWasPlaced fans out to a sync stock decrementer and async notification recorder.
  • Async processing — a DBAL-backed notifications channel on Postgres; a separate worker container consumes it with Ecotone's own CLI entrypoint (./tempest ecotone:run notifications).
  • Event sourcingShipment is an #[EventSourcingAggregate]; its stream lives in the Postgres event store; ShipmentListProjection derives the shipment_views read 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 same DeadLetterGateway.
  • 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 constructionOrderWasPlaced is 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_deduplication table; no code involved.

What it looks like

The storefront — plain Tempest: models, views, a session cart. Adding to cart and browsing never touch Ecotone.

Product grid of the Ecotone × Tempest shop

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.

Cart and checkout form

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.

Orders dashboard with summary tiles, shipments and notifications

Order details — the Order aggregate read back through the QueryBus; Cancel routes an instance command to the same Tempest model that is the aggregate.

Single order details view

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.

Mailpit inbox with order confirmation and review request emails

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.

Alerts page listing parked messages with failure details, stacktrace and replay buttons

Run it

docker compose up --build -d

Migrations 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.

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 worker

Failure story: watch a message survive an outage

The 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.

Outbox, visible in 30 seconds

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

Where the messaging lives

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

Tests & QA

docker compose exec shop composer test   # Tempest-kernel test + EcotoneLite flow tests
docker compose exec shop composer qa     # lint, format, static analysis, tests
  • tests/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.

Rebuilding from scratch

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-converter

Everything else is application code under app/Shop.

About

Demo application using Ecotone integration with Tempest

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages