Drupal 11: Object Oriented Hooks And Hook Service Classes

Hooks are used in Drupal to allow modules and themes to listen to or trigger different sorts of events in a Drupal system. During these events Drupal will pause and ask if any modules want to have any say in the event that is currently being triggered.

For example, hooks are commonly used when content is viewed, created, updated, or deleted. So, if we delete a page of content then a delete hook is triggered which allows modules to react to that content item being deleted. Using this, we can react to this and perform clean up operations on items of content that don't exist any more. Our custom module might want to remove items from a database table, or delete associated files since they wont be needed.

This is just one example of how hooks are used in Drupal as they are used in all manner of different situations, and not just listening for content events. Another example of a common Drupal hook is when creating a custom template. Many modules will use a hook called hook_theme() to register one or more templates with the theme system so that they can be used to theme custom content.

Hooks have been used in Drupal for a long time (probably since version 3) and have always been one of the harder things for beginners to understand. Module files full of specially named functions that seem to be magically called by Drupal is not an easy concept to get into and can take a while to get familiar with.

New in Drupal 11.1.0 is the ability to create object oriented (OOP) hooks, which is a shift away from the traditional procedural based hooks that have been a part of Drupal for so long. This OOP approach to hooks can be used right now, has a backwards compatible feature, and will eventually replace procedural hooks (where possible).

In this article we will look at how to create a OOP hook, how to transition to using OOP hooks in your Drupal modules, and how to create your own OOP hooks.

Defining A Service Hook

Hooks can now be defined in service classes in the same way as any other service. The hooks are then registered with the Drupal hook system using attributes.

If you want to know more about Drupal services then you can read my previous article on an introduction to services and dependency injection, but I won't assume you know everything for the time being.

The first step in creating your own hook is to create a module.services.yml file in which we define the service class that we want. Whilst the OOP hook technique is quite new the convention is to put the hooks into the "Hook" namespace and define a separate service class for each type of hook you want. One service for interacting with nodes, one service for interacting with the theme layer, and so on.

The following example creates a service class that defines hooks that will interact with the Node entity type. We also use the autowire: true option to automatically inject our dependencies.

services:
  services_hooks_example.node_hooks:
    class: \Drupal\services_hooks_example\Hook\NodeHooks
    autowire: true

The NodeHooks service class defined above will be in the directory src/Hooks in the module directory.

The hooks are defined using a PHP attribute called Hook, either attached to the method or the class. Let's look at each of these attribute locations.

Hooks As Class Attributes

To define a method in a class as a hook you need to attach the Hook attribute to the class definition. If you don't stipulate the method name then a method called __invoke() will be called when the hook is triggered.

In the following example we tell Drupal that we want the __invoke() method to be run when the hook_node_insert hook is triggered, which we define just above the class declaration.

namespace Drupal\services_hooks_example\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;

#[Hook('node_insert')]
class NodeHooks {

  /**
   * Implements hook_ENTITY_TYPE_insert() for node entities.
   */
  public function __invoke(NodeInterface $node) {
    // Act on the hook being triggered.
  }
}

You can specify the method name using the method parameter, passing in the method that needs to be called when the hook is triggered.

In the following example we tell Drupal that we want the nodeInsert() method to be run when the hook_node_insert hook is triggered.

namespace Drupal\services_hooks_example\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;

#[Hook('node_insert', method: 'nodeInsert')]
class NodeHooks {

  /**
   * Implements hook_ENTITY_TYPE_insert() for node entities.
   */
  public function nodeInsert(NodeInterface $node) {
    // Act on the hook being triggered.
  }
}

Multiple hooks can be defined in this way by listing them all in the class declaration, but it's probably a good idea to use the method attributes if you have multiple hooks in your service class as it can be confusing for developers if this or that method is defined as a hook or not.

Hooks As Method Attributes

To create a hook as a method we just need to create a method within the service class and prefix it with the Hook attribute in order to tell Drupal that we want to call this method as a hook.

In the following example we tell Drupal that we want the nodeInsert() method to be run when the hook_node_insert hook is triggered, which we place above the method declaration.

namespace Drupal\services_hooks_example\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;

class NodeHooks {

