Skip to content

ConceptFor module developers, theme developersDrupal 11

Multilingual foundations

Drupal is built to run any site in any language, or many at once, but only if module and theme code plays by the internationalization rules. This page explains how Drupal decides which language a request gets, and the three separate systems that translate interface text, content, and configuration.

Internationalization is not a feature you bolt on; it is a set of conventions the whole platform assumes. Code writes user-visible text in English and routes it through the translation system. Content entities store one set of field values per language. Configuration marks which of its values are human-readable. Get the conventions right and your module is translatable on day one without a line of language-specific code; get them wrong and no amount of contrib can fix it later.

Four core modules divide the work, and the split mirrors Drupal’s information types:

  • Language (language) defines which languages exist on the site and how requests are assigned one.
  • Interface Translation (locale) translates UI text that lives in code, and imports community translations from localize.drupal.org automatically.
  • Content Translation (content_translation) translates entities, field by field.
  • Configuration Translation (config_translation) translates the human-readable parts of configuration.

Even a single-language site runs this machinery: English is a language like any other, which is why following the conventions costs nothing.

Two separate questions, two separate mechanisms. Which language does this request get? is negotiation. Where does the translated text come from? depends on which of the three streams the text lives in: interface, content, or configuration.

Drupal negotiates not one language per request but one per language type, defined on \Drupal\Core\Language\LanguageInterface:

  • Interface text (TYPE_INTERFACE): the language t() translates into.
  • Content (TYPE_CONTENT): the language the Field API prefers when rendering entity fields; by default it follows the interface language.
  • URL (TYPE_URL): the language used when generating links.

The split exists because the right answers can differ: an editor may want the English admin interface while editing the German translation of a node.

Three languages per request

A request resolves a language separately for each of three language types: interface, content, and URL. Each type runs its own ordered chain of negotiation methods, so getCurrentLanguage without a type argument returns the interface language only, not a single page language. An editor can read an English interface while editing German content.

Each type is resolved by an ordered chain of negotiation method plugins, configurable per type at Administer → Configuration → Regional and language → Languages → Detection and selection/admin/config/regional/language/detection: URL prefix or domain, session, the user’s account preference, browser Accept-Language, with a site-default fallback. The plugins are classes with the #[LanguageNegotiation] attribute; the URL plugin, for example, doubles as an inbound and outbound path processor (a service that rewrites incoming and generated URLs) so /de/... prefixes work in both directions.

Code never re-derives any of this. Ask the language_manager service (\Drupal\Core\Language\LanguageManagerInterface):

// Fragment: $language_manager is the injected language_manager service.
$langcode = $language_manager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId();

Anything that varies by negotiated language must say so with the languages:TYPE cache context. Render-cached output gets languages:language_interface automatically, as one of the renderer’s required cache contexts; declare the content variant yourself when field output is what varies. See Caching.

Interface text: t() and TranslatableMarkup

Section titled “Interface text: t() and TranslatableMarkup”

Every user-visible string in code is written in literal English and wrapped:

// Fragment: inside a class using StringTranslationTrait.
$this->t('@name has not responded yet.', ['@name' => $attendee->getDisplayName()]);

The trait method and the global t() both construct a \Drupal\Core\StringTranslation\TranslatableMarkup object, and the object is the point: translation happens when the object is cast to a string, as late as possible. Discovery and data caches, such as plugin definitions, store the object itself, so one cache entry serves every language. The render cache stores finished HTML instead and keeps one copy per interface language. Casting early (string concatenation, strlen(), logging the “string”) freezes one language into whatever language-agnostic cache the value lands in: plugin definitions, or a cache bin you write yourself.

The rules that make strings translatable are mechanical:

  • The first argument is always a literal English string, never a variable. Static extraction reads your code to build the translation database; t($message) extracts nothing and is an injection risk besides.
  • Dynamic values go in placeholders: @var escapes, %var escapes and emphasizes, :var is for URLs. Placeholders are also the security boundary; see the constructor documentation on TranslatableMarkup for the details.
  • Plurals go through formatPlural(), never an if on the count: languages disagree about how many plural forms exist.
  • When one English word translates differently by usage, disambiguate with context: $this->t('May', [], ['context' => 'Long month name']). The same option exists everywhere strings are declared.

The same system reaches beyond PHP. In Twig, use the t filter or {% trans %} tag; in JavaScript, Drupal.t() and Drupal.formatPlural(); in YAML, route titles, menu link titles, and *.info.yml names are extracted and translated automatically. Plugin attributes take TranslatableMarkup directly, which you have already seen on every #[Block] attribute’s admin_label.

Content entities translate structurally: an entity type marked translatable stores one revisionable set of field values per language, and each field is individually marked translatable or not (a price is shared across languages; a body is not). The Content Translation module provides the editorial UI and per-bundle settings; the Entity API exposes translations as $node->getTranslation('de'), a full entity object in that language.

Rendering picks the right translation for you when you let the system do the work: render entities through view builders and field formatters, and the negotiated content language selects the translation. Loading a field value by hand and printing it skips that selection, which is one more reason the render pipeline wants data, not strings.

Configuration translates per schema. The schema data types declare intent: label, text, and date_format values are translatable; string is deliberately not. When a site adds a German translation for system.site, the override is stored as a language-specific collection alongside the original, and the configuration factory applies it transparently when German is negotiated. The Configuration Translation module builds its entire UI from schema, which is one more reason the schema files your module ships are not optional.

