Translating Text

Message catalogues, the translator service, the trans filter in Twig, and translation:extract for finding untranslated strings.

Syntax$translator->trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string

A translator is a service that turns a message id into text for one locale. A locale is a language-and-region code such as en, fr or pt_BR. A catalogue is the set of translations for one locale and one domain, where a domain is a named group of messages and messages is the default one. Text lives in the catalogue rather than in the template, so adding a language is not a template edit.

How it works

Catalogue files live in translations/ and are named domain.locale.format: messages.en.yaml, messages.fr.yaml, admin.de.xlf. Inject TranslatorInterface and call trans() with an id. In Twig, use the trans filter. An id can be literal English or a key such as greeting.hello. Keys survive copy changes; literal text reads better when a translation is missing. Pick one convention per project. An id with no entry anywhere is returned unchanged, so nothing throws.

Shorthand and longhand

t('order.status', ['%status%' => $status], 'store')
new TranslatableMessage('order.status', ['%status%' => $status], 'store')

These are the same thing. t() is imported with use function Symfony\Component\Translation\t; and returns that object. Neither is translated on the spot: whatever renders it calls trans() later, in whichever locale applies then.

Finding what is missing

translation:extract scans templates and PHP for ids and reports the ones with no entry. --dump-messages prints them, --force writes them, --clean drops ids that no longer appear.

Example

Example · php
<?php
// src/Controller/GreetingController.php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;

final class GreetingController extends AbstractController
{
    // The translator arrives through the constructor, type-hinted on the
    // interface. The controller never asks the container for it.
    public function __construct(
        private readonly TranslatorInterface $translator,
    ) {
    }

    #[Route('/greeting', name: 'app_greeting', methods: ['GET'])]
    public function greeting(): Response
    {
        // 'greeting.hello' is a message id, looked up in the "messages"
        // domain for the request locale. %name% is replaced afterwards.
        $text = $this->translator->trans('greeting.hello', ['%name%' => 'Ada']);

        // The third argument is the domain, the fourth pins the locale and
        // ignores the request locale entirely.
        $dutch = $this->translator->trans(
            'greeting.hello',
            ['%name%' => 'Ada'],
            'messages',
            'nl',
        );

        return new Response($text.' / '.$dutch, Response::HTTP_OK, [
            'Content-Type' => 'text/plain; charset=utf-8',
        ]);
    }
}

When to use it

  • Move the hard-coded English out of a template so a second language can be added without touching it.
  • Keep admin screens and customer email in separate domains with separate translator files.
  • Find every string a new feature introduced that has no Dutch entry yet.
  • Return a status label from a service as a translatable object and let the template decide the locale.

More examples

The catalogue files behind that controller

One file per domain per locale, named domain.locale.format. Nesting is only a convenience: the loader flattens it with dots, so greeting.hello here and 'greeting.hello' in the controller are the same id. Reach for a second domain when a group of messages has a different audience or a different translator, such as admin screens or transactional email.

Example · yaml
# translations/messages.en.yaml
# Nested keys become dotted ids: this file defines greeting.hello.
greeting:
    hello: 'Hello %name%'
    goodbye: 'Goodbye'

# translations/messages.nl.yaml
greeting:
    hello: 'Hallo %name%'
    goodbye: 'Tot ziens'

# translations/admin.en.yaml
# A second domain. Same locale, separate file, separate namespace of ids,
# so an admin heading can never collide with a public one.
users:
    heading: 'User accounts'

The filter and the tag in a template

The filter is the one to default to, because its output is escaped like any other Twig output. The tag exists for long blocks of prose where the id reads better as the template body. Use it only with values you produced yourself; a username or a comment belongs in the filter.

Example · twig
{# templates/greeting/index.html.twig #}

{# The filter. Arguments are placeholders, domain, locale - in that order. #}
<h1>{{ 'greeting.hello'|trans({'%name%': name}) }}</h1>

{# A different domain, then an explicit locale. #}
<p>{{ 'users.heading'|trans({}, 'admin') }}</p>
<p>{{ 'greeting.goodbye'|trans({}, 'messages', 'nl') }}</p>

{# Changes the default domain for the rest of THIS template only. It does
   not reach templates that this one includes or extends. #}
{% trans_default_domain 'admin' %}
<p>{{ 'users.heading'|trans }}</p>

{# The tag form. %name% markers are required here, and the result is NOT
   auto-escaped, so a value that came from a user must not go through it. #}
{% trans with {'%name%': 'Ada'} from 'messages' %}greeting.hello{% endtrans %}

Extracting the ids you forgot to translate

The extractor reads templates in templates/, PHP that calls trans(), code that builds translatable objects, and validation constraint attributes with a message argument. New ids are written with the prefix __ so an untranslated string is visible on the page; --no-fill leaves the value empty instead. Run it before handing files to a translator, and run it with --clean after deleting a feature.

Example · bash
# Print what is missing for Dutch. Writes nothing.
php bin/console translation:extract --dump-messages nl

# Write the missing ids into translations/messages.nl.yaml.
# Without --format the command writes XLIFF 1.2, so a YAML project must
# say so or it ends up with .xlf files sitting beside its .yaml ones.
php bin/console translation:extract --force --format=yaml --domain=messages nl

# Output:
#
# Translation Messages Extractor and Dumper
# =========================================
#
#  // Generating "nl" translation files for "default directory"
#
#  // Parsing templates...
#
#  // Loading translation files...
#
#  // Writing files...
#
#  [OK] Translation files were successfully updated.

Discussion

  • Be the first to comment on this lesson.