Skip to main content
  1. Languages/
  2. PHP Guides/

Mastering Event-Driven Architecture in PHP: From Sync to Async

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

Introduction
#

It is 2026. The days of monolithic, 2,000-line controller methods in PHP are—or at least should be—long behind us. Yet, as we scale our applications to handle the traffic demands of the modern web, we often hit a wall. A user registers, and suddenly your application is trying to save to the database, send a welcome email, subscribe them to a newsletter, generate an invoice, and notify a Slack channel. If any one of those third-party services hangs, your user is left staring at a loading spinner.

Enter Event-Driven Architecture (EDA).

EDA is not a new concept, but in the PHP ecosystem, it has evolved significantly. With the maturation of PHP 8.4+ and robust PSR-14 standards, implementing decoupled, reactive systems has never been cleaner.

In this deep dive, we aren’t just going to look at how to use a framework’s event listener (like Laravel’s). We are going to build a mental model and a working implementation from the ground up. We will start synchronous to understand the pattern, and then we will supercharge it with asynchronous processing using Redis.

By the end of this article, you will understand:

  1. How to decouple your business logic using Events and Listeners.
  2. How to implement a PSR-14 friendly Dispatcher.
  3. How to move blocking tasks to background workers.
  4. Best practices for error handling and idempotency in production.

Prerequisites and Environment
#

To follow along with the code examples, you should have a modern PHP environment set up. While EDA principles apply to any framework, we will write framework-agnostic PHP to ensure you understand the core mechanics.

Requirements:

  • PHP 8.2 or higher (We will use Readonly classes and Intersection types).
  • Composer installed.
  • Redis (Local instance or via Docker) for the asynchronous section.
  • IDE: PhpStorm or VS Code (recommended).

Let’s set up a quick project workspace:

mkdir php-eda-mastery
cd php-eda-mastery
composer init --name="phpdevpro/eda-demo" --require="predis/predis:^2.0" -n

We included predis/predis immediately because we will need it for the asynchronous message queue later.


The Core Concept: Inversion of Control
#

Before writing code, we must visualize the flow. In a traditional procedural flow, Component A tells Component B exactly what to do. In an Event-Driven flow, Component A simply shouts, “Something happened!” and doesn’t care who is listening.

Here is how the flow changes:

flowchart TD subgraph "Traditional Coupling" A[User Controller] -->|Direct Call| B[User Service] B -->|Direct Call| C[Email Service] B -->|Direct Call| D[SMS Service] B -->|Direct Call| E[Logging Service] style B fill:#f9f,stroke:#333,stroke-width:2px end subgraph "Event-Driven" UA[User Controller] -->|1. Calls| UB[User Service] UB -->|2. Dispatches| EV(Event: UserRegistered) EV -.->|3. Notifies| L1[Listener: Send Welcome Email] EV -.->|4. Notifies| L2[Listener: Send SMS] EV -.->|5. Notifies| L3[Listener: Log Activity] style EV fill:#bbf,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5 end

The beauty of the second graph is that User Service no longer needs to know that SMS Service exists. If you want to remove SMS notifications later, you delete the listener. You don’t touch the core service code.


Phase 1: The Synchronous Implementation
#

Let’s build a clean, type-safe Event Dispatcher. While there are libraries for this, building a simple one demystifies the magic.

1. The Event Interface
#

Technically, under PSR-14, an event can be any object. However, it helps to have a marker interface or a base class.

<?php
// src/Event/EventInterface.php

namespace App\Event;

interface EventInterface
{
    public function getName(): string;
    public function getOccurredAt(): \DateTimeImmutable;
}

2. A Concrete Event
#

Let’s create a scenario: A generic E-commerce Order was placed.

<?php
// src/Event/OrderPlacedEvent.php

namespace App\Event;

readonly class OrderPlacedEvent implements EventInterface
{
    public function __construct(
        public int $orderId,
        public int $userId,
        public float $amount,
        public string $currency = 'USD',
        private \DateTimeImmutable $occurredAt = new \DateTimeImmutable()
    ) {}

    public function getName(): string
    {
        return 'order.placed';
    }

    public function getOccurredAt(): \DateTimeImmutable
    {
        return $this->occurredAt;
    }
}

