This is not exactly news: bundle classes were introduced in core back in Drupal 9.3, in 2021. Yet I still see a lot of projects that do without them, and I've wanted to cover the topic here for a long time.
It's a feature that changes the game in terms of architecture and maintainability. Today we'll see how to set them up, and take the opportunity to use the new object-oriented hook syntax available since Drupal 11 (see: https://kgaut.net/blog/2024/drupal-11-utiliser-la-nouvelle-syntaxe-orientee-objet-pour-les-hooks)
Why bundle classes?
In Drupal, content has always been mostly represented by nodes. Technically, a node of type "Article" and a node of type "Concert" are both instances of the same generic class: Drupal\node\Entity\Node.
Since Drupal 8, you can override this class to add a business layer. Let's say you have a "Concert" content type with a date field. You might want a getDateConcert() method to easily get that date, nicely formatted.
The problem is that if you override the generic Node class, this getDateConcert() method becomes available on all your nodes, including blog posts and static pages, which makes no sense semantically.
This is where bundle classes come in. They fix this shortcoming by letting you define a specific PHP class for a given bundle.
Example: Concerts and Articles
Let's take a concrete example. In a custom module mon_module, we want to handle two content types:
Concert (concert): we want a method to easily get its date.
Article (article): we just want it to have its own class for future needs.
Step 1: Create the classes
To do things properly and have a solid foundation, I start by defining an abstract class. It will be the base for all my node bundle classes and will hold shared methods if needed.
File: mon_module/src/Entity/AbstractNode.php
<?php
namespace Drupal\mon_module\Entity;
use Drupal\node\Entity\Node;
/**
* Base abstract class for our bundle classes.
*/
abstract class AbstractNode extends Node {
// Methods useful to all our content types can be added here.
}
Then I create the specific class for my "Concert" content type. This is where I add my business logic.
File: mon_module/src/Entity/ConcertNode.php
<?php
namespace Drupal\mon_module\Entity;
use Drupal\Core\Datetime\DrupalDateTime;
/**
* Specific class for the 'concert' bundle.
*/
class ConcertNode extends AbstractNode {
/**
* Returns the concert date as a DrupalDateTime object.
*
* @return \Drupal\Core\Datetime\DrupalDateTime|null
* The concert date, or null if the field is empty.
* @throws \Drupal\Core\TypedData\Exception\MissingDataException
*/
public function getDate(): ?DrupalDateTime {
if ($this->hasField('field_date') && !$this->get('field_date')->isEmpty()) {
return $this->get('field_date')->date;
}
return null;
}
/**
* A helper method to know whether the concert is over.
*/
public function isPast(): bool {
$date = $this->getDate();
if ($date) {
return $date->getTimestamp() < \Drupal::time()->getRequestTime();
}
return FALSE;
}
}
I also quickly create the one for articles, even though it's empty for now:
File: mon_module/src/Entity/ArticleNode.php
<?php
namespace Drupal\mon_module\Entity;
class ArticleNode extends AbstractNode {}
Step 2: Register the classes (the new Drupal 11 way)
Now we need to tell Drupal: "When you load a node of type 'concert', use my ConcertNode class instead of the default one".
This is done with the entity_bundle_info_alter hook. Let's use the modern syntax with PHP attributes, in a class dedicated to hooks:
File: mon_module/src/Hook/EntityHooks.php
<?php
namespace Drupal\mon_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\mon_module\Entity\ArticleNode;
use Drupal\mon_module\Entity\ConcertNode;
/**
* Provides entity-related hook implementations.
*/
final class EntityHooks {
/**
* Implements hook_entity_bundle_info_alter().
*
* Alters bundle definitions to map our custom classes.
*/
#[Hook('entity_bundle_info_alter')]
public function bundleInfoAlter(array &$bundles): void {
// Mapping for the 'concert' content type
if (isset($bundles['node']['concert'])) {
$bundles['node']['concert']['class'] = ConcertNode::class;
}
// Mapping for the 'article' content type
if (isset($bundles['node']['article'])) {
$bundles['node']['article']['class'] = ArticleNode::class;
}
}
}Don't forget to clear the caches so that Drupal picks up the new hook and the new classes!
The result: simpler usage
This is where the magic happens. You don't have to change anything in the way you load entities: Drupal will automatically instantiate the right class.
In PHP code (a controller, a service...):
use Drupal\node\Entity\Node;
use Drupal\mon_module\Entity\ConcertNode;
$nid = 25; // Let's say node 25 is a concert.
$entity = Node::load($nid);
// PHP now knows that $entity is an instance of ConcertNode.
if ($entity instanceof ConcertNode) {
// We can call our custom methods directly.
$date = $entity->getDate();
if ($entity->isPast()) {
// Specific logic if the concert is over...
}
}In Twig
It's even nicer for front-end developers. Thanks to Twig's getter magic, getDate() becomes available through node.date (as long as the name doesn't clash with a field), or by calling the method directly.
{# In node--concert.html.twig #}
<article class="concert {{ node.isPast ? 'concert--past' : 'concert--upcoming' }}">
<h2>{{ label }}</h2>
{# Calling the method directly #}
<p>Date du concert : {{ node.getDate().format('d/m/Y') }}</p>
{# Using the boolean method #}
{% if node.isPast() %}
<span class="badge">Concert terminé</span>
{% endif %}
</article>No more convoluted preprocess functions to compute states or format specific dates!
Beyond nodes
The example here is about nodes, but this obviously applies to all content entity types: users, taxonomy terms, media, etc.
In your hook, you could add:
// In EntityHooks::bundleInfoAlter
// For a 'tags' vocabulary
if (isset($bundles['taxonomy_term']['tags'])) {
$bundles['taxonomy_term']['tags']['class'] = TagTerm::class;
}
// For users (the bundle is also called 'user')
if (isset($bundles['user']['user'])) {
$bundles['user']['user']['class'] = UserCustom::class;
}Conclusion
Personally, I find this feature essential on any modern Drupal project. It lets you add a whole business layer (getters, setters, utility methods) right where it belongs: in the object that represents the data.
The code becomes much cleaner, object-oriented, easier to unit test, and far more maintainable in the long run. If you're not using them yet, go for it!
See the change record on drupal.org: https://www.drupal.org/node/3191609
Comments (0)