Drupal 9: Using Custom Hooks And Events In Custom Code

Drupal 9: Using Custom Hooks And Events In Custom Code

3rd July 2022 - 18 minutes read time

Custom Drupal codebases (or any codebases really) can be difficult to maintain. New developers to the system need to familiarise themselves with how the code works and what the system is doing before they can make any contributions.

What makes things more difficult is when a site with lots of custom code has modifications that adapt the functionality to specific users.

I will occasionally come across sites that provide some sort of service, but have made modifications to their code that changes things for a particular user (or set of users). This is often to appease the largest user base on the system that wants things done in a certain way, but the site is unwilling to change things for all users.

As an example of what I am talking about, let's take a simple controller that prints out a page of content.

public function testPage() {
  $build = [];

  $build['content'] = [
    '#markup' => '<p>Some content</p>',
  ];

  return $build;
}

Now let's say that we need to adapt this content to a certain type of user. This might be users who belong to a group, or who have a particular role. Whatever that identification might be, the code would change to look like this. 

public function testPage() {
  $build = [];

  $build['content'] = [
    '#markup' => '<p>Some content</p>',
  ];

  if (in_array('SOME_ROLE', $this->currentUser()->getRoles())) {
    $build['special_content'] = [
      '#markup' => '<p>Some special content</p>',
    ];      
  }

  $build['#cache'] = [
    'contexts' => [
      'user.roles',
    ],
  ];

  return $build;
}

I have also added a cache context to the controller action in order to allow the response to be cached correctly for the given role.

This is obviously an arbitrary example as it would be easy to do this with a block or some other mechanism to inject custom content. What tends to happen is that modifications are made directly to the code where that functionality is being run. A controller changing its output is not the most common problem of this sort.

This approach becomes much more of a problem when looking at things like calculations. Let's say that a commerce site had a discount system that gave all users a 10% discount on the price of a product. Some users, however, will receive a bigger discount thanks to some agreement between the site and a company. Again, I'm using the role here, but this can be anything that identifies a group of users on the site.

$percentage = 0.10;
if (in_array('SOME_ROLE', $this->currentUser()->getRoles())) {
  $percentage = 0.20;
}

$result = $price - ($price * $percentage);

This creates custom logic, but it makes the code much more difficult to test. You need to test the code twice, once using a normal user and once with a user with the setup needed to trigger the special condition.

This problem is compounded if the site then has multiple agreements with different user groups. The code then starts to look like this.

$percentage = 0.10;
if (in_array('SOME_ROLE', $this->currentUser()->getRoles())) {
  $percentage = 0.20;
} else if (in_array('SOME_OTHER_ROLE', $this->currentUser()->getRoles())) {
  $percentage = 0.30;
}

$result = $price - ($price * $percentage);

I know that this might seem like a strange approach, but I have seen this on multiple occasions in multiple codebases. Some sites have been a real mess of custom if statements where the logic breaks out and does something just for one group of users; all of which is just embedded in the code.

Detecting user roles is actually not that common. Much more common is detecting things like email address domains or group membership and the tendency to use "magic numbers" is also quite common.

Magic numbers, if you haven't heard of it, is where the code will have a statement like `if ($id == 123) {` and there is no indication as to what 123 means. This is an anti-pattern as it makes the code much more difficult to maintain, difficult to test, and much more fragile.

This isn't a problem that only Drupal has, but Drupal has a couple of features that can help solve this. We can use either custom hooks or custom events to allow the site to proceed normally, and without messing up the codebase with lots of custom rules. The great thing about using these techniques is that they can be put in other modules, which means we don't pollute the codebase with custom logic.

Let's take a look at writing custom hooks first.

Custom Hooks

The two types of hooks that are available in Drupal are alter hooks and, well, normal hooks. Drupal defines lots of internal hooks, but it's perfectly possible to register our own hooks with the system.

Hooks are used to alter or change things as the code progresses, which gives us a neat way of moving this customisation code out into different modules.

The normal and alter hooks work in slightly different ways depending on what you want to do. Let's look at alter hooks first.

Alter Hooks

Alter hooks work by accepting arguments that are passed by reference, which means that you change the argument in the body of the hook and return nothing. They are called through the use of the alter() method of the 'module_handler' service.

