Creating a Custom Token in Drupal 8

Posted February 3rd, 2020

Tokens represent an inline replacement when evaluating or rendering text. In Drupal, the Token module goes all the way back to Drupal 4.x and still exists today for Drupal 8 (and soon, Drupal 9). This has long been the backbone of powerful features in Drupal, like dynamic user configurable aliases with Pathauto, robust control of meta tags with Metatag, allow editors to leverage tokens in textarea/WYSIWYG fields via Token Filter, and many other modules.

Out of the box, many tokens exist to solve a variety of common use cases for them. Occasionally you may encounter a requirement where no such token exists to facilitate it. Fortunately, implementing custom tokens in Drupal is pretty straightforward.

On a project I am working on we are migrating over 100,000 pieces of content from a legacy system. We've done our best to create URL alias patterns for each of the 10 content types, but there is one case that cannot be done from the UI alone with standard tokens. With that much content from an older system, the chances of encountering the same title more than once are really high. What happens then, if you are familiar with Pathauto, is that in the event of an existing alias, it gets deduped by adding a number on the end of it. So, if I had an alias of chapter-one, and I migrate another Chapter node later also titled "Chapter One" (this happens a lot), then my path alias will wind up being 'chapter-one-0' or 'chapter-one-1'.

This is a basic example and not actually a real title, but given their content architecture, some chapters are subchapters of a chapter, and all chapters are part of a book node type. Alias collisions happen quite a bit. The client expressed the ability to use a different field for the alias if it exists, instead of use the node title. They did not want numbers appended to the URL.

There are a few ways this could have been solved. One way would have been to use hook_pathauto_pattern_alter to inspect and change the pattern used to create an alias on save based on certain conditions. This is a valid approach. However, in the context of the client, I thought that this behavior would be too 'magic' and maybe not clear later on, as the editorial process begins shifting from the old application into the new Drupal platform. Secondly, it would remove the ability to dictate the URL pattern from the UI. Third, providing a new token opens up some functionality for resolving the URL without sacrificing the control from the UI, and makes that token usable in other contexts - for example, disambiguating the title from search results, affecting metatag output or other possible uses. Through the token definition, I can provide a description for it for editors so they know exactly what that token will do. This seemed like the better route to me than a black box solution with hook_pathauto_pattern_alter.

So let's create that token. When used, the token will be replaced with one of two possible values:

  • The value in the "Chapter Display Title" field
  • Fallback to the node title if this field has no value

Token Test

First we can write out a test for our token. Our module is called "mymodule_content_tokens". "mymodule_content_tokens_test" is a module within that module that is used to provide a Chapter node type definition, and a "Chapter Display Title" field definition. Enabling this ensures the node type and field exist when the test is ran. The test method creates two chapter nodes, one with a chapter display field value and one without, tests the token result for them and then removes the field value from the second node and tests that result again:

<?php

namespace Drupal\Tests\mymodule_content_tokens\Kernel\Utility;

use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\node\Entity\Node;
use Drupal\Core\Utility\Token;

/**
 * Class ContentTokensTest
 *
 * @group mymodule_content_tokens
 * @package Drupal\Tests\mymodule_content_tokens\Kernel\Utility
 */
class ContentTokensTest extends EntityKernelTestBase {

  use NodeCreationTrait;

  /**
   * @var \Drupal\Core\Utility\Token $tokenService
   */
  protected $tokenService;

  /**
   * @var array $modules
   */
  public static $modules = [
    'token',
    'node',
    'mymodule_content_tokens',
    'mymodule_content_tokens_test',
  ];

  /**
   * {@inheritdoc}
   */
  public function setUp() {
    parent::setUp();

    $this->installEntitySchema('node');
    $this->installConfig(['node']);
    $this->installSchema('node', ['node_access']);
    $this->installConfig(['mymodule_content_tokens_test']);

    $this->tokenService = $this->container->get('token');
  }

