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:
- If possible, use bundle classes as described above.
- Otherwise, have the storage class invoke
$this->entityType->setClass()
with the desired class. - 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 agetBundleFromClass()
method. - Added
Drupal\Core\Entity\ContentEntityStorageBase::getBundleFromClass()
implementation. Drupal\Core\Entity\ContentEntityBase
now implements apublic static create()
method.Drupal\Core\Entity\ContentEntityStorageBase
now implements apublic 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.
Comments
These are great release notes
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!]
BlueFusion.co.nz
Thanks / you're welcome
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
Fantastic New Feature
This is VERY awesome. Going to eliminate SO SO SO SO much cruft!
Thank you!
Amazing new capabilities !
Amazing new capabilities ! Thank you so much !
some suggestions
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?Works with bundleless/atomic entity types
It appears bundleless/atomic entity types can define their own custom classes as well, e.g.
--
gbyte.dev
I am struggling for two weeks
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?
I found the cause.
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.
No bundle for content type class?
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
But when I try creating an entity of my bundle type, I get an error
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).
Does not work with commonly used ConfigEntities
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).