Drupal 9: Entity Bundle Classes

2nd January 2022 - 21 minutes read time

Drupal 9.3.0 came with a new feature called entity bundle classes. This allows more control of entity bundles within Drupal and provides a number of benefits over the previous mechanism of using hooks to control everything.

Drupal makes extensive use of entities to manage content and configuration. User details, pages of content and taxonomy terms just a few of the things that are represented in a Drupal site as content entities. Different types of similar configuration are called configuration entities, which would include things like text format settings or even date formats.

Content entities are normally fieldable, and in the case of content types and taxonomy terms Drupal allows users to generate their own variants. These different variants of content entities are called bundles.

Entities define the types of objects found in a Drupal site, bundles represent the different sub-types of these entities. For example, node is the core entity type for creating pages of content, which we sub-type into bundles to create pages, articles, events or whatever you want to represent on the site.

In this article I want to look why entity bundle classes are useful and if you should be using them in your Drupal projects.

What Problem Does This Solve?

Before looking into how to work with entity bundle classes we need to look at what problems we are trying to solve since that's a good way of giving context to why these classes were introduced.

Take the following code, which might be found in many modules and themes in the Drupal 8/9 world.

/**
 * Implements hook_preprocess_node().
 */
function mytheme_preprocess_node(&$variables) {
  /* @var $node \Drupal\node\NodeInterface */
  $node = $variables['node'];
  if ($node->getType() == 'article') {
    $variables['colour'] = $node->get('field_article_colour')->getValue()[0];
  }
}

In the code above we are using the hook_preprocess_node() hook to intercept the rendering of a node. We then use the bundle name to find out the type of node being passed to the hook before getting a value of a field from that node and setting it to a variable in the template.

Whilst this code works, it's tends to be quite fragile. Writing if statements like this to catch which type of node has been passed is fine, but with fields used by more than one content type (e.g. the body field) this simple check soon becomes a bit tricky to manage. It's easy to add more conditions to the if statement until you end up with a complex function peppered with conditions. It's not uncommon to come across preprocess hooks that are hundreds of lines long (which is bad practice if you can't tell).

Grabbing values from fields to pass to the template like this is ok (there are better ways to do this even without bundle classes) but in practice this can become a weak point in your code. In recent years I have swapped to using the hasField() method of the entity to check to see if the field exists before using it, rather than any direct comparisons with the bundle type. This allows slightly more robust code that can be adapted to other content types without any code changes.

There is another issue here in the form of a lack of encapsulation around hooks that change the way in which entities operate in a Drupal site. What I mean by this is that there is nothing to stop multiple themes or modules from implementing the same hook and changing the template in some way. The problem is deeper than this as there are lots of hooks that change the way in which entities operate, and they don't have to be collected together in the same place.

As an example of another hook, it is possible to add a hook_entity_access() hook to allow (or disallow) access to entities in certain operations. The following code will return an access denied page if a user who does not have access to the site administration pages attempts to view it an article content type.

/**
 * Implements hook_entity_access().
 */
function mymodule_entity_access(\Drupal\Core\Entity\EntityInterface $entity, $operation, \Drupal\Core\Session\AccountInterface $account)
{
  if ($operation == 'view' && $entity->bundle() == 'article') {
    return AccessResult::forbiddenIf(!$account->hasPermission('access administration pages'));
  }
  return AccessResult::neutral();
}

My point here is that this hook can be placed in a different module than the preprocess hook above. This means that although both hooks interact with the same entity, they are in different parts of the codebase. It's common for preprocess hooks to be placed in the theme, but it depends on the style of the developer making the function. Different hooks can be placed in different modules and themes throughout a Drupal site.

This "action at a distance" anti-pattern is quite prevalent in Drupal, just due to the way in which hooks work. You might find that different modules will implement different sets of hooks that can often work against each other. Turning off modules can also have unpredictable results if it disables a certain hook that your entity was reliant upon.

This is why it's really important to employ behavioural testing on a Drupal site as there can be a disconnect between what you test using Drupal's testing frameworks and what actually happens on the site.

Entity bundle classes attempts to solve some of these problems by moving the "hook-able" functionality an entity might need into a single class. Or at the very least, a hierarchy of classes.

Adding Entity Bundle Classes

To make use of entity bundle classes you first need to tell Drupal about the class that you intend to use. This is done using the hook hook_entity_bundle_info_alter() that alters an existing bundle definition. You use this hook to inject your new class into the bundle definition.

This is an important consideration actually, and worth going over again before getting into code. Entity bundle classes are created for use with pre-existing content bundles. You might have a site that has a number of content types, taxonomies or even paragraphs and want to customise how they operate.

By using entity bundle classes you can, for example, alter the way in which the Page content type is used, or change the Header paragraph type. These bundles, however, must exist in your configuration before they can be used.

