
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.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.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.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.namespace Drupalservices_hooks_exampleController;
use DrupalCoreControllerControllerBase;
use DrupalCoreStringTranslationStringTranslationTrait;
use Drupalservices_hooks_exampleCustomHookInterface;
use SymfonyComponentDependencyInjectionContainerInterface;
class CustomHookExample extends ControllerBase {
use StringTranslationTrait;
/**
* The custom hook service.
*
* @var Drupalservices_hooks_exampleCustomHookInterface
*/
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;
}
}
namespace DrupalTestsservices_hooks_exampleUnit;
use DrupalCoreMessengerMessengerInterface;
use DrupalnodeEntityNode;
use Drupalservices_hooks_exampleHookNodeHooks;
use DrupalTestsUnitTestCase;
/**
* 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);
}
}
namespace Drupalservices_hooks_exampleHook;
use DrupalCoreHookAttributeHook;
use DrupalnodeNodeInterface;
#[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.
}
}
#[Hook('node_insert')]
public function nodeInsert(NodeInterface $node) {
$this->messenger->addStatus('Services Hooks Example: Node ' . $node->getTitle() . ' created.');
}
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.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.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).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.
Hooks As Class Attributes
The addition of OOP hooks means that we can very easily test our hooks using the built in PHPUnit testing framework.namespace DrupalTestsservices_hook_exampleKernel;
use DrupalCoreMessengerMessengerInterface;
use DrupalKernelTestsKernelTestBase;
use DrupalnodeEntityNode;
/**
* 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 Drupalservices_hooks_exampleHookNodeHooks $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]);
}
}
services:
services_hooks_example.custom_hook:
class: Drupalservices_hooks_exampleCustomHook
autowire: true
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.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.
Hooks As Method Attributes
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.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 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.Here is the code of the unit test class in full.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).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.
Testing
The controller is simple and just throws the result into a list_items
theme to render it as a HTML list.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.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.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.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.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.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.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.
Creating A Unit Test
services:
services_hooks_example.node_hooks:
class: Drupalservices_hooks_exampleHookNodeHooks
autowire: true
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.For completeness, I thought it would be good to quickly look at how to create your own hook.namespace Drupalservices_hooks_exampleHook;
use DrupalCoreHookAttributeHook;
/**
* 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'];
}
}
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.
Creating A Kernel Test
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.In the following example we tell Drupal that we want the nodeInsert()
method to be run when the hook_node_insert
hook is triggered.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.The module_handler
service is responsible for managing all hook calls in the system, so adding your own hooks is quite straightforward.use DrupalnodeNodeInterface;
#[LegacyHook]
function services_hooks_example_node_insert(NodeInterface $node) {
Drupal::service('services_hooks_example.node_hooks')->nodeInsert($node);
}
namespace Drupalservices_hooks_exampleHook;
use DrupalCoreHookAttributeHook;
use DrupalnodeNodeInterface;
class NodeHooks {
/**
* Implements hook_ENTITY_TYPE_insert() for node entities.
*/
#[Hook('node_insert')]
public function nodeInsert(NodeInterface $node) {
// Act on the hook being triggered.
}
}
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.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. /**
* 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.
}
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.
Will All Hooks Be OOP?
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.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.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.
Defining Custom Hooks
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 Drupalservices_hooks_example;
use DrupalCoreExtensionModuleHandlerInterface;
/**
* Service that defines a custom hook.
*/
class CustomHook {
/**
* Creates a new CustomHook object.
*
* @param DrupalCoreExtensionModuleHandlerInterface $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;
}
}
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.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.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.namespace Drupalservices_hooks_exampleHook;
use DrupalCoreHookAttributeHook;
use DrupalnodeNodeInterface;
#[Hook('node_insert')]
class NodeHooks {
/**
* Implements hook_ENTITY_TYPE_insert() for node entities.
*/
public function __invoke(NodeInterface $node) {
// Act on the hook being triggered.
}
}
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.parameters:
services_hooks_example.hooks_converted: true
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.
Conclusion
Our hook implementation service is actually quite small, especially as all we need to do is get a list of items.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.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.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.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.