PreviousNext: Entity theming with Pinto
Learn how to make entity theming a breeze using the Pinto module. If you haven’t already, check out the first part of this series for an introduction to all things Pinto.
by adam.bramley / 3 October 2024In our last post, we discussed Pinto concepts and how to use Theme objects to encapsulate theming logic in a central place for a component. Next, we’ll apply that knowledge to theming an entity. This will demonstrate the power of Pinto and how it will dramatically improve the velocity of delivering new components.
One of the hardest things about theming Drupal is outputting markup that matches your design system.
For example:
- Removing the “div soup” of Drupal fields
- Adding custom classes or attributes to field output
- Wrapping fields in custom tags (e.g. an h2)
While there are plenty of modules to alleviate this, it can often mean you have a mix of YAML configuration for markup, preprocess hooks, overridden templates, etc., to pull everything together. Pinto allows you to easily render an entity while reusing your frontender’s perfect template!
We need to cover a few more concepts and set things up to pull this all together. Once set up, new bundles or entity types can be added with ease.
We'll continue our Card component example from the previous post and cover:
- Setting up a bundle class. In this example, we will implement it as a Block Content bundle
- Using a custom entity view builder
- Theming a Card block using Pinto
Bundle classes
In case you’re not aware, Drupal introduced the concept of Bundle classes almost three years ago. They essentially allow business logic for each bundle to be encapsulated in its own PHP class and benefit from regular PHP concepts such as code sharing via Traits, Interfaces, etc.
At PreviousNext, our go-to for implementing bundle classes is the BCA module, which allows you to define a class as a custom Bundle class via an attribute, removing the need for hook_entity_bundle_info_alter
.
Our standard setup on projects is:
- An Interface per entity type (e.g
MyProjectBlockContentInterface
) - An abstract base class per entity type (e.g.
MyProjectBlockContentBase
) - A Bundle class per bundle
- Traits and interfaces for any shared fields/logic (e.g.
BodyTrait
for all bundles that have a Body field)
My preferred approach is to have a directory structure that matches the entity type located inside the project’s profile (e.g. src/Entity/BlockContent/Card.php
. Feel free to set this up however you like. For example, some people may prefer to separate entity types into different modules.
Let’s set up our Card bundle class:
namespace Drupal\my_project_profile\Entity\BlockContent;
use Drupal\bca\Attribute\Bundle;
use Drupal\my_project_profile\Traits\DescriptionTrait;
use Drupal\my_project_profile\Traits\ImageTrait;
use Drupal\my_project_profile\Traits\TitleTrait;
#[Bundle(entityType: self::ENTITY_TYPE_ID, bundle: self::BUNDLE)]
final class Card extends MyProjectBlockContentBase {
use TitleTrait;
use DescriptionTrait;
use ImageTrait;
public const string BUNDLE = 'card';
}
Here we use the Bundle
attribute provided by the BCA module to automatically register this class as the bundle class for the card
bundle. We’re using constants here to make it easy to reference this machine name anywhere in our codebase. The ENTITY_TYPE_ID
constant comes from the parent interface.
NOTE: I won’t go into too much detail about how the interfaces, base classes, and traits are set up. There are plenty of examples of how you might write these. Check out the change record for some basic examples!
In our case, each trait is a getter/setter pair for each of our fields required to build our Card component:
- Title - a plain text field
- Description - another plain text field
- Image - a Media reference field.
Custom entity view builder
EntityViewBuilders are PHP classes that contain logic on how to build (or render) an entity. Entity types can have custom EntityViewBuilders; for example BlockContent has its own defined in core. These are defined in the view_builder handler in an entity type's annotation and can also be overridden by using hook_entity_type_alter
.
By default, the view builder class takes all of your configuration in an entity view display (i.e. field formatter settings, view modes, etc.) and renders it. We are using a custom view builder class to bypass all of that and simply return a render array via a Pinto object.
The function that drives this is getBuildDefaults
so that’s all we need to override.
For this example, a custom view builder for the block content entity type can be as simple as:
namespace Drupal\my_project_profile\Handler;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityInterface;
use Drupal\block_content\BlockContentViewBuilder;
use Drupal\my_project_profile\Entity\Interface\BuildableEntityInterface;
class MyProjectBlockContentViewBuilder extends BlockContentViewBuilder {
/**
* {@inheritdoc}
*/
public function getBuildDefaults(EntityInterface $entity, $view_mode) {
$build = parent::getBuildDefaults($entity, $view_mode);
if (!$entity instanceof BuildableEntityInterface || !$entity->shouldBuild($view_mode)) {
return $build;
}
$cache = CacheableMetadata::createFromRenderArray($build);
$build = $entity->build($view_mode);
$cache->merge(CacheableMetadata::createFromRenderArray($build))
->applyTo($build);
return $build;
}
}
Here, we check for a custom BuildableEntityInterface
and call a shouldBuild
method. If either of those are FALSE then we fall back to Drupal’s default behaviour. Otherwise, we gather cacheable metadata from both the default build and the result of calling the build
method, and then return the output. We will cover these in more detail shortly.
Now we just need an alter hook to wire things up:
use Drupal\my_project_profile\Handler\MyProjectBlockContentViewBuilder;
/**
* Implements hook_entity_type_alter().
*/
function my_project_profile_entity_type_alter(array &$entity_types): void {
/** @var \Drupal\Core\Entity\ContentEntityType $blockContentDefinition */
$blockContentDefinition = $entity_types['block_content'];
// Override view builder class.
$blockContentDefinition->setViewBuilderClass(MyProjectBlockContentViewBuilder::class);
}
Pro tip: Use the Hux module to do this in a Hooks class.
Now, any BlockContent bundle class that implements BuildableEntityInterface
and returns TRUE from its shouldBuild
method will completely bypass Drupal’s standard entity rendering and instead just return whatever we want from its build
method.
BuildableEntityInterface
namespace Drupal\my_project_profile\Entity\Interface;
/**
* Interface for entities which override the view builder.
*/
interface BuildableEntityInterface {
/**
* Default method to build an entity.
*/
public function build(string $viewMode): array;
/**
* Determine if the entity should be built for the given view mode.
*/
public function shouldBuild(string $viewMode): bool;
}
This interface can be added to the Bundle class itself or the custom entity type interface we discussed earlier to keep all bundles consistent. This doesn’t just apply to the Block content entity type; you can use this for Paragraphs, Media, or your custom entity types. You’ll just need to override the view builder for each.
It is generally not recommended to use this for Node since you’re more likely to get value out of something like Layout Builder for rendering nodes. Those layouts would then have block content added to them, which in turn will be rendered via this method.
Back to our Card example. It was extending a custom base class MyProjectBlockContentBase
. That class may look something like this:
namespace Drupal\my_project_profile\Entity\BlockContent;
use Drupal\block_content\BlockContentTypeInterface;
use Drupal\block_content\Entity\BlockContent;
abstract class MyProjectBlockContentBase extends BlockContent implements MyProjectBlockContentInterface {
/**
* {@inheritdoc}
*/
public function shouldBuild(string $viewMode): bool {
return TRUE;
}
}
Our base class extends core’s BlockContent
class and implements our custom interface.
That custom interface can then extend BuildableEntityInterface
.
The shouldBuild
method is an optional implementation detail, but it is nice if you have multiple view modes for a bundle, which need to have differing logic. For example, you might have a media_library
view mode that you want to continue to use Drupal’s standard rendering.
Now, all we need to do is implement the build
method on our BlockContent
bundle classes.
Let’s look at the Card example:
use Drupal\my_project_ds\ThemeObject\Card as PintoCard;
final class Card extends MyProjectBlockContentBase {
// Trimmed for easy reading.
/**
* {@inheritdoc}
*/
public function build(string $viewMode): array {
return PintoCard::createFromCardBlock($this)();
}
}
Here, we’re simply returning the render array that results from invoking our Card Pinto object (aliased as PintoCard
via the use
statement).
We have also introduced a factory method createFromCardBlock
on the Pinto theme object, which takes the entity and injects its data into the object.
This is what the fully implemented Pinto object would look like
namespace Drupal\my_project_ds\ThemeObject;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\my_project_profile\Entity\BlockContent\Card as CardBlock;
use Drupal\my_project_ds\MyProjectDs\MyProjectObjectTrait;
use Pinto\Attribute\ThemeDefinition;
#[ThemeDefinition([
'variables' => [
'title' => '',
'description' => '',
'image' => '',
],
])]
final class Card implements CacheableDependencyInterface {
use MyProjectObjectTrait;
private function __construct(
private readonly string $title,
private readonly array $image,
private readonly ?string $description,
) {}
public static function createFromCardBlock(CardBlock $card): static {
return new static(
$card->getTitle(),
$card->getImage(),
$card->getDescription(),
);
}
protected function build(mixed $build): mixed {
return $build + [
'#title' => $this->title,
'#description' => $this->description,
'#image' => $this->image,
];
}
}
The build and constructor methods were covered in our previous Pinto post. All that’s new here is the createFromCardBlock
method, where we use the getters from the bundle class traits to inject the entity’s data into the constructor.
We also briefly mentioned cacheable metadata in our last post. Since our Pinto object implements CacheableDependencyInterface
, we can add that metadata directly to the theme object. For example, you should enhance the bundle class’ build method to add the Image media entity as a cacheable dependency. That way if the media entity is updated, the Card output is invalidated.
/**
* {@inheritdoc}
*/
public function build(string $viewMode): array {
$build = PintoCard::createFromCardBlock($this);
$image = $this->image->entity;
if ($image) {
$build->addCacheableDependency($image);
}
return $build();
}
Now, we have end-to-end rendering of a Drupal entity using Pinto Theme objects to render templates defined in a Storybook design system.
New bundles are simple to implement. All that’s needed is to click together the fields in the UI to build the content model, add the new Theme object, and wire that together with a bundle class.
I can’t overstate how much this has sped up our backend development. My latest project utilised Pinto from the very beginning, and it has made theming the entire site extremely fast and even… fun! 😀