x3p0-dev / x3p0-event
A dependency-free event system for WordPress plugins and themes.
Requires
- php: >=8.1
Requires (Dev)
This package is auto-updated.
Last update: 2026-07-05 22:29:48 UTC
README
A small, dependency-free event system for WordPress plugins and themes.
It gives you a clean, object-based way to let the different parts of a plugin or theme react to each other — without wiring them together directly. One part announces that something happened; anything interested responds.
What an event dispatcher does
An event dispatcher decouples the code that announces something happened from the code that reacts to it. One part of your application dispatches an event — a plain object describing what occurred — and any number of listeners registered for that event run in response. Neither side has to know the other exists; they meet only at the event.
That indirection is what makes a codebase extensible. You can bolt on behavior — logging, notifications, cache invalidation, an audit trail — without touching the code that triggers it, and other modules (or other plugins) can react to your events the same way. Features stay loosely coupled, and each listener is a small, isolated unit you can test on its own.
The whole system is five small pieces:
| Term | What it is |
|---|---|
| Event | An object describing something that happened |
| Listener | A callable that reacts to an event |
| Dispatcher | Sends an event to its listeners |
| Listener provider | Decides which listeners apply to an event |
| Subscriber | One class that registers many listeners at once |
An event is data. A listener is code that runs when that data shows up.
The dispatcher connects the two, asking a provider for the right
listeners. If you've used WordPress hooks, this is the same idea as do_action()
and add_action() — expressed with typed objects instead of string tags, and it
can still talk to your existing hooks.
Quick start
use X3P0\Event\PriorityListenerRegistry; use X3P0\Event\EventDispatcher; // 1. A registry holds your listeners; a dispatcher fires events at them. $listeners = new PriorityListenerRegistry(); $dispatcher = new EventDispatcher($listeners); // 2. An event is just a class. Give it whatever data it needs. final class PostViewed { public function __construct(public readonly int $postId) {} } // 3. A listener is any callable that accepts the event. Register it on the registry. $listeners->listen(PostViewed::class, function (PostViewed $event): void { error_log("Post {$event->postId} was viewed."); }); // 4. Dispatch the event, through the dispatcher, wherever it happens. $dispatcher->dispatch(new PostViewed(42));
dispatch() returns the same event object it was given, so you can read
anything the listeners changed on it (see the next section).
The two objects have distinct jobs: you register listeners on the registry
($listeners) and fire events through the dispatcher ($dispatcher). Keep
one of each for your whole plugin so every part shares the same listeners.
Events can carry data back
Because an event is an object passed by reference, listeners can change it,
and the code that dispatched it can read the result. This is how you'd replace a
WordPress filter (apply_filters()):
final class PriceCalculated { public function __construct(public float $price) {} } $listeners->listen(PriceCalculated::class, function (PriceCalculated $event): void { $event->price *= 0.9; // apply a 10% discount }); $event = $dispatcher->dispatch(new PriceCalculated(100.0)); echo $event->price; // 90.0
Priorities
Listeners run in priority order. A lower number runs first, and the default
is 0. Listeners with the same priority run in the order they were added. To run
before a default listener, use a negative number.
$listeners->listen(PostViewed::class, $runsSecond); // priority 0 (default) $listeners->listen(PostViewed::class, $runsFirst, -10); // negative runs earlier $listeners->listen(PostViewed::class, $runsLast, 20); // higher runs later
For the common cases you can use the ListenerPriority enum instead of a bare
number — the cases name the order, not a magnitude, so they read the same way
the rule does ("lower runs first"):
use X3P0\Event\ListenerPriority; $listeners->listen(PostViewed::class, $early, ListenerPriority::First); // before all $listeners->listen(PostViewed::class, $usual, ListenerPriority::Normal); // 0 (the default) $listeners->listen(PostViewed::class, $late, ListenerPriority::Last); // after all
First and Last are the integer extremes, so they run before and after every
other listener respectively — true bookends. Pass a plain integer for any
ordering in between; you can mix the two freely. The same values work as a
subscriber's priority and with listenOnce().
One-time listeners
A listener registered with listenOnce() fires for the first matching event and
then removes itself — handy for one-shot work that should react to an event but
never again:
$listeners->listenOnce(BootCompleted::class, function (BootCompleted $event): void { // runs on the first BootCompleted, then unregisters itself });
It takes the same priority argument as listen() and is otherwise identical. The
listener is removed before it runs, so it fires at most once even if it — or
something it calls — dispatches the same event again.
Inferring the event from the listener
A typed listener already names the event it handles — it's right there in the
parameter type. listenTo() reads it from there, so you don't repeat the class
you just type-hinted:
// listen() — the event class is named twice: $listeners->listen(PostViewed::class, function (PostViewed $event): void { /* … */ }); // listenTo() — named once, on the parameter: $listeners->listenTo(function (PostViewed $event): void { /* … */ });
It's the same registration either way — same storage, same priority ordering,
same matching — so a listenTo() listener typed against a base class or
interface still fires for every subtype, exactly as with listen(). The
priority argument works the same too, as an integer or a ListenerPriority:
$listeners->listenTo($handler, ListenerPriority::Last);
Any callable works, because the type is read from whichever parameter comes
first — a closure, an [$object, 'method'] pair, or an invokable object:
$listeners->listenTo([$analytics, 'onPostViewed']);
There's a once-only counterpart, listenOnceTo(), that combines this with
listenOnce(): the event is inferred and the listener removes itself after it
first runs.
$listeners->listenOnceTo(function (BootCompleted $event): void { /* … */ });
listenTo() needs a type to read, so reach for plain listen() when there
isn't one: a class name (resolved lazily, so there's no signature to inspect
yet), a named event's string key, or a listener whose first parameter is
untyped. A first parameter that is untyped, a builtin such as string, or a
union type throws InvalidListener — it names no single event to register
against, and guessing would be worse than asking you to say it.
Stoppable events
Sometimes one listener should be able to stop the rest from running. Make the
event implement StoppableEvent and pull in the Stoppable trait for a
ready-made implementation:
use X3P0\Event\Stoppable; use X3P0\Event\StoppableEvent; final class CommentSubmitted implements StoppableEvent { use Stoppable; public function __construct(public readonly string $text) {} } $listeners->listen(CommentSubmitted::class, function (CommentSubmitted $event): void { if (str_contains($event->text, 'spam')) { $event->stopPropagation(); // later listeners won't run } });
The dispatcher checks isPropagationStopped() before calling each listener.
Named events
An event is matched by its class, but it can also expose a string name so
listeners may register against a friendly identifier as well as (or instead of)
the class. Implement NamedEvent, and back it with a NAME constant using the Named trait:
use X3P0\Event\Named; use X3P0\Event\NamedEvent; final class OrderPlaced implements NamedEvent { use Named; public const NAME = 'order.placed'; public function __construct(public readonly int $orderId) {} }
Now the event matches listeners registered under either key:
$listeners->listen(OrderPlaced::class, $byClass); // by class, as always $listeners->listen(OrderPlaced::NAME, $byName); // by name ('order.placed') $dispatcher->dispatch(new OrderPlaced(42)); // both listeners run
You still dispatch an object — the name is an additional routing key the
event opts into, not a replacement for the typed event, so listeners still get
the real object and its typed data. And because the name lives on the class as a
constant, registering with OrderPlaced::NAME keeps autocomplete, "find usages,"
and refactoring working — unlike a bare string. The name key composes with
everything else: priorities, listenOnce(), forget(), and subscribers (use
OrderPlaced::NAME as a getSubscribedEvents() key).
Listeners as classes
A listener can be any callable, and an object with an __invoke() method is a
callable — so a listener can be a class:
final class NotifyWarehouse { public function __invoke(OrderPlaced $event): void { /* … */ } } $listeners->listen(OrderPlaced::class, new NotifyWarehouse());
If you'd rather register it by class name and have it built only when the event fires, pass the class name of any invokable class:
final class NotifyWarehouse { public function __invoke(OrderPlaced $event): void { /* … */ } } $listeners->listen(OrderPlaced::class, NotifyWarehouse::class); // resolved lazily
No marker interface is needed — the class only has to define __invoke(), which
keeps its real, typed parameter. The class is instantiated the first time its
event fires and reused after that. By default it's built with new $class(), so
a plain listener class needs no constructor arguments; to resolve listeners that
have dependencies, give the registry a resolver — for example a container:
use X3P0\Event\PriorityListenerRegistry; $registry = new PriorityListenerRegistry( fn (string $class): object => $container->get($class) ); $dispatcher = new EventDispatcher($registry);
This also works with listenOnce(). (A class-name listener is matched by
identity like any other, so remove it with forget(OrderPlaced::class) rather
than by passing the class name back.)
Subscribers
A subscriber is a single class that registers several listeners at once — handy for grouping related logic. It declares which events it handles and which of its methods handles each:
use X3P0\Event\Subscriber; final class AnalyticsSubscriber implements Subscriber { public function getSubscribedEvents(): array { return [ PostViewed::class => 'onPostViewed', // priority 0 (default) CommentSubmitted::class => ['method' => 'onComment', 'priority' => 5], ]; } public function onPostViewed(PostViewed $event): void { /* … */ } public function onComment(CommentSubmitted $event): void { /* … */ } } $listeners->subscribe(new AnalyticsSubscriber());
Each method listed is itself a listener — the subscriber just groups them.
Remember a listener is any callable, and [$object, 'method'] is a callable, so
subscribe() is shorthand for registering each method by hand:
$sub = new AnalyticsSubscriber(); $listeners->listen(PostViewed::class, [$sub, 'onPostViewed']); // priority 0 (default) $listeners->listen(CommentSubmitted::class, [$sub, 'onComment'], 5); // priority 5
Everything a subscriber registered can be removed in one call:
$listeners->unsubscribe($subscriber).
To register a subscriber whose listeners each fire at most once, use
subscribeOnce(). Every declared handler removes itself after it runs, and they
are independent — one firing doesn't disarm the others:
$listeners->subscribeOnce(new AnalyticsSubscriber());
You can still remove the whole set early with unsubscribe() before any of them
have fired.
Removing listeners
To drop individual listeners, use forget(). Pass the event type and the exact
listener to remove just that one, or the event type alone to remove every
listener for it:
$listener = function (PostViewed $event): void { /* … */ }; $listeners->listen(PostViewed::class, $listener); $listeners->forget(PostViewed::class, $listener); // remove that one listener $listeners->forget(PostViewed::class); // remove all PostViewed listeners
Listeners are matched by identity, so with forget() an inline closure can only
be removed by passing back the same closure instance. (Subscribers are removed as
a group with unsubscribe(), above.)
By handle
Every listen() call — and listenTo(), listenOnce(), listenOnceTo() —
returns a ListenerId, an opaque handle to that one registration. Pass it to
forgetId() to remove exactly that listener, no event type or closure reference
required:
$id = $listeners->listen(PostViewed::class, function (PostViewed $event): void { /* … */ }); $listeners->forgetId($id); // removes that listener, and only that one
This is the clean way to drop an inline closure — you keep the tiny handle instead of the closure itself. It works the same for a once-only listener, so you can cancel one before it ever fires:
$id = $listeners->listenOnceTo(function (BootCompleted $event): void { /* … */ }); $listeners->forgetId($id); // it will now never run
Treat the handle as opaque: hold it and hand it back, nothing more. Removing an unknown or already-removed handle does nothing.
Checking for listeners
hasListeners() reports whether anything is registered for an event type —
useful for skipping work, like building an expensive event, when nothing is
listening:
if ($listeners->hasListeners(ReportGenerated::class)) { $dispatcher->dispatch(new ReportGenerated($this->buildExpensiveReport())); }
It respects the same matching as dispatch, so a listener registered against a
base class or interface counts for its subtypes. Pass a named event's name to
check listeners registered under that name. (It reflects listeners on the
registry, not any WordPress add_action() callbacks — for those, use
has_action().)
Where listeners come from: providers
The provider is the part that answers "which listeners apply to this event?"
There are two, and you can combine them. Both implement the
ListenerProvider interface, and all of it — the interface and the concrete
classes — lives under X3P0\Event.
The name tells you which way each one goes: a …Registry is writable — you
register listeners on it — while a read-only …Provider only supplies
listeners it sources elsewhere. So of the two, only PriorityListenerRegistry
accepts registrations.
PriorityListenerRegistry (the default)
An in-memory, writable registry. This is what you register listeners and subscribers on, with priority ordering. A listener registered against a base class or interface also fires for any event that extends or implements it.
AggregateListenerProvider (combine providers)
Wraps several providers and draws listeners from all of them, in the order you list them. It is read-only — it combines what its children provide but accepts no registrations itself (mirroring PSR-14's own aggregation model). You register on the concrete registry; the aggregate reads through to it.
use X3P0\Event\AggregateListenerProvider; // Combine registries from different modules into one view. $provider = new AggregateListenerProvider( $coreListeners, // a PriorityListenerRegistry $moduleListeners // another PriorityListenerRegistry ); $dispatcher = new EventDispatcher($provider); // Register on the concrete registry, not the aggregate or the dispatcher. $coreListeners->listen(PostViewed::class, $listener);
Talking to WordPress hooks
The dispatcher never touches WordPress hooks on its own — it calls listeners and
nothing else, so no event of yours surfaces as an action unless you say so. But
an event is just an object and dispatch() returns it, so bridging to
do_action() is a one-liner you write wherever you dispatch. That keeps the hook
policy — which events become hooks, and under what tag — entirely in your hands.
Fire a hook for an event
Dispatch, then hand the same event to do_action(). Using the event's class name
as the tag gives you a unique, namespaced hook for free:
$event = $dispatcher->dispatch(new PostViewed(42)); do_action($event::class, $event);
Anywhere else — another plugin, a theme's functions.php — a developer reacts
with the class name (use ::class so the namespaced string is exact):
add_action(PostViewed::class, function (PostViewed $event): void { // Read or modify $event here. });
Respect stopped propagation
If the event is a StoppableEvent, check it before firing, so a listener that
stopped propagation also suppresses the hook:
use X3P0\Event\StoppableEvent; $event = $dispatcher->dispatch(new PostViewed(42)); if (! $event instanceof StoppableEvent || ! $event->isPropagationStopped()) { do_action($event::class, $event); }
Custom hook names
Nothing forces the class name — pass whatever stable, readable tag you want to publish. That decouples the public hook from the class, so you can rename or move the event later without breaking anyone listening:
do_action('acme/post_viewed', $event);
Firing from many places
If you fire the same event-and-hook from several spots, wrap the pair once rather than repeat it — a small helper (or your own dispatcher decorator) keeps it DRY and puts the stop-propagation check in a single place:
use X3P0\Event\Dispatcher; use X3P0\Event\StoppableEvent; function dispatch_with_hook(Dispatcher $dispatcher, object $event, string $hook = ''): object { $event = $dispatcher->dispatch($event); if (! $event instanceof StoppableEvent || ! $event->isPropagationStopped()) { do_action($hook ?: $event::class, $event); } return $event; }
Putting it all together
No service container or framework is required — you wire it up by hand:
use X3P0\Event\PriorityListenerRegistry; use X3P0\Event\EventDispatcher; // The in-memory registry holds the listeners; the dispatcher reads it. $inMemory = new PriorityListenerRegistry(); $dispatcher = new EventDispatcher($inMemory); // Register listeners on the in-memory provider… $inMemory->listen(PostViewed::class, fn (PostViewed $e) => /* … */ null); // …and, if you want, bridge to WordPress yourself after dispatch: // do_action(PostViewed::class, $dispatcher->dispatch(new PostViewed(42))); // Then dispatch events from your code. $dispatcher->dispatch(new PostViewed(42));
Keep one $dispatcher (and one provider set-up) for your whole plugin so every
part shares the same listeners.
Class reference
| Class / interface | Role |
|---|---|
Dispatcher |
PSR-14-style contract: just dispatch() |
EventDispatcher |
Dispatches events to their listeners, in the current request |
ListenerProvider |
Contract for "which listeners apply to this event?" |
ListenerPriority |
Enum of named priorities (First / Normal / Last) for listen() |
ListenerRegistry |
Contract for the write side: listen() / listenTo() / listenOnce() / listenOnceTo() / subscribe() / subscribeOnce() / unsubscribe() / forget() / forgetId() / hasListeners() |
ListenerId |
Opaque handle to one registration, returned by listen() and removed with forgetId() |
PriorityListenerRegistry |
In-memory registry; priority-ordered; listen() / subscribe() |
AggregateListenerProvider |
Combines several providers into one |
StoppableEvent |
Contract for an event whose propagation can be stopped |
Stoppable |
Trait with a ready-made StoppableEvent implementation |
NamedEvent |
Contract for an event that also matches by a string name |
Named |
Trait implementing NamedEvent from a NAME class constant |
Subscriber |
Contract for a class that registers many listeners at once |
EventException |
Marker interface implemented by every exception the library throws |
InvalidListener |
Thrown when a listener is neither a callable nor an invokable class name (extends InvalidArgumentException) |
NotInvokable |
Thrown when a class-name listener resolves to a non-invokable object (extends LogicException) |