For your own module that means: mark human-readable config values with translatable types in your schema, and they become translatable with zero further code.

  • The URL negotiation plugin (language module, plugin ID language-url) resolves the language from a path prefix or domain and rewrites every generated URL to match, by implementing the inbound and outbound path processor interfaces alongside the negotiation method interface. It is the reference implementation for writing a custom negotiation method.

  • Block labels are lazy. Every #[Block] attribute carries admin_label: new TranslatableMarkup(...); the plugin system stores the object in the discovery cache, and each admin page renders it in the viewer’s language:

    The #[Block] attribute with a translatable admin label
    <?php
    declare(strict_types=1);
    namespace Drupal\event_rsvp\Plugin\Block;
    use Drupal\Core\Block\Attribute\Block;
    use Drupal\Core\Block\BlockBase;
    use Drupal\Core\Cache\Cache;
    use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
    use Drupal\Core\Routing\RouteMatchInterface;
    use Drupal\Core\StringTranslation\TranslatableMarkup;
    use Drupal\event_rsvp\RsvpStorage;
    use Drupal\node\NodeInterface;
    use Symfony\Component\DependencyInjection\ContainerInterface;
    /**
    * Shows how many people have responded to the event on this page.
    *
    * The #[Block] attribute replaces the old @Block annotation: plain PHP
    * that your IDE and static analysis understand.
    */
    #[Block(
    id: 'event_rsvp_count',
    admin_label: new TranslatableMarkup('RSVP count'),
    category: new TranslatableMarkup('Event RSVP'),
    )]
    final class RsvpCountBlock extends BlockBase implements ContainerFactoryPluginInterface {
    /**
    * Constructs the RSVP count block.
    *
    * @param array $configuration
    * The plugin configuration.
    * @param string $plugin_id
    * The plugin ID.
    * @param mixed $plugin_definition
    * The plugin definition.
    * @param \Drupal\event_rsvp\RsvpStorage $rsvpStorage
    * The RSVP storage service.
    * @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch
    * The current route match, used to find the node being viewed.
    */
    public function __construct(
    array $configuration,
    string $plugin_id,
    mixed $plugin_definition,
    protected readonly RsvpStorage $rsvpStorage,
    protected readonly RouteMatchInterface $routeMatch,
    ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    }
    /**
    * {@inheritdoc}
    */
    public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
    return new static(
    $configuration,
    $plugin_id,
    $plugin_definition,
    $container->get('event_rsvp.storage'),
    $container->get('current_route_match'),
    );
    }
    /**
    * {@inheritdoc}
    */
    public function build(): array {
    $node = $this->getNode();
    if (!$node) {
    return [];
    }
    $count = $this->rsvpStorage->countForNode((int) $node->id());
    return [
    '#markup' => $this->formatPlural(
    $count,
    '1 person is attending.',
    '@count people are attending.',
    ),
    ];
    }
    /**
    * {@inheritdoc}
    */
    public function getCacheContexts(): array {
    // The output depends on which page (route) the block appears on:
    // a different node means a different count.
    return Cache::mergeContexts(parent::getCacheContexts(), ['route']);
    }
    /**
    * {@inheritdoc}
    */
    public function getCacheTags(): array {
    $tags = parent::getCacheTags();
    if ($node = $this->getNode()) {
    // Invalidate the cached block whenever the node is saved, or when
    // RsvpStorage invalidates its custom tag after a new RSVP.
    $tags = Cache::mergeTags($tags, $node->getCacheTags());
    $tags = Cache::mergeTags($tags, [RsvpStorage::cacheTag((int) $node->id())]);
    }
    return $tags;
    }
    /**
    * Returns the node from the current route, if there is one.
    */
    protected function getNode(): ?NodeInterface {
    $node = $this->routeMatch->getParameter('node');
    return $node instanceof NodeInterface ? $node : NULL;
    }
    }
  • The renderer varies by language for you. languages:language_interface is a required cache context on all render-cached output, so a cached block in German is never served to a French page, with no module code involved.

  • Core ships its own translations through the same pipe. Interface Translation downloads .po files (the gettext translation format) from localize.drupal.org for core and contrib alike; your module’s strings join that pipeline the moment they are wrapped in t() and the project is published.

  • “t() translates text.” t() registers and looks up source strings written in English in code. It does not translate variables, user input, or content; passing a variable extracts nothing and can introduce security bugs. Content is translated by the entity translation system, configuration by schema. The function is for interface text only.

  • “I’ll build the sentence from translated pieces.” Concatenating translated fragments assumes English word order. Use one string with placeholders and let translators rearrange: "@name's draft" can become "borrador de @name" only if the name travels inside the string.

  • “Cast it to a string, it’s the same thing.” A TranslatableMarkup cast early is frozen in the language of the moment it was cast, and cached output built from it leaks that language across the site. Pass the object through render arrays and let rendering cast it last.

  • “getCurrentLanguage() is the page language.” There are three negotiated languages per request. For interface strings the default type is right; for deciding which entity translation you are operating on, ask for TYPE_CONTENT or, better, take the language from the entity translation you were handed.

  • “One English string has one translation.” The same English word can need different translations by usage. When you see a short, ambiguous string (“May”, “Order”), give it a context so translators see two entries instead of one wrong one.

  • “Multilingual is a contrib concern I can ignore.” The conventions cost nothing on a monolingual site, and retrofitting them means touching every string, schema, and field definition your module ever shipped. Write it translatable the first time.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.