  /**
   * Implements hook_ENTITY_TYPE_insert() for node entities.
   */
  #[Hook('node_insert')]
  public function nodeInsert(NodeInterface $node) {
    // Act on the hook being triggered.
  }
}

It is even possible to stack PHP attributes and use the same method for different situations.

For example, it is common to run the same code during an insert and an update operation on an entity, so we could change the above to to be the following.

  /**
   * Implements hook_ENTITY_TYPE_insert() and
   * hook_ENTITY_TYPE_update() for node entities.
   */
  #[Hook('node_insert')]
  #[Hook('node_update')]
  public function nodeUpdateOrInsert(NodeInterface $node) {
    // Act on the hook being triggered.
  }

This method will now be triggered when a node is updated or created in our Drupal site, and we can run the same code for each operation.

For more information about what hooks you can you there is a large list of most of the available hooks on this documentation page about hooks on Drupal.org.

Testing

The addition of OOP hooks means that we can very easily test our hooks using the built in PHPUnit testing framework.

Testing hooks in Drupal is normally done implicitly, meaning that we set up the conditions to trigger the hook and see if it was triggered afterwards. With this change to OOP hooks we can also test hooks explicitly by passing a known object to the hook method and seeing what the result was.

As a side note. Yes, I realise that we could also _technically_ test hooks explicitly before this, but injecting stand-alone functions in a module file into a PHPUnit test case is a painful experience. You would often be fighting with loading in the module file and making sure the function existed before the test could start. Service classes make this far, far easier.

How you write your test will depend largely on what sort of hook you are implementing. In the examples above we are using hook_node_insert, so let's create a test for that.

If we modify the hook_node_insert method slightly so that it prints a message when the node is saved then we can test for this action quite easily. We will modify the nodeInsert() method by using the messenger service to print a message about the node being saved. Note that we have also injected the messenger service into the class here, so we need to account for that in our tests. I won't post all of the code here since everything is available on GitHub and that code is largely boilerplate/set up code.

  #[Hook('node_insert')]
  public function nodeInsert(NodeInterface $node) {
    $this->messenger->addStatus('Services Hooks Example: Node ' . $node->getTitle() . ' created.');
  }

We now have a choice about where to go next. We can either write a unit test or a kernel test in order to check that our hook is working correctly. What you select will depend on what you are trying to do, but I'll add an example of both here.

As a side note, there is little point is writing a functional test for a hook since it is impossible to isolate the hook from the rest of Drupal in that context. Functional tests should be more high level than the implementation details on your module's hook architecture.

Whilst I have picked one of the more familiar hooks here (aside from form alter) I have also picked one of the more difficult hooks to test. Our goal here is to test the hook explicitly by creating a node object and passing that object directly to the method. This, however, is complicated by the fact that if we create and save the node to the database then the hook will be called implicitly by Drupal before we get a chance to call it explicitly. What we need to do is create a node object without interacting with the database and that is bourne out in the code examples below.

Creating A Unit Test

Unit testing the nodeInsert() method involves creating a couple of mock objects. The node object can be mocked easily, as we only want to ensure that the title method exists and returns the correct output. If you want your node to do more then you can implement some mocked methods, but a blank node is fine for what we need to do.

The messenger service can be mocked as well, but in this case we want to ensure that the addStatus() method is called exactly once in the execution of the nodeInsert() method and that the correct input is received by that method. Once the test has completed PHPUnit will check this and fail the test if that the addStatus() method is called more than once and the input parameter isn't exactly correct. After that, it's just a case of creating the NodeHooks service and calling our hook method.

Here is the code of the unit test class in full.

namespace Drupal\Tests\services_hooks_example\Unit;

use Drupal\Core\Messenger\MessengerInterface;
use Drupal\node\Entity\Node;
use Drupal\services_hooks_example\Hook\NodeHooks;
use Drupal\Tests\UnitTestCase;

/**
 * Unit tests for the NodeHooks service.
 */
class NodeHooksTest extends UnitTestCase {

  /**
   * Test that a status is created when the nodeInsert hook is called.
   */
  public function testNodeServiceHookInsert() {
    // We create a mock of the node as we don't want to invoke the insert hook
    // until we are ready to do so.
    $node = $this->createMock(Node::class);
    $node->expects($this->any())
      ->method('getTitle')
      ->willReturn('qwerty');

    // Create a mock of the messenger service and ensure that the addStatus
    // method is invoked once.
    $messenger = $this->createMock(MessengerInterface::class);
    $messenger->expects($this->once())
      ->method('addStatus')
      ->with('Services Hooks Example: Node qwerty created.');

    $nodeHooksService = new NodeHooks($messenger);
    $nodeHooksService->nodeInsert($node);
  }

}

