> ## Content Index
> Fetch the complete content index at: https://blog.ecotone.tech/llms.txt
> Use this file to discover other available public pages before exploring further.

# Enterprise PHP in 2026: The Patterns You're Missing
- URL: https://blog.ecotone.tech/meet-ecotone-enterprise-ready-framework-for-php/
- Published: 2021-07-14T05:26:52.000Z
- Updated: 2026-05-14T15:11:45.000Z
- Description: How modern PHP teams ship reliable, distributed systems in 2026 — outbox, sagas, event sourcing, and per-handler resiliency without rewriting Symfony or Laravel.
- Author: Dariusz Gafka
- Tags: PHP, Enterprise PHP, Event Sourcing, CQRS, Outbox Pattern, Ecotone

*Updated on 2026-05-14*

For years the PHP community has been told its language can’t do “enterprise.” In 2026, that’s no longer true — but the gap between a Symfony or Laravel app and one that survives real production load isn’t the framework. It’s four patterns most PHP teams still hand-roll badly: the **transactional outbox**, **per-handler failure isolation**, **composable workflows** instead of status columns, and **event-sourced read models**.

> **TL;DR**`if`

### Table of contents

- [Why “enterprise PHP” feels harder than it should](#why-enterprise-php-is-hard)
- [Pattern 1 — Transactional Outbox + RabbitMQ in Two Lines](#outbox)
- [Pattern 2 — Per-Handler Failure Isolation](#isolation)
- [Pattern 3 — Composable Workflows Instead of Service Spaghetti](#workflows)
- [Pattern 4 — Projections Instead of JOINs](#projections)
- [Same Domain Code, Symfony or Laravel — Pick Your Stack](#symfony-laravel)
- [Common questions](#faq)

---

## Why “enterprise PHP” feels harder than it should

If you read the [Symfony Messenger outbox issue](https://github.com/symfony/symfony/issues/34147?ref=blog.ecotone.tech) — open since 2019, still active in 2025 — or the [Laravel Horizon “jobs lost randomly” thread](https://github.com/laravel/horizon/issues/857?ref=blog.ecotone.tech), you’ll see the same shape of pain everywhere:

- A handler runs **before** the database transaction commits, so a row that should have been read isn’t there.
- One slow consumer **blocks every other handler** behind it on the same transport.
- A retried job re-fires *all* its sibling handlers, double-charging customers or sending duplicate emails.
- A failed background job **disappears** silently because the dead-letter wiring wasn’t done correctly.

These aren’t framework bugs. They’re missing patterns. JVM teams using Spring Integration, Axon, NServiceBus or MassTransit got these patterns for free a decade ago. PHP teams have, until recently, written them by hand — and got burned every time the implementation drifted from the textbook.

> [Ecotone](https://docs.ecotone.tech/?ref=blog.ecotone.tech)

---

## Pattern 1 — Transactional Outbox + RabbitMQ in Two Lines

The dual-write problem is brutal: you save an order, then publish “OrderPlaced” to RabbitMQ. The save commits, the publish fails, and Shipping never hears about an order the customer already paid for. The textbook fix is the **outbox pattern** — write the event into the same transaction as the business state, then have a forwarder push it to the broker.

Most PHP solutions stop at “DB-only outbox” — fine for small systems, painful at scale because every consumer hammers the database. Ecotone lets you **combine** an outbox channel with a real broker so writes stay transactional *and* downstream consumers scale on RabbitMQ:

```php
final class MessagingConfiguration
{
    #[ServiceContext]
    public function databaseChannel()
    {
        return DbalBackedMessageChannelBuilder::create("database_channel");
    }

    #[ServiceContext]
    public function rabbitChannel()
    {
        return AmqpBackedMessageChannelBuilder::create("rabbit_channel");
    }
}

final class ShippingHandler
{
    #[Asynchronous(["database_channel", "rabbit_channel"])]
    #[EventHandler(endpointId: "shipping.onOrderPlaced")]
    public function handle(OrderWasPlaced $event, ShippingApi $api): void
    {
        $api->scheduleDelivery($event->orderId);
    }
}
```

*Combined channels: the event is committed atomically with the order row into `database_channel`, then a forwarder publishes it to `rabbit_channel` where consumers scale out independently.*

That’s the entire setup — no glue table, no cron, no `messenger:doctrine:outbox` package. The outbox guarantees the event is never lost; RabbitMQ gives you the throughput, fan-out and operational tooling you actually want at the consumer side.

Compare with the [hand-rolled Symfony + Outbox + RabbitMQ walkthrough](https://medium.com/devwarlocks/symfony-outbox-pattern-rabbitmq-a-key-for-reliable-microservices-10bf267fdb0a?ref=blog.ecotone.tech) — same end state, \~150 lines of plumbing replaced by two attributes.

---

## Pattern 2 — Per-Handler Failure Isolation

Symfony Messenger dispatches **one envelope** through every handler bound to it. If three listeners react to `OrderWasPlaced` and the second one throws, you have to decide globally what to do — retry the whole envelope and re-fire handler #1, or skip and lose handler #3\. There is no good answer.

Ecotone publishes a **separate copy** of the event to each handler’s channel. Handler #1 succeeded → it’s done. Handler #2 retries on its own channel with its own backoff. Handler #3 runs as if nothing happened.

```php
#[Asynchronous("billing")]
#[EventHandler(endpointId: "billing.onOrderPlaced")]
public function bill(OrderWasPlaced $event, BillingApi $api): void { /* ... */ }

#[Asynchronous("shipping")]
#[EventHandler(endpointId: "shipping.onOrderPlaced")]
public function ship(OrderWasPlaced $event, ShippingApi $api): void { /* ... */ }
```

*Two channels, two consumers, two retry policies — one event.*

This is exactly the pain in the dev.to post [“Symfony Messenger: A Great Servant, But a Terrible Master”](https://dev.to/tito10047/symfony-messenger-a-great-servant-but-a-terrible-master-or-how-asynchrony-cost-me-half-my-beard-3k5c?ref=blog.ecotone.tech) — and it’s why per-handler isolation is the single most-cited reason teams move to Ecotone.

---

## Pattern 3 — Composable Workflows Instead of Service Spaghetti

Every “we just need a status column” feature eventually becomes a 600-line service with `if ($order->isPaid && !$order->isShipped && ...)` chains. PHP teams reach for queues, listeners, and conditional code; the workflow is **implicit**, scattered across files, and impossible to reason about.

Ecotone makes the workflow **explicit** — handlers chain via `outputChannelName`, and intermediate steps are private to the workflow:

```php
final class ProcessOrder
{
    #[CommandHandler("verify.order", outputChannelName: "place.order")]
    public function verify(PlaceOrder $command): PlaceOrder
    {
        if (!$this->isValidOrder($command)) {
            throw new InvalidOrderException();
        }
        return $command;
    }

    #[InternalHandler("place.order", outputChannelName: "notify.customer")]
    public function place(PlaceOrder $command): PlaceOrder
    {
        $this->orders->save($command);
        return $command;
    }

    #[InternalHandler("notify.customer")]
    public function notify(PlaceOrder $command, Notifier $notifier): void
    {
        $notifier->orderPlaced($command->orderId);
    }
}
```

*Three steps, one workflow. Each step is a normal PHP method; chaining is declared in attributes, not buried in service calls.*

The real power shows up when you **combine workflows together**. Once you have `verify.order`, `process.payment`, `dispatch.shipment` as named building blocks, you compose them into higher-level flows with an `#[Orchestrator]` *(Ecotone Enterprise)*:

```php
final class CheckoutOrchestrator
{
    #[Orchestrator(inputChannelName: "checkout")]
    public function checkout(): array
    {
        return [
            "verify.order",
            "process.payment",
            "dispatch.shipment",
            "notify.customer",
        ];
    }
}

final class RefundOrchestrator
{
    #[Orchestrator(inputChannelName: "refund")]
    public function refund(): array
    {
        return [
            "verify.refund.eligibility",
            "process.payment.reversal",
            "notify.customer", // reuses the step from CheckoutOrchestrator
        ];
    }
}
```

*Two orchestrators, one shared `notify.customer` step. Workflows compose like Lego — change the order, swap a step, branch on metadata, all without touching the implementations.*

This is the same routing-slip pattern that JVM teams use in Apache Camel and Spring Integration. In PHP, until recently, you got it by writing it yourself badly; now it’s an attribute.

> `outputChannelName#[InternalHandler]`

---

## Pattern 4 — Projections Instead of JOINs

When `OrdersList` is taking 800ms because you’re four joins deep, the textbook answer is **CQRS with a read model** — keep the write model normalised, project events into a denormalised table optimised for the queries you actually run.

```php
#[Projection(name: "orders_list")]
final class OrdersListProjection
{
    #[EventHandler]
    public function whenPlaced(OrderWasPlaced $event, Connection $db): void
    {
        $db->insert("orders_list", [/* ... */]);
    }

    #[EventHandler]
    public function whenShipped(OrderShipped $event, Connection $db): void
    {
        $db->update("orders_list", ["status" => "shipped"], ["id" => $event->orderId]);
    }
}
```

*The projection rebuilds itself from the event stream — drop the table and replay any time.*

Ecotone Enterprise adds **partitioned projections** (one partition per aggregate, parallel rebuild) and **streaming projections** (Kafka or RabbitMQ Streams as the event source). On the OSS tier you still get sync and async projections, replay, and gap detection.

---

## Same Domain Code, Symfony or Laravel — Pick Your Stack

The patterns above all live in plain PHP classes — POPOs with attributes. They have **no dependency on the host framework**. The same `OrderService`, `ShippingHandler`, `CheckoutOrchestrator` runs identically on:

```bash
# Symfony — auto-registers via the bundle
composer require ecotone/symfony-bundle

# Laravel — auto-discovered via the service provider
composer require ecotone/laravel

# Standalone / lambda / worker / CLI tools — any PSR-11 container
composer require ecotone/lite-application
```

*Three install paths, one set of domain classes.*

Eloquent, Doctrine, Symfony Messenger Transports, Laravel Queues — Ecotone plugs into what’s already there. On Symfony, your DBAL outbox uses the existing Doctrine connection and your RabbitMQ channel can wrap a Symfony Messenger transport. On Laravel, the outbox uses Eloquent’s connection and the broker channel can ride Laravel’s Queue infrastructure.

That portability is unique in PHP. It means:

- A **shared kernel** of domain code can be reused across two services running on different stacks (a Laravel admin panel and a Symfony API, both reacting to the same `OrderWasPlaced` event).
- Migrating from one stack to the other doesn’t require rewriting your domain — only re-installing the Ecotone bridge package.
- Library authors can ship Ecotone-based modules that work for both communities out of the box.

> **not**

---

## Common questions

### Is Ecotone a replacement for Symfony Messenger or Laravel Queue?

No. It runs on top of either. Messenger and Laravel Queue are transports; Ecotone is the patterns layer (outbox, isolation, workflows, projections, ES) on top of them.

### What’s the PHP equivalent of Axon Framework?

Ecotone is the closest equivalent — same Enterprise Integration Patterns lineage as Axon, Spring Integration, NServiceBus, and MassTransit, expressed through PHP attributes.

### Workflows vs Sagas — when do I use which?

Workflows (stateless chains via `outputChannelName` and `#[Orchestrator]`) are for deterministic step-by-step processes where each step decides what runs next. Sagas (stateful, identifier-tracked) are for long-running coordinations that wait on external events — e.g. “order placed, then payment received minutes later, then shipping confirmed hours later.” Use workflows for sequencing, sagas for waiting.

### How do I test async flows without spinning up RabbitMQ?

`EcotoneLite::bootstrapFlowTesting()` gives you in-memory channels and deterministic async testing — no broker, no polling, no flaky tests.

---

## Wrapping up

The patterns themselves aren’t new — they’re the same ones running banks and logistics platforms on the JVM since the early 2010s. What’s new in 2026 is that PHP finally has them in a tool that doesn’t ask you to leave Symfony or Laravel behind.

If you want to try it on your codebase, the [tutorial](https://docs.ecotone.tech/tutorial-php-ddd-cqrs-event-sourcing?ref=blog.ecotone.tech) walks through a runnable example end-to-end, or jump straight into the [Ecotone documentation](https://docs.ecotone.tech/?ref=blog.ecotone.tech).

---

*Dariusz Gafka is a Software Architect and author of the [Ecotone Framework](https://github.com/ecotoneframework/ecotone?ref=blog.ecotone.tech). He writes about event sourcing, CQRS, and message-driven PHP architecture.*