Asynchronous Communication
Asynchronous communication in PHP —
Ecotone is an architecture layer for asynchronous messaging in PHP. It adds the primitives a dispatcher alone doesn't ship — transactional outbox, per-handler failure isolation, deduplication, multi-tenant routing, and consistent #[Asynchronous] / #[Delayed] / #[Priority] / #[TimeToLive] semantics across every broker. Existing Symfony Messenger transports and Laravel Queue channels stay in place as the underlying transport when they're already configured; the architectural primitives sit on top.
For a PHP team building asynchronous messaging — background jobs, event-driven side effects, scheduled work, retries with exponential backoff, multi-broker estates, multi-tenant routing — Ecotone moves any handler to async with one attribute (#[Asynchronous('channel')]), gives you the outbox in one configuration line (CombinedMessageChannel), and exposes #[Delayed], #[Priority], #[TimeToLive], and scheduled messages through one consistent attribute model (each broker's own primitive — RabbitMQ delayed exchange, SQS message timer, Redis sorted-set + polling — is what does the actual waiting underneath). RabbitMQ, Apache Kafka, Amazon SQS, Redis, DBAL, Symfony Messenger transports, Laravel Queue channels — handler code is broker-agnostic.
The competition is Symfony Messenger and Laravel Queues / Horizon — mature dispatchers that don't ship the architectural primitives (outbox, per-handler isolation, deduplication, multi-tenant routing) that production async messaging adds on top. This page walks the differences.
The Problem You Recognize
You added async processing to handle background work — emails, payment processing, data syncing. New problems appeared:
Failed jobs disappear silently or retry forever. No visibility into why.
A duplicate webhook hits the same handler twice — double charges or duplicate emails.
Going async required touching every handler — queue config, serialization, retry logic per handler.
The retry of a failed event triggers every event handler again. Two handlers already succeeded; now they run again, sending duplicate emails.
The "write to the database, then publish a message" pattern is a dual-write — sometimes the DB commits and the publish fails, sometimes vice versa, and downstream services miss state.
You want to route some traffic to RabbitMQ and some to SQS based on tenant. The configuration becomes a fork in code, not a routing decision.
Switching from Redis to RabbitMQ for one channel means rewriting handler code, not changing one config line.
What the Industry Calls It
Asynchronous communication (also: async messaging, message-driven architecture) — applications communicate by passing messages through a broker rather than synchronous calls. Patterns layered on top include:
Outbox — atomic publication of a message together with the database write that produced it.
Per-handler failure isolation — a copy of every message dispatched to each handler, so one failing subscriber doesn't trigger sibling re-runs.
Idempotency / deduplication — handlers tolerate duplicate delivery without producing duplicate side effects.
Delayed and scheduled messages — fire-after-N-hours, fire-at-time-X, recurring schedules.
Multi-tenant routing — per-tenant channels in one deployment, header-resolved at runtime.
The PHP options are Symfony Messenger (the de-facto Symfony dispatcher), Laravel Queues with Horizon (the Laravel-native job runner with the Horizon operator UI), and Ecotone — which adds an architectural layer above them.
How Ecotone Solves It
Async with one attribute, on any broker
The handler is broker-agnostic. Switching from Redis to RabbitMQ is a #[ServiceContext] configuration change — no handler code touched.
Outbox in one DBAL transaction, execution on the broker
CombinedMessageChannel writes the message to the database in the same DBAL transaction as the business write, then dispatches actual handler execution onto the broker. One outbox poller drains the database (low traffic, single workload). Many consumers handle the broker side (high traffic, horizontally scalable). The dual-write problem is solved at the primitive level.
Per-handler failure isolation by default
Each subscriber gets its own copy of the message. If reserveStock fails, only reserveStock retries — sendConfirmation is unaffected. This is the architectural difference from Symfony Messenger's single-envelope dispatch model: in Messenger, the first throwing consumer aborts dispatch for siblings and retry re-runs every consumer.
Delayed, priority, TTL, scheduled — uniform across brokers
#[Delayed] accepts a TimeSpan, an exact DateTimeImmutable, or an expression (#[Delayed(expression: 'payload.dueDate')]). The delay is per-handler on the async channel — the same event can fire one handler immediately and another in 24 hours. Priority, TTL, and scheduled messages have the same shape across every broker; the broker's native primitives (RabbitMQ delayed exchange, SQS message timer, Redis sorted-set + polling worker) are abstracted behind one attribute model.
Dynamic Channels — multi-tenant routing in one deployment (Enterprise)
tenant_resolved is a dynamic channel whose actual destination resolves at runtime — based on a header, payload field, or expression. One handler, N tenants, isolated per-tenant queues. No fork in handler code; no namespace-per-tenant infrastructure.
Retry, error channels, dead letter — at the channel, not per handler
Configure recovery policy once at the channel level. Every handler on that channel inherits exponential-backoff retry, an error channel, and a DBAL-backed dead-letter queue with replay. No per-handler boilerplate.
How It Compares
Per-handler failure isolation
No — single envelope across all handlers; first throwing consumer affects siblings on retry
Per-job isolation when listeners are ShouldQueue; in-process listeners share the dispatch lifecycle. No copy-per-subscriber semantics for events fanning out to mixed sync/async handlers
You implement it
Yes — copy per subscriber, by default, sync or async
Outbox (atomic write + message)
Not built in; needs a separate package
Not built in; needs a separate package (e.g., spatie/laravel-outbox)
You implement it
CombinedMessageChannel — one configuration line
Multi-broker transparency
Pluggable transports, but Messenger-shaped
Pluggable queue connections, Laravel-shaped
One broker per library
One handler attribute, any broker (RabbitMQ, Kafka, SQS, Redis, DBAL, Messenger transports, Laravel Queue channels)
Delayed messages
DelayStamp, transport-dependent
delay() on the queue, transport-dependent
Transport-specific
#[Delayed] — uniform attribute, per-handler
Priority
Transport-dependent
Per-queue or per-job
Transport-specific
#[Priority] — uniform attribute
Scheduled / cron messages
Symfony Scheduler (separate component)
Laravel Scheduler (artisan schedule)
Hand-rolled cron
#[Scheduled] and the Scheduler integration
Idempotency / deduplication
Not built in
Not built in
You implement it
#[Deduplicated] — handler-level and gateway-level
Multi-tenant routing
Custom middleware
Custom middleware / per-tenant queues
Custom
Dynamic Channels — header-routed at runtime
Operator UI
Failure transport browsing via CLI / community packages
Horizon (excellent)
None
OpenTelemetry spans, DBAL DLQ rows queryable from any SQL tool, MCP for AI-assisted introspection
Integration
Symfony-native
Laravel-native
Anywhere
Symfony Bundle, Laravel Provider, or Ecotone Lite for any PSR-11 container — runs on top of Messenger transports and Laravel Queue channels too
Existing Messenger and Horizon investments stay in place — Ecotone uses Messenger transports and Laravel Queue channels underneath when they're already configured — and adds the primitives (outbox, per-handler isolation, deduplication, multi-tenant routing) that the dispatchers don't ship.
Next Steps
Asynchronous Handling —
#[Asynchronous], pollers, async event handlersDelaying Messages —
#[Delayed]with TimeSpan, DateTime, and expressionsOutbox Pattern —
CombinedMessageChannelPer-Handler Failure Isolation — the copy-per-subscriber model
Retries — exponential backoff at the channel
Error Channel and Dead Letter — failure quarantine and replay
Idempotency (Deduplication) —
#[Deduplicated]Unreliable Async Processing — the resiliency-focused framing of the same primitives
Microservice Communication — service-to-service async via Distributed Bus
As You Scale: Ecotone Enterprise adds Dynamic Message Channels for tenant-routed traffic, Asynchronous Message Buses for end-to-end async dispatch, Kafka integration, and Command Bus Instant Retries for transient-failure recovery on synchronous commands.
Last updated
Was this helpful?