Filed Under

In Part 1 of this blog series, we explored the concepts of entities, bundles, entity classes, and the new feature of bundle subclasses now available in Drupal Core 9.3.0. #2570593: Allow entities to be subclassed using "bundle classes" is one of the biggest changes to Drupal Core in many releases, although it’s only directly available to developers. In this post, we’ll look at how to start making use of this functionality to improve your life. If you’re not a developer, you might be more interested in parts 1 and 3 of this series, although hopefully everyone will find this useful and informative.

The Introducing bundle classes change record provides a good introduction and some suggestions for how to make use of the feature.

Disclaimer on Object-Oriented Coding Styles

Before we dive into the gory and wonderful details of bundle subclasses, we must acknowledge that there are a lot of different schools of thought and approaches to object-oriented coding. Like everyone else, I have my own personal opinions and biases about some of these “best practices” and have certain beliefs and thoughts on what makes for a “good” design.

Computer programming can be both a science and an art form. There are many ways to accomplish the same result, and there are a vast number of dimensions from which to assess any given approach. As one of the co-authors of this feature, I don’t intend to impose my own beliefs about design and style on anyone else.

This post isn’t going to hash out the pros and cons of all sides in a debate about Duck Typing and try to pick sides. There are totally legitimate reasons to use different ways of thinking about (and therefore organizing) code depending on the project and its needs.

This post will be hands-on, and we’ll look at some specific code samples. As such, it will reflect some of the choices we’ve made for the project we’ve been making the most use of this feature on. That doesn’t mean this is the only way to do something, or that what’s written here is necessarily the best approach. But it works for us, and we’re excited about it, and we wanted to write about it to get a collective conversation going.

As I wrote at the change record:

Encapsulating all the required logic for each bundle into its own subclass opens up many possibilities for making more clear, simple, maintainable, and testable code. Drupal itself has no opinion on how you should structure your code, but this change allows you to use whatever object oriented style/design/paradigm you prefer.

Sharing Code

Defining bundle subclasses is ultimately about organizing site logic in a structure that was designed to make it easy to share code. We can now unlock all the power of object-oriented programming and use it for the customizations that make every Drupal site unique. Even relatively simple sites end up with multiple node types, usually with overlapping but not identical sets of functionality. Setting up bundle subclasses lets you put code in one place and elegantly share it with all the bundles that need it.

Since we’re dealing with PHP objects here, we’re only constrained by what the language itself supports. But in general, there are two main approaches for setting up a class hierarchy for bundle subclasses to organize and share the code:

  1. Setting up an abstract base class
  2. Using interfaces and traits

Abstract Base Class

You could define an abstract base class for your project (and have it extend from the default entity class), and then define subclasses for every bundle and register them all. Then shared code lives in the base class, while bundle-specific code lives in the child classes.

Often with this approach, the base class would define has* methods to go with each kind of functionality any bundle might provide. Bundles that support the feature will implement the hasX() to return TRUE , while the other bundles will return FALSE . Usually the base class defines the default implementation of all the has* methods to return FALSE , and specific child classes override those to opt-in to supporting a given feature.

Going this route, it’s important that if you enable a bundle subclass for an entity type, that every bundle for that entity type defines its own bundle subclass. You cannot opt-in and only define subclasses for the bundles that need them, defaulting to the base entity class for the other, “standard” bundles. If you start relying on calling $node->hasX() , every bundle must define its own subclass of the abstract base class. If you pick this approach, I highly recommend writing some automated tests that enforce this, so that the test would fail if a new bundle was added to the site that didn’t define its own bundle subclass.

Interfaces and Traits

You could make heavy use of PHP's interfaces and traits. Each bundle subclass would have its own interface that extends \Drupal\node\NodeInterface and whatever other custom interfaces are appropriate for that bundle. Each custom interface would come with a trait that contains all the shared code to implement the interface. The bundle subclasses would then extend the base entity class and use all the traits for all the custom interfaces that it additionally implements.

Since the interfaces define what functionality a given bundle supports, we don’t need to enforce that every bundle defines a child class of a shared abstract base class full of has methods. We can more slowly opt-in to bundle classes this way. Given the size and complexity of the site where we’ve been making the most use of bundle subclasses, this evolutionary approach was more suited to our needs. Most of the complications live in a subset of our node types, so we’re only introducing bundle subclasses as we have time and energy to focus on a specific bundle.

For example, we’ve got a couple of different node types that have a fancy multi-valued entity reference field to point to various author profile nodes. Instead of relying on the default “Submitted by [a single Drupal user ID]” behavior from core, we need a nicely formatted byline that gracefully handles multiple authors or a single author, optionally with links to the author profile(s). So, we’ve got a CustomAuthorInterface that blog_entry , magazine_article and reference all implement. It looks something like this:

namespace Drupal\custom_common;

