Extending WordPress and Drupal: Hooks, Services, and What Changed

Fishhook illustrations on a bubble background
By the Dev Team in Development

Drupal spent three releases of 11.x making the .module file close to optional. WordPress 6.9 added a registry where every capability declares its own schema and permission check. Two real changes to how these platforms get extended, both inside the last two years.

Extension architecture is how you change behavior you didn’t write. It’s most of what agency work actually is. It’s also where these two platforms have the least in common.

WordPress: A Global Array

WordPress extensions are hooks, and hooks are a global array.

$wp_filter maps hook names to WP_Hook objects, which hold callbacks in buckets keyed by integer priority. add_filter() drops a callable into a bucket. apply_filters() walks the buckets in order and passes a value through each callback it finds. That’s the system.

Copied!

PHP

add_filter( 'the_content', 'da_append_disclaimer', 20 ); function da_append_disclaimer( $content ) { if ( is_singular( 'post' ) ) { $content .= '<p class="disclaimer">Views are our own.</p>'; } return $content; }

This gets condescended to more than it deserves. There’s no registration step, no service definition, no container to rebuild before your change does anything. Any callable works. A plugin is a folder with a header comment in it. You can meaningfully extend WordPress from a two-line file, and that low floor is most of the reason the plugin ecosystem is the size it is.

Work in it for a year, though, and the same handful of things keep costing you afternoons.

Filters have no contract. A filter is supposed to return roughly what it was handed. Nothing checks. Forget the return and the next callback in the chain receives null, and the error surfaces somewhere unrelated, usually in a template.

Priority is a magic number. Everything defaults to 10, so most callbacks pile into one bucket and execute in plugin load order. Getting ahead of a competing plugin means guessing at integers. Nine, then five, then one, then zero.

Removal is the part that actually hurts. remove_filter() has to reconstruct the same identity add_filter() generated. A named function is trivial. An object method needs a reference to that exact instance, which plugins almost never expose. A closure needs the closure itself, so anything registered anonymously inside someone else’s plugin is effectively permanent. You end up filtering the output of the thing you wanted to remove. It works, and it feels terrible.

And nothing declares a dependency on anything. You call class_exists() and hope, or you register on a later hook and hope harder.

Drupal: A Container

Drupal runs on Symfony’s dependency injection container. Behavior lives in services declared in mymodule.services.yml, with dependencies injected rather than looked up. Event subscribers handle the request lifecycle. Plugins cover swappable implementations like blocks and field formatters, discovered through PHP attributes, which landed in 10.2 and are gradually displacing the old annotation syntax.

Except Drupal also had hooks, and they were the embarrassing room in an otherwise renovated house. Controllers, plugins, services: all object-oriented, all testable. Hooks: procedural functions in a .module file, prefixed with the module name, with no way to inject anything. You reached for \Drupal::service() and accepted the function would never be unit tested.

Closing that gap is what Drupal 11 has spent three releases doing.

The #[Hook] Attribute

Drupal 11.1 introduced the #[Hook] attribute. Hook implementations became methods on autowired classes in src/Hook/:

Copied!

PHP