If you are doing interesting things with the node in this context then you might want to check that methods in the mocked node were called. For our test we just wanted to make sure that the addStatus() method is called and has our message as the parameter.

We are mocking a lot of objects here, but they are being used sensibly as those methods will be used in the hook. If you find yourself mocking lots of objects then take a step back and think about what you are trying to test with you unit test. Mocking lots of objects is a bit of a bad smell, but not necessarily bad.

Creating A Kernel Test

The kernel test is slightly longer in implementation, but this is because there's a bit of setup code. Plus, as we have access to the full messenger service we can use the service to check that our message was set in the correct way using the service itself (rather than being inferred from the mocked method call).

To get around the hook_insert_node being called when we save the node we create a node object in the normal way, but we don't invoke the save method that saves the node to the database. Instead, we just ensure that any values we might expect to be presented to the hook method are added to the object before calling the hook method. In this case we explicitly set the node ID of 1 in the new node object.

Once the hook has been called we can grab the messenger service and inspect the messages that have been created during the execution of the method.

Here is the code for the kernel test class in full.

namespace Drupal\Tests\services_hook_example\Kernel;

use Drupal\Core\Messenger\MessengerInterface;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;

/**
 * Kernel tests for the NodeHooks service.
 */
class NodeHooksTest extends KernelTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'services_hooks_example',
    'node',
    'user',
  ];

  /**
   * {@inheritdoc}
   */
  public function setUp(): void {
    parent::setUp();
    $this->installEntitySchema('user');
    $this->installEntitySchema('node');
  }

  /**
   * Test that a status is created when the nodeInsert hook is called.
   */
  public function testNodeHookServiceInsert() {
    // Create a test node, but do not save it.
    $node = Node::create([
      'title' => 'qwerty',
      'type' => 'page',
    ]);
    // Set the nid value, which is what the save action would have done.
    $node->set('nid', 1);

    // Get and invoke our node_insert hook from the service class.
    /** @var \Drupal\services_hooks_example\Hook\NodeHooks $nodeHookService */
    $nodeHookService = \Drupal::service('services_hooks_example.node_hooks');
    $nodeHookService->nodeInsert($node);

    // Test that the messenger was populated correctly.
    $messenger = \Drupal::service('messenger');
    $this->assertCount(1, $messenger->messagesByType(MessengerInterface::TYPE_STATUS));
    $this->assertEquals('Services Hooks Example: Node qwerty created.', $messenger->messagesByType(MessengerInterface::TYPE_STATUS)[0]);
  }

}

Remember, try not to write tests that target Drupal's hook system itself, that's not what you want to be testing in your module tests. Instead, you should be dealing with the code within the hook itself.

Legacy Hooks

For the time being, you are encouraged to add a shim procedural hook in the usual place (i.e. in the *.module file). This means that you can start writing OOP hooks even if your module isn't going to be installed on a Drupal 11.1.0 site.

You can save some time by simply referencing your new OOP hooks inside your procedural hook, which is preferable to writing the hook implementation twice. Adding the #[LegacyHook] attribute to the top of the hook function tells Drupal that this is a legacy hook and so it won't be run if an equivalent OOP hook exists.

use Drupal\node\NodeInterface;

#[LegacyHook]
function services_hooks_example_node_insert(NodeInterface $node) {
  \Drupal::service('services_hooks_example.node_hooks')->nodeInsert($node);
}

With this in place your hooks will function just as they did before, with the added benefit of being able to unit test those hooks in your system. When the time comes to turn off or remove the old hook then all you have to do is remove these legacy hooks, rather than rewrite a lot of code.

If you have no legacy hooks, or you don't want to support them, then you can improve performance by setting this parameter in your *.services.yml file. This just goes into the root of the services file.

parameters:
  services_hooks_example.skip_procedural_hook_scan: true

You should be sure what you are doing here. Don't remove legacy support for modules that need to work with Drupal <11.0.0.

