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-30 16:05:06 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.
Table of Contents
- Overview
- Events
- Listeners
- Subscribers
- Providers
- Talking to WordPress hooks
- Putting it all together
- Class reference
Overview
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\Listener\ListenerRegistry; use X3P0\Event\EventDispatcher; // 1. A registry holds your listeners; a dispatcher fires events at them. $listeners = new ListenerRegistry(); $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 Events can carry data back).
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
An event is the plain object you hand to the dispatcher. On its own it's just data, but three opt-in behaviors change how it interacts with the listeners that receive it.
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
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\StoppableEvent; use X3P0\Event\Stoppable; 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\NamedEvent; use X3P0\Event\Named; 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 (call
$registry->listen(OrderPlaced::NAME, ...) from subscribeTo()).
Listeners
A listener is any callable registered against an event type. This section covers how registration order works, the variants that limit how many times a listener fires, how to skip repeating the event type, listener classes, and how to remove what you've registered.
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\Listener\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.
Listening until a condition holds
listenOnce() always stops after exactly one delivery. listenUntil()
generalizes that: it takes a predicate, checked against the event about to be
delivered, and keeps firing until that predicate returns true — at which
point that delivery is its last:
$listeners->listenUntil( UsageRecorded::class, function (UsageRecorded $event): void { if ($event->percentOfQuota < 90) { return; // still under threshold, keep listening } // … email the account holder that they're near their quota }, fn (UsageRecorded $event): bool => $event->percentOfQuota >= 90 );
That fires on every UsageRecorded event, but only sends once usage
crosses 90% — and once it does, the listener removes itself, so it won't
send the same warning again on every event after. listenOnce() is the
special case where the predicate always returns true; listenUntil() is
what you reach for when "stop" depends on something richer than "the first
delivery."
Removal happens once per registration, not once per subject inside the
event stream — the same listener instance handles every matching event, so
the predicate should track state that's global to it (a count, a flag, a
specific qualifying event), not state scoped to one entity on the event. A
predicate like fn ($e) => $attempts[$e->id] >= 5 looks reasonable but isn't:
the first entity to hit that count removes the listener for every entity,
not just its own. Per-entity cutoffs need their own bookkeeping outside
listenUntil().
As with listenOnce(), the predicate is checked — and removal happens — before
the listener runs, so a listener never fires more times than the predicate
allows, even across a reentrant dispatch or a thrown exception. It takes the
same priority argument as listen(), and there's a listenUntilTo()
counterpart that derives the event type from the listener's first parameter,
exactly as listenTo() does:
$listeners->listenUntilTo( function (UsageRecorded $event): void { /* … */ }, fn (UsageRecorded $event): bool => $event->percentOfQuota >= 90 );
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.
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\Listener\ListenerRegistry; $registry = new ListenerRegistry( 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.)
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() — see Writing a subscriber.)
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().)
Subscribers
A subscriber groups several related listeners into one class, so they're registered — and removed — together instead of one call at a time.
Writing a subscriber
A subscriber is a single class that registers several listeners at once — handy for grouping related logic. It registers its own listeners on the registry it is given, the same way you would by hand:
use X3P0\Event\Listener\Listenable; use X3P0\Event\Listener\ListenerSubscriber; final class AnalyticsSubscriber implements ListenerSubscriber { public function subscribeTo(Listenable $registry): void { $registry->listen(PostViewed::class, $this->onPostViewed(...)); // priority 0 (default) $registry->listen(CommentSubmitted::class, $this->onComment(...), 5); // priority 5 } public function onPostViewed(PostViewed $event): void { /* … */ } public function onComment(CommentSubmitted $event): void { /* … */ } } $listeners->subscribe(new AnalyticsSubscriber());
subscribe() just calls subscribeTo(), handing it the registry to
register on — so a subscriber can use listen(), listenTo(), or either
once-only variant, freely mixing them for different listeners in the same
class. Everything it registers is tracked together, so the whole set can be
removed in one call: $listeners->unsubscribe($subscriber).
Make just one of a subscriber's listeners once-only by calling
listenOnce()/listenOnceTo() for that one, and listen()/listenTo() for
the rest — there's no separate registration mode for it, since the subscriber
already has full control:
public function subscribeTo(Listenable $registry): void { $registry->listen(PostViewed::class, $this->onPostViewed(...)); // every time $registry->listenOnce(PostViewed::class, $this->onFirstView(...)); // once only }
Declaring a subscriber's listeners with attributes
subscribeTo() can also be generated for you, so the registration lives on
the method itself instead of being spelled out by hand. There is one
attribute per Listenable method, named to match it, so nothing new has to
be learned beyond the method names already covered above:
| Attribute | Registered with |
|---|---|
Listen |
listen() |
ListenTo |
listenTo() |
ListenOnce |
listenOnce() |
ListenOnceTo |
listenOnceTo() |
ListenUntil |
listenUntil() |
ListenUntilTo |
listenUntilTo() |
Pull in the DiscoversListeners trait and mark each listening method
with whichever attribute matches how you'd otherwise have registered it by
hand:
use X3P0\Event\Listener\Attributes\ListenTo; use X3P0\Event\Listener\DiscoversListeners; use X3P0\Event\Listener\ListenerSubscriber; final class AnalyticsSubscriber implements ListenerSubscriber { use DiscoversListeners; #[ListenTo] public function onPostViewed(PostViewed $event): void { /* … */ } #[ListenTo(priority: 5)] public function onComment(CommentSubmitted $event): void { /* … */ } } $listeners->subscribe(new AnalyticsSubscriber());
This is the same ListenerSubscriber contract, just discovered instead of
hand-written — subscribe() and unsubscribe() both work exactly as above.
ListenTo and ListenOnceTo read the event type from the method's own first
parameter, the same way listenTo()/listenOnceTo() read it for a plain
callable, so it's declared once rather than repeated as an attribute
argument; priority defaults to 0 and accepts a ListenerPriority case
just like everywhere else:
#[ListenTo(priority: ListenerPriority::Last)] public function onComment(CommentSubmitted $event): void { /* … */ }
Listen and ListenOnce take the event type as their first argument
instead, for a named event's string key or any type reflection can't read —
exactly when you'd reach for listen()/listenOnce() by hand:
use X3P0\Event\Listener\Attributes\Listen; #[Listen(OrderPlaced::class)] public function onOrderPlaced(OrderPlaced $event): void { /* … */ }
Both are repeatable, so the same method can be registered under more than one key, such as both a class and a named event's string:
#[Listen(OrderPlaced::class)] #[Listen(OrderPlaced::NAME)] public function onOrderPlaced(OrderPlaced $event): void { /* … */ }
Use ListenOnce/ListenOnceTo for a listener that should remove itself
after it first runs — a method can even carry a mix of once and
not-once attributes, since each is independent:
use X3P0\Event\Listener\Attributes\ListenOnceTo; #[ListenOnceTo] public function onFirstView(PostViewed $event): void { /* … */ }
ListenUntil and ListenUntilTo are the attribute counterparts to
listenUntil() and listenUntilTo(), taking $until as their extra
argument. They require PHP 8.5 or later — a closure is only a legal
attribute argument as of that version, and $until is one. Constructing
either attribute (whether written as #[ListenUntil(...)] or built by hand
with new) on an older PHP throws UnsupportedPhpVersion:
use X3P0\Event\Listener\Attributes\ListenUntil; #[ListenUntil(UsageRecorded::class, until: fn (UsageRecorded $e): bool => $e->percentOfQuota >= 90)] public function onUsageRecorded(UsageRecorded $event): void { /* … */ }
use X3P0\Event\Listener\Attributes\ListenUntilTo; #[ListenUntilTo(until: fn (UsageRecorded $e): bool => $e->percentOfQuota >= 90)] public function onUsageRecorded(UsageRecorded $event): void { /* … */ }
Reach for a hand-written subscribeTo() instead of attributes when
registration has to branch conditionally at runtime; the discovered
declarations are fixed once per class and cached.
Each attribute is a small ListenerAttribute — it knows only how to call its
own Listenable method, given the registry and the method's own
callable. DiscoversListeners never branches on which one it found;
that same interface is what a fifth, custom attribute would need to implement
to be discovered the same way.
Providers
The provider is the part that answers "which listeners apply to this event?" — the dispatcher's only dependency.
Where listeners come from
The dispatcher only needs a ListenerProvider — anything that can answer that
question works, whether or not it also accepts registrations.
ListenerRegistry, under X3P0\Event\Listener, is the one concrete
implementation shipped with the library: an in-memory, writable registry with
priority ordering. It's what you register listeners and subscribers on, and
what you hand the dispatcher — a single instance plays every role at once,
since it implements the read side (ListenerProvider) and the full write
side (Subscribable) together. A listener registered against a base class or
interface also fires for any event that extends or implements it.
The write side, layered
Registering and subscribing are two separate contracts, one building on the
other — both under X3P0\Event\Listener:
| Interface | Adds |
|---|---|
Listenable |
listen() / listenTo() / listenOnce() / listenOnceTo() / listenUntil() / listenUntilTo() / forget() / forgetId() / hasListeners() |
Subscribable |
+ subscribe() / unsubscribe() |
A ListenerSubscriber and a ListenerAttribute are handed a Listenable —
enough to add, remove, and inspect listeners — but never a Subscribable, so
a subscriber can't call subscribe()/unsubscribe() on the registry it's
registering on. That's not just tidiness: the registry tracks what a
subscriber registers by assuming a subscribe() call never triggers another
one, so handing a subscriber the ability to call subscribe() on itself
would break that bookkeeping. If you write your own registry and don't need
subscriber support, implementing just Listenable is already a complete,
valid one — Subscribable is there for when it's needed, not a tier every
registry has to reach.
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.
Implement BroadcastableEvent and pull in BroadcastsToHooks to opt in.
Broadcast an event
Chain broadcast() onto dispatch():
use X3P0\Event\BroadcastableEvent; use X3P0\Event\BroadcastsToHooks; final class PostViewed implements BroadcastableEvent { use BroadcastsToHooks; public function __construct(public readonly int $postId) {} } $event = $dispatcher->dispatch(new PostViewed(42))->broadcast();
broadcast() returns the same instance — same as dispatch() — so the two chain,
and fires do_action(PostViewed::class, $event). Using the class name as the tag
gives a unique, namespaced hook for free, the same key dispatch() already uses to
match typed listeners:
add_action(PostViewed::class, function (PostViewed $event): void { // Read or modify $event here. });
A second hook when the event is named
If the event also implements NamedEvent, broadcast() fires a second action
under eventName() — class-then-name, the same order dispatch() uses to match
typed and named listeners:
use X3P0\Event\NamedEvent; use X3P0\Event\Named; final class PostViewed implements BroadcastableEvent, NamedEvent { use BroadcastsToHooks; use Named; public const NAME = 'acme/post-viewed'; public function __construct(public readonly int $postId) {} } $dispatcher->dispatch(new PostViewed(42))->broadcast(); // fires do_action(PostViewed::class, $event) // and do_action('acme/post-viewed', $event)
That gives you a stable, renamable public hook name (the NAME constant)
alongside the always-unique class-based one, without having to choose just one.
Respecting stopped propagation
If the event is also a StoppableEvent, broadcast() checks
isPropagationStopped() before firing either hook, so a listener that stops
propagation suppresses both:
use X3P0\Event\StoppableEvent; use X3P0\Event\Stoppable; final class PostViewed implements BroadcastableEvent, StoppableEvent { use BroadcastsToHooks; use Stoppable; public function __construct(public readonly int $postId) {} } $listeners->listen(PostViewed::class, function (PostViewed $event): void { $event->stopPropagation(); // broadcast() below won't fire either hook }); $dispatcher->dispatch(new PostViewed(42))->broadcast();
Rolling your own
BroadcastableEvent only asks for a broadcast(): static method —
BroadcastsToHooks is the ready-made WordPress implementation, but nothing
stops a using class from implementing broadcast() some other way (a custom tag,
a queue, a log) instead of pulling in the trait.
Putting it all together
No service container or framework is required — you wire it up by hand:
use X3P0\Event\Listener\ListenerRegistry; use X3P0\Event\EventDispatcher; // The in-memory registry holds the listeners; the dispatcher reads it. $inMemory = new ListenerRegistry(); $dispatcher = new EventDispatcher($inMemory); // Register listeners on the in-memory provider… $inMemory->listen(PostViewed::class, fn (PostViewed $e) => /* … */ null); // …and, if `PostViewed` implements `BroadcastableEvent`, bridge to WordPress // by chaining `broadcast()` onto dispatch: // $dispatcher->dispatch(new PostViewed(42))->broadcast(); // 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 |
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 |
BroadcastableEvent |
Contract for an event that can push itself out beyond the dispatcher's typed listeners |
BroadcastsToHooks |
Trait implementing BroadcastableEvent via WordPress do_action(), class name and (if named) eventName() |
EventException |
Marker interface implemented by every exception the library throws |
Listener\ListenerProvider |
Contract for "which listeners apply to this event?" |
Listener\ListenerPriority |
Enum of named priorities (First / Normal / Last) for listen() |
Listener\Listenable |
Write contract: listen()/listenTo()/listenOnce()/listenOnceTo()/listenUntil()/listenUntilTo()/forget()/forgetId()/hasListeners() — what a subscriber or attribute is handed |
Listener\Subscribable |
Extends Listenable with subscribe() / unsubscribe() |
Listener\ListenerId |
Opaque handle to one registration, returned by listen() and removed with forgetId() |
Listener\ListenerRegistry |
In-memory registry; priority-ordered; the one concrete ListenerProvider shipped |
Listener\ListenerSubscriber |
Contract for a class that registers its own listeners via subscribeTo() |
Listener\DiscoversListeners |
Trait implementing ListenerSubscriber::subscribeTo() from listener attributes |
Listener\Attributes\ListenerAttribute |
Contract for an attribute that registers the method it marks on a given registry |
Listener\Attributes\Listen |
Attribute counterpart to listen() |
Listener\Attributes\ListenTo |
Attribute counterpart to listenTo() |
Listener\Attributes\ListenOnce |
Attribute counterpart to listenOnce() |
Listener\Attributes\ListenOnceTo |
Attribute counterpart to listenOnceTo() |
Listener\Attributes\ListenUntil |
Attribute counterpart to listenUntil() (PHP 8.5+) |
Listener\Attributes\ListenUntilTo |
Attribute counterpart to listenUntilTo() (PHP 8.5+) |
Listener\InvalidListener |
Thrown when a listener is neither a callable nor an invokable class name (extends InvalidArgumentException) |
Listener\NotInvokable |
Thrown when a class-name listener resolves to a non-invokable object (extends LogicException) |
Listener\Attributes\UnsupportedPhpVersion |
Thrown when ListenUntil/ListenUntilTo is built on PHP older than 8.5 (extends LogicException) |