/**
 * Defines the interface for content types that have authors.
 *
 * The authors are represented with profile nodes.
 *
 * @see Drupal\custom_common\CustomAuthorTrait
 */
interface CustomAuthorInterface {

  /**
   * Constructs the right byline for a node based on the authorship.
   *
   * If there's only 1 author, use it.
   * If 2, use "LabelA and LabelB".
   * If 3 or more "Label1, ... LabelN-1 and LabelN".
   *
   * @param bool $return_as_link
   *   Option to return byline authors as link or string. Defaults to TRUE.
   *
   * @return string
   *   The appropriate byline. This can include raw HTML if the author's profile
   *   is published, so when rendering this via Twig, use `|raw`. The profile
   *   labels are properly escaped by this function, so they're safe to print
   *   directly.
   */
  public function getByline(bool $return_as_link = TRUE): string;

}

For now, that’s the only method on the interface. But we’ve got room to grow if we have other needs that come up. Here’s the corresponding CustomAuthorTrait that implements getByline() :

namespace Drupal\custom_common;

use Drupal\Component\Utility\Html;

/**
 * Implements the interface for content types that have authors.
 *
 * @see Drupal\custom_common\CustomAuthorInterface
 */
trait CustomAuthorTrait {

  /**
   * {@inheritdoc}
   */
  public function getByline(bool $return_as_link = TRUE): string {
    $authors = [];
    $author_profiles = $this->get('author_profile')->referencedEntities();
    if (!empty($author_profiles)) {
      foreach ($author_profiles as $profile) {
        // Since this needs to be printed in Twig via 'raw' to handle possible
        // links, escape these here to avoid potential XSS via profile labels.
        $safe_label = Html::escape($profile->label());
        // If the profile node is published, link to it.
        $authors[] = (!$profile->status->value || !$return_as_link) ? $safe_label :
                   $profile->toLink($safe_label)->toString();
      }
    }
    $last = array_pop($authors);
    $byline = implode(', ', $authors);
    if (!empty($byline)) {
      $byline .= ' ' . t('and') . ' ' . $last;
    }
    else {
      $byline = $last;
    }
    return t('By') . ' ' . $byline;
  }

}

A node type that includes the author_profile field and needs to support a byline would define an interface that looks something like this:

namespace Drupal\custom_blog_entry\Entity;

...
use Drupal\custom_common\CustomAuthorTrait;
...

/**
 * Provides an interface for custom blog entries.
 */
interface BlogEntryNodeInterface extends NodeInterface, CustomAuthorInterface, CustomBasicsInterface, CustomEditorialWorkflowInterface, CustomSocialShareInterface, CustomStatsInterface ... {
...

The class looks something like this:

namespace Drupal\custom_blog_entry\Entity;

use Drupal\node\Entity\Node;
...
use Drupal\custom_common\CustomAuthorTrait;
...

/**
 * Bundle-specific subclass of Node for blog_entry.
 */
class BlogEntryNode extends Node implements BlogEntryNodeInterface {

  use CustomAuthorEmailTrait;
  use CustomAuthorTrait;
  use CustomBasicsTrait;
  use CustomEditorialWorkflowTrait;
  use CustomRevisionLogTrait;
  use CustomSocialShareTrait;
  use CustomStatsTrait;
  use CustomWordCountTrait;
  use StringTranslationTrait;
  ...
}

The BlogEntryNode class goes on to define and implement a bunch more of its own methods, but you start to see how much functionality we can easily share across different bundles based on interfaces and traits. Some (lots?) of the bundle-specific functionality will inevitably move into traits and be reused as the site evolves over time and features need to be shared across multiple node types.

Moving From Template Preprocess To get*() Methods

Template preprocess methods have provided a lot of power and flexibility for Drupal over many years. But hopefully they’re on their way out. They generally end up being a dumping ground for all kinds of logic for all sorts of different things. Some classic examples from core are template_preprocess_node() and template_preprocess_comment(), but they get even more complicated and chaotic in custom site-specific modules.

For us, one of the first big changes was to start gutting our template preprocess methods, move the code into the appropriate bundle subclass, and start calling methods that do fancy things directly from the Twig templates that need them. As I wrote at the change record:

Drupal's TwigSandboxPolicy class allows Twig templates to directly invoke any entity public method beginning with the word get . So instead of sprinkling complicated logic in various preprocess functions all across your site, you can put that logic directly in a bundle subclass and then invoke it directly from your Twig templates.

For example, if a bundle subclass defines a public function getByline(): string method, a Twig template for a specific view mode could render the byline directly with: {{ node.getByline() }} .

This has been such a win for us. There was no simple way to reuse the code across the different preprocess methods associated with each template, leading to duplication and skew. Using get methods on bundle subclasses makes it very easy to put some custom logic into a single place (either a class or a trait depending on your approach) and reuse it from as many different Twig templates as you need.

Discovering Functionality and Compatibility

Regardless of how you decide to organize and share your code, anything you put into bundle subclasses becomes easier to discover and work with.

Entity storage handlers will always return bundle classes, if possible. Therefore, at any layer of the system, once an entity has been loaded, if its entity type and bundle define a subclass, your loaded entity object will be an instance of the subclass you defined. This is true in many contexts. Continuing with the node example:

