Change record status: 
Project: 
Introduced in branch: 
9.3.x
Introduced in version: 
9.3.0-alpha1
Description: 

Introduction

Entity bundles are essentially business objects, and now they can declare their own class, encapsulating the required business logic. A bundle class must be a subclass of the base entity class, such as \Drupal\node\Entity\Node. Modules can define bundle classes for their own entities by defining the class property in hook_entity_bundle_info(), and can alter bundle classes of entities defined in other modules with hook_entity_bundle_info_alter(). Each bundle subclass must be unique. If you attempt to reuse a subclass for multiple bundles, an exception will be thrown.

Possibilities

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.

Moving from template preprocess to get*() methods

Drupal's TwigSandboxPolicy class allows Twig templates to directly invoke any entity public method beginning with the word get. So instead of sprinkling complicated business 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() }}.

Sharing code

Since they are regular PHP classes, there are many ways to share code between bundle subclasses. 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.

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.

Any OO design that makes sense for your project is now possible.

Writing automated tests

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.

Examples

For example, a custom module could declare a bundle class for a node type like this:

use Drupal\mymodule\Entity\BasicPage;

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

Interfaces and traits

You can define an interface for specific custom logic, for example, support for a body field:

namespace Drupal\mymodule\Entity;

interface BodyInterface {

  /**
   * Returns the body.
   *
   * @return string
   */
  public function getBody(): string;

}

An interface can then be created for the bundle in a custom module, extending both NodeInterface and the custom BodyInterface:

namespace Drupal\mymodule\Entity;

use Drupal\node\NodeInterface;

interface BasicPageInterface extends NodeInterface, BodyInterface {

}

The bundle classes themselves extend the entity class, but implement any additional required methods from the other interfaces it provides.

namespace Drupal\mymodule\Entity;

use Drupal\node\Entity\Node;

class BasicPage extends Node implements BasicPageInterface {

  // Implement whatever business logic specific to basic pages.
  public function getBody(): string {
    return $this->get('body')->value;
  }

}

Alternatively, the getBody() implementation could live in a BodyTrait that was shared across multiple bundle subclasses.

Using an abstract base class

You can start to introduce common functionality for all of your node types with an abstract class. This approach requires defining a bundle subclass for every node type on the site (or every bundle of whatever entity type you're using this for: media, etc).

Introduce a common interface:

namespace Drupal\myproject\Entity;

use Drupal\node\NodeInterface;

interface MyProjectNodeInterface extends NodeInterface {

  public function getTags(): array;

  public function hasTags(): bool;

}

And then introduce an abstract base class:

namespace Drupal\myproject\Entity;

use Drupal\node\Entity\Node;

abstract class MyProjectNodeBase extends Node implements MyProjectNodeInterface {
  public function getTags: array {
    return [];
  }
  public function hasTags: bool {
    return FALSE;
  }
}

And then even some traits:


namespace Drupal\myproject\Entity;

trait NodeWithTagsTrait {

  public function getTags(): array {
   // Put a real implementation here.
    return $this->get('tags')->getValue();
  }

  public function hasTags(): bool {
    return TRUE;
  }

}

Then you can rewrite your basic page as follows:

namespace Drupal\mymodule\Entity;

use Drupal\node\Entity\Node;
use Drupal\myproject\Entity\NodeWithTagsTrait;
use Drupal\myproject\Entity\MyProjectNodeBase;

class BasicPage extends MyProjectNodeBase implements BasicPageInterface {

  use NodeWithTagsTrait

  // Implement whatever business logic specific to basic pages.
  public function getBody(): string {
    return $this->get('body')->value;
  }

}

How code can react to loaded entities

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();
}

API changes

All of these benefits required some changes to the Drupal Core API. In some rare cases, custom or contributed code might need to know about these changes. Nothing will break, but certain code paths might now trigger deprecation warnings and require updates before Drupal 10.0.0.

Changes to how postLoad() is invoked in entity classes

Once a site starts defining and using bundle subclasses, if multiple entities from different bundles are loaded at once, the way EntityInterface::postLoad() is invoked has changed. Previously, since there was only a single class, postLoad() would always be passed the full $entities array, including all entities loaded in that operation. Now, each bundle subclass's postLoad() method will be invoked with a subarray including only the entities of the same bundle. The postLoad() method in this case can no longer manipulate all possible entities (only entities of its own kind), nor try to change the order of all items (which wasn't supported, but some code relied on this quirk).

Impact on custom entity storage classes

The protected \Drupal\Core\Entity\EntityStorageBase::entityClass property has been removed.

Getting the entity class

Entity storage handlers that need to know the entity class should call \Drupal\Core\Entity\EntityStorageBase::getEntityClass() instead.

Before
    $entity = new $this->entityClass($values, $this->entityTypeId);
After
    $entity_class = $this->getEntityClass();
    $entity = new $entity_class($values, $this->entityTypeId);

Setting the entity class

Entity storage handlers that need to change the class that will be used for creating entities can no longer set the $this->entityClass property directly. Doing this triggers a deprecation warning in Drupal 9.3.0 and above, and will have no effect starting in Drupal 10.0.0. Here are the supported alternatives:

  1. If possible, use bundle classes as described above.
  2. Otherwise, have the storage class invoke $this->entityType->setClass() with the desired class.
  3. Or, the custom storage class can override getEntityType() and do whatever it needs.

New exceptions

