{"id":6338,"date":"2026-08-03T21:00:34","date_gmt":"2026-08-03T21:00:34","guid":{"rendered":"https:\/\/developer.wordpress.org\/news\/?p=6338"},"modified":"2026-08-04T14:08:12","modified_gmt":"2026-08-04T14:08:12","slug":"rethinking-do_action-events-as-objects-hooks-as-class-names","status":"publish","type":"post","link":"https:\/\/developer.wordpress.org\/news\/2026\/08\/rethinking-do_action-events-as-objects-hooks-as-class-names\/","title":{"rendered":"Rethinking do_action(): Events as objects, hooks as class names"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">For a recent project, I explored the concept of building an event dispatcher for WordPress following the <a href=\"https:\/\/www.php-fig.org\/psr\/psr-14\/\">PSR-14 standard<\/a>. You may be asking why I would even attempt that when WordPress already has its own system that dispatches events, called <a href=\"https:\/\/developer.wordpress.org\/plugins\/hooks\/actions\/\">actions<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It was part fun exploration, part for my own edification. And I learned a lot along the way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you build on top of WordPress, you&#8217;ve used <a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/do_action\/\"><code>do_action()<\/code><\/a>. You might have called it a hundred times. Maybe even thousands. And you&#8217;ve probably written <a href=\"https:\/\/developer.wordpress.org\/plugins\/hooks\/custom-hooks\/\">custom hooks<\/a> in your own plugins and themes so other code can hang behavior off yours. It&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But somewhere along the way, we all quietly accepted a few rough edges as just &#8220;how hooks work.&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A tiny change to <em>how<\/em> you call <code>do_action()<\/code> \u2014 passing a plain object instead of a fistful of arguments \u2014 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<div class=\"wp-block-group has-light-grey-2-background-color has-background is-layout-flow wp-block-group-is-layout-flow\" style=\"border-radius:2px;margin-top:var(--wp--preset--spacing--30);margin-bottom:var(--wp--preset--spacing--30);padding-top:var(--wp--preset--spacing--30);padding-right:var(--wp--preset--spacing--30);padding-bottom:var(--wp--preset--spacing--30);padding-left:var(--wp--preset--spacing--30)\">\n<p class=\"has-large-font-size wp-block-paragraph\" style=\"font-style:normal;font-weight:600;line-height:1\">Table of Contents<\/p>\n\n\n<nav aria-label=\"Table of Contents\" class=\"wp-block-table-of-contents\"><ol><li><a class=\"wp-block-table-of-contents__entry\" href=\"#the-hooks-to-tolerate\">The hooks to tolerate<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#the-one-line-idea\">The one-line idea<\/a><ol><li><a class=\"wp-block-table-of-contents__entry\" href=\"#naming-the-hook-the-class-or-a-custom-string\">Naming the hook: the class, or a custom string<\/a><\/li><\/ol><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#dispatching-an-event-based-hook\">Dispatching an event-based hook<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#listening-to-an-event-based-hook\">Listening to an event-based hook<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#what-the-event-object-bought-you\">What the event object bought you<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#an-old-idea-that-wordpress-had-early\">An old idea that WordPress had early<\/a><ol><li><a class=\"wp-block-table-of-contents__entry\" href=\"#how-far-this-approach-can-take-you\">How far this approach can take you<\/a><\/li><li><a class=\"wp-block-table-of-contents__entry\" href=\"#where-to-go-from-here\">Where to go from here<\/a><\/li><\/ol><\/li><\/ol><\/nav><\/div>\n\n\n\n<h2 id=\"the-hooks-to-tolerate\" class=\"wp-block-heading\">The hooks to tolerate<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Suppose you\u2019re building a plugin that registers new members. Let\u2019s look at examples of a couple of custom hooks that might appear in that plugin.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, you might that announce that a registration happened:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">do_action( 'myplugin_member_registered', $userId, $plan );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And another plugin might listen:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">add_action( 'myplugin_member_registered', static function ( $userId, $plan ) {\n\t\/\/ ...\n}, 10, 2 );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Second, your plugin needs an answer back, not just a notice: <em>should this member get a welcome email?<\/em> That&#8217;s a decision, so you apply filters instead:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">$sendWelcomeEmail = apply_filters( 'myplugin_send_welcome_mail', true, $userId, $plan );\n\nif ( $sendWelcomeEmail ) {\n\t\/\/ ...queue the welcome email.\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And another plugin listens, changing the value based on the arguments provided:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">add_filter( 'myplugin_send_welcome_email', static function ( $send, $userId, $plan ) {\n\tif ( 'free' === $plan ) {\n\t\t$send = false;\n\t}\n\n\treturn $send;\n}, 10, 3 );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Both of these techniques are fine, and they\u2019re on par with how WordPress itself fires its own hooks. They&#8217;re also carrying the same small annoyances that&#8217;s easy to stop noticing:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The arguments are positional:<\/strong> Was the order <code>$userId, $plan<\/code> or <code>$plan, $userId<\/code>? How many were there? You have to go read the call to be sure, and remember to pass <code>10, 2<\/code> (or <code>10, 3<\/code> for the filter) so that every argument gets passed along.<\/li>\n\n\n\n<li><strong>The name lives in the global scope:<\/strong> <code>myplugin_member_registered<\/code> and <code>myplugin_send_welcome_mail<\/code> each share one flat namespace with every other plugin on the site. You prefix them and hope.<\/li>\n\n\n\n<li><strong>Nothing is typed:<\/strong> <code>$plan<\/code> could be anything, such as a string, an ID, an object. Most code editors can&#8217;t help you, because as far as they know, it&#8217;s just a variable named <code>$plan<\/code>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The filter adds a fourth papercut all its own: <strong>you have to remember to return the value.<\/strong> If you forget <code>return $send;<\/code>, it breaks any filter along the chain, and it\u2019s hard to trace the problem back to the filter you added.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">None of these are <em>hook<\/em> problems. They&#8217;re <em>payload<\/em> 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.<\/p>\n\n\n\n<h2 id=\"the-one-line-idea\" class=\"wp-block-heading\">The one-line idea<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Instead of passing loose arguments, pass one object that describes what happened. And instead of inventing a string name, use the object&#8217;s own class name as the hook:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">do_action( $event::class, $event );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s the whole idea. The <strong>payload<\/strong> of the hook is now a single, typed object. The name is the class of that object, so it&#8217;s namespaced by construction. No other plugin&#8217;s <code>FullyQualified\\ClassName<\/code> is going to collide with yours without a fatal error.<\/p>\n\n\n\n<div class=\"wp-block-wporg-notice is-info-notice\"><div class=\"wp-block-wporg-notice__icon\"><\/div><div class=\"wp-block-wporg-notice__content\"><p>Notice this is still <code>do_action()<\/code>, not <code>apply_filters()<\/code>. That&#8217;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&#8217;s return value would otherwise carry, minus the return-discipline problem the filter version has. We&#8217;ll watch that play out with the welcome-email decision in a moment.<\/p><\/div><\/div>\n\n\n\n<h3 id=\"naming-the-hook-the-class-or-a-custom-string\" class=\"wp-block-heading\">Naming the hook: the class, or a custom string<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">There are two good ways to name the hook, and the choice comes down to a single question: do you want a name that&#8217;s guaranteed never to change?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every hook in WordPress is global. Anyone can call <code>add_action()<\/code> on any name. So this isn&#8217;t about who&#8217;s allowed to listen; it&#8217;s about whether the name stays put when you refactor.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Use the class name<\/strong> when you&#8217;re fine with the hook name tracking the class:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">do_action( MemberRegistered::class, $event );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">It&#8217;s zero ceremony, it&#8217;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 <code>MemberRegistered<\/code> into another namespace or rename it, the hook name changes too. Then, any <code>add_action()<\/code> still pointed at the old name silently stops matching. Of course, solid deprecation procedures in your code should mitigate issues like this, but that&#8217;s a topic for another day.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Use a custom string<\/strong> when you want the name locked down for good:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">do_action( 'myplugin\/member-registered', $event );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Because the tag is <code>myplugin\/member-registered<\/code> and not the class path, you can rename or relocate <code>MemberRegistered<\/code> however you like and the hook name never moves. It&#8217;s guaranteed to be stable by definition. A listener can hardcode <code>myplugin\/member-registered<\/code> and never reference your class at all.<\/p>\n\n\n\n<div class=\"wp-block-wporg-notice is-tip-notice\"><div class=\"wp-block-wporg-notice__icon\"><\/div><div class=\"wp-block-wporg-notice__content\"><p>The rule of thumb: <code>::class<\/code> when you don&#8217;t mind the name following the class, a fixed string when you want it to never change.<\/p><\/div><\/div>\n\n\n\n<h2 id=\"dispatching-an-event-based-hook\" class=\"wp-block-heading\">Dispatching an event-based hook<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Let&#8217;s put together a slimmed down example of registering a new member to showcase how the concept works. It&#8217;s small enough to follow in one sitting, and the shape maps onto a signup flow you&#8217;ve probably seen before: create the account, announce it, then let listeners decide what happens next.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">All the example classes live in one namespace, <code>MyPlugin\\Members<\/code>, which is what keeps the eventual hook name unique.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, let\u2019s 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):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">namespace MyPlugin\\Members;\n\nfinal class MemberRegistered\n{\n\tpublic function __construct(\n\t\tpublic readonly int    $userId,\n\t\tpublic readonly string $plan,\n\t\tpublic          bool   $sendWelcomeEmail = true,\n\t) {}\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Notice the two kinds of properties:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>$userId<\/code> and <code>$plan<\/code> are <code>readonly<\/code>. This is the context a listener reads to make a decision, not something it should rewrite.&nbsp;<\/li>\n\n\n\n<li><code>$sendWelcomeEmail<\/code> is mutable on purpose: it&#8217;s the &#8220;answer&#8221; the plugin reads back after dispatch, and flipping it off is how a listener says &#8220;skip the welcome email for this one.&#8221; That&#8217;s filter-like behavior coming out of a plain action because the property is writable.<\/li>\n<\/ul>\n\n\n\n<div class=\"wp-block-wporg-notice is-tip-notice\"><div class=\"wp-block-wporg-notice__icon\"><\/div><div class=\"wp-block-wporg-notice__content\"><p><code>MemberRegistered<\/code> is a class like any other. I&#8217;ve kept it to public properties because that&#8217;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.<\/p><\/div><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">namespace MyPlugin\\Members;\n\nfinal class MemberRegistrar\n{\n\tpublic function register( int $userId, string $plan ): void\n\t{\n\t\t\/\/ ...create the account, assign the plan, etc.\n\n\t\t$event = new MemberRegistered( userId: $userId, plan: $plan );\n\n\t\t\/\/ The member is registered. Announce it before anything else\n\t\t\/\/ happens, and let anything interested read or adjust it.\n\t\tdo_action( $event::class, $event );\n\n\t\tif ( $event-&gt;sendWelcomeEmail ) {\n\t\t\t\/\/ ...queue the welcome email.\n\t\t}\n\t}\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s really the technique in a nutshell. You&#8217;re just passing a single event object into <code>do_action()<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">do_action( $event::class, $event );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Compare this to the function signature of <code>do_action()<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">do_action( string $hook_name, mixed ...$arg );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>$hook_name<\/code> is just a string, which <code>$event::class<\/code> satisfies. And <code>$arg<\/code> is a <a href=\"https:\/\/www.php.net\/manual\/en\/language.types.mixed.php\">mixed<\/a> <a href=\"https:\/\/www.php.net\/manual\/en\/functions.arguments.php#functions.variable-arg-list\">variadic<\/a>. So the <code>$event<\/code> object is passed as <code>$arg[0]<\/code> with no validation or conversion on WordPress\u2019s end.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;s outside the scope of the technique described here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Notice there&#8217;s no <code>apply_filters()<\/code> call anywhere in <code>MemberRegistrar<\/code>. The <code>$sendWelcomeEmail<\/code> property is doing that job instead: a listener customizes it, <code>MemberRegistrar<\/code> reads it back after <code>do_action()<\/code> has executed, and there&#8217;s no <code>return<\/code> for any listener to forget along the way.<\/p>\n\n\n\n<h2 id=\"listening-to-an-event-based-hook\" class=\"wp-block-heading\">Listening to an event-based hook<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Now that you&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A listener that <strong>observes<\/strong> reads the event and reacts, but leaves it alone. Here, a logging listener records every registration for auditing:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">use MyPlugin\\Members\\MemberRegistered;\n\nadd_action( MemberRegistered::class, static function ( MemberRegistered $event ): void {\n\terror_log( sprintf(\n\t\t'Member #%d registered on the %s plan.',\n\t\t$event-&gt;userId,\n\t\t$event-&gt;plan\n\t) );\n} );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">It reads <code>$event-&gt;userId<\/code> and <code>$event-&gt;plan<\/code> and reacts, but never touches <code>$sendWelcomeEmail<\/code>. That&#8217;s an observer: it responds to the event without changing it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And a listener that <strong>mutates<\/strong> changes the event, and because objects pass by handle, the <code>MemberRegistrar<\/code> reads the change back. Here, free-plan members skip the paid welcome sequence without the registrar knowing anything about plans:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code lang=\"php\" class=\"language-php\">use MyPlugin\\Members\\MemberRegistered;\n\nadd_action( MemberRegistered::class, function ( MemberRegistered $event ): void {\n\t\/\/ Free-plan members skip the paid welcome sequence.\n\tif ( 'free' === $event-&gt;plan ) {\n\t\t$event-&gt;sendWelcomeEmail = false;\n\t}\n} );<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Look at that second listener&#8217;s signature: <code>function ( MemberRegistered $event )<\/code>. Your editor now autocompletes <code>$event-&gt;userId<\/code> and <code>$event-&gt;plan<\/code>. No positional arguments to memorize. No <code>10, 3<\/code> to remember. No guessing what&#8217;s in the payload.<\/p>\n\n\n\n<h2 id=\"what-the-event-object-bought-you\" class=\"wp-block-heading\">What the event object bought you<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Step back and count what changed by passing an object instead of loose arguments:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Typed listeners\/actions:<\/strong> 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.<\/li>\n\n\n\n<li><strong>Collision-free names:<\/strong> The hook is a fully-qualified class name (or a namespaced string of your own). No <code>myplugin_<\/code> prefix roulette.<\/li>\n\n\n\n<li><strong>Filter-like mutation through a plain action:<\/strong> 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&#8217;s mutable by which properties you leave writable.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">All of that from one convention, using functions that shipped with WordPress years ago.<\/p>\n\n\n\n<div class=\"wp-block-wporg-notice is-tip-notice\"><div class=\"wp-block-wporg-notice__icon\"><\/div><div class=\"wp-block-wporg-notice__content\"><p>Assuming you\u2019re following standard PHP coding practice, it\u2019s 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 <code>Event<\/code> or <code>Hook<\/code> subfolder.<\/p><\/div><\/div>\n\n\n\n<h2 id=\"an-old-idea-that-wordpress-had-early\" class=\"wp-block-heading\">An old idea that WordPress had early<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If the words <em>event<\/em>, <em>listener<\/em>, and <em>dispatcher<\/em> are ringing a bell, it&#8217;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&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>An <strong>event<\/strong> is an object carrying information about something that happened.<\/li>\n\n\n\n<li>A <strong>listener<\/strong> is any callable that receives the event and reacts.<\/li>\n\n\n\n<li>A <strong>dispatcher<\/strong> hands the event to the listeners.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">That\u2019s essentially how <code>do_action()<\/code> and <code>add_action()<\/code> work. And here&#8217;s the part I find genuinely remarkable: WordPress has had this since the Plugin API landed in <a href=\"https:\/\/developer.wordpress.org\/apis\/hooks\/\">version 1.2, back in 2004<\/a>. It&#8217;s easy to forget how early WordPress bet on extensibility.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Passing a typed object under its own class name isn&#8217;t some clever hack bolted onto hooks. It&#8217;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&#8217;re not adopting a new paradigm. You&#8217;re finally using the one you&#8217;ve been standing on for years, and letting the object carry its weight.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I don&#8217;t want to oversell the resemblance. PSR-14 is a formal contract with parts that don&#8217;t exist in WordPress. I&#8217;ll get to what&#8217;s missing in a moment. But the core shape (event, listener, dispatcher) is right there in <code>do_action()<\/code>, and it always has been.<\/p>\n\n\n\n<h3 id=\"how-far-this-approach-can-take-you\" class=\"wp-block-heading\">How far this approach can take you<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Honestly?<\/em> This covers the majority of custom hooks you&#8217;ll probably ever write. The two genuinely hard problems \u2014 safe names and structured, typed payloads \u2014 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What you&#8217;re not getting (if you go looking) is most of what a formal event system like PSR-14 adds on top:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Propagation control: a listener saying &#8220;stop, no one else runs&#8221;. This goes beyond what <code>remove_action()<\/code> and priorities already give you.<\/li>\n\n\n\n<li>Custom listener providers that decide which listeners apply to an event.<\/li>\n\n\n\n<li>Subscriber objects that register a batch of listeners at once (though you could bolt a form of this onto action hooks).<\/li>\n\n\n\n<li>A swappable dispatcher you can replace wholesale, for example, to capture events in a test.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;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&#8217;ve written. You&#8217;re not painting yourself into a corner. You&#8217;re picking a better default while keeping the door open.<\/p>\n\n\n\n<h3 id=\"where-to-go-from-here\" class=\"wp-block-heading\">Where to go from here<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;ve probably outgrown the convention. From there, it&#8217;s worth considering a fuller event system, with a dispatcher, a listener provider, and subscriber classes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But that&#8217;s a decision for the day you actually hit the wall. Until then, the next time you reach for <code>do_action()<\/code>, try reaching for an event object too. It&#8217;s the same hook you already know, just sending a better payload.<\/p>\n\n\n\n<p class=\"has-text-align-right wp-block-paragraph\"><em>Props to <a href='https:\/\/profiles.wordpress.org\/juanmaguitar\/' class='mention'><span class='mentions-prefix'>@<\/span>juanmaguitar<\/a> and <a href='https:\/\/profiles.wordpress.org\/bph\/' class='mention'><span class='mentions-prefix'>@<\/span>bph<\/a> for feedback on this post.<\/em><\/p>\n","protected":false},"excerpt":{"rendered":"<p>How to use typed event objects to improve the payload you send to plugin and theme extenders.<\/p>\n","protected":false},"author":20482,"featured_media":6348,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"edge","default_image_id":0,"font":"","enabled":false},"version":2},"_wpas_customize_per_network":false,"jetpack_post_was_ever_published":false},"categories":[4,40,38],"tags":[],"class_list":["post-6338","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-advanced","category-plugins","category-themes","mentions-bph","mentions-juanmaguitar"],"revision_note":"","jetpack_publicize_connections":[],"jetpack_featured_media_url":"https:\/\/developer.wordpress.org\/news\/files\/2026\/08\/wp-hook-event-objects.jpg","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/posts\/6338","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/users\/20482"}],"replies":[{"embeddable":true,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/comments?post=6338"}],"version-history":[{"count":7,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/posts\/6338\/revisions"}],"predecessor-version":[{"id":6350,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/posts\/6338\/revisions\/6350"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/media\/6348"}],"wp:attachment":[{"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/media?parent=6338"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/categories?post=6338"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/developer.wordpress.org\/news\/wp-json\/wp\/v2\/tags?post=6338"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}