Event System (PSR-14)

Powerful event-driven architecture based on PSR-14, enabling loosely coupled application components through publish-subscribe patterns

Chapter 26: Part of the Larafony Framework - A modern PHP 8.5 framework built from scratch. Full implementation details with tests available at masterphp.eu

Overview

Chapter 26 introduces a powerful event-driven architecture based on PSR-14 (Event Dispatcher), enabling loosely coupled application components through publish-subscribe patterns. This implementation provides attribute-based listener registration, automatic event type inference, priority-based execution, and stoppable event propagation — all while maintaining strict PSR compliance.

The event system serves as the foundation for framework-wide observability, powering features like database query logging, cache monitoring, view rendering hooks, and route matching events. It eliminates tight coupling between components by allowing any part of the application to react to events without direct dependencies.

Key features include automatic listener discovery using PHP 8.5 attributes (#[Listen]), priority-based listener execution (higher priority = earlier execution), container-based listener resolution for dependency injection, and framework events for application lifecycle (booting/booted), database operations (queries, transactions), cache operations (hit/miss/write/forget), view rendering (before/after), and route matching.

Key Components

Event Dispatcher

Attribute-Based Registration

Framework Events

The framework dispatches events at critical points in the application lifecycle:

Application Events

Database Events

Cache Events

View Events

Routing Events

Stoppable Events

PSR Standards Implemented

New Attributes

#[Listen]

Marks a method as an event listener with optional event class and priority configuration.

Parameters:

Target: Methods only (Attribute::TARGET_METHOD)

Repeatable: Yes (Attribute::IS_REPEATABLE) - one method can listen to multiple events

use Larafony\Framework\Events\Attributes\Listen;
use Larafony\Framework\Events\Database\QueryExecuted;
use Larafony\Framework\Events\Cache\CacheHit;

class MyListener
{
    // Explicit event class
    #[Listen(event: QueryExecuted::class, priority: 10)]
    public function onQuery(QueryExecuted $event): void
    {
        // High priority (10) - executes before priority 0
    }

    // Auto-inferred from parameter type
    #[Listen]
    public function onCacheHit(CacheHit $event): void
    {
        // Event type inferred from parameter
    }

    // Multiple listeners on same method
    #[Listen(event: CacheHit::class)]
    #[Listen(event: CacheMissed::class)]
    public function onCacheAccess(object $event): void
    {
        // Handles both CacheHit and CacheMissed
    }
}

Usage Examples

Basic Event Listening

use Larafony\Framework\Events\EventDispatcher;
use Larafony\Framework\Events\ListenerProvider;
use Larafony\Framework\Events\Database\QueryExecuted;

// Manual listener registration
$provider = new ListenerProvider();
$dispatcher = new EventDispatcher($provider);

// Register listener with priority
$provider->listen(
    QueryExecuted::class,
    function (QueryExecuted $event) {
        echo "Query: {$event->sql}\n";
        echo "Time: {$event->time}ms\n";
    },
    priority: 5
);

// Dispatch event
$event = new QueryExecuted(
    sql: 'SELECT * FROM users WHERE id = ?',
    rawSql: 'SELECT * FROM users WHERE id = 1',
    bindings: [1],
    time: 2.45,
    connection: 'mysql'
);

$dispatcher->dispatch($event);

Attribute-Based Listeners

use Larafony\Framework\Events\Attributes\Listen;
use Larafony\Framework\Events\Database\QueryExecuted;
use Larafony\Framework\Events\Cache\CacheHit;
use Larafony\Framework\Events\Cache\CacheMissed;

class ApplicationMonitor
{
    // High priority query logging
    #[Listen(priority: 100)]
    public function logSlowQueries(QueryExecuted $event): void
    {
        if ($event->time > 100) {
            // Log slow queries (>100ms)
            error_log("SLOW QUERY: {$event->sql} ({$event->time}ms)");
        }
    }

    // Cache monitoring
    #[Listen]
    public function trackCacheHitRate(CacheHit $event): void
    {
        // Increment cache hit counter
        $this->incrementMetric('cache.hits');
    }

    #[Listen]
    public function trackCacheMissRate(CacheMissed $event): void
    {
        // Increment cache miss counter
        $this->incrementMetric('cache.misses');
    }

    // Multiple events, one handler
    #[Listen(event: CacheHit::class)]
    #[Listen(event: CacheMissed::class)]
    public function logCacheAccess(object $event): void
    {
        $type = $event instanceof CacheHit ? 'HIT' : 'MISS';
        echo "Cache {$type}: {$event->key}\n";
    }

    private function incrementMetric(string $name): void
    {
        // Implementation...
    }
}

Stoppable Events

use Larafony\Framework\Events\StoppableEvent;
use Larafony\Framework\Events\Attributes\Listen;

// Custom stoppable event
class UserRegistering extends StoppableEvent
{
    public function __construct(
        public string $email,
        public string $password,
        public ?string $reason = null
    ) {
    }
}

class RegistrationValidator
{
    #[Listen(priority: 100)]
    public function validateEmail(UserRegistering $event): void
    {
        if (!filter_var($event->email, FILTER_VALIDATE_EMAIL)) {
            $event->reason = 'Invalid email format';
            $event->stopPropagation(); // Stop further listeners
        }
    }

    #[Listen(priority: 50)]
    public function checkBlacklist(UserRegistering $event): void
    {
        if ($this->isBlacklisted($event->email)) {
            $event->reason = 'Email is blacklisted';
            $event->stopPropagation();
        }
    }

    #[Listen(priority: 0)]
    public function createUser(UserRegistering $event): void
    {
        // Only executes if not stopped
        echo "Creating user: {$event->email}\n";
    }

    private function isBlacklisted(string $email): bool
    {
        return false;
    }
}

// Usage
$event = new UserRegistering('spam@example.com', 'password');
$dispatcher->dispatch($event);

if ($event->isPropagationStopped()) {
    echo "Registration failed: {$event->reason}\n";
}

Testing

The event system is covered by comprehensive test suites:

EventDispatcherTest

Location: tests/Larafony/Events/EventDispatcherTest.php

Coverage: 6 tests covering:

All tests pass: ✅ 6/6 tests

ListenerProviderTest

Location: tests/Larafony/Events/ListenerProviderTest.php

Coverage: 8 tests covering listener provider functionality

All tests pass: ✅ 8/8 tests

ListenerDiscoveryTest

Location: tests/Larafony/Events/ListenerDiscoveryTest.php

Coverage: 7 tests covering listener discovery functionality

All tests pass: ✅ 7/7 tests

Total Test Coverage:

Learn More: This implementation is explained in detail with step-by-step tutorials, tests, and best practices at masterphp.eu