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

# Async PHP Done Right — Per-Handler Channels
- URL: https://blog.ecotone.tech/asynchronous-php/
- Published: 2021-08-18T15:47:41.000Z
- Updated: 2026-05-15T15:02:11.000Z
- Description: Why one attribute on a method beats hand-rolled queue workers in PHP. Per-handler channels, transactional outbox + RabbitMQ, and the same code on Symfony or Laravel.
- Author: Dariusz Gafka
- Tags: PHP, Async PHP, Message Queue, RabbitMQ, Ecotone

*Updated on 2026-05-17*

Your PHP registration endpoint takes 4 seconds. You profile it: 3.6 of those seconds are spent waiting for SendGrid to acknowledge the welcome email. The user’s account is created on line 1; everything after is them watching a spinner because SMTP is slow today. The fix isn’t a faster mail provider — it’s **moving the email out of the request thread entirely**. That’s what async PHP is for.

**The short version.** Async in PHP doesn’t have to mean a separate queue worker library, a Symfony Messenger config tree, or a custom job class hierarchy. Mark a handler with one attribute, point it at a channel (in-memory, database, RabbitMQ, SQS), and Ecotone runs it off the request thread — with per-handler retry policies, transactional outbox if you want it, and the same code on Symfony, Laravel, or standalone PHP.

---

## The pain — everything in one request

The classic synchronous registration looks fine until something downstream is slow:

```php
final class UserService
{
    #[CommandHandler]
    public function register(RegisterUser $command): void
    {
        $user = new User($command->email);
        $this->users->save($user);

        $this->mailer->sendWelcome($user);     // 3.6s on a bad day
        $this->crm->syncContact($user);        // could be down entirely
        $this->analytics->trackSignup($user);  // 200ms, every time
    }
}
```

*The user waits for every downstream call. If the CRM is down, registration fails — even though the user row was already saved.*

Three problems compound here. **Latency**: the user waits for the slowest hop. **Failure cascade**: one downstream timeout fails the whole request. **Inconsistent state**: rolling back the database undoes the registration, but if the email already went out, the user got a welcome message for an account that no longer exists.

---

## The fix — one attribute, one channel

The first three sub-flows are *independent* of the request — they need to happen, but not in front of the user. Move them onto event handlers and mark each one async:

```php
final class UserService
{
    #[CommandHandler]
    public function register(RegisterUser $command, EventBus $events): void
    {
        $user = new User($command->email);
        $this->users->save($user);

        $events->publish(new UserWasRegistered($user->id(), $command->email));
    }
}

#[Asynchronous("notifications")]
#[EventHandler(endpointId: "welcome.mail")]
public function sendWelcome(UserWasRegistered $event, Mailer $mailer): void
{
    $mailer->sendWelcome($event->email);
}
```

*The request returns as soon as the user row is committed and the event is enqueued. The mail handler runs on a separate worker.*

To enable a channel, register it once in a `ServiceContext`:

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

Then run the consumer:

```bash
# Symfony
bin/console ecotone:run notifications -vvv

# Laravel
php artisan ecotone:run notifications -vvv
```

*Same command, two stacks. The consumer auto-registers from the channel name.*

---

## Per-handler isolation — the bit that matters in production

If you mark *three* handlers async on the same event and the second one fails, what happens to the other two? In Symfony Messenger, all three live in the same envelope — a retry re-runs all three (and re-sends the welcome email). In Laravel queues, they’re separate jobs but share queue-level configuration.

Ecotone publishes a **separate copy** of the event to each handler’s own channel. Failures, retries, and dead-lettering are *per-handler*:

```php
#[Asynchronous("notifications")]
#[EventHandler(endpointId: "welcome.mail")]
public function sendWelcome(UserWasRegistered $event, Mailer $mailer): void { /* ... */ }

#[Asynchronous("crm")]
#[EventHandler(endpointId: "crm.sync")]
public function syncCrm(UserWasRegistered $event, CrmClient $crm): void { /* ... */ }

#[Asynchronous("analytics")]
#[EventHandler(endpointId: "analytics.track")]
public function track(UserWasRegistered $event, Analytics $a): void { /* ... */ }
```

*Three handlers, three channels, three retry policies. The CRM handler can be down for an hour without affecting the welcome mail.*

---

## Outbox + RabbitMQ — never lose a message, never poll your DB

The risk with naive async is the **dual-write problem**: the request commits the user row, then publishes to RabbitMQ. If the broker is down between commit and publish, you have a user with no welcome email and no record of the missed event.

The textbook fix is the transactional outbox. Ecotone bundles it as a combined channel — commit the event into the database in the same transaction as the user row, then forward it to RabbitMQ:

```php
#[Asynchronous(["database_channel", "rabbit_channel"])]
#[EventHandler(endpointId: "welcome.mail")]
public function sendWelcome(UserWasRegistered $event, Mailer $mailer): void
{
    $mailer->sendWelcome($event->email);
}
```

*Combined channels: the event is committed atomically with the user row into `database_channel`, then forwarded to `rabbit_channel` for the consumer to pick up.*

The event is **never lost** — even if RabbitMQ is down for hours — and downstream consumers scale on the broker, not on your database. For the deeper rationale, see [Implementing the Outbox Pattern in PHP](https://blog.ecotone.tech/implementing-outbox-pattern-in-php-symfony-laravel-ecotone/).

---

## Symfony, Laravel, or standalone — same code

The handlers and channel definitions above have no framework-specific imports. Pick your install path:

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

# RabbitMQ transport (any of the above)
composer require ecotone/amqp
```

*Same handlers, three install paths. Migrating between stacks doesn’t rewrite the domain.*

---

## Common questions

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

No — it works *with* them. Messenger and Laravel Queue are transports; Ecotone’s `#[Asynchronous]` sits on top, adding per-handler isolation, combined channels, outbox, and routing-by-attribute. Keep your existing transport configuration.

### 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. The same handlers run in CI under 100ms.

### What happens when a handler keeps failing?

Configure retries per channel and a dead-letter queue for permanent failures. Failed messages stay inspectable and replayable from the CLI — see [error channel and dead letter](https://docs.ecotone.tech/modelling/recovering-tracing-and-monitoring/resiliency/error-channel-and-dead-letter?ref=blog.ecotone.tech).

---

## Wrapping up

Going async in PHP used to mean adopting a job library, writing job classes, configuring a worker, and accepting that one slow consumer could stall the whole queue. With per-handler channels and a combined outbox, that whole list collapses into one attribute and one channel definition. Companion read: [Event Handling in PHP — From Tangled Code to Clean Flows](https://blog.ecotone.tech/event-handling-in-php/) covers the synchronous version of the same pattern, and [Enterprise PHP in 2026: The Patterns You’re Missing](https://blog.ecotone.tech/meet-ecotone-enterprise-ready-framework-for-php/) bundles outbox, isolation, and workflows together.

Full surface in the [async handling docs](https://docs.ecotone.tech/modelling/asynchronous-handling?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.*