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

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

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

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:


In this example screen, the Drupal admin Manage Display screen is shown with “My Module Computed Status Field” format being set to ‘Display a course status”

Now, when you view this term you will see the request time that the entity was first displayed.

In this rendered output example screen, “My Module Computed Status Field” shows a value of “1582727613”, which is a Unix timestamp value

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',
    ],
  ],
];

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

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

  }

3. You’ll also need to define your service in mymodule.services.yml

services:
  myservice:
    class: Drupal\mymodule\MyService

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',
    ],
  ],
];

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

The callback function will now check the current user’s permissions and show the appropriate field based on those permissions.

In this first example rendered screen, the “My Module Computed Status Field” has a value of “Administrator only information 1582734877”
while in the second rendered screen, the value for a user is “User only information 1582734892”

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 code 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.