Imagine shipping a module that only ever says "Submit" and "Cancel" in English — fine for one site, a real limitation the moment a client needs French, Arabic, or Japanese. Drupal's translation system exists so you never have to choose between writing your module now and supporting other languages later: you write every user-facing string through one small wrapper, and the entire translation pipeline (the Interface Translation UI, exported .po files, community translation servers) picks it up automatically, with zero extra work from you at write time.
What you'll learn in this lesson
- Why every user-facing string in a module should be wrapped in a translation call
- The real difference between the global
t()function and$this->t()— and which one modern Drupal actually wants you to use - How placeholders keep dynamic values both translatable and safely escaped
- What happens if you skip this step, and why it's much cheaper to do it now than to retrofit it later
The translation call, in practice
// Inside a class that uses StringTranslationTrait (FormBase, ControllerBase,
// BlockBase, and most other base classes already include it):
$message = $this->t('Your settings have been saved.');
// With a dynamic value — never concatenate, always use a placeholder:
$message = $this->t('Hello, @name! You have @count new messages.', [
'@name' => $account->getDisplayName(),
'@count' => $unread_count,
]);
Every string a visitor might actually read — button labels, form descriptions, status messages, error text — should go through this call. Internal debug output, log messages, and machine names should not; translating those adds noise without helping any real user.
$this->t() vs. the global t() function
You'll still see the standalone t('...') function in older tutorials and in procedural code (a plain .module file's hook implementations, for instance, which aren't inside a class at all). Inside a class, though, always prefer $this->t() from StringTranslationTrait — it's dependency-injected properly rather than reaching for the global container, which makes your class easier to unit test in isolation. If a class doesn't already extend something that includes the trait, add use Drupal\Core\StringTranslation\StringTranslationTrait; yourself.
Placeholders: three prefixes, three behaviors
$this->t('Path: @path, Markup: %markup, Verbatim: :url', [
'@path' => $path, // HTML-escaped plain text — the default choice
'%markup' => $markup, // HTML-escaped and wrapped in for emphasis
':url' => $url, // NOT escaped — use only for a value already safe as a URL
]);
Reach for @variable almost every time — it's the safe default for any dynamic text. %variable is the same escaping with visual emphasis added, useful for highlighting a filename or label inline. :variable skips escaping entirely and exists specifically for building a URL inside an href attribute — using it for anything else is a real XSS risk.
t()/$this->t() with a literal string argument. If you build a string first and translate it after the fact (`$this->t($built_string)`), the scanner can't see it, and it silently never gets exported for translators. The string itself needs to be the literal argument, with only the placeholder values built dynamically — get this pattern right from lesson one, not after your module already has forty un-scannable strings.Quick check: which placeholder prefix would you use to safely output a value that's already been validated and built as a full
<a href="...">link target? (Answer::variable— it's the one case where skipping HTML-escaping is actually correct, because the value is a URL, not display text.)
Key takeaways
- Wrap every user-facing string in
$this->t()(inside a class) ort()(in procedural code) — never output raw English text a visitor might see. - The translated string must be a literal argument to the function; only the placeholder values may be built dynamically, or the translation scanner can't find it.
- Use
@variableas your default placeholder prefix; reach for%variableonly for visual emphasis, and:variableonly for values that are genuinely safe, pre-built URLs. - Prefer
$this->t()over the globalt()function inside any class — it comes from dependency injection viaStringTranslationTrait, which keeps your class testable. - Skipping translation wrapping early is expensive to fix later — every raw string you ship is one more string a future translation pass has to hunt down and rewrite by hand.
Coming up next
Wrapping strings in t() is only half the picture — those strings still need actual translations to display. Next, we look at .po files: the standard format Drupal uses to store a real set of translated strings for a given language, and how your module ships and imports them.