PSR-14 event dispatcher with attribute-based listener discovery, async dispatch support, ordering/priority, and persistence abstraction. Zero framework dependencies — PSR interfaces only.
- 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
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.
// 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.
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
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
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)
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 are plain PHP objects. No base class required. No interface. No trait.
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,
) {}
}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;
}
}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.
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.
#[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).
#[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);
}
}#[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.
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)
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.
$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.
// 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 immediatelySync 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
The provider manages the in-memory event→handlers mapping. Implements PSR-14 ListenerProviderInterface.
$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,
);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),
]);// 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.
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 AuditLoggerMatching order: exact class → parent classes → interfaces. All sorted by order.
$provider->hasListeners(UserRegistered::class); // bool
$provider->getAll(); // array<string, list<ListenerEntry>>
$provider->removeListeners(UserRegistered::class); // remove all for this event
$provider->clear(); // remove everythingThree interfaces that the framework implements. The event package ships with default in-memory implementations.
Scans the codebase for #[Listener] attributes. Framework implements using its attribute scanner.
interface ListenerDiscoveryInterface
{
/** @return list<ListenerEntry> */
public function discover(): array;
}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;
}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;
}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 staleRuns 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 synchronouslyThe ListenerRepositoryInterface enables runtime listener management without code deploy.
// 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);// Change execution order without code change
$repository->setOrder(UserRegistered::class, SendWelcomeEmail::class, 10);
$repository->setOrder(UserRegistered::class, SyncToMailchimp::class, 1); // now runs firstWhen 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 overridesEventException (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().
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.
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.
| 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 |
#[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)
)
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,
)
final class EventDispatcher implements EventDispatcherInterface
__construct(
ListenerProvider $provider,
ContainerInterface $container,
AsyncDispatcherInterface $async,
LoggerInterface $logger,
)
dispatch(object $event): object
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
abstract class StoppableEvent implements StoppableEventInterface
isPropagationStopped(): bool
stopPropagation(): void
interface ListenerDiscoveryInterface
discover(): list<ListenerEntry>
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
interface AsyncDispatcherInterface
publishAsync(object $event, string $handlerClass, int $priority = 0): void
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
final class SyncOnlyDispatcher implements AsyncDispatcherInterface
__construct(ContainerInterface $container)
publishAsync(object $event, string $handlerClass, int $priority = 0): void
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
MIT