Changing the controller action from earlier we can remove the custom code that was created for a user and move it into an alter hook. As the 'module_handler' is available in the controller through the moduleHander() method. The first argument to the alter() method is the name of the alter hook and you can pass up to three additional arguments to the alter method. If you want to pass more arguments then you need to use an associative array to do that.

Here's is the controller action from above, but changed to use an alter hook instead of detecting user roles.

public function testPage() {
  $build = [];

  $build['content'] = [
    '#markup' => '<p>Some content</p>',
  ];

  $this->moduleHandler()->alter('some_content', $build);

  $build['#cache'] = [
    'contexts' => [
      'user.roles',
    ],
  ];

  return $build;
}

Since I have named the alter hook 'some_content' then any implementations of the hook must have the name '[module]_some_content_alter()'.

Here is the implementation of the hook alter. This does the same user role detection and changes the $build array within the body of the hook.

/**
 * Hook hook_some_content_alter().
 */
function somemodule_some_content_alter(&$build) {
  $currentUser = \Drupal::currentUser();
  if (in_array('SOME_ROLE', $currentUser()->getRoles())) {
    $build['new_content'] = [
      '#markup' => '<p>Custom user content.</p>',
    ];
  }
}

Note that we could also move the cache setting of the controller action into the hook, but we would need to be careful here. Since the first call to the hook would be cached it all subsequent calls to the action might not be cached in the correct way. How you set up your cache here really depends on how you detect the users in your hooks.

With this code in place, when a user with the relevant role visits the page they will see different content due to the hook being triggered and the changes made to the content array.

Normal Hooks

Normal Drupal hooks work in a similar way, except that the hook accepts arguments and then returns a value. Drupal will merge together all of the return values from the hooks and return them.

To create a custom hook we need to call the invokeAll() method of the 'module_handler' Drupal service. The first argument is the name of the hook and the second is an array of arguments that will be exploded into arguments for the hook.

public function testPage() {
  $build = [];

  $build['content'] = [
    '#markup' => '<p>Some content</p>',
  ];

  $build = $this->moduleHandler()->invokeAll('some_content', [$build]);

  $build['#cache'] = [
    'contexts' => [
      'user.roles',
    ],
  ];

  return $build;
}

Since I have named the alter hook 'some_content' then any implementations of the hook must have the name '[module]_some_content()'.

Here is an implementation of the hook we just created.

/**
 * Hook hook_some_content().
 */
function somemodule_some_content($build) {
  $user = \Drupal::currentUser();
  if (in_array('SOME_ROLE', $currentUser()->getRoles())) {
    $build['new_content'] = [
      '#markup' => '<p>More user content.</p>',
    ];
  }
  return $build;
}

With this code in place, when the user with a given role visits the page they will receive different content.

Custom Events

Events are the new way of triggering custom actions in Drupal 8+. There is a little more boiler plate code than is involved when setting up hooks, but they can be just as powerful.

The components we need to add events are:

  • A class to hold the event name as a constant.
  • A class that extends the Drupal core Event class and adds any custom information required for the event to function.
  • The addition of code to dispatch the event in the appropriate place.

The first thing to do is create the class to hold the event name that will be used when the event is triggered. As it doesn't do much else the class is pretty simple.

<?php

namespace Drupal\mymodule\Event;

final class SomeContentEvents {

  /**
   * Event token for the content event.
   *
   * @Event
   *
   * @var string
   */
  const SOME_CONTENT = 'some_content.content_generated';

}

Next, we create the event object, which is just a way of sending data to the event method. Since we want to send data from our controller and base the outcome on the current user we create an event object with the current user account and the current content from the controller action. The event objects in Drupal need to extend the core Event object, which gives them access to some of the upstream Symfony event dispatcher methods.

<?php

namespace Drupal\mymodule\Event;

use Drupal\Component\EventDispatcher\Event;
use Drupal\Core\Session\AccountInterface;

class SomeContentEvent extends Event {

  /**
   * The current account.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $account;

  /**
   * The content.
   *
   * @var string
   */
  protected $content;