Will All Hooks Be OOP?

The Drupal core development team is hard at work removing all the old procedural hook functions from Drupal. At the time of writing it looks like all of the existing hooks will be replaced with OOP equivalents. 

The install and update hooks might remain as procedural hooks since they rely on minimally bootstrapping Drupal and so it might not be possible to load in all service based hooks at that level of bootstrap. Work is being done to explore changing them to the new format as well.

Preprocess functions will be removed in future versions of Drupal and instead preprocess hooks will be defined as callbacks in the hook_theme implementation in the OOP hook. This removal might not happen until Drupal 13 but is available to use in Drupal 11.2.0.

Defining Custom Hooks

For completeness, I thought it would be good to quickly look at how to create your own hook.

The module_handler service is responsible for managing all hook calls in the system, so adding your own hooks is quite straightforward.

As an example, let's create a hook called hook_example_get_items that can be used to collect a list of items together, which we will then print out on a page. A somewhat silly example, but it shows the hook registration system at work.

First, we need to define a service that will invoke the hook so we create this on our module *.services.yml file. We are using the autowire option here so that we can add the dependency of the module_handler service to the controller in the class.

services:

    services_hooks_example.custom_hook:
    class: \Drupal\services_hooks_example\CustomHook
    autowire: true

The class just needs to use the module_handler service to invoke a hook using the invokeAll() method. The result of this method is the return values of all the hooks that implement this hook, merged together into a single array.

Our hook implementation service is actually quite small, especially as all we need to do is get a list of items.

namespace Drupal\services_hooks_example;

use Drupal\Core\Extension\ModuleHandlerInterface;

/**
 * Service that defines a custom hook.
 */
class CustomHook {

  /**
   * Creates a new CustomHook object.
   *
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
   *   The module handler service.
   */
  public function __construct(protected ModuleHandlerInterface $moduleHandler) {
  }

  /**
   * Invokes the hook_example_get_items hook and returns a list of items found.
   *
   * @return array
   *   The list of items found.
   */
  public function getItems():array {
    $items = $this->moduleHandler->invokeAll('example_get_items');
    sort($items);
    return $items;
  }

}

With this in place we need to call that service in a controller so that we can render the outcome of the hook.

The controller is simple and just throws the result into a list_items theme to render it as a HTML list.

namespace Drupal\services_hooks_example\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\services_hooks_example\CustomHookInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

class CustomHookExample extends ControllerBase {

  use StringTranslationTrait;

  /**
   * The custom hook service.
   *
   * @var \Drupal\services_hooks_example\CustomHookInterface
   */
  protected CustomHookInterface $customHook;

  /**
   * {@inheritDoc}
   */
  public static function create(ContainerInterface $container) {
    $instance = new static();
    $instance->customHook = $container->get('services_hooks_example.custom_hook');
    return $instance;
  }

  /**
   * Callback for the route 'services_hooks_example'.
   */
  public function listItems() {
    // Call the getItems() method in the services_hooks_example.custom_hook
    // service, which will invoke the hook_example_get_items hook and return
    // a list of items.
    $items = $this->customHook->getItems();

    $build = [];

    $build['list_of_items'] = [
      '#theme' => 'item_list',
      '#title' => $this->t('Some items'),
      '#items' => $items,
      '#type' => 'ul',
      '#empty' => $this->t('No items found.'),
    ];

    return $build;
  }

}

Finally, all we need to do now is define a hook_example_get_items hook and use it to add items to the list. Our hook method just returns an array.

namespace Drupal\services_hooks_example\Hook;

use Drupal\Core\Hook\Attribute\Hook;

/**
 * Module hooks for interacting with the hook_example_get_items() hook.
 */
#[Hook('example_get_items', method: 'getItems')]
class ExampleGetItemsHooks {

  /**
   * Implements hook_example_get_items().
   */
  public function getItems() {
    return ['one', 'two', 'three'];
  }

}

Now, when we visit the path defined in our routing file for the controller we trigger the hook and our getItems() hook method is called. This results in a list of items being printed to the page.

Conclusion

OOP service hooks are here to stay and all of the core modules have already been converted to use the new hook system. In fact, if you are running Drupal 11.1.0 and above then you can search the codebase for the string #[Hook] to see examples of service hooks in action. All of the module *.api.php files are still present, which are used to document the procedural hooks, but I would expect those files to be removed when the full switch to OOP hooks is made.