Note the use of PHP 8.2 readonly classes. Events should be immutable. Once an event happens, facts about it cannot change.

3. The Listener Provider
#

We need a way to map Events to Listeners.

<?php
// src/Event/ListenerProvider.php

namespace App\Event;

class ListenerProvider
{
    private array $listeners = [];

    public function addListener(string $eventType, callable $listener): void
    {
        $this->listeners[$eventType][] = $listener;
    }

    public function getListenersForEvent(object $event): iterable
    {
        $eventType = get_class($event);
        if (isset($this->listeners[$eventType])) {
            return $this->listeners[$eventType];
        }
        return [];
    }
}

4. The Dispatcher
#

The dispatcher takes the event and hands it to the provider’s listeners.

<?php
// src/Event/EventDispatcher.php

namespace App\Event;

class EventDispatcher
{
    public function __construct(
        private ListenerProvider $provider
    ) {}

    public function dispatch(object $event): object
    {
        $listeners = $this->provider->getListenersForEvent($event);

        foreach ($listeners as $listener) {
            // In a real framework, we might check if propagation stopped here
            call_user_func($listener, $event);
        }

        return $event;
    }
}

5. Wiring It Together
#

Now, let’s create a simulation script sync_demo.php to see this in action.

<?php
// sync_demo.php

require_once __DIR__ . '/vendor/autoload.php';

// Quick & Dirty Autoloader for our src/ files (since we didn't set up PSR-4 in composer.json yet)
spl_autoload_register(function ($class) {
    $prefix = 'App\\';
    $base_dir = __DIR__ . '/src/';
    $len = strlen($prefix);
    if (strncmp($prefix, $class, $len) !== 0) return;
    $relative_class = substr($class, $len);
    $file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
    if (file_exists($file)) require $file;
});

use App\Event\ListenerProvider;
use App\Event\EventDispatcher;
use App\Event\OrderPlacedEvent;

// 1. Setup Infrastructure
$provider = new ListenerProvider();
$dispatcher = new EventDispatcher($provider);

// 2. Register Listeners (Business Logic)

// Listener A: Send Email
$provider->addListener(OrderPlacedEvent::class, function (OrderPlacedEvent $event) {
    echo "[Email Service] Sending confirmation for Order #{$event->orderId} to User #{$event->userId}...\n";
    sleep(1); // Simulate network delay
    echo "[Email Service] Sent!\n";
});

// Listener B: Update Inventory
$provider->addListener(OrderPlacedEvent::class, function (OrderPlacedEvent $event) {
    echo "[Inventory] Decrementing stock for items in Order #{$event->orderId}...\n";
    // Simulate Logic
});

// Listener C: Analytics
$provider->addListener(OrderPlacedEvent::class, function (OrderPlacedEvent $event) {
    echo "[Analytics] Recording sale of {$event->amount} {$event->currency}.\n";
});

// 3. The Trigger (Controller Logic)
echo "--- Application Start ---\n";
echo "Processing Checkout...\n";

// Create the event
$event = new OrderPlacedEvent(1001, 55, 99.99);

// Dispatch!
$dispatcher->dispatch($event);

echo "--- Application End ---\n";

The Problem with Synchronous Events
#

If you run php sync_demo.php, you will notice a pause at sleep(1).

--- Application Start ---
Processing Checkout...
[Email Service] Sending confirmation for Order #1001...
(waits 1 second)
[Email Service] Sent!
[Inventory] Decrementing stock...
[Analytics] Recording sale...
--- Application End ---

The user is waiting for the email to be sent before they see the “Thank You” page. This is coupling in time, even if the code is decoupled in structure. This brings us to Phase 2.


Phase 2: Going Asynchronous
#

To achieve true scalability, we need to offload heavy listeners to a background process. We will keep the Inventory update synchronous (because we need immediate consistency—we don’t want to oversell stock), but we will move Email and Analytics to the background.