The following code block implements the hook_entity_bundle_info_alter() hook to add a custom class called Article to the pre-existing Article content type.

<?php

use Drupal\mymodule\Entity\Article;

function mymodule_entity_bundle_info_alter(array &$bundles): void {
  if (isset($bundles['node']['article'])) {
    $bundles['node']['article']['class'] = Article::class;
  }
}

The implementation of the Article class is pretty simple. Since we are adding a bundle class to a content type we need to extend the base entity class of Node. Since the Node class is not an abstract class we don't need to add any other methods out of the box.

<?php

namespace Drupal\mymodule\Entity;

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\node\Entity\Node;

class Article extends Node {
}

This obviously isn't useful, but the real power comes when we want to do things with this class.

Detecting Bundles

Perhaps the simplest thing you can do is use the class instance to detect what the bundle can do. For example, let's take the above preprocess hook that looked for the bundle type as a string and replace it with a check for the instance of a bundle class.

use Drupal\mymodule\Entity\Article;

/**
 * Implements hook_preprocess_node().
 */
function mytheme_preprocess_node(&$variables) {
  /* @var $node \Drupal\node\NodeInterface */
  $node = $variables['node'];
  if ($node instanceof Article) {
    $variables['colour'] = $node->get('field_article_colour')->getValue()[0]['value'];
  }
}

With this simple change we can be sure that the $node variable is an instance of the bundle class you are looking for. Also, since this is essentially an object we can use some of the OOP mechanisms available to PHP to look for particular types of object.

For example, we can use an interface to detect the type of bundle, rather than the actual class. The following is a simple interface called NodeColourInterface that defines a single method called getColour().

<?php

namespace Drupal\mymodule\Entity;

interface NodeColourInterface {
  public function getColour(): string;
}

If we get the Article class to implement the NodeColourInterface then we need to add the getColour() method. This just moves the code from the preprocess hook into the Article class.

<?php

namespace Drupal\mymodule\Entity;

use Drupal\node\Entity\Node;

class Article extends Node implements NodeColourInterface {

  public function getColour(): string {
    return $this->get('field_article_colour')->getValue()[0]['value'];
  }
}

We can now further change the preprocess hook to use the interface to detect the type of bundle and swap out the field value code to be just getColour().

​use Drupal\mymodule\Entity\NodeColourInterface;

/**
 * Implements hook_preprocess_node().
 */
function mytheme_preprocess_node(&$variables) {
  /* @var $node \Drupal\node\NodeInterface */
  $node = $variables['node'];
  if ($node instanceof NodeColourInterface) {
    $variables['colour'] = $node->getColour();
  }
}

This means that if the class implements the NodeColourInterface interface then we can assume that it has the getColour() method and use that in the same way for all nodes that have that field. Different bundles might have different implementations of the colour field, but as long as they return a string that represents a colour then the preprocess hook will function in the same way. This could be extended further by having an intermediate abstract class or even using traits to allow multiple classes access to the same code.

The benefit of using this mechanism to grab the colour value is that we technically don't need to use a hook to inject it into the template. We can instead call the method directly within the template.

{{ node.getColour() }}

Since we have removed the need for the preprocess hook, we can also look at removing other hooks using bundle classes.

Replacing Hooks

I mentioned earlier about a lack of encapsulation being a problem in Drupal, and this is what entity bundle classes can be useful in solving. Since the bundle class code is called at different times during the life cycle of an entity the class they can be used to replace hooks and move functionality into a bundle class.

Taking a common example of the hook_entity_insert() hook, which is called after an entity has been inserted into the database. The code below is a trivial example of this hook that just mimics the current default Drupal functionality of printing out a message to the user.

/**
 * Implements hook_entity_insert().
 */
function mymodule_entity_insert(\Drupal\Core\Entity\EntityInterface $entity) {
  if ($entity instanceof Article) {
    \Drupal::messenger()->addStatus(t('Article @title has been created.', ['@title' => $entity->toLink()->toString()]));
  }
}

With this in place, when an Article is saved a message is printed out to the user. Insert hooks can be used to perform all kinds of actions and are not uncommon in Drupal sites. I have used this hook in the past to send emails to users, write data to external systems (e.g. search systems) or just create entities that work with the original node.

We can remove this hook entirely and replace it with code within the Article class. There is a method in the Node class called postSave() that is called after the entity has been saved, so all we need to do is override this method to perform our custom work. We also need to ensure that the parent class code gets called so that the upstream code gets called correctly. The following code has exactly the same functionality as the above hook, but is encapsulated within the Article bundle class.

<?php

namespace Drupal\mymodule\Entity;

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\node\Entity\Node;

class Article extends Node {

  public function postSave(EntityStorageInterface $storage, $update = TRUE) {
    if ($update == FALSE) {
      \Drupal::messenger()->addStatus(t('Article @title has been created.', ['@title' => $this->toLink()->toString()]));
    }
    parent::postSave($storage, $update);
  }
}

