Event Handling in PHP — From Tangled Code to Clean Flows
Stop cramming side effects into one PHP service. Use domain events with EventBus and per-handler isolation to split main flows from sub-flows — works on Symfony, Laravel, and standalone.
Updated on 2026-05-14
You’ve seen this PHP service before — maybe you wrote it. UserService::register() creates a user, sends an email, writes an audit log, calls a CRM webhook, and updates a search index. It’s 200 lines, has six dependencies, and every change to one feature risks breaking the others. The fix isn’t more layers — it’s splitting the main flow from the sub-flows using domain events.
The short version. A domain event is a message that says “this happened” — UserWasRegistered, OrderWasPlaced. The main flow publishes the event; sub-flows (email, audit, CRM) subscribe as separate handlers. Each handler is independently testable, independently deployable, and independently retriable when something fails. With Ecotone you get this from a single attribute on a method.
The pain — one service, too many jobs
Here’s what the tangled version usually looks like:
final class UserService
{
public function register(string $email, string $name): void
{
$user = new User($email, $name);
$this->users->save($user);
$this->mailer->sendWelcome($user);
$this->audit->log("user.registered", ["email" => $email]);
$this->crm->syncContact($user);
$this->search->index($user);
}
}What’s wrong here isn’t the code — it’s the coupling. The main flow is “create and save a user.” Everything else is a sub-flow that happens because the user got registered. Mixing them means you can’t change the email template without risking the audit log, can’t retry the CRM call without re-sending the email, and can’t test registration without mocking five collaborators.
The fix — publish a domain event
An event is a plain PHP class describing something that already happened. No interface to implement, no base class to extend.
final class UserWasRegistered
{
public function __construct(
public readonly string $userId,
public readonly string $email,
) {}
}The main flow publishes the event through the EventBus — Ecotone auto-registers it in the container so you just inject it.
final class UserService
{
public function __construct(private EventBus $events) {}
#[CommandHandler]
public function register(RegisterUser $command): void
{
$user = new User($command->email, $command->name);
$this->users->save($user);
$this->events->publish(new UserWasRegistered(
$user->id(),
$command->email,
));
}
}Now every sub-flow becomes its own handler — a single method with a single responsibility:
final class WelcomeMailHandler
{
#[EventHandler]
public function send(UserWasRegistered $event, Mailer $mailer): void
{
$mailer->sendWelcome($event->email);
}
}
final class CrmSyncHandler
{
#[EventHandler]
public function sync(UserWasRegistered $event, CrmClient $crm): void
{
$crm->syncContact($event->userId, $event->email);
}
}
final class AuditHandler
{
#[EventHandler]
public function log(UserWasRegistered $event, AuditLog $audit): void
{
$audit->record("user.registered", ["userId" => $event->userId]);
}
}Notice the second parameter on each handler. Ecotone’s method-level dependency injection means you don’t have to inject every collaborator into the class constructor — pass them straight to the method that needs them. The handler signature documents what it actually depends on.
Per-handler isolation — the bit other event systems miss
Symfony’s EventDispatcher and Laravel’s events both run all listeners for an event in the same process. If the CRM sync throws, the email might already have been sent (if the listener ran first) or might never run (if it didn’t). There’s no clean recovery story.
Ecotone publishes a separate copy of the event to each handler’s own channel. The mail handler succeeded? Done. The CRM handler failed? It retries on its own channel without re-sending the welcome email. This is the same pattern message-driven JVM systems have used for over a decade — and the single most-cited reason teams move to it from Symfony’s EventDispatcher.
Make it production-safe — async + outbox
Sending email in the same web request is fragile: SMTP times out and the user sees a 500 even though their account was created. Move the sub-flows off the request thread by marking them #[Asynchronous] and choosing a channel:
#[Asynchronous(["database_channel", "rabbit_channel"])]
#[EventHandler(endpointId: "welcome_mail")]
public function send(UserWasRegistered $event, Mailer $mailer): void
{
$mailer->sendWelcome($event->email);
}database_channel, then forwarded to rabbit_channel for the consumer to pick up.The combination of DBAL outbox + RabbitMQ means the event is never lost — even if the broker is down at commit time — and downstream consumers scale on the broker, not on your database. There’s no separate outbox library to wire up; the channel definitions live in a ServiceContext and Ecotone handles the rest.
For the full breakdown of why this matters, see Enterprise PHP in 2026: The Patterns You’re Missing.
Symfony, Laravel, or standalone — same code
The handlers above are framework-agnostic POPOs. 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-applicationCommon questions
Is this a replacement for Symfony EventDispatcher or Laravel events?
Not a replacement — a different layer. EventDispatcher and Laravel events are in-process, synchronous, framework-bound. Ecotone events are domain events: per-handler isolated, async-capable, and outbox-safe. You can keep using EventDispatcher for framework lifecycle hooks and use Ecotone for business events.
Where do these events live — what about an event store?
Plain #[EventHandler] publishes events to handlers in-memory or via channels — no event store needed. If you want an audit trail or event sourcing, add an event-sourced aggregate; the event classes stay the same.
Can two handlers run in different services?
Yes — Ecotone’s distributed bus lets one service publish an event and another service subscribe via RabbitMQ, SQS, or Kafka. The handler signature is identical in both services.
Wrapping up
Splitting main flows from sub-flows is one of those changes that pays for itself within a sprint — every new feature touches one handler, not the world. The pattern itself is older than PHP; the part that’s new is having a tool that bundles the publishing, the per-handler isolation, the async channels, and the outbox into a single attribute.
If you want to dig deeper, the Ecotone event handling docs walk through the full surface, or jump straight into the documentation index.
Dariusz Gafka is a Software Architect and author of the Ecotone Framework. He writes about event sourcing, CQRS, and message-driven PHP architecture.