objects namespaces and application architecture
Event-Driven Architecture Orientation
Event-driven architecture uses events to announce that something important has happened. Other parts of the application can react without the original code needing to know every follow-up action.
In PHP applications, events appear in many forms: Symfony events, Laravel events and listeners, domain events, queue messages, webhook events, audit events, and integration events between services. The vocabulary differs, but the core idea is the same: one part of the system publishes a fact, and one or more handlers respond.
An event should describe something that already happened. UserRegistered, OrderPaid, LessonCompleted, and InvoiceRefunded are events. RegisterUser and SendReceiptEmail are commands because they ask the system to do something.
Why Events Help
Events reduce direct coupling between the main use case and secondary work.
When a user registers, the application may need to send a welcome email, create an audit log entry, add the user to a mailing list, update analytics, and notify another service. If the registration service directly calls every one of those tasks, it grows every time a new side effect is added.
An event lets registration publish UserRegistered. Handlers can then react.
<?php
declare(strict_types=1);
final readonly class UserRegistered
{
public function __construct(
public int $userId,
public string $email,
) {
}
}
final class SendWelcomeEmail
{
public function __invoke(UserRegistered $event): void
{
echo 'Sending welcome email to ' . $event->email . PHP_EOL;
}
}
$event = new UserRegistered(10, 'ada@example.com');
$handler = new SendWelcomeEmail();
$handler($event);
// Prints:
// Sending welcome email to ada@example.com
The event is a fact. The handler decides what to do with that fact.
A Simple Event Dispatcher
Frameworks usually provide an event dispatcher, but a small example shows the moving parts.
<?php
declare(strict_types=1);
final readonly class UserRegistered
{
public function __construct(
public int $userId,
public string $email,
) {
}
}
final class EventDispatcher
{
/** @var array<class-string, list<callable(object): void>> */
private array $listeners = [];
public function listen(string $eventClass, callable $listener): void
{
$this->listeners[$eventClass][] = $listener;
}
public function dispatch(object $event): void
{
foreach ($this->listeners[$event::class] ?? [] as $listener) {
$listener($event);
}
}
}
$dispatcher = new EventDispatcher();
$dispatcher->listen(UserRegistered::class, function (object $event): void {
if (!$event instanceof UserRegistered) {
return;
}
echo 'Audit: user ' . $event->userId . ' registered.' . PHP_EOL;
});
$dispatcher->dispatch(new UserRegistered(10, 'ada@example.com'));
// Prints:
// Audit: user 10 registered.
This example is synchronous: the listener runs immediately during dispatch(). Queue-backed events are different because the handler may run later in another process.
Synchronous And Asynchronous Events
Synchronous events run before the current request finishes. They are easier to reason about, but a slow handler slows the request. A failing handler may also fail the main operation unless failures are caught deliberately.
Asynchronous events are usually placed on a queue. The request can finish quickly, and workers process handlers later. This is useful for email, analytics, notifications, and integration work. The tradeoff is eventual consistency: the work has not happened yet when the response is returned.
For example, after checkout the user may see "order placed" before the receipt email has actually been sent. That is fine if the product accepts it. It is not fine if the user must not proceed until the follow-up has completed.
Event Payloads
Event payloads should contain enough information for handlers to do their work, but not every object in the application.
Good event payloads often include identifiers and important immutable facts:
<?php
declare(strict_types=1);
final readonly class OrderPaid
{
public function __construct(
public int $orderId,
public int $userId,
public int $amountPence,
public string $currency,
) {
}
}
Avoid passing large mutable objects that may change after the event is created. If a handler needs current data, it can load it by ID. If it needs the exact data from the time of the event, include that data directly.
Idempotency
Event handlers should often be idempotent. Idempotent means running the same handler more than once does not create duplicate damage.
Queues may retry messages after failures. Webhooks may be delivered more than once. A worker may crash after doing work but before marking a message complete.
<?php
declare(strict_types=1);
final readonly class ReceiptRequested
{
public function __construct(
public int $orderId,
public string $email,
) {
}
}
final class ReceiptSender
{
/** @var array<int, bool> */
private array $sentReceipts = [];
public function __invoke(ReceiptRequested $event): void
{
if (isset($this->sentReceipts[$event->orderId])) {
echo 'Receipt already sent.' . PHP_EOL;
return;
}
$this->sentReceipts[$event->orderId] = true;
echo 'Sending receipt to ' . $event->email . PHP_EOL;
}
}
$sender = new ReceiptSender();
$event = new ReceiptRequested(500, 'ada@example.com');
$sender($event);
$sender($event);
// Prints:
// Sending receipt to ada@example.com
// Receipt already sent.
In a real application, the "already processed" check would usually live in a database, cache, or external provider idempotency key.
The Outbox Problem
One difficult event-driven problem is making sure database changes and event publication stay consistent.
Imagine a checkout request saves an order to the database and then publishes OrderPaid to a queue. If the database save succeeds but queue publishing fails, the order exists but the event is missing. If the event publishes first and the database save fails, handlers may react to an order that does not exist.
The outbox pattern handles this by saving the event in the same database transaction as the business change. A separate worker later reads the outbox table and publishes the event.
You do not need to implement a full outbox as a junior developer, but you should recognise why "save then publish" can fail.
When Events Are A Bad Fit
Events are not a replacement for clear method calls. If one action must happen immediately and the caller needs the result, a direct call is usually clearer.
Events can make code harder to follow because behaviour is spread across handlers. When debugging, you need to know which handlers listen to the event, whether they run synchronously or asynchronously, and what happens when one fails.
Use events when decoupling and extensibility are worth that extra tracing cost.
What You Should Be Able To Do
After this lesson, you should be able to explain the difference between a command and an event, create a small event object, attach handlers, and identify failure concerns such as retries, duplicate delivery, and eventual consistency.
For junior work, the practical skill is to avoid hiding essential behaviour behind events without a reason. Events are powerful when they announce meaningful facts and handlers are designed for failure.
Practice
Practice: Dispatch A User Registered Event
Create a small event-driven PHP example for user registration.
Task
Build:
- a
UserRegisteredevent - a simple event dispatcher
- one handler that prints an audit message
- one handler that prints a welcome email message
The dispatcher should allow multiple handlers for the same event. Then dispatch one UserRegistered event and show both handlers running.
Use strict types. Keep the expected output in the PHP code block as printed lines or comments.
Check Your Work
Confirm:
- the event describes something that already happened
- both handlers run for the same event
- the registration code does not directly call the two handlers
Afterward, explain one risk if these handlers were moved to a queue.
Show solution
<?php
declare(strict_types=1);
final readonly class UserRegistered
{
public function __construct(
public int $userId,
public string $email,
) {
}
}
final class EventDispatcher
{
/** @var array<class-string, list<callable(object): void>> */
private array $listeners = [];
public function listen(string $eventClass, callable $listener): void
{
$this->listeners[$eventClass][] = $listener;
}
public function dispatch(object $event): void
{
foreach ($this->listeners[$event::class] ?? [] as $listener) {
$listener($event);
}
}
}
final class AuditUserRegistration
{
public function __invoke(UserRegistered $event): void
{
echo 'Audit: user ' . $event->userId . ' registered.' . PHP_EOL;
}
}
final class SendWelcomeEmail
{
public function __invoke(UserRegistered $event): void
{
echo 'Email: welcome message sent to ' . $event->email . PHP_EOL;
}
}
$dispatcher = new EventDispatcher();
$dispatcher->listen(UserRegistered::class, new AuditUserRegistration());
$dispatcher->listen(UserRegistered::class, new SendWelcomeEmail());
$dispatcher->dispatch(new UserRegistered(10, 'ada@example.com'));
// Prints:
// Audit: user 10 registered.
// Email: welcome message sent to ada@example.com
The registration flow only needs to dispatch UserRegistered; it does not need to know every follow-up task. If these handlers moved to a queue, they might run later, fail, or run more than once, so the handlers would need retry and idempotency rules.