The benefit of using this method (other than removing a hook) is that the same code is used when creating or updating the Article. Which means we don't need to add different functions for different situations, we can just adapt this function to do what we need.

Some Common Use Cases

I thought it might be useful to look at some common use cases of entity bundles that could be used instead of hooks.

Load An Entity

To hook into the action that loads entities you would use the postLoad() method. This is instead of using hooks like hook_ENTITY_TYPE_load().

<?php

namespace Drupal\mymodule\Entity;

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\node\Entity\Node;

class Article extends Node {
  
  public static function postLoad(EntityStorageInterface $storage, array &$entities) {
    foreach ($entities as $entity) {
        \Drupal::messenger()->addStatus(t('Article @title loaded', ['@title' => $entity->getTitle()]));
    }

    parent::postLoad($storage, $entities);
  }
}

Deleting An Entity

The opposite of loading an entity is deleting it, which can be done using the hook_node_delete(). By adding the postDelete() method to our class we can intercept any entities that were deleted and report on the data we removed.

<?php

namespace Drupal\mymodule\Entity;

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\node\Entity\Node;

class Article extends Node {
  
  public static function postDelete(EntityStorageInterface $storage, array $nodes) {
    foreach ($nodes as $entity) {
      \Drupal::messenger()->addStatus(t('Article @title deleted', ['@title' => $entity->getTitle()]));
    }
    parent::postDelete($storage, $nodes);
  }
}

Entity Permission Check

I take about using the hook_entity_access() hook at the start of this article. The same hook can be implemented within the bundle class using the access() method. This works in the same way in that you must return an AccessResultInterface object (or a boolean).

<?php

namespace Drupal\mymodule\Entity;

use Drupal\Core\Session\AccountInterface;
use Drupal\node\Entity\Node;

class Article extends Node {

  public function access($operation = 'view', AccountInterface $account = NULL, $return_as_object = FALSE) {
    if ($operation == 'view') {
      return AccessResult::forbiddenIf(!$account->hasPermission('access administration pages'));
    }
    return parent::access($operation, $account, $return_as_object);
  }
}

If you can think of any more examples of where bundle classes would be used in place of hooks then add a comment below and I'll update this list.

Conclusion

Whilst I've talked about how bundle classes work there is one thing to realise here. As we have extended the Node class we also have inherited all of the code that exists upstream for that class. This means that there is a balance we need to make between listening to events or processing fields and the business logic behind the site. If you add too much business logic to your bundle class you will end up polluting your codebase and making things difficult to test.

Entity bundle classes aren't atomic and will share code with a few parent classes and some traits. I have already shown that even though we can loop into the updating or deletion code we absolutely have to pass the execution upstream to the parent class or things will easily break. In fact, the upstream call to postDelete invalidates cache tags, so not calling that method will cause your site to have cache invalidation problems.

Bundle classes don't lend themselves to SOLID principles very well. Adding a postSave() method to your bundle class when the node is created means that you might need to alter that class if the node is created, which violates the single use policy of SOLID. Instead of relying on entity bundle classes you should strive to create your own services to pull out business logic into smaller, testable chunks.

Ultimately, this is a massive step in the right direction in terms of encapsulation and customisation in Drupal. The old system of using hooks for everything is being deprecated in favour of events and plugins and this system is a part of that general movement in Drupal. This will empower developers to write better, more maintainable and testable code. You can easily mock entity objects so that they can be tested and having classes and objects to test with is much better than named functions.

If you like what you see here and want to take this a step further then take a look at the Drupal module Typed Entity. This module takes the approach of wrapping entities so that you can concentrate on creating your business logic outside of your Drupal specific code. I won't cover that module here as it needs a full article to go through everything that module can do.

If you want to read through the original release notes for entity bundle classes then take a look at the Drupal documentation page introducing them. It's actually a very good introduction to the feature and shows how it might be used in a few different situations.

Let us know how you get on with entity bundle classes, we would be interested to hear your thoughts in the comments below.

Comments

Permalink

Nice articlea,l thanks a lot. And yeah I've got some hooks to substitute now.

TomSaw (Mon, 01/03/2022 - 21:42)

Permalink

Some more interesting, hook substituting methods to overwrite in \Drupal\Core\Entity\EntityBase:

EntityBase::preCreate // Useful to prepopulate entity forms with dynamic defaults

EntityBase::preDelete // F.e. intervene deletions a la [Do you really want to delete ... ?]

EntityBase::postDelete // Destruct further stuff

 
 

 

 

TomSaw (Tue, 01/04/2022 - 18:27)

Permalink

Thanks for the input TomSaw, much appreciated. Really happy you took the time to read it :)

I did see preCreate() in my look at bundle classes. Nice way of injecting content into the node but I hadn't thought about using it to change things in entity forms. Nice idea!

Add new comment

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