Replacing tokens programmaticallyfor Drupal 8 , 9 , 10 , and 11

Last updated :  

You've now built both halves of a token: hook_token_info() registered [example:username] and [example:user_email], and hook_tokens() taught Drupal how to fill them in. But so far, only Drupal's own token-aware fields (things like URL alias patterns or email templates) have ever actually triggered that replacement. This lesson closes the loop: how your own module code processes a string full of tokens on demand, whenever and wherever you need it — not just inside a field a site builder configured.

What you'll learn in this lesson

  • How to call the token service directly from your own code
  • What the $data and $options parameters are actually for
  • Why BubbleableMetadata matters even more here than inside hook_tokens() itself
  • A realistic use case: generating a personalized message in a queue worker or cron job, where no "field" exists at all

Calling the token service directly

use Drupal\Core\Render\BubbleableMetadata;

/**
 * Replaces tokens in an arbitrary string, programmatically.
 */
function example_token_programmatically() {
  $token_service = \Drupal::token();

  $text_with_tokens = 'Hello [example:username], your email is [example:user_email]';

  $data = [];
  $options = [];
  $bubbleable_metadata = new BubbleableMetadata();

  $replaced_text = $token_service->replace($text_with_tokens, $data, $options, $bubbleable_metadata);

  return $replaced_text;
}

\Drupal::token()->replace() is the single entry point that ties everything together: it scans $text_with_tokens for bracket patterns, groups whatever it finds by type, and calls every module's hook_tokens() implementation for the types present — including, for this example string, the example_token_tokens() function you wrote two lessons ago. You never call your own hook_tokens() implementation directly; you call the token service, and it dispatches to every relevant hook for you.

$data — giving the replacement hooks context

An empty $data array works here because example_token_tokens() always resolves against the currently logged-in user — it has no need for extra context. But core's own node tokens ([node:title], [node:author]) need to know which node — that's exactly what $data is for: pass ['node' => $node] and every token hook that cares about a node receives it as part of its own $data parameter. Get this wrong — call replace() with node tokens in the string but no node in $data — and those specific tokens silently fail to resolve, same as the keying mistake from the last lesson.

Don't skip BubbleableMetadata here either. If the code calling replace() is itself building something cacheable — a render array, a cached queue result — you need to actually apply the collected metadata afterward with $bubbleable_metadata->applyTo($build) (or merge it into whatever cache metadata your own code already tracks). Skip this and you get the exact same stale-cache bug discussed last lesson, just one layer further removed from where the token was actually consumed.

A realistic use case: no field involved at all

This is where programmatic replacement earns its keep. Picture a queue worker that sends a personalized notification — there's no CKEditor field, no URL alias pattern, no UI a site builder configured. You're generating the string entirely in PHP (perhaps loaded from a plain-text template stored in configuration) and need it populated with real values before sending. \Drupal::token()->replace() is exactly the tool for that: the same registration and replacement logic you wrote for the UI-facing case works identically when called from a cron job, a queue worker, or a REST controller.

Quick check: your queue worker calls replace() with a string containing [node:title], but forgets to pass ['node' => $node] in $data. What happens? (The token is left as literal, unreplaced bracket text in the output — no error is thrown, since core's node token hook simply has nothing to work with and returns no replacement for that token. This is the exact same silent-failure shape as a missing cache dependency: nothing crashes, something's just quietly wrong.)

Key takeaways

  • \Drupal::token()->replace() is the single entry point for consuming tokens from your own code — it dispatches to every relevant module's hook_tokens() for you.
  • Pass whatever context object a token type needs (a node, a user, a term) via $data — without it, tokens depending on that context silently fail to resolve.
  • Apply the collected BubbleableMetadata to whatever cacheable output you're building, or you reintroduce the same stale-cache risk from the previous lesson one level up.
  • Programmatic replacement works identically whether it's triggered by a UI field or called directly from a queue worker, cron job, or controller — there's no special "headless" mode.

Coming up next

That's the full Token API arc: register, replace, and consume. Next, we shift focus to something every one of those token examples has been quietly relying on — how a module actually reaches into Drupal's stored content in the first place. The next lesson covers accessing entities programmatically, the foundation almost every other API in this course sits on top of.