For a recent project, I explored the concept of building an event dispatcher for WordPress following the PSR-14 standard. You may be asking why I would even attempt that when WordPress already has its own system that dispatches events, called actions.
It was part fun exploration, part for my own edification. And I learned a lot along the way.
If you build on top of WordPress, you’ve used do_action(). You might have called it a hundred times. Maybe even thousands. And you’ve probably written custom hooks in your own plugins and themes so other code can hang behavior off yours. It’s one of the oldest extensibility tricks in WordPress, and it still works as beautifully as it did when it was introduced in WordPress 1.2.0.
But somewhere along the way, we all quietly accepted a few rough edges as just “how hooks work.”
A tiny change to how you call do_action() — passing a plain object instead of a fistful of arguments — smooths every one of them out. It needs no custom library. No framework. No new dependency. Just a better default for a function you already use.
As I explored my own event dispatcher, it turned out that nearly all the value lived in one small habit. Let me walk through what I mean.
Table of Contents
The hooks to tolerate
Suppose you’re building a plugin that registers new members. Let’s look at examples of a couple of custom hooks that might appear in that plugin.
First, you might that announce that a registration happened:
do_action( 'myplugin_member_registered', $userId, $plan );
And another plugin might listen:
add_action( 'myplugin_member_registered', static function ( $userId, $plan ) {
// ...
}, 10, 2 );
Second, your plugin needs an answer back, not just a notice: should this member get a welcome email? That’s a decision, so you apply filters instead:
$sendWelcomeEmail = apply_filters( 'myplugin_send_welcome_mail', true, $userId, $plan );
if ( $sendWelcomeEmail ) {
// ...queue the welcome email.
}
And another plugin listens, changing the value based on the arguments provided:
add_filter( 'myplugin_send_welcome_email', static function ( $send, $userId, $plan ) {
if ( 'free' === $plan ) {
$send = false;
}
return $send;
}, 10, 3 );
Both of these techniques are fine, and they’re on par with how WordPress itself fires its own hooks. They’re also carrying the same small annoyances that’s easy to stop noticing:
- The arguments are positional: Was the order
$userId, $planor$plan, $userId? How many were there? You have to go read the call to be sure, and remember to pass10, 2(or10, 3for the filter) so that every argument gets passed along. - The name lives in the global scope:
myplugin_member_registeredandmyplugin_send_welcome_maileach share one flat namespace with every other plugin on the site. You prefix them and hope. - Nothing is typed:
$plancould be anything, such as a string, an ID, an object. Most code editors can’t help you, because as far as they know, it’s just a variable named$plan.
The filter adds a fourth papercut all its own: you have to remember to return the value. If you forget return $send;, it breaks any filter along the chain, and it’s hard to trace the problem back to the filter you added.
None of these are hook problems. They’re payload problems, and they show up whether the hook is an action or a filter. A hook can carry a single object just as easily as it can carry multiple loose arguments. And an object fixes all of it at once.
The one-line idea
Instead of passing loose arguments, pass one object that describes what happened. And instead of inventing a string name, use the object’s own class name as the hook:
do_action( $event::class, $event );
That’s the whole idea. The payload of the hook is now a single, typed object. The name is the class of that object, so it’s namespaced by construction. No other plugin’s FullyQualified\ClassName is going to collide with yours without a fatal error.
Notice this is still do_action(), not apply_filters(). That’s not an oversight. Part of the merit of this technique is that a mutable property on the event can stand in for whatever a filter’s return value would otherwise carry, minus the return-discipline problem the filter version has. We’ll watch that play out with the welcome-email decision in a moment.
Naming the hook: the class, or a custom string
There are two good ways to name the hook, and the choice comes down to a single question: do you want a name that’s guaranteed never to change?
Every hook in WordPress is global. Anyone can call add_action() on any name. So this isn’t about who’s allowed to listen; it’s about whether the name stays put when you refactor.
Use the class name when you’re fine with the hook name tracking the class:
do_action( MemberRegistered::class, $event );
It’s zero ceremony, it’s namespaced for free, and when you rename or move the class, your IDE renames the hook right along with it. The catch is exactly that: the tag is the class path, so the day you move MemberRegistered into another namespace or rename it, the hook name changes too. Then, any add_action() still pointed at the old name silently stops matching. Of course, solid deprecation procedures in your code should mitigate issues like this, but that’s a topic for another day.
Use a custom string when you want the name locked down for good:
do_action( 'myplugin/member-registered', $event );
Because the tag is myplugin/member-registered and not the class path, you can rename or relocate MemberRegistered however you like and the hook name never moves. It’s guaranteed to be stable by definition. A listener can hardcode myplugin/member-registered and never reference your class at all.
The rule of thumb: ::class when you don’t mind the name following the class, a fixed string when you want it to never change.
Dispatching an event-based hook
Let’s put together a slimmed down example of registering a new member to showcase how the concept works. It’s small enough to follow in one sitting, and the shape maps onto a signup flow you’ve probably seen before: create the account, announce it, then let listeners decide what happens next.
All the example classes live in one namespace, MyPlugin\Members, which is what keeps the eventual hook name unique.
First, let’s look at the event itself, which is a plain object. It carries the two facts a listener needs to know (who registered and on what plan) plus one property listeners are allowed to change (whether to send a welcome email):
namespace MyPlugin\Members;
final class MemberRegistered
{
public function __construct(
public readonly int $userId,
public readonly string $plan,
public bool $sendWelcomeEmail = true,
) {}
}
Notice the two kinds of properties:
$userIdand$planarereadonly. This is the context a listener reads to make a decision, not something it should rewrite.$sendWelcomeEmailis mutable on purpose: it’s the “answer” the plugin reads back after dispatch, and flipping it off is how a listener says “skip the welcome email for this one.” That’s filter-like behavior coming out of a plain action because the property is writable.
MemberRegistered is a class like any other. I’ve kept it to public properties because that’s all this event needs, but nothing stops you from giving an event custom methods, exactly as you would with other classes. When you want to guard how a value changes, make the property private and expose a setter that validates it. When a value is derived, add a getter. When listeners keep repeating the same dance, wrap it in a helper method. Public properties are just the basic end of the spectrum, not a special rule for events.
Here’s where the member gets registered and the event goes out: a small registrar class creates the account, then fires the event right after registration, and reads the result back before deciding whether to queue a welcome email:
namespace MyPlugin\Members;
final class MemberRegistrar
{
public function register( int $userId, string $plan ): void
{
// ...create the account, assign the plan, etc.
$event = new MemberRegistered( userId: $userId, plan: $plan );
// The member is registered. Announce it before anything else
// happens, and let anything interested read or adjust it.
do_action( $event::class, $event );
if ( $event->sendWelcomeEmail ) {
// ...queue the welcome email.
}
}
}
That’s really the technique in a nutshell. You’re just passing a single event object into do_action():
do_action( $event::class, $event );
Compare this to the function signature of do_action():
do_action( string $hook_name, mixed ...$arg );
$hook_name is just a string, which $event::class satisfies. And $arg is a mixed variadic. So the $event object is passed as $arg[0] with no validation or conversion on WordPress’s end.
Your plugin would naturally have the account-creation and plan-assignment logic in place before this point, and the email-sending logic itself living somewhere else, but that’s outside the scope of the technique described here.
Notice there’s no apply_filters() call anywhere in MemberRegistrar. The $sendWelcomeEmail property is doing that job instead: a listener customizes it, MemberRegistrar reads it back after do_action() has executed, and there’s no return for any listener to forget along the way.
Listening to an event-based hook
Now that you’re passing event objects via action hooks, other code can react using the event object itself. There are two shapes a listener takes, and this pattern supports both.
A listener that observes reads the event and reacts, but leaves it alone. Here, a logging listener records every registration for auditing:
use MyPlugin\Members\MemberRegistered;
add_action( MemberRegistered::class, static function ( MemberRegistered $event ): void {
error_log( sprintf(
'Member #%d registered on the %s plan.',
$event->userId,
$event->plan
) );
} );
It reads $event->userId and $event->plan and reacts, but never touches $sendWelcomeEmail. That’s an observer: it responds to the event without changing it.
And a listener that mutates changes the event, and because objects pass by handle, the MemberRegistrar reads the change back. Here, free-plan members skip the paid welcome sequence without the registrar knowing anything about plans:
use MyPlugin\Members\MemberRegistered;
add_action( MemberRegistered::class, function ( MemberRegistered $event ): void {
// Free-plan members skip the paid welcome sequence.
if ( 'free' === $event->plan ) {
$event->sendWelcomeEmail = false;
}
} );
Look at that second listener’s signature: function ( MemberRegistered $event ). Your editor now autocompletes $event->userId and $event->plan. No positional arguments to memorize. No 10, 3 to remember. No guessing what’s in the payload.
What the event object bought you
Step back and count what changed by passing an object instead of loose arguments:
- Typed listeners/actions: Every callback type-hints the event, so you get autocomplete and static analysis on the payload instead of untyped variables you have to trace by hand.
- Collision-free names: The hook is a fully-qualified class name (or a namespaced string of your own). No
myplugin_prefix roulette. - Filter-like mutation through a plain action: This is the underrated one. A listener can change the event, and because objects pass by handle, the code that dispatched it reads those changes back. And you decide exactly what’s mutable by which properties you leave writable.
All of that from one convention, using functions that shipped with WordPress years ago.
Assuming you’re following standard PHP coding practice, it’s worth noting each event/hook would have its own file. A side benefit of this is that your extension points become self-documenting. As your code base grows, you may even decide to split them into their own Event or Hook subfolder.
An old idea that WordPress had early
If the words event, listener, and dispatcher are ringing a bell, it’s because this is one of the oldest patterns in software design. The broader programming world calls it by various names, depending on who you ask and how you squint. The names change; the idea doesn’t.
One part of a program announces that something happened, and any number of other parts, which the first part knows nothing about, get a chance to respond.
- An event is an object carrying information about something that happened.
- A listener is any callable that receives the event and reacts.
- A dispatcher hands the event to the listeners.
That’s essentially how do_action() and add_action() work. And here’s the part I find genuinely remarkable: WordPress has had this since the Plugin API landed in version 1.2, back in 2004. It’s easy to forget how early WordPress bet on extensibility.
Passing a typed object under its own class name isn’t some clever hack bolted onto hooks. It’s the same mental model the rest of the PHP world standardized on in PSR-14, expressed in the API WordPress has had all along. You’re not adopting a new paradigm. You’re finally using the one you’ve been standing on for years, and letting the object carry its weight.
I don’t want to oversell the resemblance. PSR-14 is a formal contract with parts that don’t exist in WordPress. I’ll get to what’s missing in a moment. But the core shape (event, listener, dispatcher) is right there in do_action(), and it always has been.
How far this approach can take you
Honestly? This covers the majority of custom hooks you’ll probably ever write. The two genuinely hard problems — safe names and structured, typed payloads — are solved the moment you pass an object under its class name. And for the hooks that would otherwise have been filters, you get a third win for free: no return value for a listener to forget, since the object itself carries the answer.
What you’re not getting (if you go looking) is most of what a formal event system like PSR-14 adds on top:
- Propagation control: a listener saying “stop, no one else runs”. This goes beyond what
remove_action()and priorities already give you. - Custom listener providers that decide which listeners apply to an event.
- Subscriber objects that register a batch of listeners at once (though you could bolt a form of this onto action hooks).
- A swappable dispatcher you can replace wholesale, for example, to capture events in a test.
Here’s the thing: most hooks never need any of that, and every bit of it can be layered on later without throwing away what you’ve written. You’re not painting yourself into a corner. You’re picking a better default while keeping the door open.
Where to go from here
If you find yourself wanting a listener to halt the rest, wiring up dozens of listeners across a large codebase by hand, or wishing you could swap the whole dispatch mechanism for tests, you’ve probably outgrown the convention. From there, it’s worth considering a fuller event system, with a dispatcher, a listener provider, and subscriber classes.
But that’s a decision for the day you actually hit the wall. Until then, the next time you reach for do_action(), try reaching for an event object too. It’s the same hook you already know, just sending a better payload.
Props to @juanmaguitar and @bph for feedback on this post.