Skip to main content

A modern alternative to Hooks

This post introduces a completely new way of implementing Drupal hooks. You can finally get rid of your .module files, eliminating many calls to \Drupal with dependency injection in hooks.

by daniel.phin /

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.

Hux

Things 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! 🪝

Posted by daniel.phin
Drupal Developer

Dated

Comments

Comment by gambry

Dated

Hi Daniel. Nice work. Well done to PreviousNext team.

I have two questions:
1) A part from codebase organisation and this approach been more unit-testable, would you say using this bring any performance improvements, compared to hooks in .module files? Or we drop some?

2) "An events-based approach to hooks doesn’t need to be the next evolution of hooks in Drupal." right, but what's the benefit of this approach compared to an event based?

Thanks

Comment by daniel.phin

Dated

Performance is about the same.

From what I've seen there's typically a very small overhead for Hux's decorated service itself, typically single digit millisecond. There wouldn't be too much loss or gain from file loading itself if you have PHP caching enabled.

A task is already in progress to optimise discovery, though I don't expect any noticeable real world gains.

what's the benefit of this approach compared to an event based?

Comparing to Symfony's event/subscribers, the Hux model is pretty much a form of event subscriber under the hood. The hooks classes are converted to tagged services, just like Symfony. The method of listening is the main point of comparison, where hooks classes do not need to implement a specific interface + method, what I'd consider unnecessary boilerplate. They add an attribute listening for the hook string, instead of a specific event. Hux already includes a priority (weight) system just like Symfony. 

Pagination