Architecture Change: The Queue
#

We need a “Middleman.” Instead of the Dispatcher executing the listener immediately, it will serialize the event and push it to Redis. A separate PHP worker process will pop it and execute it.

Comparison: Sync vs. Async
#

Feature Synchronous (Blocking) Asynchronous (Non-Blocking)
Response Time Slow (Sum of all listeners) Fast (Only critical logic)
Reliability If email fails, the request might crash If email fails, it can be retried
Complexity Low Medium (Requires queue workers)
Debugging Easy (Stack trace is linear) Harder (Logs are distributed)
Use Case Data consistency (Inventory, Payments) Notifications, Reports, heavy calc

1. The Async Dispatcher
#

We will modify our approach. We need a way to mark listeners as “Async”. In sophisticated frameworks like Symfony Messenger or Laravel Queues, this is handled via configuration or interfaces (e.g., ShouldQueue).

Let’s create a specialized AsyncEventDispatcher.

<?php
// src/Event/AsyncEventDispatcher.php

namespace App\Event;

use Predis\Client;

class AsyncEventDispatcher
{
    public function __construct(
        private Client $redis,
        private string $queueName = 'events_queue'
    ) {}

    public function dispatch(object $event): void
    {
        // 1. Serialize the event
        $payload = serialize($event);
        
        // 2. Push to Redis List (RPUSH)
        $this->redis->rpush($this->queueName, [$payload]);
        
        echo "[AsyncDispatcher] Queued event: " . get_class($event) . "\n";
    }
}

2. The Producer Script
#

Let’s update our checkout flow. We will assume Inventory logic happens inline, but we fire an event for everything else.

<?php
// async_producer.php

require_once __DIR__ . '/vendor/autoload.php';
// ... (include the same autoloader from sync_demo.php) ...
require_once __DIR__ . '/src/Autoloader.php'; // Assumption: you saved the autoloader

use App\Event\AsyncEventDispatcher;
use App\Event\OrderPlacedEvent;
use Predis\Client;

$redis = new Client([
    'scheme' => 'tcp',
    'host'   => '127.0.0.1',
    'port'   => 6379,
]);

$dispatcher = new AsyncEventDispatcher($redis);

echo "--- Checkout Process Started ---\n";

// 1. Create Event
$event = new OrderPlacedEvent(orderId: 2045, userId: 12, amount: 250.00);

// 2. Dispatch to Queue
$dispatcher->dispatch($event);

echo "--- Checkout Process Finished (User sees 200 OK) ---\n";

When you run this, it finishes instantly. The event is sitting in Redis.

3. The Worker Script
#

Now we need a script that runs continuously in the background (managed by Supervisor or Docker) to process these events.

<?php
// async_worker.php

require_once __DIR__ . '/vendor/autoload.php';
// ... (include autoloader) ...

use App\Event\OrderPlacedEvent;
use Predis\Client;

$redis = new Client();
$queueName = 'events_queue';

echo "[Worker] Listening for events on '{$queueName}'...\n";

while (true) {
    // BLPOP: Blocking Left Pop. Waits until an item is available.
    // Timeout 0 means wait indefinitely.
    $result = $redis->blpop([$queueName], 0);
    
    // Result is an array: [0] => key name, [1] => value
    if ($result) {
        $payload = $result[1];
        
        try {
            $event = unserialize($payload);
            
            if ($event instanceof OrderPlacedEvent) {
                handleOrderPlaced($event);
            } else {
                echo "[Worker] Unknown event type.\n";
            }
            
        } catch (\Throwable $e) {
            echo "[Worker] Error processing event: " . $e->getMessage() . "\n";
            // In production: Push to a Dead Letter Queue (DLQ)
        }
    }
}

function handleOrderPlaced(OrderPlacedEvent $event)
{
    echo " -> Processing Order #{$event->orderId}\n";
    
    // Simulate Email
    echo "    [Email] Sending...\n";
    sleep(2); // Simulate slow SMTP
    echo "    [Email] Sent.\n";
    
    // Simulate Analytics
    echo "    [Analytics] Tracked.\n";
    echo " -> Done.\n";
}

