phpdot / event
PSR-14 event dispatcher with attribute-based listener discovery, async dispatch, ordering, and persistence abstraction.
Requires
- php: >=8.3
- psr/container: ^2.0
- psr/event-dispatcher: ^1.0
- psr/log: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0
README
PSR-14 event dispatcher with attribute-based listener discovery, async dispatch support, ordering/priority, and persistence abstraction. Zero framework dependencies — PSR interfaces only.
Table of Contents
- Install
- Quick Start
- Why This Package
- Architecture
- Events
- Listeners
- Dispatching Events
- ListenerProvider
- Contracts (Interfaces)
- Default Implementations
- Admin GUI Management
- Exception Handling
- Framework Wiring
- Comparison
- API Reference
- License
Install
composer require phpdot/event
| Requirement | Version |
|---|---|
| PHP | >= 8.3 |
| psr/event-dispatcher | ^1.0 |
| psr/container | ^2.0 |
| psr/log | ^3.0 |
Zero phpdot dependencies. Zero framework coupling.
Quick Start
// 1. Define an event — any PHP class, no base class needed final readonly class UserRegistered { public function __construct( public int $userId, public string $email, ) {} } // 2. Create a handler — the attribute IS the registration #[Listener(UserRegistered::class, order: 1)] final class SendWelcomeEmail { public function __construct(private MailerInterface $mailer) {} public function __invoke(UserRegistered $event): void { $this->mailer->send($event->email, 'Welcome!'); } } // 3. Wire and dispatch $provider = new ListenerProvider(); $provider->addListener(UserRegistered::class, SendWelcomeEmail::class, order: 1); $dispatcher = new EventDispatcher($provider, $container, $asyncDispatcher, $logger); $dispatcher->dispatch(new UserRegistered(userId: 1, email: 'omar@example.com'));
No central configuration file. No service provider. No YAML. The handler declares what it handles.
Why This Package
Every PHP event dispatcher forces centralized listener registration:
| Framework | Registration |
|---|---|
| Laravel | EventServiceProvider::$listen array — every team edits one file |
| Symfony | YAML tags, compiler passes, or getSubscribedEvents() |
| PHPdot | #[Listener] attribute on the handler class — no central file |
PHPdot inverts the registration. Each handler declares what it handles. Discovery finds all listeners at boot time. The runtime dispatcher uses zero-cost in-memory lookups.
Additionally:
- Async support built-in via
AsyncDispatcherInterface(Laravel has ShouldQueue, but tied to Illuminate) - Order + Priority correctly separated (order = execution sequence, priority = queue urgency)
- Persistence abstraction for admin GUI management (enable/disable without deploy)
- PSR-14 compliant and replaceable by any PSR-14 implementation
Architecture
Boot Time vs Runtime
Boot time (once, cached):
ListenerDiscoveryInterface scans #[Listener] attributes
→ ListenerProvider stores event→handlers mapping in memory
→ Optionally loads overrides from ListenerRepositoryInterface (DB)
Runtime (every dispatch, zero I/O):
dispatch(object $event)
→ ListenerProvider→getListenersForEvent($event) ← in-memory lookup
→ sorted by order
→ for each listener:
if sync: resolve from PSR-11 container → call __invoke($event)
if async: AsyncDispatcherInterface→publishAsync(event, handler, priority)
if StoppableEvent and stopped: break
→ log via PSR-3 LoggerInterface
→ return event object
Dispatch Pipeline
EventDispatcher::dispatch(object $event)
│
├── Is StoppableEvent and already stopped? → return immediately
│
├── ListenerProvider::getListenersForEvent($event)
│ ├── Match exact class
│ ├── Match parent classes
│ ├── Match interfaces
│ └── Sort by order (ascending)
│
└── For each ListenerEntry:
├── Skip if disabled (enabled: false)
├── Check StoppableEvent::isPropagationStopped() → break if true
│
├── If sync:
│ ├── Container::get($handlerClass)
│ ├── Validate callable
│ ├── Call $handler($event)
│ └── Log via PSR-3 (debug on success, error on failure)
│
└── If async:
├── AsyncDispatcherInterface::publishAsync($event, $handlerClass, $priority)
└── Log via PSR-3 (debug on success, error on failure)
Package Structure
src/
├── Attribute/
│ └── Listener.php # #[Listener] attribute — repeatable, class-target
│
├── EventDispatcher.php # PSR-14 dispatcher — sync/async, stop propagation, logging
├── ListenerProvider.php # PSR-14 provider — in-memory map, class hierarchy matching
│
├── DTO/
│ └── ListenerEntry.php # Immutable descriptor — event, handler, order, async, priority, enabled
│
├── Contract/
│ ├── ListenerDiscoveryInterface.php # Scanning abstraction — framework implements
│ ├── ListenerRepositoryInterface.php # Persistence abstraction — framework implements
│ └── AsyncDispatcherInterface.php # Queue abstraction — framework implements
│
├── Event/
│ └── StoppableEvent.php # Base class for PSR-14 StoppableEventInterface
│
├── Provider/
│ ├── InMemoryListenerRepository.php # Default — no DB needed
│ └── SyncOnlyDispatcher.php # Default — runs async handlers synchronously
│
└── Exception/
├── EventException.php # Base (extends RuntimeException)
├── ListenerException.php # Handler resolution/execution failure
└── AsyncDispatchException.php # Queue publishing failure
13 source files. 812 lines.
Events
Events are plain PHP objects. No base class required. No interface. No trait.
Notification Events (Immutable)
One-way signal: "something happened." Listeners react but don't modify the event.
final readonly class UserRegistered { public function __construct( public int $userId, public string $email, public DateTimeImmutable $registeredAt, ) {} } final readonly class OrderPlaced { public function __construct( public int $orderId, public int $userId, public float $total, ) {} }
Enhancement Events (Mutable)
Two-way signal: "modify this before I use it." Listeners enrich the event.
final class ResponseCreated { /** @var list<string> */ public array $headers = []; public function __construct( public readonly Response $response, ) {} public function addHeader(string $header): void { $this->headers[] = $header; } }
Stoppable Events
First handler that can handle it wins. Extend StoppableEvent and call stopPropagation().
use PHPdot\Event\Event\StoppableEvent; final class RouteMatched extends StoppableEvent { public ?Route $route = null; public function __construct( public readonly string $path, ) {} } // First listener that matches stops propagation #[Listener(RouteMatched::class, order: 1)] final class ApiRouteResolver { public function __invoke(RouteMatched $event): void { if (str_starts_with($event->path, '/api/')) { $event->route = $this->resolveApiRoute($event->path); $event->stopPropagation(); } } } #[Listener(RouteMatched::class, order: 2)] final class WebRouteResolver { public function __invoke(RouteMatched $event): void { // Only reached if API resolver didn't match $event->route = $this->resolveWebRoute($event->path); } }
Events that don't need stopping are plain objects — no StoppableEvent inheritance needed.
Listeners
The #[Listener] Attribute
use PHPdot\Event\Attribute\Listener; #[Listener( event: UserRegistered::class, // required — event class to listen for order: 1, // execution sequence (lower = first, default 0) async: false, // sync (default) or queue priority: 0, // queue priority for async (0-10, higher = urgent) )]
The attribute is IS_REPEATABLE — one handler can listen to multiple events.
Single Event Listener
#[Listener(UserRegistered::class)] final class SendWelcomeEmail { public function __construct( private readonly MailerInterface $mailer, ) {} public function __invoke(UserRegistered $event): void { $this->mailer->send($event->email, 'Welcome!'); } }
Handlers must be callable — implement __invoke(). Resolved from the PSR-11 container (constructor injection works).
Multi-Event Listener
#[Listener(UserRegistered::class, order: 1)] #[Listener(UserUpdated::class, order: 1)] final class UpdateSearchIndex { public function __invoke(UserRegistered|UserUpdated $event): void { $this->search->index('users', $event->userId); } }
Async Listener
#[Listener(OrderPlaced::class, order: 3, async: true, priority: 5)] final class SendOrderConfirmation { public function __invoke(OrderPlaced $event): void { $this->mailer->send($event->userId, 'order.confirmation'); } }
Async listeners are published to the queue via AsyncDispatcherInterface. They don't block the dispatch call. The handler runs later when a queue worker consumes the message.
Ordering
Order controls execution sequence within a single event. Lower numbers run first.
#[Listener(OrderPlaced::class, order: 1)] // runs 1st final class ValidateOrder { ... } #[Listener(OrderPlaced::class, order: 2)] // runs 2nd final class ChargePayment { ... } #[Listener(OrderPlaced::class, order: 3)] // runs 3rd final class ReserveInventory { ... } #[Listener(OrderPlaced::class, order: 4, async: true, priority: 5)] // queued 4th final class SendConfirmation { ... } #[Listener(OrderPlaced::class, order: 5, async: true, priority: 1)] // queued 5th, lower queue priority final class TrackAnalytics { ... }
Order and priority are separate concerns:
order— when this listener runs relative to others for the same event (sync and async)priority— how urgently the queue should process this async listener (async only)
Dispatching Events
Basic Dispatch
use Psr\EventDispatcher\EventDispatcherInterface; final class OrderService { public function __construct( private readonly EventDispatcherInterface $dispatcher, ) {} public function place(int $userId, Cart $cart): Order { $order = $this->createOrder($userId, $cart); $this->dispatcher->dispatch(new OrderPlaced( orderId: $order->id, userId: $userId, total: $cart->total(), )); return $order; } }
The emitter depends on PSR-14 EventDispatcherInterface — knows nothing about handlers.
Dispatch with Stop Propagation
$event = new RouteMatched('/api/users'); $dispatcher->dispatch($event); // $event->route is set by the first resolver that matched if ($event->route !== null) { $this->executeRoute($event->route); }
If the event implements StoppableEventInterface and isPropagationStopped() returns true, remaining listeners are skipped — including async ones.
Dispatch with Async Handlers
// Async handlers are published to the queue — dispatch returns immediately $dispatcher->dispatch(new OrderPlaced(42, 1, 99.99)); // ChargePayment (sync) ran inline // ReserveInventory (sync) ran inline // SendConfirmation (async) → published to queue → returns immediately // TrackAnalytics (async) → published to queue → returns immediately
Dispatch with Mixed Sync/Async
Sync handlers block. Async handlers return immediately. Order is respected across both.
dispatch(OrderPlaced)
→ order 1: ValidateOrder (sync) → container.get() → __invoke() → done
→ order 2: ChargePayment (sync) → container.get() → __invoke() → done
→ order 3: ReserveInventory (sync) → container.get() → __invoke() → done
→ order 4: SendConfirmation (async) → queue.publish(priority: 5) → returns immediately
→ order 5: TrackAnalytics (async) → queue.publish(priority: 1) → returns immediately
→ return event
ListenerProvider
The provider manages the in-memory event→handlers mapping. Implements PSR-14 ListenerProviderInterface.
Manual Registration
$provider = new ListenerProvider(); $provider->addListener( eventClass: UserRegistered::class, handlerClass: SendWelcomeEmail::class, order: 1, async: false, priority: 0, ); $provider->addListener( eventClass: UserRegistered::class, handlerClass: SyncToMailchimp::class, order: 2, async: true, priority: 5, );
Bulk Loading
use PHPdot\Event\DTO\ListenerEntry; $provider->load([ new ListenerEntry(UserRegistered::class, SendWelcomeEmail::class, order: 1), new ListenerEntry(UserRegistered::class, SyncToMailchimp::class, order: 2, async: true, priority: 5), new ListenerEntry(OrderPlaced::class, ChargePayment::class, order: 1), ]);
Loading from Repository
// Load DB overrides — merges with existing entries $provider->loadFromRepository($repository);
Repository entries override existing entries with the same event+handler pair. New entries are added. Used for admin GUI management.
Event Class Hierarchy
Listeners registered on a parent class or interface are triggered by subclass events:
// Listener on parent class $provider->addListener(BaseUserEvent::class, AuditLogger::class); // These events all trigger AuditLogger: $dispatcher->dispatch(new UserRegistered(...)); // extends BaseUserEvent $dispatcher->dispatch(new UserUpdated(...)); // extends BaseUserEvent $dispatcher->dispatch(new UserDeleted(...)); // extends BaseUserEvent // Listener on interface $provider->addListener(AuditableInterface::class, AuditLogger::class); // Any event implementing AuditableInterface triggers AuditLogger
Matching order: exact class → parent classes → interfaces. All sorted by order.
Querying Listeners
$provider->hasListeners(UserRegistered::class); // bool $provider->getAll(); // array<string, list<ListenerEntry>> $provider->removeListeners(UserRegistered::class); // remove all for this event $provider->clear(); // remove everything
Contracts (Interfaces)
Three interfaces that the framework implements. The event package ships with default in-memory implementations.
ListenerDiscoveryInterface
Scans the codebase for #[Listener] attributes. Framework implements using its attribute scanner.
interface ListenerDiscoveryInterface { /** @return list<ListenerEntry> */ public function discover(): array; }
ListenerRepositoryInterface
Persists listener mappings for admin GUI management. Framework implements using its database layer.
interface ListenerRepositoryInterface { /** @return list<ListenerEntry> */ public function getAll(): array; /** @return list<ListenerEntry> */ public function getByEvent(string $eventClass): array; public function save(ListenerEntry $entry): void; public function setEnabled(string $eventClass, string $handlerClass, bool $enabled): void; public function setOrder(string $eventClass, string $handlerClass, int $order): void; public function delete(string $eventClass, string $handlerClass): void; /** @param list<ListenerEntry> $discovered */ public function sync(array $discovered): void; }
AsyncDispatcherInterface
Publishes events to a message queue. Framework implements using its queue layer.
interface AsyncDispatcherInterface { public function publishAsync(object $event, string $handlerClass, int $priority = 0): void; }
Default Implementations
InMemoryListenerRepository
No database needed. Full CRUD. Preserves admin overrides on sync.
use PHPdot\Event\Provider\InMemoryListenerRepository; $repo = new InMemoryListenerRepository(); $repo->save(new ListenerEntry(UserRegistered::class, SendEmail::class, order: 1)); $repo->setEnabled(UserRegistered::class, SendEmail::class, false); $repo->setOrder(UserRegistered::class, SendEmail::class, 5); $repo->delete(UserRegistered::class, SendEmail::class); $repo->getAll(); $repo->getByEvent(UserRegistered::class); $repo->sync($discoveredEntries); // merge, preserve overrides, remove stale
SyncOnlyDispatcher
Runs async handlers synchronously. Useful for development, testing, and simple deployments where no message queue is configured.
use PHPdot\Event\Provider\SyncOnlyDispatcher; $async = new SyncOnlyDispatcher($container); // "Async" handlers just run inline $async->publishAsync($event, SendEmail::class, priority: 5); // SendEmail::__invoke($event) called synchronously
Admin GUI Management
The ListenerRepositoryInterface enables runtime listener management without code deploy.
Enable/Disable Listeners
// Disable a listener — it will be skipped during dispatch $repository->setEnabled(UserRegistered::class, SendWelcomeEmail::class, false); // Re-enable $repository->setEnabled(UserRegistered::class, SendWelcomeEmail::class, true);
Reorder Listeners
// Change execution order without code change $repository->setOrder(UserRegistered::class, SendWelcomeEmail::class, 10); $repository->setOrder(UserRegistered::class, SyncToMailchimp::class, 1); // now runs first
Sync After Discovery
When the application boots, newly discovered listeners are merged with stored ones. Admin overrides (enabled/disabled, reordered) are preserved. Handlers removed from code are cleaned up.
$discovered = $discovery->discover(); $repository->sync($discovered); $provider = new ListenerProvider(); $provider->load($discovered); $provider->loadFromRepository($repository); // applies DB overrides
Exception Handling
Exception Hierarchy
EventException (extends RuntimeException)
├── ListenerException — handler resolution or execution failure
│ ├── getHandlerClass(): string
│ └── getEventClass(): string
└── AsyncDispatchException — queue publishing failure
├── getHandlerClass(): string
└── getEventClass(): string
All exceptions carry the original cause as getPrevious().
Catching Exceptions
use PHPdot\Event\Exception\ListenerException; use PHPdot\Event\Exception\AsyncDispatchException; use PHPdot\Event\Exception\EventException; try { $dispatcher->dispatch(new OrderPlaced(...)); } catch (ListenerException $e) { // Sync handler failed $e->getHandlerClass(); // 'App\Listener\ChargePayment' $e->getEventClass(); // 'App\Event\OrderPlaced' $e->getPrevious(); // original exception } catch (AsyncDispatchException $e) { // Queue publishing failed $e->getHandlerClass(); // 'App\Listener\SendConfirmation' $e->getEventClass(); // 'App\Event\OrderPlaced' } catch (EventException $e) { // Catch-all }
A ListenerException is also thrown when a handler resolved from the container is not callable.
Framework Wiring
How phpdot/dot (or any framework) wires this package at boot time:
// 1. Discover #[Listener] attributes $discovery = new AttributeListenerDiscovery($attributeScanner, $paths); $entries = $discovery->discover(); // 2. Build the provider $provider = new ListenerProvider(); $provider->load($entries); // 3. Optionally load DB overrides $repository = new DatabaseListenerRepository($db); $provider->loadFromRepository($repository); // 4. Wire the async dispatcher $asyncDispatcher = new QueueAsyncDispatcher($queue, $serializer); // 5. Create the event dispatcher $dispatcher = new EventDispatcher( provider: $provider, container: $container, async: $asyncDispatcher, logger: $logger, ); // 6. Register as PSR-14 $container->set(EventDispatcherInterface::class, $dispatcher);
The framework implementations (AttributeListenerDiscovery, DatabaseListenerRepository, QueueAsyncDispatcher) live in the framework, not in this package.
Comparison
| Feature | PHPdot | Symfony | Laravel |
|---|---|---|---|
| Registration | #[Listener] attribute |
YAML/tags/subscriber | EventServiceProvider array |
| Central config file | None | services.yaml | EventServiceProvider |
| Auto-discovery | Via ListenerDiscoveryInterface | Compiler pass | handle() type-hint |
| Admin GUI manageable | ListenerRepositoryInterface | No | No |
| Enable/disable without deploy | Yes | No | No |
| Events | Any PHP object | Extends Event (optional) | Any class |
| Type safety | Class-based identity | String or FQCN | FQCN |
| Stop propagation | StoppableEvent | Event::stopPropagation() | return false |
| Async dispatch | AsyncDispatcherInterface | Messenger (separate) | ShouldQueue |
| Order control | order param |
Priority (numeric) | Registration order |
| Queue priority | priority param |
N/A | $queue property |
| PSR-14 compliant | Yes | Yes | No |
| Standalone | Yes | Yes | No (illuminate/*) |
| Replaceable | Any PSR-14 impl | Any PSR-14 impl | No |
API Reference
Listener Attribute API
#[Attribute(TARGET_CLASS | IS_REPEATABLE)]
final readonly class Listener
__construct(
public string $event, // event class name
public int $order = 0, // execution order (lower = first)
public bool $async = false, // run via queue
public int $priority = 0, // queue priority (0-10)
)
ListenerEntry DTO API
final readonly class ListenerEntry
__construct(
public string $eventClass,
public string $handlerClass,
public int $order = 0,
public bool $async = false,
public int $priority = 0,
public bool $enabled = true,
)
EventDispatcher API
final class EventDispatcher implements EventDispatcherInterface
__construct(
ListenerProvider $provider,
ContainerInterface $container,
AsyncDispatcherInterface $async,
LoggerInterface $logger,
)
dispatch(object $event): object
ListenerProvider API
final class ListenerProvider implements ListenerProviderInterface
getListenersForEvent(object $event): iterable<ListenerEntry>
addListener(string $eventClass, string $handlerClass, int $order = 0, bool $async = false, int $priority = 0): void
load(list<ListenerEntry> $entries): void
loadFromRepository(ListenerRepositoryInterface $repository): void
getAll(): array<string, list<ListenerEntry>>
hasListeners(string $eventClass): bool
removeListeners(string $eventClass): void
clear(): void
StoppableEvent API
abstract class StoppableEvent implements StoppableEventInterface
isPropagationStopped(): bool
stopPropagation(): void
ListenerDiscoveryInterface API
interface ListenerDiscoveryInterface
discover(): list<ListenerEntry>
ListenerRepositoryInterface API
interface ListenerRepositoryInterface
getAll(): list<ListenerEntry>
getByEvent(string $eventClass): list<ListenerEntry>
save(ListenerEntry $entry): void
setEnabled(string $eventClass, string $handlerClass, bool $enabled): void
setOrder(string $eventClass, string $handlerClass, int $order): void
delete(string $eventClass, string $handlerClass): void
sync(list<ListenerEntry> $discovered): void
AsyncDispatcherInterface API
interface AsyncDispatcherInterface
publishAsync(object $event, string $handlerClass, int $priority = 0): void
InMemoryListenerRepository API
final class InMemoryListenerRepository implements ListenerRepositoryInterface
getAll(): list<ListenerEntry>
getByEvent(string $eventClass): list<ListenerEntry>
save(ListenerEntry $entry): void
setEnabled(string $eventClass, string $handlerClass, bool $enabled): void
setOrder(string $eventClass, string $handlerClass, int $order): void
delete(string $eventClass, string $handlerClass): void
sync(list<ListenerEntry> $discovered): void
SyncOnlyDispatcher API
final class SyncOnlyDispatcher implements AsyncDispatcherInterface
__construct(ContainerInterface $container)
publishAsync(object $event, string $handlerClass, int $priority = 0): void
Exceptions API
EventException (extends RuntimeException)
ListenerException (extends EventException)
__construct(string $message, string $handlerClass, string $eventClass, int $code = 0, ?Throwable $previous = null)
getHandlerClass(): string
getEventClass(): string
AsyncDispatchException (extends EventException)
__construct(string $message, string $handlerClass, string $eventClass, int $code = 0, ?Throwable $previous = null)
getHandlerClass(): string
getEventClass(): string
License
MIT