  • Inside a Twig template with {{ node.getWhateverYouNeed() }} .
  • The object returned by \Drupal::routeMatch()->getParameter('node'); on a route that includes a {node} parameter.
  • What you get if you directly load the node: $node = $this->entityTypeManager->getStorage('node')->load($nid);
  • ...

Therefore, we can rely on the data types rather than having to call $node->bundle() . If we need to, we could check if the node implemented a specific bundle interface:

if ($node instanceof BasicPageInterface) {
  // Do something specific to basic pages:
  ...
}

Better yet, we could check if the node implements an interface that we'd like to call a method from:

if ($node instanceof BodyInterface) {
  // Do something since we know there's a body, regardless of what node type it is.
  $body = $node->getBody();
  ...
}

Or, if we're using the abstract base class solution above, we could do something like this:

if ($node->hasTags()) {
  // Some logic which applies only to nodes with tags.
  $tags = $node->getTags();
}

Regardless of what you do, there’s always going to be a simple way when writing code to see if a given entity supports the functionality you’re trying to use.

I love this feature. And I’m one of those old-timers that still writes all my code in Emacs. All the cool kids with IDEs must love it even more than I do, since one of the things it does is it allows you to autocomplete all the custom methods your bundle subclasses implement, read the API documentation inline, and so on.

Organizing all the custom logic into bundle subclasses makes it so much easier for people to learn their way around the code, understand what’s happening, what’s shared, what’s specific, and so on.

Making the Code More Testable

Putting your custom logic into bundle subclasses makes it much easier to write automated tests, since the code lives in a class instead of being spread around numerous procedural hook implementations, preprocess methods, and so on. You can now make heavy use of Kernel tests, and even Unit tests, instead of relying on end-to-end Functional or FunctionalJavascript tests (which are much more resource intensive and slower to run). The benefits of writing tests are outside the scope of this change record, but the point is it's now much easier to do so.

For this site, we’ve been using Drupal Test Traits and the ExistingSite test plumbing, and we’re loving it. Moving custom logic into bundle subclasses has let us put more of the code into classes and expose more of it to Unit testing. Maybe we’ll need a bonus part 4 in this series some day to get into the gory details of mocking enough services to Unit test bundle subclasses.

But with almost no effort, you can write ExistingSite tests to validate all of the custom logic for each of your bundle subclasses. For example:

namespace Drupal\Tests\custom_blog_entry\ExistingSite;
...
use weitzman\DrupalTestTraits\ExistingSiteBase;
...
class CustomBlogEntryContentTest extends ExistingSiteBase {
  /**
   * Test custom methods on the BlogEntryNodeInterface.
   */
  public function testBlogEntryNodeInterface() {
    $node = $this->createNode([
      'type' => 'blog_entry',
      'title' => $this->randomMachineName(20),
    ]);
    $this->assertNotEmpty($node);
    $this->assertInstanceOf(BlogEntryNodeInterface::class, $node);
    $this->assertInstanceOf(NodeInterface::class, $node);

    // With none of the topic fields set, this should return an empty array.
    $this->assertEmpty($node->getTopic());
    $this->assertEmpty($node->getClinicalPathway());
    ...
  }
}

The Possibilities Are Endless

I’ve only begun to explore some of the possibilities that bundle subclasses open up to the people developing custom code for Drupal projects.

I haven’t started to ponder if or how contributed modules might fit into the picture. Perhaps we’ll start to see modules sprouting up on Drupal.org that contain traits and interfaces for use with various content entity bundle subclasses? With a little bit of cleanup and generalization, the CustomAuthorInterface and CustomAuthorTrait above could be shared across multiple projects, and at that point, they might as well live as a contributed module that anyone could use and contribute back to. There are some tricky issues with these bundle subclass classes and traits needing access to fields that might not exist. Further research is required on the good ways to have bundle subclasses defining fields, entity properties, possibly interacting with the Plugin system, and more.

We’re also starting to consider how to make use of bundle subclasses to better organize some aspects of Drupal Core itself. For example: #3233398: Add a bundle subclass for forum.module's business logic. It’s possible that core might want to start providing its own bundle subclasses for various media types, too.

I hope that this blog series gets people thinking about bundle subclasses, and that we get a collective discussion going on how to make the most of this functionality.

Wrap Up

In Part 1, we learned about entity classes and bundle subclasses, and why this change is important. We’ve now seen how to actually make use of bundle subclasses to improve your life. In the final article in the series, we draw out some lessons from contributing this change to Drupal Core: using a Gitlab merge request (MR) instead of patches, what it takes to get something like this committed, how and why to contribute “upstream”, and more.