Running the Async Demo
#

  1. Open Terminal 1: Run php async_worker.php. It will sit and wait.
  2. Open Terminal 2: Run php async_producer.php.

You will see the producer finish immediately. Terminal 1 will wake up, process the email, and go back to sleep.


Sequence Diagram: The Full Lifecycle
#

To visualize exactly what happened in the async flow, let’s look at the sequence of operations.

sequenceDiagram participant User participant App as PHP App (Web) participant DB as Database participant Redis participant Worker as PHP Worker (CLI) participant SMTP as Email Provider User->>App: Click "Checkout" App->>DB: Save Order DB-->>App: Order ID: 2045 Note right of App: Inventory Updated (Sync) App->>Redis: RPUSH "OrderPlacedEvent" Redis-->>App: OK App-->>User: Show "Success Page" (Instant) loop Background Process Worker->>Redis: BLPOP (Wait for job) Redis-->>Worker: Returns Event Data Worker->>Worker: Unserialize Event Worker->>SMTP: Send Email SMTP-->>Worker: 250 OK Worker->>Worker: Log Analytics end

Best Practices for Production
#

Writing the code is the easy part. Operating EDA in production requires discipline. Here are the traps intermediate developers fall into.

1. Handling Failures (Dead Letter Queues)
#

What if the email server is down? In our worker script above, the event is popped from Redis. If the script crashes, the event is lost forever.

Solution:

  • Use RPOPLPUSH (Redis Pop and Push) to move the job from a main_queue to a processing_queue atomically.
  • Only remove it from processing_queue when the job finishes successfully.
  • If it fails, catch the exception, increment a retry counter, and if it exceeds limits, push it to a failed_jobs queue (Dead Letter Queue) for manual inspection.

2. Idempotency is King
#

In distributed systems, events might be delivered twice (e.g., network timeout during ACK). If your listener charges a credit card, you must ensure it doesn’t charge twice if the event runs twice.

Pattern:

function handlePayment(PaymentEvent $event) {
    if (Payment::where('transaction_id', $event->txId)->exists()) {
        return; // Already processed
    }
    // Process payment...
}

3. Keep Events Lightweight
#

Do not pass the entire User Eloquent/Doctrine entity inside the Event.

  • Why? The user’s data might change between the time the event was dispatched and the time the worker processes it.
  • Fix: Pass IDs (userId, orderId). Let the worker re-fetch the fresh data from the database.

4. Serialization Hazards
#

Since we are passing serialized PHP objects to Redis:

  • Ensure the class definition exists on both the Producer (Web) and the Worker (CLI).
  • Be careful with __wakeup and __sleep methods.
  • If you deploy new code, old serialized events in the queue might fail to unserialize if the class structure changed. Deployment strategy: Drain queues before deploying breaking class changes.

Conclusion
#

Event-Driven Architecture is a powerful tool in your PHP arsenal. It allows you to write cleaner, more maintainable code by adhering to the Open/Closed Principle—you can add new functionality (listeners) without modifying existing code (controllers).

Moving to an asynchronous model using Redis takes this further, allowing your application to handle traffic spikes gracefully without degrading the user experience.

Your Action Plan:

  1. Audit your current controllers. Look for “side effects” (sending emails, API calls to 3rd parties).
  2. Refactor one side effect into a synchronous Event/Listener pair.
  3. Once comfortable, introduce a Redis queue to handle that listener asynchronously.

The future of PHP is robust, fast, and scalable. By mastering EDA, you ensure your applications are ready for 2026 and beyond.

Further Reading
#

  • PSR-14 Event Dispatcher Specification: The official standard.
  • Symfony Messenger Component: A battle-tested implementation of what we built today.
  • Laravel Events & Queues: Excellent abstraction over these concepts.
  • RabbitMQ vs. Redis: When you need more advanced routing than Redis Lists can provide.