Translating Strings in Drupal Code: t(), TranslatableMarkup, Plurals, Twig and JavaScript
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
Every string that a visitor can read must pass through the Translation API, or it will stay in English on a Spanish page
no matter how many languages the site has enabled. This page was checked on 2026-10-30 against Drupal 11.4.x, with Drush
13/14 and PHP 8.3+. It follows a string from the source code to the screen: the t() function and its object-oriented
form $this->t(), the TranslatableMarkup objects they return, placeholders and their escaping rules, plurals, context,
the Twig trans tag, Drupal.t() in JavaScript, strings declared in YAML files and plugin attributes, and finally
configuration that is translatable because its schema says so.
The running scenario is the fictitious Meridian Museum. The module meridian_collection has to show messages such as
"3 objects on display" in English and Spanish, and the theme meridian has to translate its Twig templates and
JavaScript. The site-builder side of the story (adding languages, the User interface translation screen, language
negotiation, content translation) is in Drupal multilingual sites; this page is about
the code that feeds those screens.
How a string reaches the visitor
Drupal’s interface translation has two halves. At development time, a string extractor reads source files, finds calls to the translation functions and registers the literal strings as source strings. At run time, the translation service looks the source string up in the current language, applies placeholders and returns the result. Because the first half reads code without executing it, the way a string is written matters as much as the function that wraps it.
The extraction half is what the Interface Translation (locale) module records in its locales_source table, and the
translations typed in the User interface translation screen or imported from .po files land in locales_target.
Strings found at run time that have no translation yet are still registered, which is why a freshly visited page can
add new rows to the translation screen.
t() and $this->t()
The global function t($string, array $args = [], array $options = []) is defined in core/includes/bootstrap.inc. It
does not translate immediately: it returns a TranslatableMarkup object that is converted to the translated text when
it is cast to a string, late in the rendering process. The documentation states the rule for classes plainly: inside a
class that uses StringTranslationTrait, call $this->t(); otherwise create a TranslatableMarkup object directly.
The global t() function is meant for procedural code (.module files, .install files, theme .theme files) and
it is not deprecated in Drupal 11.
StringTranslationTrait (in Drupal\Core\StringTranslation) adds two protected methods to a class: t() and
formatPlural(). It also provides getNumberOfPlurals() and the public setStringTranslation(). The trait looks up the
string_translation service on demand with \Drupal::service() if none was set, which is a convenience that hides a
static call. Following the project’s rule that classes use constructor injection, a service or plugin should receive the
translation service and hand it to the trait, as the following service does.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Builds the human-readable summaries shown on the collection pages.
*/
final class CollectionSummaryBuilder {
use StringTranslationTrait;
public function __construct(
#[Autowire(service: 'string_translation')]
TranslationInterface $stringTranslation,
) {
$this->stringTranslation = $stringTranslation;
}
/**
* Describes the display state of an object in one sentence.
*/
public function describeDisplayState(string $title, bool $onDisplay): \Stringable {
return $onDisplay
? $this->t('@title is on display in the museum.', ['@title' => $title])
: $this->t('@title is in storage and not currently on display.', ['@title' => $title]);
}
}
Source: Translation API overview
Register the class as an autowired service in meridian_collection.services.yml.
services:
Drupal\meridian_collection\CollectionSummaryBuilder:
autowire: true
meridian_collection.summary_builder:
alias: Drupal\meridian_collection\CollectionSummaryBuilder
Classes that extend a core base class (ControllerBase, FormBase, ConfigFormBase, BlockBase, PluginBase-derived
classes with the trait already mixed in) already have $this->t() and do not need to repeat the use statement.
Hook implementations written as methods in src/Hook classes (see
hooks and object-oriented hooks) are plain classes, so they use the trait and
get the service by constructor injection.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Form hooks of the Meridian collection module.
*/
final class FormHooks {
use StringTranslationTrait;
public function __construct(
#[Autowire(service: 'string_translation')]
TranslationInterface $stringTranslation,
) {
$this->stringTranslation = $stringTranslation;
}
/**
* Adds a translatable help text to the exhibition node form.
*/
#[Hook('form_node_exhibition_form_alter')]
public function exhibitionFormAlter(array &$form): void {
$form['title']['widget'][0]['value']['#description'] = $this->t('Use the public exhibition name, for example "Light and Motion".');
}
}
In the examples, the apostrophe-free English sentence and the literal string argument are deliberate: they are what the extractor will find.
TranslatableMarkup: a lazy translation
Drupal\Core\StringTranslation\TranslatableMarkup is the object t() returns. Its constructor is
new TranslatableMarkup($string, array $arguments = [], array $options = [], ?TranslationInterface $string_translation = NULL);
$string must be a string, or an \InvalidArgumentException is thrown. Three facts about the object explain most
translation bugs.
-
It is lazy.
__toString()callsrender(), which translates the string once and caches the result inside the object. Because the object is untranslated until it is cast, it can be stored in a render array, a cache entry or a plugin definition and still be shown in whichever language is current when the page is finally rendered. The API documentation therefore advises against casting the object to a string yourself; let the render system do it as late as possible. -
It is serialisable.
__sleep()keeps only the string, the arguments and the options, and the translation service is fetched again when needed. A render array that containsTranslatableMarkupcan safely go into a cache bin. -
Its language is fixed at render time, not at creation time, unless the
langcodeoption says otherwise. A string created while building an English page, cached, and rendered for a Spanish visitor comes out in Spanish if the cache entry varies correctly (see render API and cache metadata).
The two documented options are langcode, which forces a language other than the one displaying the page, and context
(see below). Outside classes that have the trait (for example in a static factory or in an attribute) construct the
object directly, which is also how plugin metadata is translated.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Lists the objects currently on display.
*/
#[Block(
id: 'meridian_on_display_now',
admin_label: new TranslatableMarkup('On display now'),
category: new TranslatableMarkup('Meridian'),
)]
final class OnDisplayNowBlock extends BlockBase {
/**
* {@inheritdoc}
*/
public function build(): array {
return [
'#markup' => $this->t('Objects on display today.'),
];
}
}
Source: Plugin API overview
Older books such as Drupal 10 Development Cookbook (2023) show plugin metadata in @Block(...) annotations and wrap the
label in @Translation("..."). Plugins use PHP attributes since Drupal 10.2, and an attribute takes new TranslatableMarkup() where an annotation took @Translation. Annotations still work for now but are on a deprecation
path, and the version that removes them has not been fixed in the documentation consulted.
To produce a translated string in a specific language explicitly, for example for an e-mail to a Spanish-speaking member sent from a queue worker running in English, pass the language code as an option.
$subject = $this->t('Your tickets for @exhibition', ['@exhibition' => $title], ['langcode' => $member->getPreferredLangcode()]);
Placeholders and security
Strings are translated first and formatted second, so text that varies at run time never goes into the source string.
Variable parts are placeholders, written as keys in the $args array. The first character of the key decides how the
value is treated; the rules are implemented by FormattableMarkup::placeholderFormat().
| Prefix | Behaviour | Typical use |
|---|---|---|
|
Escapes the value with |
Plain text inside the sentence: |
|
Like |
Emphasis of a user-supplied name: |
|
For URL values in an |
|
Security consequences follow directly from those rules.
-
Never put
@or%inside the angle brackets of a tag or in an attribute value. Use:forhref, and build anything more complex as a render array with thelinkorhtml_tagtypes instead of string-gluing HTML. -
Passing a
MarkupInterfaceobject (for example aMarkup::create()result or anotherTranslatableMarkup) to an@placeholder is allowed and not escaped, so only pass markup you built yourself, never anything from a user. -
An unrecognised prefix raises a warning and the argument is dropped, so the placeholder text stays in the output. Material written for older Drupal versions uses a
!prefix for raw output. That prefix is not among the prefixes the current implementation handles; replace it with@plus a trusted markup object, or a:URL. -
Do not concatenate untrusted data into the string itself. It will not be extracted, it will not match a translation and it escapes none of the escaping above.
The correct pattern for a sentence with a link is a literal string with a :url placeholder, as in the museum’s "visit
planning" message.
$message = $this->t('Plan your visit on the <a href=":url">Visit page</a> before you arrive.', [
':url' => Url::fromRoute('entity.node.canonical', ['node' => 12])->toString(),
]);
Source: Translation API overview
Literal strings only
The extractor reads source code without running it. The Translation API overview says that for the most part you cannot
use variables inside t() calls, because t($variable) and concatenations such as t('Delete ' . $name) produce
nothing to extract. The coding standards check enforces the same rule (the Drupal.Semantics.FunctionT.NotLiteralString
sniff, visible in the phpcs:ignore comment inside core’s own t() implementation). Extraction also relies on the method
names, so call t() and formatPlural(); calling translate() on the service directly is not found by the extractor.
// Wrong: not extractable, and the "Delete " prefix cannot be reordered by a translator.
$label = $this->t('Delete ' . $title);
// Right: one sentence, one placeholder, word order is the translator's choice.
$label = $this->t('Delete @title', ['@title' => $title]);
// Right: the possible values are literals somewhere, so each one is extracted.
$state = $onDisplay ? $this->t('On display') : $this->t('In storage');
Source: Translation API overview
The two exceptions the overview gives are variables whose possible values were passed to t() as literals elsewhere, and
values read from a recognised source such as YAML, Twig or configuration schema.
Plurals with formatPlural()
English has two plural forms; Spanish has two; Russian and Polish have three or more; Arabic has six. A sentence that
contains a number must go through formatPlural() so that translators can supply the right number of forms.
StringTranslationTrait::formatPlural($count, $singular, $plural, array $args = [], array $options = []) returns a
PluralTranslatableMarkup. The documentation for that class gives these rules:
-
the plural string should use
@count; the singular string is used when the count is 1 and usually spells the number out ("1 object"), without the placeholder -
@countdoes not need to appear in$args, because the object adds'@count' => $countitself -
the singular and plural forms are stored together as one source string in the locale tables (joined by a delimiter defined by
PoItem), and in a.pofile they are onemsgidwith amsgid_pluraland severalmsgstr[n]entries -
the number of plural forms for a language comes from its plural formula, which Interface Translation reads from the
.poheader
$summary = $this->formatPlural(
$count,
'1 object on display',
'@count objects on display',
);
The translated .po entry for the Spanish site looks like this. Drupal’s own locale tooling writes and reads this format.
msgid "1 object on display"
msgid_plural "@count objects on display"
msgstr[0] "1 objeto expuesto"
msgstr[1] "@count objetos expuestos"
Do not build plurals with a conditional such as $count === 1 ? $this->t('1 object') : $this->t('@count objects'). It
works for English and Spanish only by accident and cannot express a language with three or more forms. Use
formatPlural() even if the site currently has two languages.
The procedural equivalent in older code is \Drupal::translation()->formatPlural(). The old function format_plural()
that appears in Drupal 7 tutorials and older books no longer exists.
Context: disambiguating short strings
The same English word often needs different translations. "Open" is an adjective on the opening-hours block (abierto)
and a verb on a button (abrir). The context option adds a label that becomes part of the string’s identity, so the
two usages are stored and translated separately.
$status = $this->t('Open', [], ['context' => 'Museum opening status']);
$action = $this->t('Open', [], ['context' => 'Button label']);
Source: Translation API overview
The same option exists in every layer: t() and formatPlural() in PHP, the context key inside the with clause of the
Twig trans tag, Drupal.t() and Drupal.formatPlural() in JavaScript, _title_context in route files and title_context
in menu link files, and a translation context key in configuration schema. The overview’s own example is
Drupal.t('May', {}, {context: "Long month name"}), contrasted with the short month. Two cautions apply.
-
A string with a context and the same string without it are different source strings. Adding a context later makes every existing translation of the old string orphaned.
-
The context is shown to translators in the translation screen, so write it for people ("Button label"), not for code.
Translation in Twig
Templates have three tools. For a short, fixed phrase, use the t filter, whose long alias is trans. For text with
variables or plurals, use the {% trans %} block. Both feed the same extractor and the same placeholder rules.
{# templates/exhibition-teaser.html.twig in the meridian theme #}
<article class="exhibition-teaser">
<h2>{{ title }}</h2>
<p>{{ 'Closing soon'|t }}</p>
<p>{{ 'Open until @date'|t({'@date': end_date}) }}</p>
<p>
{% trans %}
Curated by {{ curator_name }} for the {{ gallery }} gallery.
{% endtrans %}
</p>
<p>
{% trans %}
{{ count }} object is shown in this exhibition.
{% plural count %}
{{ count }} objects are shown in this exhibition.
{% endtrans %}
</p>
<button type="button">
{% trans with {'context': 'Button label'} %}Open{% endtrans %}
</button>
</article>
How the block works:
-
Inside
{% trans %}you write the text as-is, without quotes. The variables{{ curator_name }}become placeholders. Only plain variable names are allowed there, not expressions: compute a value in a preprocess function (see template suggestions and preprocess functions) and pass it as a variable. -
{% plural count %}separates the singular text from the plural text, and the block compiles toformatPlural(); without it the block compiles tot(). With Twig debugging enabled (see Twig templates and debugging) an HTML comment shows the exact source string, including its@countform, which is the string a translator must provide. -
Placeholders in the block are escaped by default. The
placeholderfilter emphasises a value in the same way as%:{% trans %}Submitted on {{ date|placeholder }}{% endtrans %}. -
The context and language options are passed in a
with { ... }clause in the opening tag, as in the button above. -
The documentation notes that only strings should be passed to translation, not variable text. Do not write
{{ some_text|t }}: the extractor sees no literal and a translator never sees the string.
The t filter has no placeholder substitution of its own beyond the argument array shown above, where the keys follow the
same @, % and : rules as in PHP. Prefer the trans block as soon as there is a variable, because template authors
then do not need to know the prefix conventions.
Twig is also the correct place for text that belongs to a theme. A string in a .theme file or a preprocess function is
equally extractable, but a label that never changes with logic is easier to review and to translate when it sits in the
template, next to its markup.
Translation in JavaScript
Client-side strings use Drupal.t() and Drupal.formatPlural(), both defined in core/misc/drupal.js. The rules are
stricter than in PHP because the extractor is a text scanner.
# meridian.libraries.yml
exhibition-filter:
version: 1.x
js:
js/exhibition-filter.js: {}
dependencies:
- core/drupal
- core/once
(function (Drupal, once) {
Drupal.behaviors.meridianExhibitionFilter = {
attach(context) {
once('meridian-filter', '.exhibition-filter', context).forEach((filter) => {
const total = filter.querySelectorAll('li').length;
const status = filter.querySelector('.exhibition-filter__status');
status.textContent = Drupal.formatPlural(
total,
'1 exhibition found',
'@count exhibitions found',
{},
{ context: 'Exhibition filter status' },
);
filter.querySelector('button').setAttribute(
'aria-label',
Drupal.t('Show only @topic', { '@topic': filter.dataset.topic }),
);
});
},
};
}(Drupal, once));
Source: Translation API overview
Rules specific to JavaScript:
-
The library must depend on
core/drupal, which provides theDrupal.t()function and the translation dictionary. -
Do not alias the function (
const t = Drupal.t;) and do not passnullfor unused arguments; pass{}. Write the arguments inline as literals, not through variables, or the extractor does not recognise the call. -
Never build the string dynamically.
Drupal.t('Show ' + topic)cannot be extracted; use a placeholder. -
The extractor reads JavaScript files that are attached through libraries. Translations for those strings are loaded only after the library’s JavaScript was parsed, and parsing happens when the cache is rebuilt, so a new string appears on the translation screen only after
drush cache:rebuild. -
Minified or bundled files cannot be read by the extractor. Ship readable source for any file whose strings must be translated, or keep the strings in a small unminified file that the bundle imports at run time. This matters for the build pipeline described in front-end tooling.
-
HTML in a translated string needs the same care as in PHP:
@escapes, so use a placeholder for user data and never assign the result toinnerHTMLwithout sanitising.
Strings in YAML files
Several YAML files define text that the extractor reads directly, so they need no t() call:
| File and key | Meaning |
|---|---|
|
Names and descriptions of modules and themes on the Extend and Appearance screens. |
|
Page titles. A title that depends on the request must come from a controller |
|
Menu entries, tabs and action links. |
|
Permission names on the permissions page. |
Source: Translation API overview
# meridian_collection.routing.yml
meridian_collection.on_display:
path: '/collection/on-display'
defaults:
_controller: 'Drupal\meridian_collection\Controller\OnDisplayController::page'
_title: 'Objects on display'
requirements:
_permission: 'access content'
# meridian_collection.links.menu.yml
meridian_collection.on_display:
title: 'Objects on display'
description: 'See which museum objects are currently exhibited.'
route_name: meridian_collection.on_display
menu_name: main
weight: 20
# meridian_collection.permissions.yml
administer meridian collection settings:
title: 'Administer Meridian collection settings'
description: 'Change the collection synchronisation and display settings.'
restrict access: true
Translatable configuration through schema
Configuration is the third kind of translatable text. A module ships default configuration under config/install, and
the strings inside it (a block title, a view label, the module’s own settings) are translatable only when the
configuration schema marks them as such. Configuration schema is described in
the Configuration API and schema page; this section covers only its
translation role.
The schema metadata page defines two translatable base types:
-
label: a short human-readable string that must be plain text and editable with a text field (markedtranslatable: true) -
text: a longer string that may contain several lines or HTML (also translatable)
A key typed string is not translatable. The documentation recommends changing the type to label rather than adding
translatable: true to a string, so the value appears in translation forms. The translation context key, spelled with a
space, gives translators a hint; core uses it on its date_format type so that the format letter Y is not read as an
abbreviation of "Yes".
The texts of the public collection pages are kept in their own configuration object, meridian_collection.display, apart
from the synchronisation settings in meridian_collection.settings. Its schema marks which of its values are
translatable:
# config/install/meridian_collection.display.yml
on_display_heading: 'Objects on display now'
empty_message: 'No objects are on display at the moment.'
accession_prefix: 'MM'
max_items: 12
opening_date_format: 'j F Y'
# config/schema/meridian_collection.schema.yml (excerpt)
meridian_collection.display:
type: config_object
label: 'Meridian collection display texts'
mapping:
on_display_heading:
type: label
label: 'Heading of the on-display list'
empty_message:
type: text
label: 'Message shown when nothing is on display'
accession_prefix:
type: string
label: 'Prefix of accession numbers (not translated)'
max_items:
type: integer
label: 'Maximum items in the list'
opening_date_format:
type: string
label: 'Opening date format'
translatable: true
translation context: 'PHP date format'
Source: Configuration schema/metadata
With this schema, Configuration Translation (config_translation) can generate a translation form for
meridian_collection.display that shows on_display_heading, empty_message and opening_date_format, and hides
accession_prefix and max_items. The Interface Translation module also uses the schema to pick out the strings in
shipped configuration, so that translations downloaded from localize.drupal.org or loaded from .po files apply to
default configuration and need not be typed into the form. Stored translations are configuration overrides in
language-specific collections, and the site builder’s workflow for editing them is documented on
the multilingual page.
Code that reads the configuration gets the translated value automatically when it asks the injected config.factory
service, because the language override is applied for the current language. The block below reads the values in the
request language, and reads the stored Spanish override directly when an editor tool needs it.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Heading and empty message for the on-display list, from translated config.
*/
#[Block(
id: 'meridian_on_display_heading',
admin_label: new TranslatableMarkup('On display heading'),
category: new TranslatableMarkup('Meridian'),
)]
final class OnDisplayHeadingBlock extends BlockBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
private readonly ConfigFactoryInterface $configFactory,
private readonly LanguageManagerInterface $languageManager,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('config.factory'),
$container->get('language_manager'),
);
}
public function build(): array {
// The config factory applies the override for the current interface language.
$settings = $this->configFactory->get('meridian_collection.display');
return [
'#markup' => $settings->get('on_display_heading'),
'#cache' => [
'tags' => $settings->getCacheTags(),
'contexts' => ['languages:language_interface'],
],
];
}
/**
* Reads the Spanish override, regardless of the request language.
*/
public function spanishHeading(): ?string {
$override = $this->languageManager
->getLanguageConfigOverride('es', 'meridian_collection.display');
return $override->get('on_display_heading');
}
}
Source: Configuration API
The languages:language_interface cache context in the render array is the important detail: without it the block would be
cached with whichever language rendered first. See render API and cache
metadata for how contexts and tags combine.
Two pitfalls are specific to translatable configuration.
-
A configuration key that is
type: stringnever appears in translation forms anddrush config:exportshows only the source-language value. If a Spanish heading cannot be edited, check the schema type before anything else. -
Strings in
config/installare the source language of the configuration, which is English for a module in the project. Do not put Spanish text there and mark itlangcode: esunless the whole file is intended to be Spanish.
Shipping the Spanish translation with the module
A module published on drupal.org gets its translations from localize.drupal.org. A custom module such as
meridian_collection ships .po files itself and tells the Interface Translation module where they are. Two keys in the
.info.yml file are documented in the locale module’s API group: interface translation project (the project name, which
is required) and interface translation server pattern (a path or URL pattern that may use the %project, %version and
%language tokens and stream wrappers).
# meridian_collection.info.yml
name: 'Meridian collection'
type: module
description: 'Collection objects, on-display block and kiosk sync for the Meridian Museum.'
package: Meridian
core_version_requirement: ^11.1
dependencies:
- drupal:node
- drupal:locale
interface translation project: meridian_collection
interface translation server pattern: modules/custom/meridian_collection/translations/%project-%version.%language.po
Commands for the translation workflow run through Drush, which is installed per project with Composer.
# Add the second language and let Drupal download core and contrib translations
ddev drush language:add es
# Which projects have a newer translation available?
ddev drush locale:check
# Download and import them (also applies to local .po files by the pattern above)
ddev drush locale:update
# Export the Spanish interface strings of the custom module for a translator
ddev drush locale:export es --types=customized > meridian_collection.es.po
# Import a finished file
ddev drush locale:import es meridian_collection.es.po --type=customized --override=all
# Clear caches so parsed JavaScript and templates register their new strings
ddev drush cache:rebuild
Source: Drush: locale:check
Testing that strings are translatable
A Kernel test can prove that a string passes through the translation service and that its placeholder is escaped, without
a browser. The test uses the langcode option and a translation stored in the locale tables.
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\meridian_collection\CollectionSummaryBuilder;
use PHPUnit\Framework\Attributes\Group;
/**
* Checks that summaries are lazy translations with escaped placeholders.
*/
#[Group('meridian_collection')]
final class SummaryTranslationTest extends KernelTestBase {
protected static $modules = ['meridian_collection'];
public function testPlaceholderIsEscaped(): void {
$builder = $this->container->get(CollectionSummaryBuilder::class);
$markup = $builder->describeDisplayState('<script>alert(1)</script>', TRUE);
$this->assertInstanceOf('Drupal\Core\StringTranslation\TranslatableMarkup', $markup);
$this->assertStringNotContainsString('<script>', (string) $markup);
}
}
Source: PHPUnit in Drupal
Common errors
| Symptom | Cause | Fix |
|---|---|---|
A string stays in English after the Spanish translation was saved |
The string was built by concatenation or through a variable, so the saved source string does not match the one in code; or the translation was saved with a different context |
Use one literal string with placeholders, and use the same |
The translation screen shows no entries for a JavaScript string |
The call is aliased, uses |
Call |
Placeholder text such as |
The key in |
Use |
Two different meanings share one translation ("Open") |
No |
Add a |
Configuration heading cannot be translated in the UI |
The schema type is |
Use |
The wrong language appears for some visitors after a cache warm-up |
|
Add the cache context; keep |
A plural reads "1 objects" in Spanish |
The sentence was built with a ternary instead of |
Use |
Source: Translation API overview
Version notes and outdated advice
-
t()is not deprecated in Drupal 11; the guidance is to prefer$this->t()or aTranslatableMarkupobject whenever a class is involved and to keep the global function for procedural code. -
Plugin attributes replace annotations since 10.2. The
@Translationwrapper in annotation strings is replaced bynew TranslatableMarkup()in an attribute. Annotations still work and are on a deprecation path. -
Hook implementations can be written as methods on classes with the
#[Hook]attribute since 11.1. Those classes are plain services, so they useStringTranslationTraitwith an injected translation service rather than calling\Drupal::translation(). -
Older books, for example Drupal 10 Development Cookbook (2023), teach hooks as functions in
.modulefiles, plugin annotations and\Drupal::calls inside classes. For translation this meanst()called in procedural hook functions; the current pattern is a hook class with the trait and constructor injection. Use the code-level conventions above in place of its class examples. -
Placeholders in material written for Drupal 7 and 8 mention a
!prefix for unescaped output. The currentplaceholderFormat()documents@,%and:only and warns on any other prefix. -
The Drupal 7 tutorials about
format_plural()andvariable_get()for translatable site-wide strings do not apply; useformatPlural()and translatable configuration with schema. -
Drupal 10 reaches end of life on 2026-12-09 and Drupal 12.0.0 is planned for the week of 2026-12-07. The Translation API used here is unchanged between 10.x and 11.x, so none of this code needs to change when upgrading, apart from moving to the attribute and hook-class forms.
Related pages
-
Multilingual sites: adding languages, negotiation, content and configuration translation screens