Design Patterns And Data Architecture

Observer And Event Dispatcher Pattern

The observer pattern lets one part of a program announce that something happened while other parts react without the announcer knowing every reaction. In PHP applications, this usually appears as events, listeners, subscribers, hooks, or dispatchers. Frameworks such as Symfony and Laravel provide event dispatching tools, and many custom applications use a smaller in-process dispatcher.

The pattern is useful when a primary operation should not directly coordinate every secondary reaction. For example, after an order is paid, the system may send a receipt, update analytics, write an audit entry, notify a warehouse, and schedule a follow-up email. The payment use case should own payment correctness. It should not become a long list of unrelated side effects if those side effects can be handled separately.

Events are not magic decoupling. They create a different kind of coupling: listeners depend on event names, payload shape, dispatch timing, transaction behavior, and retry rules. A good event design makes those contracts explicit.

Observer Vocabulary

The subject or publisher announces a change. Observers, listeners, or subscribers react. A dispatcher sits between them and routes events to listeners. In PHP frameworks, listeners are often container services registered by configuration or attributes.

Events should be named after facts that happened, not commands to do work. OrderWasPaid is an event. SendReceiptEmail is a command. This distinction matters because several listeners can react to one fact, while a command usually has one intended handler.

An event payload should contain enough stable information for listeners to act. Often that means IDs and relevant values, not full mutable ORM objects.

Payload Design

Event payloads should be small, explicit, and stable. Include identifiers that let listeners reload current state when needed. Include facts that are part of the event itself, such as amount paid, currency, actor ID, and occurrence time. Avoid passing full entities when listeners may run later, because the object may be stale, unserializable, or tied to a closed database session.

Be careful with personal data and secrets. Events are often logged, queued, retried, inspected, or forwarded. A password reset event may need a user ID and token reference, but not the raw token in every loggable payload. A payment event may need a provider reference, but not full card or provider payload data.

Payload changes are compatibility changes when events are queued or consumed outside the current call stack. Add fields before removing fields, and consider versioned event names or schemas for integration events.

A Small Event Dispatcher

This teaching example shows the shape without a framework.

PHP example
<?php

declare(strict_types=1);

final readonly class OrderWasPaid
{
    public function __construct(public int $orderId) {}
}

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(OrderWasPaid::class, function (OrderWasPaid $event): void {
    echo 'Send receipt for order ' . $event->orderId . PHP_EOL;
});

$dispatcher->dispatch(new OrderWasPaid(42));

// Prints:
// Send receipt for order 42

Real dispatchers add listener priority, subscribers, propagation control, container integration, async transport, tracing, and error behavior. The core idea remains: the publisher announces a fact and listeners react.

Domain Events Versus Integration Events

A domain event records something meaningful inside the domain model or application, such as SubscriptionCancelled or InvoiceApproved. It is usually consumed inside the same application boundary.

An integration event crosses a system boundary. It may be published to a queue, message broker, webhook, or another service. Integration events need stronger compatibility, versioning, idempotency, and observability because consumers may deploy separately or receive messages later.

Do not publish every internal domain object change as an external event. External consumers need stable contracts, not your private object lifecycle.

Dispatch Timing

Dispatch timing is one of the most important design choices. If listeners run immediately inside the same call stack, their failures can fail the original operation. That may be correct for a validation listener, but risky for email or analytics.

If events are dispatched before the database transaction commits, listeners may observe state that later rolls back. If events are dispatched after commit, the system needs a reliable way to ensure they are not lost. The outbox pattern is often used when database changes and event publication must stay consistent.

Document whether an event means "this happened and committed" or only "this was attempted inside the current process". Listeners need to know.

Listener Responsibilities

Listeners should do one reaction. A receipt listener sends or schedules a receipt. An audit listener records audit. A search listener updates indexing. If one listener performs several unrelated reactions, it becomes another hidden service.

Listeners should be idempotent when events may be retried or delivered more than once. A listener can use processed-event records, unique constraints, provider idempotency keys, or state checks to avoid duplicate side effects.

Listeners should not assume ordering unless the dispatcher or transport guarantees it. Many systems do not guarantee listener order across asynchronous boundaries.

Listener Discovery

Events can make control flow harder to follow. A reviewer should be able to answer which listeners run for a given event without searching the entire codebase blindly. Framework configuration, attributes, tags, or subscriber classes should be organized consistently, and important events should have tests proving their listener wiring.

Document critical listeners near the use case or in a small event map when missing a listener would change business behavior. This is not extra bureaucracy; it is how the team prevents hidden side effects from surprising maintainers. If a listener is best-effort analytics, mark it as such. If it updates billing or permissions, make that criticality visible.

Good event systems are discoverable as well as decoupled. Hidden listeners are operational risk.

Make the reaction graph reviewable before production incidents.

Events And Transactions

Events do not remove transaction design. Suppose OrderWasPaid is dispatched and the receipt listener sends email before the payment transaction commits. If the transaction rolls back, the customer receives a receipt for an unpaid order.

For side effects that must reflect durable state, record the event or outbox entry in the same transaction as the state change. A worker can publish or process it after commit. This adds complexity, but it gives a clear recovery path.