  /**
   * Constructs a SomeContentEvent object.
   *
   * @param \Drupal\Core\Session\AccountInterface $uid
   *   The user account.
   * @param array $content
   *   The content.
   */
  public function __construct(AccountInterface $account, $content) {
    $this->account = $account;
    $this->content = $content;
  }

  // snipped out the getter and setter methods for brevity.
}

To trigger the event, we need to create a new SomeContentEvent object and send that object to the dispatch() method of the 'event_dispatcher' Drupal service. The second parameter of this method is the event we are triggering, which in this case is SomeContentEvents::SOME_CONTENT.

Any changes made to the event object upstream can be extracted back into the render array.

Here is the controller class in full. I've added in the namespace and dependency injection code to make it easier to adapt this to your own needs.

<?php

namespace Drupal\mymodule\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\mymodule\Event\SomeContentEvent;
use Drupal\mymodule\Event\SomeContentEvents;
use Symfony\Component\DependencyInjection\ContainerInterface;

class TestController extends ControllerBase {

  protected $eventDispatcher;

  public function __construct($event_dispatcher) {
    $this->eventDispatcher = $event_dispatcher;
  }

  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('event_dispatcher')
    );
  }

  public function testPage() {
    $build = [];

    $build['content'] = [
      '#markup' => '<p>Some content</p>',
    ];

    $sessionEvent = new  SomeContentEvent($this->currentUser(), $build);
    $this->eventDispatcher->dispatch($sessionEvent, SomeContentEvents::SOME_CONTENT);
    $build = $sessionEvent->getContent();

    $build['#cache'] = [
      'contexts' => [
        'user.roles',
      ],
    ];

    return $build;
  }
}

That's pretty much it for creating the event, the next step is to create a module that will react to that event.

In another module (which we will call 'somemodule') we set up an event subscriber in the *.services.yml file.

services:
  somemodule.event_subscriber:
    class: Drupal\somemodule\EventSubscriber\ContentEventSubscriber
    tags:
      - { name: event_subscriber }

The event itself will register that it will react to SomeContentEvents::SOME_CONTENT events, calling the method onSomeContentEvent() when this event is triggered.

When reacting to the event we look at the user account of the event and then alter the content within the object. As the event object is passed by reference and we know that the content is pulled out of the object upstream we just need to add our own variation here to alter that content.

<?php

namespace Drupal\somemodule\EventSubscriber;

use Drupal\mymodule\Event\SomeContentEvent;
use Drupal\mymodule\Event\SomeContentEvents;
use \Symfony\Component\EventDispatcher\EventSubscriberInterface;

class HookInitEventSubscriber implements EventSubscriberInterface {

  public static function getSubscribedEvents() {
    $events[SomeContentEvents::SOME_CONTENT] = ['onSomeContentEvent'];
    return $events;
  }

  public function onSomeContentEvent(SomeContentEvent $event) {
    $content = $event->getContent();
    if (in_array('SOME_ROLE', $event->getAccount->getRoles())) {
      $content['new_content'] = [
        '#markup' => '<p>More user content.</p>',
      ];
    }
    return $event->setContent($content);
  }

}

With this code in place, when a user with a given role visits the page they will see customised content. This works in the same way as the hooks, but in this case we are using the events system in Drupal.

Conclusion

Using hooks and events is a good way of tackling this problem, but it should be used to caution!

By moving the code that creates customisations for the user into other modules we create an 'action at a distance' anti-pattern. This means that the output of code will be different for different users and the code that makes these alterations will be held in a different location from the code that generates the page or does the calculation.

You also need to be sure that you don't overlap changes unless that's what you are looking for. You can quite easily get into a mess by allowing different hooks or events to alter the content in different ways. This can happen if a user has more than one role, which is perhaps why roles aren't the best thing to look out for here.

For this reason it is essential that you document exactly what custom hooks or events are available, and what modules have implementations for them. It's a really good idea to group together changes for a particular user into a single module. Not only does this encapsulate the customisations for the user, but it also means that if the user in question closes their account then the customisations can be deactivated by just uninstalling a module. No custom code needs to be altered to remove their footprint from the site.

Have you seen this problem on Drupal sites? If so, how did you go about solving it? Let us know in the comments.

Add new comment

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