Two new exceptions can now be thrown if you try to use a bundle subclass inappropriately:

  • Drupal\Core\Entity\Exception\AmbiguousBundleClassException: Thrown when multiple bundles try to reuse the same bundle subclass.
  • Drupal\Core\Entity\Exception\BundleClassInheritanceException: Thrown when a bundle subclass does not include the base entity class as an ancestor.

Other API changes

  • Added Drupal\Core\Entity\BundleEntityStorageInterface which defines a getBundleFromClass() method.
  • Added Drupal\Core\Entity\ContentEntityStorageBase::getBundleFromClass() implementation.
  • Drupal\Core\Entity\ContentEntityBase now implements a public static create() method.
  • Drupal\Core\Entity\ContentEntityStorageBase now implements a public static create() method.

Code generation

Drush 11 provides the entity:bundle-class generator which saves you time by quickly generating entity bundle classes for any content entity.

Impacts: 
Module developers
Themers
Distribution developers
Updates Done (doc team, etc.)
Online documentation: 
Not done
Theming guide: 
Not done
Module developer documentation: 
Not done
Examples project: 
Not done
Coder Review: 
Not done
Coder Upgrade: 
Not done
Other: 
Other updates done

Comments

AndyD328’s picture

These are great release/change record notes, thanks!

[I commented on how great the notes are, but not on the awesomeness of the feature. I've used this on pretty much every project I've touched since it was released and it's so much cleaner to work with. Greatest feature ever? Maybe!]

dww’s picture

Thank you very much! Glad you like them. We spent quite a bit of time getting them to this state. It's been a real group effort:

https://www.drupal.org/node/3191609/revisions

Cheers,
-Derek

___________________
3281d Consulting

johnfallen’s picture

This is VERY awesome. Going to eliminate SO SO SO SO much cruft!

Thank you!

Renrhaf’s picture

Amazing new capabilities ! Thank you so much !

redgluten’s picture

I’ve been waiting for this change for a loooong time, this is great, thanks to all the contributors involved! 👍

This announcement is also very useful, could it be transformed into a proper Documentation guide? I’m happy to help if a more experienced contributor can guide me.

The section on “Writing automated tests” does not show any example of how to write tests with these new classes, can we change that?

I’m wondering if it’s a bug or something I missed but I cannot figure out how to make Kernel tests instantiate the correct bundle class: let’s say you make one for 'article' nodes following the above examples, this same code would returns the proper `Article` class when a full Drupal request is created but returns `Node` when run in a Kernel test: \Drupal::entityTypeManager()->getStorage('node')->create(['type'=>'article']). Again, I’d be happy to create an issue if it turns out to be a bug. Any idea?

gbyte’s picture

It appears bundleless/atomic entity types can define their own custom classes as well, e.g.

use Drupal\my_module\Entity\CustomUser;

function hook_entity_bundle_info_alter(array &$bundles): void {
  if (isset($bundles['user']['user'])) {
    $bundles['user']['user']['class'] = CustomUser::class;
  }
}
PROMES’s picture

I am struggling for two weeks now to get abstract classes working.
In: 'Using an abstract base class' you write:
class MyProjectNodeInterface extends NodeInterface {
public function getTags(): array;
public function hasTags(): bool;
But I get erors because the class should contain a body.
Got error 'PHP message: PHP Fatal error: Non-abstract method Drupal\\kbg\\Entity\\CommonNaamInterface::getNaam() must contain body in ... CommonNaamInterface.php

If I change CLASS into interface I get:
Error: Interface 'Drupal\kbg\Entity\Bundle\CommonNaamInterface' not found in include() (line 14 of ... /KunstenaarTermInterface.php)

I must do something wrong, But I don't know what.
Can you help me out?

PROMES’s picture

I found the cause. In 'class MyProjectNodeInterface extends NodeInterface'
1. Class must be interface.
2. I put other interfaces after NodeInterface (conforming 'An interface can then be created for the bundle in a custom module, extending both NodeInterface and the custom BodyInterface:'. But with an abstract base class, you shouldn't do.

sunset_bill’s picture

This looks great. I've been explaining bundles to co-workers as things that extend content types for a long time now, so it's good to be able to do that in actual code. I've run into a problem with my first attempt, though. I've got a content type that extends ContentEntityBase and a bundle class that extends my content type class, both created using drush generators. I've got my bundle class registered in hook_entity_bundle_info in my .module file, and the entity bundle service is seeing it

\Drupal::service('entity_type.bundle.info')->getBundleInfo('my_content_type')
=> [
     "my_content_type" => [],
     "dc_thing" => [
       "label" => Drupal\Core\StringTranslation\TranslatableMarkup {#5566},
       "class" => "Drupal\my_module\Entity\Bundle\DcThing",
     ],
   ]

But when I try creating an entity of my bundle type, I get an error

$thing = \Drupal::service('entity_type.manager')->getStorage('my_content_type')->create(['type' => 'dc_thing', 'label' => 'Particle Mang'])
Drupal\Core\Entity\EntityStorageException with message 'Missing bundle for entity type my_content_type'

I'm confused why a) my content type needs its own bundle, since it's just a base class that I'm extending and not something I would be creating any content for, and b) why it shows as missing even when I do register a bundle for it in hook_entity_bundle_info (I've tried a few variations in the hook, but no change in results).

hudri’s picture

Note that ConfigEntityBundleBase classes (like core taxonomy vocabulary or contrib webforms) are not bundle classes. A ConfigEntityBundleBase is storing bundle information for other classes, it is not a bundle class itself. You can not use the bundle classes described here with them.

(This does not mean that it won't work with ConfigEntities at all, just those entities mentioned above are not bundles and I don't know of any config entity in core that uses bundles).