For purely in-memory notifications that do not cross durable boundaries, immediate dispatch can be fine. The important point is choosing deliberately.

Error Handling

Listener failure policy must be explicit. In synchronous dispatch, should one failing listener stop later listeners? Should the original operation fail? Should failures be collected and reported? In asynchronous dispatch, should failures retry, dead-letter, alert, or be ignored?

Different listeners may need different policies. Audit failure may be critical. Analytics failure may be best-effort. Email failure may retry. Do not let the default framework behavior decide product-critical reliability by accident.

Log event type, listener name, safe event ID, attempt count, and failure category. Avoid logging sensitive payloads.

Events Versus Commands

Events say something happened. Commands ask something to happen. Confusing the two creates unclear ownership.

UserRegistered can trigger welcome email and analytics. SendWelcomeEmail is a command to one handler. If a listener receives UserRegistered and then dispatches several commands, that can be a good design when each command has separate reliability policy.

Do not name an event SendEmailRequested unless the fact you want to record is truly that a request was made. If the business fact is user registration, name that fact.

Testing Event Designs

Test the publisher to prove it records or dispatches the expected event after the correct state change. Test listeners independently with event fixtures. Test dispatcher wiring so events reach their listeners. For async systems, test serialization, retry, dead-letter, and idempotency.

Avoid only testing that dispatch() was called. That can miss wrong event payloads, wrong timing, missing outbox persistence, and listener failures.

For public integration events, add contract tests or schema checks. Consumers should not discover field changes during production processing.

Common Failure Modes

One failure mode is event soup: every small state change emits events with unclear ownership. Developers cannot tell which listener changes which state.

Another failure mode is hidden critical behavior. If cancelling a subscription only works because a listener updates billing state, the use case may be harder to reason about. Critical invariant changes should usually be explicit.

A third failure mode is duplicate side effects after retries. Events and listeners need idempotency when delivery can repeat.

A fourth failure mode is versionless payloads crossing service boundaries. Internal refactors then break external consumers.

Review Criteria

When reviewing an event design, ask what fact the event represents, when it is emitted, whether the fact is durable, who listens, and what happens if a listener fails. Check payload stability, sensitivity, and idempotency.

Check whether a command would be clearer. If there is exactly one intended handler and the publisher expects work to happen, a command may express ownership better.

What To Check

Before moving on, make sure you can:

  • explain observer, event, listener, subscriber, and dispatcher roles
  • name events as facts rather than commands
  • distinguish domain events from integration events
  • choose dispatch timing around transactions deliberately
  • define listener failure and retry policy
  • design idempotent listeners for repeated delivery
  • test publishers, listeners, wiring, and async behavior separately

After this lesson, you should be able to decide when events make a PHP workflow clearer and when they hide important behavior behind dispatch machinery.

Practice

Task: Design A Domain Event

An order payment use case needs to notify receipts, audit, and warehouse workflows after payment succeeds.

Design the event and listener split.

Requirements

Name the event, payload, listeners, dispatch timing, and whether the event should be stored durably.

Check your work

The event should name a fact, not a command.

Show solution

Use an event named OrderWasPaid or OrderPaid. Its payload should include stable identifiers such as order ID, account ID, payment ID, paid amount, currency, and occurred-at time if listeners need them.

Listeners can include SendReceiptAfterOrderPaid, RecordOrderPaidAudit, and NotifyWarehouseOfPaidOrder. If these side effects should happen only after the payment is durable, record the event or outbox entry in the same database transaction and process it after commit.

The event is a fact: the order was paid. It is not named SendReceiptEmail, because receipt delivery is only one reaction.

Task: Review Listener Side Effects

A synchronous listener sends email, calls analytics, updates inventory, and catches all exceptions silently.

Review the design.

Requirements

Discuss listener responsibility, failure policy, transaction timing, and idempotency.

Check your work

The answer should not hide critical workflow failures.

Show solution

The listener owns too many unrelated reactions. Split email, analytics, and inventory into separate listeners or commands. Inventory may be critical and should not fail silently. Analytics may be best-effort. Email may need retries.

Catching all exceptions silently is unsafe because the system loses operational evidence. Each listener needs a failure policy: fail the original operation, retry asynchronously, dead-letter, or log as best-effort.

If inventory updates must reflect committed payment state, dispatch after commit or use an outbox. Each listener should also be idempotent if events can be retried, using unique event IDs, state checks, or processed-event records.

Task: Test Event Dispatch

A SubscriptionCancelled event should be recorded after a subscription is cancelled and then processed by a billing listener and an email listener.

Plan tests.

Requirements

Cover publisher, listener behavior, wiring, retry, duplicate delivery, and payload compatibility.

Check your work

The tests should not only assert that dispatch() was called.

Show solution

Publisher tests should prove the subscription state changes and the event is recorded only after a successful cancellation. If an outbox is used, assert the outbox payload is written in the same transaction.

Listener tests should pass a SubscriptionCancelled fixture to billing and email listeners separately. Billing should update or schedule billing changes idempotently. Email should send or enqueue the correct message and avoid duplicates on repeated delivery.

Wiring tests should prove the event is routed to both listeners. Worker tests should cover retry, dead-letter behavior, and duplicate delivery. Payload compatibility tests should protect fields consumed by async or external systems.