Async PHP Done Right — Per-Handler Channels
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.
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:
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
}
}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:
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);
}To enable a channel, register it once in a ServiceContext:
final class MessagingConfiguration
{
#[ServiceContext]
public function notifications()
{
return AmqpBackedMessageChannelBuilder::create("notifications");
}
}Then run the consumer:
# Symfony
bin/console ecotone:run notifications -vvv
# Laravel
php artisan ecotone:run notifications -vvvPer-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:
#[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 { /* ... */ }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:
#[Asynchronous(["database_channel", "rabbit_channel"])]
#[EventHandler(endpointId: "welcome.mail")]
public function sendWelcome(UserWasRegistered $event, Mailer $mailer): void
{
$mailer->sendWelcome($event->email);
}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.
Symfony, Laravel, or standalone — same code
The handlers and channel definitions above have no framework-specific imports. Pick your install path:
# 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/amqpCommon 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.
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 covers the synchronous version of the same pattern, and Enterprise PHP in 2026: The Patterns You’re Missing bundles outbox, isolation, and workflows together.
Full surface in the async handling docs.
Dariusz Gafka is a Software Architect and author of the Ecotone Framework. He writes about event sourcing, CQRS, and message-driven PHP architecture.