namespace Drupal\da_example\Hook; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Session\AccountProxyInterface; use Drupal\node\NodeInterface; class NodeHooks { public function __construct( protected AccountProxyInterface $currentUser, ) {} #[Hook('node_presave')] public function nodePresave(NodeInterface $node): void { if ($node->isNew()) { $node->set('field_created_by_role', $this->currentUser->getRoles()); } } }

Constructor injection in a hook. If you’ve maintained Drupal modules for any length of time, that’s a small event. The class instantiates in a unit test with a mocked user service, which was never true of anything living in a .module file.

It arrived in pieces. Drupal 11.2 brought preprocess hooks over, added an order argument, and introduced a ReOrderHook attribute for repositioning another module’s implementation, which replaces hook_module_implements_alter(). Nobody will miss it. Drupal 11.3 extended attribute hooks to themes and shipped a sizable performance gain, some of it from resolving hooks more efficiently. Contributed modules that still support older Drupal keep a thin procedural shim tagged #[LegacyHook] that calls into the class.

Procedural hooks still work. Install hooks are still procedural. Core’s change record puts removal of procedural hooks at Drupal 13 at the earliest, so nobody needs to rewrite anything this year.

The Abilities API

WordPress moved too, from the other direction entirely. Version 6.9 shipped the Abilities API, a central registry where core, plugins and themes declare discrete capabilities with schemas attached:

Copied!

PHP

add_action( 'wp_abilities_api_init', function () { wp_register_ability( 'da-events/get-upcoming', [ 'label' => 'Get upcoming events', 'description' => 'Returns published events after today.', 'category' => 'da-events', 'input_schema' => [ /* JSON Schema */ ], 'output_schema' => [ /* JSON Schema */ ], 'permission_callback' => fn() => current_user_can( 'read' ), 'execute_callback' => 'da_events_get_upcoming', ] ); } );

Set that next to add_filter(). Typed inputs and outputs, an explicit permission check, a description a human can read, a namespaced identifier. Execution runs a chain: validate the input schema, check permissions, run the callback, validate the output. Both exposures are opt-in rather than automatic: REST requires setting show_in_rest deliberately, and reaching abilities over MCP means installing the separate MCP Adapter plugin and flagging each ability public.

WordPress 7.0 added a JavaScript counterpart and an explorer UI, and subsequent releases have filled in registry filtering through wp_get_abilities() and lifecycle hooks like wp_after_execute_ability. The API gets credited to 7.0 constantly and it shouldn’t be; it landed in 6.9, and 7.0 built the client layer on top.

Look at what bootstraps it, though. You register an ability inside an action hook. The global array is still underneath the whole thing. WordPress didn’t replace its extension model, it built a contract layer on top of one it has no intention of replacing.

What Changes Day to Day

Testing is the biggest practical difference and it isn’t close. A Drupal hook class is a plain object with injected dependencies, so a unit test constructs it with mocks and asserts against the result. A WordPress filter callback typically reaches for globals and singletons, which pushes you toward integration tests, a database, and a fixture setup that takes longer to write than the feature did. Anyone who has tried to get meaningful coverage on a mid-sized WordPress plugin knows exactly where this goes.

Overriding someone else’s work is the other one. Drupal gives you declarative attributes to reorder or replace. WordPress gives you the callback identity problem described above, and often a downstream filter as consolation.

Discoverability favors Drupal too, though less dramatically. The container is inspectable and hook classes are just classes you can find. Finding WordPress hook implementations has historically meant dumping $wp_filter and reading it. The Abilities API is core’s first real answer, but it covers abilities, not the thousands of existing hooks.

Onboarding gets argued about, and the answer depends on which PHP you already know. Anyone coming from Symfony recognizes Drupal’s container and service definitions on sight, and with Xdebug attached is shipping modules in days. What’s new to them is Drupal’s vocabulary.

WordPress has the opposite problem. You can write thoroughly modern PHP inside a plugin, but it stops at your own boundary, because core still hands you a global array and a procedural callback. The one real cost on the Drupal side is the rebuild: change a service definition and you clear caches before anything happens, where WordPress has no compile step at all.

In Conclusion

Neither of these is the modern one and the other the legacy one, whatever your preferred platform’s marketing suggests. Drupal is retrofitting object orientation onto a hook system it has carried for more than twenty years, slowly enough to keep two decades of contrib working. WordPress is bolting schemas and permissions onto a global registry, largely because agents and automation tools need something machine-readable to call and add_filter() was never going to provide it.

The practical advice is the same on both sides. Writing Drupal modules now, start new hooks as attributed classes and leave the .module file empty. Writing WordPress plugins, anything you’d want another plugin or an agent to invoke belongs in the Abilities API rather than behind a custom REST route or an undocumented filter.