Personalized Content with Computed Fields & “lazy_builder” using Drupal
Drupal 8 is amazing and the cache improvements it provides are top-notch. However, what happens when you need to display a cached page that shows the same entity with personalized content to different users?
Why would you need to do this? Perhaps you need to show user statistics on a dashboard. Maybe a control panel needs to show information from a 3rd party system. Maybe you need to keep track of a user’s progress as they work through an online learning course. Anytime you want to reuse the UI/layout of an entity, but also want to display dynamic/personalized information alongside that entity, this could work for you.
The Challenge
In a recent project, we needed to create a view of taxonomy terms showing courses to which a user was enrolled. The taxonomy terms needed to show the user’s current progress in each course and this status would be different for each user. Taking that a step further, each course had lesson nodes that referenced it and each of those lesson nodes needed to display a different status based on the user. To add a little more complexity, the status on the lesson nodes would show different information depending on the user’s permissions. 😱
The challenge was how to display this highly personalized information to different users while still maintaining Drupal’s internal and dynamic page caching.
The Solution
Computed fields
First, we relied on computed fields that would allow us to dynamically get information for individual entities and output those fields in the render array of the entity.
To create a computed field for the course taxonomy term you first need to:
1. Generate a computed field item list in /modules/custom/mymodule/src/Plugin/Field/TermStatusItemList.php:
<?php
namespace Drupal\mymodule\Plugin\Field;
use Drupal\Core\Field\FieldItemList;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\TypedData\ComputedItemListTrait;
/**
* TermStatusItemList class to generate a computed field.
*/
class TermStatusItemList extends FieldItemList implements FieldItemListInterface {
use ComputedItemListTrait;
/**
* {@inheritdoc}
*/
protected function computeValue() {
$entity = $this->getEntity();
// This is a placeholder for the computed field.
$this->list[0] = $this->createItem(0, $entity->id());
}
}
PHP
All Drupal fields can potentially have an unlimited cardinality, and therefore need to extend the FieldItemList
class to provide the list of values stored in the field. The above is creating the item list for our computed field and is utilizing the ComputedItemListTrait
to do the heavy lifting of the requirements for this field.
2. Next, generate a custom field formatter for the computed field:
<?php
namespace Drupal\mymodule\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Field\FieldItemListInterface;
/**
* Plugin implementation of the mymodule_term_status formatter.
*
* @FieldFormatter(
* id = "mymodule_term_status",
* module = "mymodule",
* label = @Translation("Display a personalized field"),
* field_types = {
* "integer"
* }
* )
*/
class TermStatusFormatter extends FormatterBase {
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
foreach ($items as $delta => $item) {
$entity_id = $item->getValue();
if (is_array($entity_id)) {
$entity_id = array_shift($entity_id);
}
// Show the request time for now.
$elements[] = [
'#markup' => \Drupal::time()->getRequestTime(),
];
}
return $elements;
}
}
PHP
The formatter handles the render array that is needed to display the field. Here we are looping through the items that were provided in the computeValue
method from earlier and generating a render array for each value. We are using the requestTime()
method to provide a dynamic value for this example.
3. Let Drupal know about our new computed field with hook_entity_base_field_info
:
<?php
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
/**
* Implements hook_entity_base_field_info().
*/
function mymodule_entity_base_field_info(EntityTypeInterface $entity_type) {
if ($entity_type->id() === 'taxonomy_term') {
$fields['mymodule_term_status'] = BaseFieldDefinition::create('integer')
->setName('mymodule_term_status')
->setLabel(t('My Module Computed Status Field'))
->setComputed(TRUE)
->setClass('\Drupal\mymodule\Plugin\Field\TermStatusItemList')
->setDisplayConfigurable('view', TRUE);
return $fields;
}
}
PHP
Now that we have the field and formatter defined, we need to attach it to the appropriate entity. The above uses hook_entity_base_field_info
to add our field to all entities of type taxonomy_term
. Here we give the field a machine name and a label for display. We also set which class to use and whether a user can manage display through the UI.
4. Next you need to define a display mode and add the new computed field to the entity’s display:
Since this example used the integer
BaseFieldDefinition, default formatter is of the type integer. Change this to use the new formatter type:
Now, when you view this term you will see the request time that the entity was first displayed.
Great… but, since the dynamic page cache is enabled, every user that views this page will see the same request time for the entity, which is not what we want. We can get a different results for different users by adding the user cache context to the #markup
array like this:
$elements[] = [
'#markup' => \Drupal::time()->getRequestTime(),
'#cache' => [
'contexts' => [
'user',
],
],
];
PHP
This gets us closer, but we will still see the original value every time a user refreshes this page. How do we get this field to change with every page load or view of this entity?
Lazy Builder
Lazy builder allows Drupal to cache an entity or a page by replacing the highly dynamic portions with a placeholder that will get replaced very late in the render process.
Modifying the code from above, let’s convert the request time to use the lazy builder. To do this, first we update the field formatter to return a lazy builder render array instead of the #markup
that we used before.
1. Convert the #markup
from earlier to use the lazy_builder
render type:
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
foreach ($items as $delta => $item) {
$entity_id = $item->getValue();
if (is_array($entity_id)) {
$entity_id = array_shift($entity_id);
}
$elements[] = [
'#lazy_builder' => [
'myservice:getTermStatusLink,
[$entity_id],
],
'#create_placeholder' => TRUE,
];
}
return $elements;
}
PHP
Notice that the #lazy_builder
type accepts two parameters in the array. The first is a method in a service and the second is an array of parameters to pass to the method. In the above, we are calling the getTermStatusLink
method in the (yet to be created) myservice
service.
2. Now, let’s create our service and getTermStatusLink
method. Create the file src/MyService.php:
<?php
namespace Drupal\mymodule;
class MyService {
/**
* @param int $term_id
*
* @return array
*/
public function getTermStatusLink(int $term_id): array {
return ['#markup' => \Drupal::time()->getRequestTime()];
}
}
PHP
3. You’ll also need to define your service in mymodule.services.yml
services:
myservice:
class: Drupal\mymodule\MyService
YAML
After clearing your cache, you should see a new timestamp every time you refresh your page, no matter the user. Success!?… Not quite 😞
Cache Contexts
This is a simple example that currently shows how to setup a computed field and a simple lazy builder callback. But what about more complex return values?
In our original use case we needed to show four different statuses for these entities that could change depending on the user that was viewing the entity. An administrator would see different information than an authenticated user. In this instance, we were using a view that had a user id as the contextual filter like this /user/%user/progress
. In order to accommodate this, we had to ensure we added the correct cache contexts to the computed field lazy_builder
array.
$elements[] = [
'#lazy_builder' => [
'myservice:getTermStatusLink,
[
$entity_id,
$user_from_route,
],
],
'#create_placeholder' => TRUE,
'#cache' =>; [
'contexts' => [
'user',
'url',
],
],
];
PHP
Now, to update the lazy builder callback function to show different information based on the user’s permissions.
/**
* @param int $term_id
* @return array
*/
public function getCourseStatusLink(int $term_id): array {
$markup = [
'admin' => [
'#type' => 'html_tag',
'#tag' => 'h2',
'#access' => TRUE,
'#value' => $this->t('Administrator only information %request_time', ['%request_time' => \Drupal::time()->getRequestTime()]),
],
'user' => [
'#type' => 'html_tag',
'#tag' => 'h2',
'#access' => TRUE,
'#value' => $this->t('User only information %request_time', ['%request_time' => \Drupal::time()->getRequestTime()]),
],
];
if (\Drupal::currentUser()->hasPermission('administer users')) {
$markup['user']['#access'] = FALSE;
}
else {
$markup['admin']['#access'] = FALSE;
}
return $markup;
}
PHP
The callback function will now check the current user’s permissions and show the appropriate field based on those permissions.
There you have it, personalized content for entities while still allowing Drupal’s cache system to be enabled. 🎉
Final Notes
Depending on the content in the render array that is returned from the lazy builder callback, you’ll want to ensure the appropriate cache tags are applied to that array as well. In our case, we were using custom entities in the callback so we had to ensure the custom entities cache tags were included in the callback’s render array. Without those tags, we were seeing inconsistent results, especially once the site was on a server using Varnish.
Download the source for the above code: github.com/pfrilling/personalized-content-demo
Thanks for reading and we hope this helped someone figure out how to work towards a personalized but performant digital product.