  /**
   * Tests that the chapter title token returns what we expect.
   */
  public function testChapterDisplayTitleTokenValue() {
    /** @var \Drupal\node\Entity\Node $node1 */
    $node1 = $this->createNode(['title' => 'Test Chapter One', 'type' => 'chapter']);

    /** @var \Drupal\node\Entity\Node $node2 */
    $node2 = $this->createNode(['title' => 'Test Chapter Two', 'type' => 'chapter', 'field_chapter_display_title' => 'Title Override']);

    $this->assertEqual('Test Chapter One', $this->tokenService->replace('[mymodule:chapter_display_title]', ['node' => $node1]));
    $this->assertEqual('Title Override', $this->tokenService->replace('[mymodule:chapter_display_title]', ['node' => $node2]));

    // Should revert back to node title when field_chapter_display_title is empty.
    $node2->field_chapter_display_title = '';
    $node2->save();

    $this->assertEqual('Test Chapter Two', $this->tokenService->replace('[mymodule:chapter_display_title]', ['node' => $node2]));
  }

}

Fairly simple, and provides a clear path to our implementation.

The Token

To provide a new token(s), we need to use two hooks to tell Drupal about them: hook_token_info, and hook_tokens. The info hook lets us define one or more tokens as well as a group to put them in:

<?php

declare(strict_types = 1);

use Drupal\Core\Render\BubbleableMetadata;

/**
 * Implements hook_token_info().
 */
function mymodule_content_tokens_token_info() : array {
  $info = [];

  $info['types']['mymodule'] = [
    'name' => t('MyModule Tokens'),
    'description' => t('Custom tokens to solve use-case problems for the our website.'),
  ];

  $info['tokens']['mymodule']['chapter_display_title'] = [
    'name' => 'Chapter Display Title',
    'description' => t('This token will return the chapter of a title either using the default node title, or the chapter_display_title field value on the Chapter. Useful for setting URL alias pattern.')
  ];

  return $info;
}

This will display the token everywhere in the system in the group type "MyModule Tokens". You can use existing groups if you want, like Node. In my case I wanted to separate them out so they are easy to find. The token has a friendly name and description that describes what it does, which appears in the token browser.

The second hook, hook_tokens, tells Drupal what to replace our token with when encountered:

/**
 * Implements hook_tokens().
 */
function mymodule_content_tokens_tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) : array {
  $replacements = [];

  if ($type == 'mymodule') {
    foreach ($tokens as $name => $original) {
      switch ($name) {
        case 'chapter_display_title':
          $node = $data['node'];

          if ($node->hasField('field_chapter_display_title') && !$node->get('field_chapter_display_title')->isEmpty()) {
            $text = $node->field_chapter_display_title->value;
          }

          $replacements[$original] = $text ?? $node->getTitle();
          break;

        default:
          break;
      }
    }
  }

  return $replacements;
}

We take the data context of node, and check if this node object has the field (a simple check in case the token was mistakenly used for another node type). If it does and has a value, then we use that. If not, it defaults to the node title which will always exist. The token will be replaced with the value assigned to $text. If the node is updated and that field is set to empty, then the token will just return the node title. This makes the token useful for a site editor.

Now if we run our tests:

phpunit -- --group=mymodule_content_tokens
PHPUnit 6.5.14 by Sebastian Bergmann and contributors.

Testing 
.                                                                   1 / 1 (100%)

Time: 20 seconds, Memory: 10.00MB

OK (1 test, 10 assertions)

Woohoo! We are now free to use our new token, and have laid the groundwork for adding more tokens in the future if we need them. You may be wondering why we are only testing the token replace result and not also checking a generated alias. That was after all our main requirement right? Sure. This particular tokens main use will be in Pathauto. All we really want to test though is that the token is replaced the way we expect it to be. Since it is, we know that Pathauto is going to work. We don't need to add tests and specifically test results of alias generation.