Symfony 8.2 brings many improvements to the Translation component and its integration with Twig and forms, including better XLIFF files and a translation:extract command that finds many more messages.

Meaningful XLIFF Source Elements

mauricedefelicec24 Nicolas Rigaud
Contributed by mauricedefelicec24 and Nicolas Rigaud in #62023 and #65802

In XLIFF files, the <source> element is meant to contain the original text to translate, which is what translation tools and translators display. However, the XLIFF files generated by translation:extract store the translation key in it:

1
2
3
4
5
<!-- translations/messages.fr.xlf -->
<trans-unit id="hVNhFLX" resname="navbar.home">
    <source>navbar.home</source>
    <target>__navbar.home</target>
</trans-unit>

First, Symfony 8.2 now keeps the <source> element when loading and dumping XLIFF files, instead of replacing it with the translation key. Second, a new command fills the <source> elements of your XLIFF files with the messages of the default locale:

1
2
3
4
$ php bin/console translation:update-xliff-sources

# you can also restrict the update to some locales and domains
$ php bin/console translation:update-xliff-sources --locales=fr --domains=messages

If the English translation of navbar.home is Home (and en is the default locale), the <source> element of the previous example now contains Home. The command only updates files stored in your translations/ directory and in the paths configured in framework.translator.paths; it never changes the translation files of your vendors or bundles.

A File-Wide Default Domain in Twig

Matthias Pigulla
Contributed by Matthias Pigulla in #63986

The trans_default_domain Twig tag applies to the current template scope. That scope doesn't include the body of {% embed %} tags, because Twig compiles them as separate templates. In the following example, product.title is translated in the catalog domain, but product.add_to_cart uses messages:

1
2
3
4
5
6
7
{% trans_default_domain 'catalog' %}

<h1>{{ 'product.title'|trans }}</h1>

{% embed 'components/card.html.twig' %}
    {% block footer %}{{ 'product.add_to_cart'|trans }}{% endblock %}
{% endembed %}

In Symfony 8.2, add the new for _self modifier to apply the domain to the whole template file, including embed bodies and macros:

1
{% trans_default_domain 'catalog' for _self %}

The domain must be a literal string and you can only declare it once per template, outside of any block and before any embed tag. It doesn't apply to other templates (e.g. the ones included or extended), and a regular trans_default_domain tag still takes precedence where it applies.

Translatable Choice Group Labels

Valentin Salmeron
Contributed by Valentin Salmeron in #65257

The group_by option of ChoiceType creates <optgroup> elements to group choices. Their labels were already translated, but the callable could only return strings. In Symfony 8.2, it can also return TranslatableInterface objects, like the choice_label option already allows:

1
2
3
4
5
6
7
8
9
10
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Translation\TranslatableMessage;

$builder->add('country', ChoiceType::class, [
    'choices' => $countries,
    'group_by' => fn (Country $country): TranslatableMessage => new TranslatableMessage(
        'continent.'.$country->getContinent(),
        domain: 'countries',
    ),
]);

If your custom form theme overrides the choice_widget_options block, render the group label from choice.label (falling back to the loop key) because the array key is no longer the label when using translatable objects.

Smarter Extraction of Messages in PHP

Guillaume Sainthillier
Contributed by Guillaume Sainthillier in #66051 and #66284

The translation:extract command only found messages passed to trans(), t() or new TranslatableMessage() as literal strings, concatenations of literals or class constants. In Symfony 8.2, the extractor also resolves ternary and null-coalescing expressions, match expressions, interpolated strings, variables assigned earlier and values returned by methods:

1
2
3
4
5
6
7
8
9
// extracts 'quote.amended' and 'quote.new'
$this->translator->trans($isAmended ? 'quote.amended' : 'quote.new');

// extracts 'status.draft' and 'status.published'
$label = match ($status) {
    Status::Draft => 'status.draft',
    Status::Published => 'status.published',
};
$this->translator->trans($label);

Methods are resolved when the extractor knows the class of the object ($this, typed parameters and properties, enum cases, return types, etc.) and when they are defined in the extracted paths:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum Status
{
    case Draft;
    case Published;

    public function label(): string
    {
        return match ($this) {
            self::Draft => 'status.draft',
            self::Published => 'status.published',
        };
    }
}

// extracts 'status.draft' and 'status.published'
$this->translator->trans($article->getStatus()->label());

Extracting Form Labels and Help Messages in Twig

Nicolas Grekas
Contributed by Nicolas Grekas in #66988

When rendering forms in Twig, you can override the label and help messages defined in the form type. In Symfony 8.2, translation:extract finds the messages that you pass to the form_label(), form_widget(), form_help() and form_row() functions:

1
2
3
4
5
6
7
{{ form_label(form.apiToken, 'admin.company.api_token') }}

{{ form_row(form.email, {
    label: 'user.email',
    help: 'user.email_help',
    translation_domain: 'admin',
}) }}

These messages are extracted in the domain defined by the translation_domain variable or in the default domain otherwise. The trans_default_domain tag is ignored on purpose, because form themes don't use it to translate labels and help messages.

Published in #Living on the edge