Modernizing Drupal Hooks: Get the 1990s out of your .module file

The old way

As a Drupal developer, you've almost certainly used the "hook" functionality it provides to allow your custom modules to modify all manner of things in Drupal core or contrib modules. Hooks are a way for Drupal to essentially say, "Hey, does anybody want to make changes to this thing I'm about to do?" It's been a powerful foundational feature that has facilitated countless customizations by us developers.

For most of Drupal's life, your hook code was a bunch of global functions placed in a module's .module file. Every hook function has to be globally unique across the entire application, so we named them with a prefix to fake a namespace. There's no protected helper for the bits that two hooks share — those become more global functions, or they get duplicated. There's no constructor, so any service you need is grabbed via \Drupal::service('whatever') mid-function. It's the kind of code that makes me remember (not so fondly) the COBOL days at the start of my career (don't even ask).

Hey Drupal developer - the 1990s called, and they want their coding style back! That's how I felt when I started learning how to write hook handlers in Drupal 7.

Wavy horizontal line

How to make the new magic happen

Drupal 10.3 introduced a way out using object-oriented (OO) code, as explained in the Drupal change record. It's the path I take by default now. It involves two simple pieces to wire things up.

Part One: Classes with the hook attribute

Hooks now become methods on a class. Yes, I said a class. 

  1. Create a normal PHP class in the src/Hook folder of your module. (That location is a standard convention started by core, not a requirement.)
  2. Add the Hook attribute to the public hook methods.
  3. Specify the hook in the attribute decoration. 

The hook name in the attribute does not include replacing the "hook_" prefix with a unique name as with the old method. For example, a method with #[Hook('form_alter')] is wired up the same way that mymodule_form_alter() was using the old method. 

Since my custom module's .module file is now completely empty, I just deleted it. In this particular module, all my hook logic now lives in src/Hook/Hooks.php, src/Hook/ViewsHooks.php, and src/Hook/TokenHooks.php. More on this later.

namespace Drupal\mymodule\Hook;
use Drupal\Core\Hook\Attribute\Hook;
class Hooks {
 #[Hook('form_alter')]
 public function formAlter(&$form, &$form_state, $form_id) {
   // ...
 }
 #[Hook('preprocess_page_title')]
 public function preprocessPageTitle(&$variables) {
   // ...
 }
}

Part Two: Wire up the class in mymodule.services.yml

To allow Drupal to instantiate the class and discover the hooks, register it as a service in the module. The minimum:

services:
  Drupal\mymodule\Hook\Hooks:
    autowire: true
    autoconfigure: true

autowire: true injects the class's constructor arguments by matching the parameter type-hints to services in the container — replacing the explicit manual arguments: list in the service registration.

autoconfigure: true applies automatic tagging based on what the class is; for hook handlers, it sees the #[Hook] attributes and adds the tag the discovery system needs. Without it, you'd write tags: [{ name: _hook }] yourself and have to ask AI to remind you of the syntax or inevitably typo it. 

These two things are features of service registration by Symfony in general, and not specific to hooks. If you're really interested, you can read more about them here:

Hand drawn arrow pointing down

All the OO goodies (to name a few)

  • These classes are namespaced, so I don't need creative unique names for them. In fact, multiple modules can have a Hooks.php file containing the same formAlter method without any collision.
  • I can now have normal protected or private helper methods to organize my code. No more duplicating code to avoid global helper functions or adding insidious "please pretend this is private" underscore prefixes.
  • You can also use traits. For example, I could include the StringTranslationTrait so that t() now works as a method on the class rather than as a global function. It's a small thing, but it's the kind of small thing that helps me sleep at night.

Did someone say Dependency Injection?

Since the functions in the .module file were global functions without a constructor, you had no choice but to acquire services directly from the service container or static helper methods like \Drupal::entityTypeManager(). However, once your hook implementation is a method on a class, that excuse evaporates. The class has a constructor, so the dependencies go there:

class Hooks {
 public function __construct(
   protected readonly RouteMatchInterface $currentRouteMatch,
   protected readonly AccountProxyInterface $currentUser,
   protected readonly EntityTypeManagerInterface $entityTypeManager,
 ) {
 }
 
 // ... hook methods that use $this->entityTypeManager, etc.
}

You can now start to think about organizing your hook code in all the normal OO ways. A class shouldn't have a buffet of responsibilities, for example. Hence, I've grouped my hooks into distinct classes by functionality - views, tokens, etc.

The dependencies I add in the constructor are now a point of code review and refactoring. If I find I have to add a new dependency for just one out of the twelve methods in the class, it could be a code smell that I'm grouped too much functionality together. We can now think like software engineers trying to deliver clean, maintainable, easy to follow code, even for hooks.

A few gotchas

Getting the right reference

You'll notice the AccountProxyInterface, and not AccountInterface, included in the example above. This is how you get the current user. If you type-hint AccountInterface and expect autowiring to figure it out, you'll get a confusing failure. I had to dig a little to figure out the correct interface to reference in the constructor to get the actual thing I was looking for.

Service aliases

When you think you've got everything set up, you may still get errors like: the argument "$groupRelationTypeManager" of method "__construct()" references interface "Drupal\group\Plugin\Group\Relation\GroupRelationTypeManagerInterface" but no such service exists

You are referencing the right interface, and the service definitely exists, so what's up?

Autowiring matches type-hints by FQCN. If the constructor says EntityTypeManagerInterface $foo, the container looks for a service registered under Drupal\Core\Entity\EntityTypeManagerInterface. But Drupal services have historically been registered under snake-case IDs — `entity_type.manager`, `current_user`, etc. Core has been adding FQCN aliases for its services so this just works for core stuff, but your own services and many contrib modules still don't have them.

For my own services that were already in use and referenced by the snake-case ID, I kept that and added an alias for the FQCN:

mymodule.group_finder:
  class: Drupal\mymodule\GroupFinder
  autowire: true
Drupal\mymodule\GroupFinder: "@mymodule.group_finder"

The same trick applies to other modules' services. The groupmedia module exposes the AttachMediaToGroup service under groupmedia.attach_group with no FQCN alias. My hook class can't autowire it until I add one — in my own services.yml:

Drupal\groupmedia\AttachMediaToGroup: "@groupmedia.attach_group"

Yes, you can add aliases for someone else's services from within your own module. The container is global; aliases are additive. The pattern I follow now: when autowiring fails on a constructor argument, add the FQCN alias and see if that fixes it.

Limitations

The Drupal documentation list a handful of hooks that you must still do in the old procedural fashion. I find most of them are hooks I hope to never have to use again. That page also states that "Hooks implemented by themes must remain procedural."

Why I'm not going back

Globally-named functions, \Drupal::service() calls strewn through the body, helpers prefixed to fake privacy — none of it's wrong. It's just that proper namespaces, real protected helpers, constructor injection, and simple registration is a better option. Move the hooks out of the .module file. Put them on a class. Inject what they need. Alias what doesn't resolve. You'll end up with code that is more modern, better organized, and easier to maintain. 

Plus, if you're like me, you'll sleep better.

Need a fresh perspective on a tough project?

Let's talk about how RDG can help.

Contact Us
Module Roundup
Back-end Development
Drupal