Drupal Multilingual Sites: Languages, Negotiation and Translation
|
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. |
Drupal is one of the few content management systems in which multilingual support is a core feature rather than an
add-on. Four core modules cover every kind of text a site contains, and no contributed module is required for a bilingual
museum site, a trilingual city portal or a right-to-left news site. This page was checked on 2026-10-30 against
Drupal 11.4.x, with Drush 13/14 and PHP 8.3+. It explains what each module does, how Drupal decides which language to use
for a request (language negotiation), how to make fields, menus, blocks and views translatable, how to import and export
.po files, how site builders see t() and the Twig |t filter, and what changes for right-to-left (RTL) languages.
The running scenario is the fictitious Meridian Museum. Its site is published in English (the default language) and
Spanish. The exhibition and event content types, the topic and gallery vocabularies, the main menu, the
"On display now" block and the exhibitions view all have to appear in both languages. The module meridian_collection
and the theme meridian supply strings that must be translatable.
The three kinds of translatable text
The Drupal User Guide separates all translatable text into three categories, and each category has its own module, storage and workflow. Most mistakes on multilingual sites come from using the wrong tool for a category.
| Kind of text | Examples at the Meridian Museum | Module | Where the translation is stored |
|---|---|---|---|
User interface text (source strings) |
"Save", "Log in", "Read more", labels printed by |
Interface Translation ( |
Database tables of the |
Configuration text |
Site name and slogan, field labels, menu names, block titles, view titles, date formats, text format names, taxonomy vocabulary names, e-mail templates |
Configuration Translation ( |
Configuration overrides, one |
Content text and files |
Exhibitions, events, news, basic pages, taxonomy terms, custom blocks, menu links, media items, |
Content Translation ( |
The same entity (same ID) with one set of field values per language, in the entity’s own field tables |
The Language module (language) is the foundation of all three: it defines which languages exist, how a request is
mapped to a language, and which language each piece of content carries. The official guide describes it as the module
needed "for any non-English or multi-lingual site". Source:
User Guide: Concept: Language Handling
The four modules in detail
Language (language)
Enabling the Language module adds the Languages page at Configuration > Regional and language > Languages
(/admin/config/regional/language). A language is a configuration entity (language.entity.es) with an ID, a label,
a text direction (ltr or rtl), a weight and a locked flag for the two built-in locked pseudo-languages ("Not
specified" and "Not applicable", used by content that has no linguistic meaning, such as an image). The module also adds:
-
the Detection and selection tab (
/admin/config/regional/language/detection) described below -
a Language selector on forms of content whose entity type has been made translatable
-
the Language switcher blocks
-
the Language column and filter in the content administration view
-
a "Language" field on the user’s account for a preferred language
-
the language negotiation API (
LanguageManager,LanguageNegotiatorand theLanguageNegotiationplugins)
The default language is selected in the language list. It is the language of the source strings, of configuration exported from the site and of any content that has no explicit language.
Interface Translation (locale)
The module is called Interface Translation in the extension list. Its machine name is still locale, the name it had
before Drupal 8. It provides:
-
User interface translation at
/admin/config/regional/translate, with tabs to search, import and export -
Translation updates of the status report (
/admin/reports/translations), which compare the installed projects with the translations available on localize.drupal.org -
the import and export of
.po(GNU gettext Portable Object) files -
the registry of every string passed through
t()or a Twig translation filter
Enabling the module does not add any translations by itself. When a non-English language is added in the UI, Drupal downloads the translations for core and all installed projects. Strings that appear on a page but have no translation are registered the first time that page is rendered in a non-default language, which is why a string can be missing from the search form until somebody has visited the page that shows it.
Content Translation (content_translation)
Content Translation adds the "Translate" operation to entities and the form at Configuration > Regional and language >
Content language and translation (/admin/config/regional/content-language). Internally the entity is language
independent: the entity ID is shared by all translations, a translation is a set of values for the translatable fields,
and a small table of translation metadata records the source language, author, status and creation or change time of each
translation. This is why a translated node keeps one node/42 identity and gets a different URL alias per language.
Configuration Translation (config_translation)
Configuration Translation adds a Translate operation to every configuration entity that has a correct schema, and the
overview page at /admin/config/regional/config-translation (Configuration > Regional and language > Configuration
translation). The guide lists 28 translatable element types, including Block, View, Menu, Taxonomy vocabulary, Text
format and System information. Translation relies on the configuration schema: a configuration key is offered for
translation only when its schema type is label, text or a type that inherits from them. A module that ships
configuration with an incomplete schema therefore silently produces forms with missing fields.
Installing and setting up languages
The steps in the user interface
The User Guide chapter "Making Your Site Multilingual" (chapter 10 of the guide written for Drupal 11) follows this order:
-
Install the four modules (Extend page, or Composer-free through Drush as shown below).
-
Add a language at
/admin/config/regional/language. The Add language form downloads translations for core and the installed modules, then shows the new language in the list. -
Choose the translatable content in Content language and translation (
/admin/config/regional/content-language). -
Place the Language switcher block in a region, see Blocks and Block Layout.
-
Translate content and configuration.
Source: User Guide: Adding a Language
A repeatable setup with Drush and configuration
Because core modules ship with Drupal, no Composer command is needed. On the DDEV project described in Installation with Composer and DDEV:
# Enable the four multilingual core modules
ddev drush pm:install language locale content_translation config_translation -y
# Check which translations are available, then download and import them
ddev drush locale:check
ddev drush locale:update
# Export the configuration so the language setup travels with the code
ddev drush config:export -y
The locale:check and locale:update commands compare the installed projects with the translation server.
locale:update accepts a --langcodes=es option and updates all languages when it is omitted.
Source: Drush 13: locale:update
Languages themselves are configuration. After adding Spanish in the UI and exporting, the site has a file
config/sync/language.entity.es.yml, which the staging and production environments import with the rest of the
configuration (see Configuration Management):
langcode: en
status: true
dependencies: { }
id: es
label: Spanish
direction: ltr
weight: 0
locked: false
Source: User Guide: Adding a Language
Translation updates and the translation server
Interface translations come from localize.drupal.org, the community translation server.
Since Drupal 8 the former Localization Update module is part of Interface Translation. The form at
/admin/config/regional/translate/settings (Translation updates settings) sets:
-
whether to check for updates automatically and how often
-
whether to import from the translation server, from local files, or from both
-
which strings may be overwritten by an import: not customized translations, customized translations, or neither
-
the directory for local
.pofiles,translations://, which by default issites/default/files/translations -
whether the module should store and use string translations for the English language
These settings are stored in locale.settings and travel with the exported configuration. A minimal excerpt for the
Meridian Museum site, which downloads from the server and also reads local files, protects hand-edited strings from being
overwritten and uses an environment-specific directory:
# config/sync/locale.settings.yml (excerpt)
translation:
use_source: remote_and_local
overwrite_customized: false
overwrite_not_customized: true
update_interval_days: 0
import_enabled: true
path: 'sites/default/files/translations'
The translation server publishes community translations, so the guide advises reading about the security implications of
translations from localize.drupal.org: a translation is text that Drupal renders on pages. Strings are filtered like
other interface text, but a site with strict requirements can set use_source to local and review the files before
import. Local mode is also the right choice for hosts without outbound network access.
Language negotiation
Language types and negotiation methods
Drupal does not have one "current language". It has three language types, and each is negotiated separately:
-
Interface language: the language of translated UI text, configuration and labels.
-
Content language: the language in which content entities are presented. By default it inherits the interface language’s value.
-
URL language: the language used for generating URLs when no explicit language is given.
Fixed types have module-defined negotiation settings and do not appear on the configuration page. Out of the box only the interface language is configurable. A site that wants content in a language that differs from the interface (for instance, an English administrator reading Spanish content) makes the content language configurable with an alteration in a custom module:
<?php
use Drupal\Core\Language\LanguageInterface;
/**
* Implements hook_language_types_info_alter().
*
* Makes the content language configurable at
* /admin/config/regional/language/detection.
*/
function meridian_collection_language_types_info_alter(array &$language_types): void {
unset($language_types[LanguageInterface::TYPE_CONTENT]['fixed']);
}
Source: API: LanguageNegotiatorInterface
This hook is the one that must remain a procedural function in the .module file or, from Drupal 11.1, be implemented as
a method with the #[Hook('language_types_info_alter')] attribute in src/Hook (see
Hooks and Object-Oriented Hooks). Once the content language is configurable, the
detection page shows a second section, Content language detection, and the page offers a second language switcher
block for content.
A negotiation method is a plugin (LanguageNegotiation plugin type) that decides which language applies to a type.
The built-in methods are:
| Method (plugin ID) | Name in the UI | Behaviour |
|---|---|---|
|
URL |
Reads the language from a path prefix ( |
|
Session |
Reads a request or session parameter (a query-string argument). Weight -6. |
|
User |
Uses the preferred language in the account. |
|
Account administration pages |
Uses the account’s administration language on admin pages. |
|
Browser |
Maps the |
|
Selected language |
Always returns a configured language, the site default or a chosen one. It is the fallback. |
Each type keeps its own enabled methods and order. The methods are tried from the top of the list down: if a method does not return a valid, enabled language code, the next one is tried. The default language is always enabled as the last resort.
URL: path prefix or domain
The URL method is enabled and first on a default setup, and it is what makes translated sites cacheable and indexable:
each language has its own address. Its settings are at Detection and selection > URL > Configure
(/admin/config/regional/language/detection/url) and are stored in language.negotiation:
# config/sync/language.negotiation.yml (excerpt)
selected_langcode: site_default
url:
source: path_prefix
prefixes:
en: ''
es: es
domains:
en: ''
es: ''
session:
parameter: language
With source: path_prefix, the first path segment is matched against each language’s prefix. The default language may
use an empty prefix, so /exhibitions is English and /es/exposiciones is Spanish. With source: domain, the request
host (without the port) is matched against each language’s domain, for example meridian.example for English and
es.meridian.example for Spanish. On the way in the plugin removes the prefix from the path so that routing sees the
same path in every language. On the way out it adds the prefix (or switches the host) when it generates URLs.
Consequences that matter in production:
-
Domain negotiation needs one virtual host or DNS alias per language, a TLS certificate that covers all of them, and the
trusted_host_patternssetting for every domain (see settings.php and Environments). A missing pattern gives "The provided host name is not valid" errors. -
Cookies and sessions are scoped to a host, so a user who logs in on one language’s domain is not logged in on the others unless
cookie_domainis widened. -
Prefixes must be unique, and a prefix must not collide with a path of the site (a language called
adminwould hide the admin area). -
Switching a language on the same page works through the Language switcher block, which produces one link per language for the current route and so keeps the visitor on the translation of the same node when it exists.
Source: API: LanguageNegotiationUrl
Session
The Session method reads a parameter from the query string (named by session.parameter in language.negotiation)
and then from the session. The language is persisted in the session only for authenticated users. For anonymous
visitors the plugin appends the parameter to every generated link, and adds the url.query_args cache context and the
negotiation cache tags, which multiplies the number of cached variants. It is a niche method, for example for an
embedded kiosk that picks its language through a link with ?language=es. For public content the URL method is almost
always the better choice, because one URL then identifies one page.
Source: API: LanguageNegotiationSession
Browser
The Browser method reads the Accept-Language header and passes it, together with the enabled language codes and the
mappings in language.mappings, to the user-agent matcher. The Browser configuration page maps browser codes such as
es-419 or es-ES to the language es. The plugin always triggers the page_cache_kill_switch service: the internal
page cache cannot store one page for several languages that depend on a request header, so using the Browser method
disables the internal page cache for those responses. A reverse proxy such as Varnish must also Vary on
Accept-Language. See Performance and Caching for how cache contexts
(languages:language_interface, url.path) interact with proxies.
A sound pattern for a public site is to put URL first and Browser second. A visitor who arrives at the bare domain gets a redirect-free default, and a visitor who clicks the language switcher always wins because the URL is first.
Source: API: LanguageNegotiationBrowser
User, administration language and selected language
The User method follows the preferred language stored in the user account. The Account administration pages method lets each editor use an administration language of their own, independent from the language of the public pages. To set it up (an arrangement that allows the Meridian curators to work in Spanish while the English editors translate):
-
Install the required languages at
/admin/config/regional/language. -
On the detection page, enable Account administration pages in the Interface text language detection section.
-
Each user chooses the language in Language settings of the profile edit form.
To keep the administration pages in one language while translating content, enable Account administration pages, User and Selected language in the interface section (with the administration method at the top), and URL, Interface and Selected language in the content section.
The Selected language method returns the language chosen in its settings (selected_langcode, with the value
site_default meaning the default language). It guarantees that negotiation always ends with a language.
Reading the negotiated language in code
A custom module reads the language of each type from the language manager. Code in a class receives the service by
injection; \Drupal:: is shown only for a procedural snippet:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Service;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
/**
* Tells the collection importer which language to request from the API.
*/
final class CollectionLanguageResolver {
public function __construct(
private readonly LanguageManagerInterface $languageManager,
) {}
public function contentLangcode(): string {
return $this->languageManager
->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)
->getId();
}
public function interfaceLangcode(): string {
return $this->languageManager
->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE)
->getId();
}
}
Translatable content
Making a content type translatable
At /admin/config/regional/content-language, under Custom language settings, tick the entity type (Content, Content
block, Custom menu link, Taxonomy term, Media, User…), then the bundles and then, for each bundle, the fields that vary
by language. The page also offers the default language of new items (the site default, the interface language, the
author’s language or a fixed one), whether to show the language selector on the create and edit forms, and the option
to hide fields that are not translatable on translation forms.
For the Meridian exhibition content type (see
Content Types and Fields):
| Field | Translatable | Reason |
|---|---|---|
Title ( |
Yes |
Text the visitor reads. |
URL alias ( |
Yes |
Each language has its own address, |
Published ( |
Yes |
A Spanish translation can stay a draft while the English page is already live. |
Start and end dates ( |
No |
Dates are the same in both languages, so editors enter them once. |
Hero image ( |
Yes, with the alternative text and title translatable and the file shared |
The same file is reused; only alt text differs (see Image fields below). |
Topic and gallery references |
No |
The term is the same entity; its name is translated on the term. |
Authored by, Authored on, Changed, Promoted, Sticky |
No |
Technical fields. In the User Guide example only Title, Published, URL alias and Body are translatable on a basic page. |
The setting is stored in two places, which you will see in a configuration export. The bundle settings are in
language.content_settings.<entity_type>.<bundle>, and the per-field flag is the translatable key of the field
configuration:
# config/sync/language.content_settings.node.exhibition.yml
langcode: en
status: true
dependencies:
config:
- node.type.exhibition
module:
- content_translation
third_party_settings:
content_translation:
enabled: true
bundle_settings:
untranslatable_fields_hide: '0'
id: node.exhibition
target_entity_type_id: node
target_bundle: exhibition
default_langcode: site_default
language_alterable: true
# config/sync/field.field.node.exhibition.field_dates.yml (excerpt)
field_name: field_dates
entity_type: node
bundle: exhibition
label: 'Exhibition dates'
translatable: false
A field that was untranslatable and later becomes translatable keeps its existing values as the values of the original language. The reverse change is blocked by the UI as soon as translations exist, because it would discard data. Decide translatability before editors start entering content.
Shared (untranslatable) fields
An untranslatable field is shared by all translations: changing it on the original or on any translation changes it everywhere. This behaviour is a property of the entity object, not of the form. Code does not have to manage language for a field. With the option to hide non-translatable fields on translation forms enabled, the Spanish form shows only the fields that can differ, which prevents an editor from believing that a date edit on the Spanish form affects only Spanish.
Translating an item as an editor
-
Open Content (
/admin/content), open the operations menu of the row and choose Translate. -
On the Translations of page, click Add in the row of the target language.
-
Fill in the translated title, body and URL alias.
-
Click Save (this translation).
The source language is the language of the original and is recorded on the translation. The Translate link only
appears for users with the translate permissions (translate any entity, or translate <entity type> / per-bundle
permissions); see Users, Roles and Permissions. A translator can be
given permission to translate without being allowed to edit the original.
Source: User Guide: Translating Content
Translations also take part in the editorial workflow. With Content Moderation, the workflow state is per translation, so the Spanish version of an exhibition can be in Draft while the English one is Published; see Content Moderation, Workflows and Revisions.
Translating other content entities
The same page covers any content entity type that opts in. For the Meridian site:
-
Taxonomy terms (
topic,gallery): translate the term name and description, see Taxonomy. The vocabulary name is configuration, the term name is content. -
Custom blocks: reusable block content has its own translation; the block’s title is configuration.
-
Media: translate the name and the alternative text of the image field; see Media and the Media Library. Make the media entity translatable and keep the file reference untranslatable.
-
Custom menu links: they are content, so translation must be enabled for them in Content language and translation.
-
Users: the preferred language is a field of the account and the account entity can be made translatable for per-language profile text.
-
The custom
collection_objectentity ofmeridian_collectionbecomes translatable when its entity type attribute declarestranslatable: TRUEand alangcodeentity key, see Custom Entity Types.
Translations in code
The Entity Translation API puts translation methods on the entity itself. The following service loads the translation that matches the current content language and falls back to the original, which is what a block or a controller needs:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Service;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\node\NodeInterface;
/**
* Chooses the right translation of an exhibition for the current request.
*/
final class ExhibitionTranslator {
public function __construct(
private readonly EntityRepositoryInterface $entityRepository,
) {}
/**
* Returns the exhibition in the current content language when it exists.
*/
public function inContext(NodeInterface $exhibition): NodeInterface {
return $this->entityRepository->getTranslationFromContext($exhibition);
}
/**
* Adds a Spanish translation, copying the title, and saves it unpublished.
*/
public function addSpanishDraft(NodeInterface $exhibition, string $title): NodeInterface {
if (!$exhibition->hasTranslation('es')) {
$translation = $exhibition->addTranslation('es', $exhibition->toArray());
$translation->setTitle($title);
$translation->setUnpublished();
$exhibition->save();
return $translation;
}
return $exhibition->getTranslation('es');
}
}
Source: Entity Translation API
Points from the API documentation: getTranslation('es') returns a clone of the entity with a different internal
language; hasTranslation() should be checked before addTranslation(); removeTranslation() throws when asked to
remove the original; saving the entity saves all its translations; and adding or removing a translation in storage fires
hook_entity_translation_insert() and hook_entity_translation_delete(). Query code that must return results in one
language joins the data table and filters on langcode; an entity query that forgets accessCheck() still fails in the
same way for translated content as for any other (see
Database API and Entity Queries).
Translating configuration: menus, blocks, views and more
The overview page and the Translate operation
All configuration translation starts from /admin/config/regional/config-translation. Some items appear directly (the
site information, the account settings), and grouped types such as Views or Date formats have a List button that opens
every item of the type. Each item has a Translate button, which lists the languages with an Add or Edit button.
The generic recipe is: find the item, click Translate, click Add for the target language, enter the translation, save.
Most configuration pages also have a Translate tab once Configuration Translation is installed.
Site name, slogan and e-mail texts
Basic site settings are translated at /admin/config/system/site-information/translate. The same pattern applies to the
automatic e-mails of the Account settings page, which the Meridian site needs for its membership welcome message.
Menus
Menu names (and descriptions) are configuration: go to Structure > Menus (/admin/structure/menu) and choose
Translate in the menu’s operations list. Menu links created by hand are content, not configuration. To translate them:
-
enable translation for Custom menu link in Content language and translation (otherwise no translate link appears)
-
edit the menu and use Translate in the link’s operations list
-
translate the link title, the description and, if the link points to an internal path, the destination follows the translated page automatically
Links that come from code (module *.links.menu.yml files) are not content. Their titles are translated as interface
text, see below. See Menus and Path Aliases for the menu system itself.
Blocks
A block placed in a region has a configuration entity (block.block.<id>) whose label (the title) is translatable
configuration. The content of a custom block is a content entity, translated through Content Translation. Therefore a
translated "Opening hours" block needs two translations: the title with Configuration Translation and the body with
Content Translation. The "On display now" block plugin of meridian_collection has a label from its plugin definition,
which is translatable interface text. Its output is translated by the strings that the plugin’s build() method uses.
The official User Guide page does not give a separate procedure for standalone block titles, but the Translate tab of the block configuration form follows the general pattern. For layout-level tools, see Blocks and Block Layout and Layout Builder (Layout Builder per-node layouts follow the translatability of the layout field).
Views
From the overview, click List in the Views row, then Translate for the view (for example exhibitions) and Add in
the target language row. Labels of displays are offered: the display title, the exposed form’s submit button text, the
"Items per page" label, header and footer text, the empty-text message and the exposed filter labels. Save the translation
and check it with the language switcher. A view that lists translated content also needs its Rendering Language
setting under the Advanced section: with the default (Content language of view row) each row appears in its own
language, and the view can be filtered by Content: Translation language. See Views.
Fields, vocabularies, text formats and date formats
Field labels and help text are in the field’s configuration (field.field.node.exhibition.field_dates), so translate them
with the Translate tab of the Manage fields page. Vocabulary names, text format names, date formats, image styles
and contact forms are configuration too. All these translations are stored as overrides in
language.config.es.<name> objects. They are included in drush config:export and can be reviewed in a pull request. A
configuration translation overrides the original only for display, so exports of the source language stay stable.
Exporting configuration translations
Core cannot export or import .po files for configuration translation. The contributed module Config Translation PO
(config_translation_po) adds export and import tabs at /admin/config/regional/config-translation and Drush commands
(config_translation_po:export and config_translation_po:import), but the import command was still a work in progress
in an open issue at the time of checking. For release workflows, keep the translated overrides in configuration
management instead.
The |trans alternative for Twig-rendered strings
For strings that are configuration but printed in a template, such as a Views block title or the title of a custom menu
item, the guide shows an alternative to Configuration Translation: override the template in the custom theme and add the
|trans filter, for example label|render|striptags|trans in block.html.twig or item.title|trans in the menu
template. The strings then become interface text and can be translated once in the Interface Translation screen or in a
.po file that is deployed with the code. The cost is a template override, and the strings are tied to the exact
English text.
Interface translation and .po files
Searching and editing strings
At /admin/config/regional/translate the User interface translation page lists all registered source strings. Filter
by string, by language, by translation state (translated or untranslated) and by text group. A translated string that an
editor changes is marked customized. Customized translations are protected from automatic updates unless the update
settings allow overwriting them.
To override wording on an English site without a second language ("Basket" instead of "Cart"), enable the module and
translate the string in English, or use the locale_custom_strings_en entry of $settings in settings.php.
Importing .po files
The Import tab accepts one .po file. It asks for the language, the type of strings (customized or not) and whether to
overwrite existing translations. Community translations are the files available on
localize.drupal.org, where a group per language reviews and approves them. The same file
format is exported on the Export tab, which can also export the source strings as a .pot template for translators.
Drush is better for scripted deployments:
# Export the translations customised by the museum's editors
mkdir -p assets/translations
ddev drush locale:export es --types=customized > assets/translations/custom-translations.es.po
# Import a single file, replacing existing translations
ddev drush locale:import es assets/translations/custom-translations.es.po --type=customized --override=all
# Import every .po file in a directory (not recursive)
ddev drush locale:import-all assets/translations --type=customized --override=all
# Export the source strings as a template
ddev drush locale:export --template > meridian.pot
locale:export accepts --types with the values not-customized, customized and not-translated, and --template
writes a .pot file. locale:import-all (Drush 12.2 and later) imports every file in a directory, non-recursively; its
--override option takes none, customized, not-customized or all, and defaults to the behaviour set in the user
interface. For a relocated web root, adjust the relative paths.
Source: Drush 13: locale:import-all
Source: Drush 13: locale:export
Translations for custom code
Strings of meridian_collection and the meridian theme are not hosted on localize.drupal.org, so the Locale module has
to be told where the .po files live. A custom module declares two keys in its .info.yml:
# modules/custom/meridian_collection/meridian_collection.info.yml
name: 'Meridian collection'
type: module
description: 'Collection objects for the Meridian Museum.'
package: Meridian
core_version_requirement: ^11.3
interface translation project: meridian_collection
interface translation server pattern: modules/custom/meridian_collection/translations/%language.po
With this declaration the translation status report includes the module, and locale:update imports
translations/es.po from the module directory. The tokens available in the pattern are %core, %project, %version
and %language. The pattern hardcodes the module path, so it must be changed when the module is moved. The project may
also use the translations:// stream wrapper. Projects hosted on drupal.org can change their settings with
hook_locale_translation_projects_alter().
Strings that are missing from the translation screen
If a string does not appear, it has not been registered yet: switch to a non-default language and visit the page that
shows it. Strings that were built by concatenation or that contain variables cannot be extracted. Run an extractor such
as the contributed Translation extractor (potx) on custom modules to produce a .pot file for translators.
t(), Twig translation and site builders
A site builder never has to call t() to translate a site, but should know what the function does because it is the
mechanism behind every interface string:
-
Code or a template passes an English source string through a translation function.
-
At render time Drupal looks up the string for the interface language and returns the translation, or the source string if none exists.
-
The string is registered in the Locale tables so that it appears on the translation page.
In PHP, classes use the StringTranslationTrait and the method $this->t() (which also builds objects of the
TranslatableMarkup class), and $this->formatPlural() for counts. The global t() function remains for procedural
code and tests only. Placeholders protect against injection: @name escapes HTML, %name escapes and emphasises, and
:url is for attribute values. A string is never concatenated before it 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;
/**
* Shows how many collection objects are on display.
*/
#[Block(
id: 'meridian_on_display_now',
admin_label: new TranslatableMarkup('On display now'),
category: new TranslatableMarkup('Meridian'),
)]
final class OnDisplayNowBlock extends BlockBase {
public function build(): array {
$count = 128;
return [
'#markup' => $this->formatPlural(
$count,
'1 object is on display now.',
'@count objects are on display now.',
),
'#cache' => ['contexts' => ['languages:language_interface']],
];
}
}
Source: Translation API overview
Twig offers two forms, both documented in the theming guide:
{# A short, literal string #}
<h2>{{ 'Opening hours'|t }}</h2>
{# A block with placeholders and a plural #}
{% trans %}
Welcome back, {{ name }}. You have {{ count }} saved object.
{% plural count %}
Welcome back, {{ name }}. You have {{ count }} saved objects.
{% endtrans %}
The |t filter is short for |trans, and both run the string through the translation function. The {% trans %} tag
is the form for placeholders and singular/plural pairs: text before {% plural count %} is used when the count is 1.
Only literal strings are translatable: never pass a variable’s value through |t, a {% trans %} block that contains
only a variable, or the |placeholder filter on data entered by users. Translation extraction works on literal text, and
translating arbitrary values is both useless and an XSS-prone pattern. Autoescaping stays on; |raw is never needed on
a translated string that has placeholders.
Section links for the developer side: see Translation in Code. For the template pipeline see Twig Templates and Debugging.
Strings in YAML files of a module (*.links.menu.yml, *.permissions.yml, route titles) are extracted as interface text
too, with the context given by the key. The same string in two contexts can be translated differently: for example
"Visit" as a verb in a button and as a noun in a menu. Use the context option of t() for that case.
Right-to-left languages
Direction is a property of each language. To add Arabic or Hebrew, add the language and the Language form records the
direction rtl (the direction key in language.entity.<langcode>). Drupal then sets dir="rtl" on the html
element through the html.html.twig template (<html{{ html_attributes }}>), and the theme must supply the mirrored
styles.
-
A theme declares a stylesheet per direction in its libraries: a
*-rtl.csssibling of a CSS file is loaded automatically for RTL languages when the theme uses the standard asset loader. The Meridian theme listscss/meridian.cssand shipscss/meridian-rtl.css. -
Prefer CSS logical properties (
margin-inline-start,padding-inline-end,text-align: start) in new work; they need no separate RTL file. See Libraries and Assets and Accessibility and Performance in Themes. -
Use a theme that supports RTL. The multilingual guide recommends a core theme with RTL support; the core themes Olivero and Claro of Drupal 11 support RTL, and a custom theme must be tested. They leave core in Drupal 12; Default Admin (based on Gin) is the replacement.
-
Embedded content (maps, videos, JavaScript widgets) and numbers and punctuation inside RTL text need testing in a real browser; the
langanddirattributes should also be set on translated fragments that mix directions.
# modules/custom/meridian_collection/meridian_collection.libraries.yml
collection_widget:
css:
theme:
css/collection-widget.css: {}
With this declaration, a css/collection-widget-rtl.css beside the file is picked up when the page is rendered in an RTL
language.
Multilingual in everyday site work
URL aliases and path auto-generation
Aliases are per language. A translated node gets an alias in its language, and the path alias entity carries a langcode.
Link fields and menu links built with internal:/node/42 follow the alias of the current language. Pathauto patterns can
differ per language. See Menus and Path Aliases.
SEO
Every translated page should link to its siblings in the <head> with hreflang alternates and a self-referencing one.
Core renders hreflang links for translations of a page, and the contributed Metatag and Simple XML Sitemap modules
extend the markup. Redirect rules must never send a visitor from one language to another based only on the browser’s
language, because search engines then cannot crawl all versions.
Caching
Every translated response varies by language: the render cache contexts languages:language_interface,
languages:language_content and url.path are added by core. Custom render arrays that contain translated text but
bypass these contexts (a block with #cache set by hand) must declare them, as in the block example above.
Recipes and distributions
Drupal CMS and the site templates in Drupal CMS and Site Templates install their content in English. A multilingual project applies the language modules first, imports translations, and then applies content recipes.
Common errors and pitfalls
-
Interface translation is not content translation. Editing "Home" at the translation screen changes the menu label for all pages, while translating the node changes the page. Editors need training on the three categories.
-
Strings not translated after import. The language of the string, the "customized" protection and the overwrite setting decide whether an import changes anything. Run
drush cache:rebuildafter a large import. -
Machine names differ from labels. The "Interface Translation" module is
locale; Drush and.info.ymluselocale. -
Language changes lose paths. Changing a URL prefix after launch changes all public URLs. Add redirects.
-
Missing translations. A visitor sees the original language, or an unpublished translation is hidden. Menu links, blocks and views must exist in every language.
-
Browser negotiation and caching. Browser negotiation turns off the internal page cache for those responses and needs
Vary: Accept-Languageon every cache in front. -
Domain negotiation and sessions. Logging in on one domain does not log in on another.
-
Untranslatable fields edited on a translation. Changes apply to all languages.
-
Updates. Install new modules, then
drush locale:checkanddrush locale:update: the Locale updater fetches translations of newly installed projects only when an update runs.
Version notes and corrections of older material
-
The four multilingual modules have been in core since Drupal 8 and are unchanged in concept in Drupal 11.4. The Localization Update functionality is part of Interface Translation, and the module is still called
localeinternally. -
Drupal 10 ends on 2026-12-09 and Drupal 12.0.0 is planned for the week of 2026-12-07. Use Drupal 11.3 or later for new multilingual sites. The tutorial chapter of the User Guide uses a basic page; the Standard installation profile of 11.4 no longer creates the Article and Page content types, so create the content type (or use a recipe) before following any tutorial that assumes it.
-
Plugin annotations are used in older code; since Drupal 10.2 plugins such as blocks use PHP attributes, as in the block above. Since 11.1 hooks can be implemented as methods with the
#[Hook]attribute. -
Older books such as Drupal 10 Development Cookbook (2023) cover multilingual configuration for Drupal 10.0, with
\Drupal::calls and.modulehook functions in class code; today classes use injected services,StringTranslationTraitand attribute-based plugins. The same book’s Drush examples use annotation-based commands anddrush.services.yml, which Drush 13 no longer supports. -
The User Guide chapter "Making Your Site Multilingual" is numbered 10 in the current guide written for Drupal 11 (pages
language-add.html,language-content-config.html,language-content-translate.htmlandlanguage-config-translate.html); references to it as chapter 9 use an older numbering. -
Drupal 10 Masterclass (2023) presents Recipes, Project Browser and Automatic Updates as future initiatives. They now exist, and recipes (see Recipes) can enable the multilingual modules, but recipes have no update path: once applied, the site is ordinary Drupal.
-
Colour, RDF, Aggregator and other modules removed from core (see Removed Core Modules and Their Replacements) do not affect multilingual setup. Before upgrading, install the contributed versions of removed modules that you use.
-
Drupal 12 removes the Claro and Olivero themes from core and moves the Toolbar to contrib. Language administration remains in core. Check RTL support in the replacement admin theme before moving an RTL site (see Preparing for Drupal 12).
-
Documentation and tutorials that mention the contributed Localization Update module (Drupal 6 and 7) or the Garland theme describe older releases; in Drupal 8 and later the update feature is part of Interface Translation.
For the full list of outdated advice see Where Older Books and Tutorials Are Outdated.
Checklist for the Meridian Museum
-
Enable
language,locale,content_translationandconfig_translation; export configuration. -
Add Spanish; choose URL prefix negotiation (
es) and keep Browser as a second method only if the reverse proxy varies onAccept-Language. -
Place the Language switcher block in the header region.
-
Mark
exhibition,event, news, basic page, custom blocks, menu links and thetopicandgalleryterms translatable; leave dates and references shared. -
Translate the site name, main menu, blocks and the
exhibitionsview. -
Declare the custom module’s
.pofile, then rundrush locale:update. -
Test with an RTL language even if none is planned, so layout bugs are found early.
-
Review the translation permissions of the Content Editor and Curator roles.