A modern alternative to Hooks
This post introduces a completely new way of implementing Drupal hooks. You can finally get rid of your <em>.module</em>
files, eliminating many calls to <em>\Drupal</em>
with dependency injection in hooks.
by
daniel.phin
/ 27 April 2022
Introduction
A pattern emerged in Drupal 8 where hooks would be implemented in a traditional .module file, then quickly handed off to a class via a service call or instantiated via the ClassResolver.
Drupal core utilises the ClassResolver
hook pattern thoroughly in .module
files in Content Moderation, Layout Builder, and Workspaces module in order for core hooks to be overridable and partially embrace Dependency Injection (DI).
/** * Implements hook_entity_presave(). */function content_moderation_entity_presave(EntityInterface $entity) { return \Drupal::service('class_resolver') ->getInstanceFromDefinition(EntityOperations::class) ->entityPresave($entity);}
With Drupal 9.4, core has been improved to a point where almost all* hook invocations are dispatched via the ModuleHandler
service. This now allows third party projects to supplement ModuleHandler
via the service decorator pattern.
Hux is one such project taking advantage of this centralisation, allowing hooks to be implemented in a new way:
Sample 1: my_module/src/Hooks/SampleHooks.php
declare(strict_types=1);namespace Drupal\my_module\Hooks;use Drupal\hux\Attribute\Hook;/** * Sample hooks. */final class SampleHooks { #[Hook('entity_access')] public function myEntityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult { return AccessResult::neutral(); }
This file is all that's needed to implement hooks. Keep reading to uncover how this works, including alters, hooks overrides, and dependency injection.
HuxThings you’ll need
- Drupal 9.4 or later.Patches in this issue can be used for Drupal 9.3.
- PHP 8.0 or later
- The Hux project
composer require drupal/hux
Implementing Hooks Classes and Hooks
To begin implementing hooks, create a new class in the Hooks
namespace, in a 'Hooks' directory. The class name can be anything.
Sample 2: my_module/src/Hooks/SampleHooks.php
declare(strict_types=1);namespace Drupal\my_module\Hooks;/** * Sample hooks. */final class SampleHooks {}
Add a public method with the Hook
attribute. PHP attributes are new to PHP 8.0 and are similar to annotations already made familiar by Drupal 8. Don’t forget to import the Hook
attribute with use
.
The method name can be anything. The first parameter of the hook attribute must be the hook without the ‘hook_’ prefix. For example, if implementing hook_entity_access
, use Hook('entity_access')
. Alters use a different attribute, scroll down for information about alters.
Add the parameters and return typehints specific to the hook being implemented. Though these are not enforced or validated by Hux or Drupal.
Sample 3: my_module/src/Hooks/SampleHooks.php
declare(strict_types=1);namespace Drupal\my_module\Hooks;use Drupal\hux\Attribute\Hook;/** * Sample hooks. */final class SampleHooks { #[Hook('entity_access')] public function myEntityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult { return AccessResult::neutral(); }}
As of April 2022, Drupal’s Coder does not yet recognise PHP attributes. So an untagged development version of Coder is needed if methods need to have documentation without triggering coding standards errors.
Sample 4: my_module/src/Hooks/SampleHooks.php
declare(strict_types=1);namespace Drupal\my_module\Hooks;use Drupal\hux\Attribute\Hook;/** * Sample hooks. */final class SampleHooks { /** * Implements hook_entity_access(). */ #[Hook('entity_access')] public function myEntityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult { return AccessResult::neutral(); }}
This is all that is needed to implement hooks using Hux.
Once caches have been cleared, the hook class will be discovered. From then, you don’t need to tediously clear the cache to add more hooks. Hux will discover hooks automatically, thanks to the super-powers of PHP attributes.
Implementing Alters
Alters work very similarly to Hux Hook implementations. Alters can be implemented alongside Hooks in a hooks class.
Add a public method with the Alter
attribute, and import it with use
.
The method name can be anything. The first parameter of the alter attribute must be the alter without both the `hook_' prefix and the ‘_alter’ suffix. For example, if implementing hook_user_format_name_alter
, use Alter('user_format_name')
.
Sample 5: my_module/src/Hooks/SampleHooks.php
declare(strict_types=1);namespace Drupal\my_module\Hooks;use Drupal\hux\Attribute\Alter;/** * Sample hooks. */final class SampleHooks { #[Alter('user_format_name')] public function myCustomAlter(string &$name, AccountInterface $account): void { $name .= ' altered!'; }}
A minority of hooks in Drupal and contrib are alters only by name, such as hook_views_query_alter
, and instead go through the hook invocation system. So the Hook
attribute must be used, while retaining the '_alter' suffix.
Hook Replacements
You can even declare a hook is a replacement for another hook, causing the replaced hook to not be invoked.
For example, if we want to replace Medias’ media_entity_access
hook, which is an implementation of hook_entity_access
Sample 6: my_module/src/Hooks/SampleHooks.php
declare(strict_types=1);namespace Drupal\my_module\Hooks;use Drupal\hux\Attribute\ReplaceOriginalHook;/** * Sample hooks. */final class SampleHooks { #[ReplaceOriginalHook(hook: 'entity_access', moduleName: 'media')] public function myEntityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult { return AccessResult::neutral(); }}
A callable can optionally be received to directly invoke the replaced hook.
Set originalInvoker
parameter to TRUE
and add a callable
parameter before the original hook parameters:
Sample 7: my_module/src/Hooks/SampleHooks.php
declare(strict_types=1);namespace Drupal\my_module\Hooks;
use Drupal\hux\Attribute\ReplaceOriginalHook;/** * Sample hooks. */final class SampleHooks { #[ReplaceOriginalHook(hook: 'entity_access', moduleName: 'media', originalInvoker: TRUE)] public function myEntityAccess(callable $originalInvoker, EntityInterface $entity, string $operation, AccountInterface $account): AccessResult { $originalResult = $originalInvoker($entity, $operation, $account); return AccessResult::neutral(); }}
Dependency Injection with Hooks Classes
An advantage of using Hooks classes is dependency injection. No longer do you need to reach out to \Drupal::service
and friends. Instead, all external dependencies of hook can be known upfront, which also improves the unit-testability of hooks.
Sample 8: my_module/src/Hooks/SampleHooks.php
declare(strict_types=1);namespace Drupal\my_module\Hooks;use Drupal\hux\Attribute\Hook;use Drupal\Core\DependencyInjection\ContainerInjectionInterface;/** * Sample hooks. */final class SampleHooks implements ContainerInjectionInterface { public function __construct( private EntityTypeManagerInterface $entityTypeManager, ) { } public static function create(ContainerInterface $container): static { return new static( $container->get('entity_type.manager'), ); } #[Hook('entity_access')] public function myEntityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult { // Do something with dependencies. $this->entityTypeManager->loadMultiple(...); return AccessResult::neutral(); }}
Continue reading for dependency injection without ContainerInjectionInterface
Hooks Classes without Auto-discovery
In some cases, you might find that more control is needed over the hooks class, such as wanting the class to live in a different directory, or to declare dependencies without using container injection or being container-aware.
In this case, a service can be declared in a services.yml
file, tagging the service with ‘hooks’. Hux will pick up the service and treat it exactly like the auto-discovery method. In fact, auto-discovery does exactly this under the hood, declaring private hooks-tagged services.
services: my_module.my_hooks: class: Drupal\my_module\MyHooks arguments: - '@entity_type.manager' tags: - { name: hooks, priority: 100 }
This approach is ideal if you want to quickly migrate existing .module
hooks → ClassResolver
implementations to Hooks classes. Simply remove the hooks in .module
files, add an entry to a services.yml
file, and then add appropriate attributes.
Summary
- For classes in
Hooks/
directories to be discovered, they need at least one public method with a Hux attribute. Without an attribute, these classes/files will be ignored. - Once the container is aware of a hooks class, more hooks can be added without cache clears.
- Each module can have as many hook classes as you desire, named in any way.
- A hook can be implemented multiple times per module!
- A hook method can have any name.
- A hook class has no interface.
- Using container injection is completely optional. Alternatively, DI can be achieved by declaring a service manually.
- Performance is priority. Hux acts as a decorator for core
ModuleHandler
. After discovery, there is only a very small runtime overhead. - * Works with most hooks.
hook_theme
is a notable example of a hook that does not work, along with theme preprocessors. Though preprocessors are less hooks and more analogous to callbacks.
Concluding...
Hux is a step towards a cleaner codebase. Eliminate .module
files and messy .inc
files. In most cases, procedural, or functions in the global namespace are no longer needed.
An events-based approach to hooks doesn’t need to be the next evolution of hooks in Drupal.
Thanks to Lee Rowlands for the idea of the auto-discovery approach and to clients of PreviousNext which have adopted this approach in the early days.
Consider Hux for your next round of private or contrib development! 🪝
Tagged