I would highly suggest that you start writing OOP service hooks in your modules as soon as you can, with the caveat that you should also include the legacy hooks in your *.module file for the time being. If your module doesn't support Drupal versions under 11.1.0 then you might want to think about removing the old procedural hooks from the codebase.

When Drupal 12 is released (currently slated for late 2026) then the old style of hooks will be depreciated so I would expect to start seeing procedural hooks being removed from modules that support only Drupal 11 and 12. Some procedural hooks might not be removed until Drupal 13, but you should write code in preparation for this change now, rather than scrambling to fix things later.

There are probably going to be quite a few changes in terms of altering hooks, setting hook weights, and other things like that. I'll probably write an update in the future for that since this article is quite long already.

If you want to see this code in action then I have created a GitHub repository that is full of Drupal services examples, including a sub module dedicated to service hooks that shows the hooks system in action as well as the implementation of a custom hook.

More in this series

Comments

I love this article! Thank you for promoting OOP hooks. I've been working on a large article like this for a few weeks and just decided to break it up into much shorter installments so I appreciate how much work goes into this!

 

I have a few notes for readers that might be useful to incorporate!

Defining A Service Hook

You only need to define the hooks in services if you are using legacy hooks. If you only support 11.1 and up you don't need the services entry! Core doesn't have any for example.

Hooks As Class Attributes

While this section is accurate, I personally wouldn't promote class attributes at all. I think method attributes are the way to go, but that's personal preference.

Drupal that this is a legacy hook and so it won't be run if an equivalent OOP hook exists.

Technically this prevents the function with this attribute from being registered so in 11.1 and up these will not run at all. The existence of the OOP version is not part of the equation at all.

What this means is that if the LegacyHook attribute is there the hook will not run even if there is no OOP equivalent.

services_hooks_example.hooks_converted: true

This attribute changed in 11.2 so that we could support preprocess functions. Change record is here: https://www.drupal.org/node/3498595

services_hooks_example.skip_procedural_hook_scan: true

Ok for other types of hooks:

rely on minimally bootstrapping Drupal

The reason is actually more complex than that, at a high level it's the namespaces are not available at the time install is happening (it can happen during Drupal install) and they don't pass through module handler!

Preprocess functions will be removed in future versions of Drupal and instead preprocess hooks will be defined as callbacks in the hook_theme implementation in the OOP hook

That is only template_preprocess_HOOK, hook_preprocess and hook_preprocess_HOOK in modules can be converted normally too: https://www.drupal.org/node/3496491 Deprecation and removal is still up for discussion, if it is deprecated it will not be removed until Drupal 13!

The module_handler service is responsible for managing all hook calls in the system

ModuleHandler doesn't handle all hooks (just all OOP hooks right now) other systems that manage hooks, moduleInstaller, the update system, the install system, the theme system and even views! You can see some more info here: https://www.drupal.org/project/drupal/issues/3472165 If I want to get really deep in the weeds, ModuleHandler is just a secondary discovery mechanism for preprocess hooks. HookCollectorPass discovers them, then in the theme it psuedo invokes them (using invokeAllWith with no execution) in order to register them in the theme system.

When Drupal 12 is released (currently slated for late 2026) then the old style of hooks will be depreciated

This is not set in stone yet actually, but we are discussing this!

There are probably going to be quite a few changes in terms of altering hooks, setting hook weights, and other things like that

Ordering hooks is available in 11.2, but yes, it's a huge topic.

See here for my first article on the timeline: https://nlighteneddevelopment.com/hook-timeline/

I plan to release more articles in the future.

Permalink

Thank you so much nicxvan! This is a brilliant comment :)

I know what you mean about splitting things up, I very nearly wrote two article on this. That being said, there's room to add more!

It is sometimes quite difficult to find the detail on how things work without just digging into the code and figuring it out. Most of this article was created by reading the change logs and then just getting stuck in. As OOP hooks are so new there isn't a lot of information on them just yet, and what information exists is quickly out of date.

So, thank you so much for the corrections! I will update the article as soon as I can.

Name
Philip Norton
Permalink

Add new comment

The content of this field is kept private and will not be shown publicly.