Nuts & bolts of internationalization and localization with Symfony

Mateo Fuzul
Undabot
9 min readMar 3, 2022

--

Working on a variety of products targeting specific locales and looking to expand to new ones, I’ve caught on to the importance of internationalization and localization early in my career.

In this blog post I’d like to share the knowledge acquired and show you why it’s important to translate your application and how you can do it using the Symfony translation package.

What is i18n and l10n?

Before embarking on the adventure of exploring the vast world of the Symfony translation package, it is important to understand the terms internationalization (i18n) and localization (l10n). Let us begin.

Internationalization is a process of designing and developing the software architecture, i.e., a product, adaptable to various cultures, regions and languages. The end result should be a product that can cover any number of languages with minimal effort on the engineering level.

Fun fact: the term i18n is coined from the number of letters between “i” and “n”.

Some of the processes internationalization covers:

  • Designing placeholders for translations, instead of hardcoded values per language,
  • Cultural formatting for: numbers, time zones, currencies, etc.,
  • Organizing source code.

Localization might be considered the younger brother of i18n. It is a process of adapting a given product to meet the cultural, regional and language needs of a specific target market. The target market is also known as the locale. Adaptation for the locale is done through adding resources and translating content.

Fun fact: just as its older brother, l10n is coined from the number of letters between “l” and “n”.

Some of the processes localization covers:

  • Localizing date and time differences,
  • Currency, accounting standards,
  • Culturally appropriate images, symbols and hand gestures.

Why should I translate my product?

This is a question worthy of a separate blog post, maybe even a series of blog posts. In order to follow the KISS principle, here are just a few strong points:

  • Targeting a larger audience is good for business expansion; it involves a certain additional cost, but has a long-term benefit
  • Improve SEO — translated content ranks better on locale specific search engines
  • Reality check: the world doesn’t speak just English (internet users by country) — a localized app allows the user to consume its content easier, making it more trustworthy in the long run

Enter: Symfony Translation

Symfony has been around for quite some time now, providing the world with i18n capabilities all the way back from version 2.7 (latest stable release is 6.0 at the time of writing).

The translation process is pretty simple and consists of 4 steps:

  1. Installation and configuration
  2. Abstraction of messages using the Translator component
  3. Creating translation resources/files per locale
  4. Determining, setting and managing the user’s locale

Installation and configuration

To prepare your translating field, install the symfony/translation package by running:

composer require symfony/translation

The installation process will create a translation.yaml file, where all the configuration is stored. After configuring the original translation.yaml, the file should look similar to this:

# config/packages/translation.yamlframework:    default_locale: '%default_locale%'    translator:        default_path: '%kernel.project_dir%/translations'        fallbacks:            - '%default_locale%'

The %default_locale% represents your default app supported locale defined in parameters.yaml. Symfony will look for translation files in the default path or Resources/translations of any bundle.

The locale is stored on the request, typically via the _locale attribute set on the route, as shown in the example below:

/*** @Route(*     {*         "en": "/undabots-roll-out",*         "de": "/undabots-rollen-aus"*     },*     name="undabots_roll_out"* )*/public function rollOut(): Response{    return new Response(...);}

In case your backend needs to provide the client with multiple translations on the same endpoint, it is necessary to create a kernel request listener where the locale can be determined before going through with the rest of the request.

The following example resolves the locale using the Accept-Language header to negotiate which translation to provide to the client.

final class RequestLocaleListener{    public function __construct(private string $defaultLocale)    {    }    public function onKernelRequest(RequestEvent $event): void    {        $request = $event->getRequest();        $language = AcceptHeader::fromString($request->headers->get('Accept-Language'))->first();        // if header passed extract the locale        if (null !== $language) {            [$locale] = explode('-', $language->getValue());        }
// fallback to default locale otherwise if (false === isset($locale)) { $locale = $this->defaultLocale; } $request->setLocale($locale); }}

Note: if you run bin/console debug:event kernel.request, you will notice Symfony has a listener for determining the locale from attributes (_locale). Your listener must be set with a higher priority to run first, as illustrated in the example below.

App\Infrastructure\Driven\EventListener\RequestLocaleListener:    arguments:        $defaultLocale: '%default_locale%'    tags:        - { name: kernel.event_listener, event: kernel.request, priority: 101 }

And that’s it! You’re ready to start internationalizing your application. Let’s explore how you can do that.

Supported formats and different approaches

All of the translations are being handled by the Translator class provided. As seen before, it is possible to translate whole URL paths. We will now cover how to translate the content of the application.

Supported formats

First of all, it is necessary to define placeholders for translations. These placeholders are defined in the translation path using the following format for naming files: domain.locale.loader.

Domain

Represents a way to organize messages into groups. The default domain is messages and is usually sufficient, unless there is a need for chunking messages.

Example:

  • Backend provides translations for a client web page and administration system or CMS
  • Client web page uses the domain front
  • CMS uses the domain admin

Locale

Covers the locale that the translations are for (e.g., en, de, hr, etc.)

Loader

Tells Symfony how to load and parse the file. Symfony supports a number of loaders where the most commonly used ones are: .yaml (YAML) and .xlf (XLIFF). It is recommended to use YAML for simple projects and use XLIFF where it’s necessary to cooperate with translators using specialized programs.

# translations/messages.en.yamlundabots:    roll_out: 'Undabots, roll out!'

Approaches

With translations defined in domains, locales and loaders, they can be used in code and/or templates through the Translator class with the trans method.

Use translations in code

To use the translations in code, inject the TranslatorInterface service using Dependency Injection. The trans method provided by the interface takes the following parameters:

  • key — the key of the message defined in resources
  • parameters — an array of parameters defined in the message under key, wrapped with % in the message definition
  • domain — group under which the key is stored
  • locale — locale determined from Request, can be forced, if necessary
# translations/messages.en.yamlmy_name_is: 'My name is %name%'
// My name is Slim Shady$translator->trans('my_name_is', ['name' => 'Slim Shady']);

Other than parameterized messages, the Translator also supports pluralization. Just pass in the count as a parameter and define the message boundaries for the count.

# translations/messages.en.yamli_am_counting_sheep: '{0} No sheep, no sleep.|{1} I counted 1 sheep. Still can’t sleep.|[2, Inf] I counted %count% sheep. Zzz'// I counted 5 sheep. Zzz$translator->trans('i_am_counting_sheep', ['count' => 5]);

The problem with pluralization might not be obvious in English, but other languages have various forms of words when pluralized and this would have to be resolved programmatically due to the limited support in message formatting. The problem can be resolved using the ICU message format described below.

Use translations in templates

Aside from translations in the application code, translations can also be used in Twig (the default rendering engine for Symfony) templates (e.g., email templates sent via backend). Symfony provides two ways of translating content in templates:

  • The first one is by using the Twig trans tag
{% trans with {'%name%': 'Slim Shady'} %}My name is %name%{% endtrans %}
  • The other, more commonly used approach, is by using the Twig trans filter
{{ 'my_name_is'|trans{'%name%': 'Slim Shady'} }}

ICU message format

Starting from Symfony 4.2, a new IntlFormatter class was introduced. Its goal: resolve the complexity behind pluralization and other variable translation use cases. It takes advantage of the ICU message format and the PHP intl extension, which is a wrapper around the International Components for Unicode library.

To use the ICU message format, all the translation resources need to have their domain appended with the +intl-icu suffix, e.g., messages.en.yaml becomes messages+intl-icu.en.yaml.

Pluralization issues are then resolved like so:

# messages+intl-icu.en.yamli_am_counting_sheep: >-    {results, plural,        =0 {No sheep, no sleep.}        =1 {I counted one sheep. Still can't sleep.}        other {I counted # sheep. Zzz}    }# messages+intl-icu.hr.yamli_am_counting_sheep: >-    {results, plural,        =0 {Nema ovaca, nema spavanja.}        =1 {Prebrojao sam jednu ovcu. Još uvijek nema sna.}        one {Prebrojao sam # ovcu. Zzz}        few {Prebrojao sam # ovce. Zzz}        other {Prebrojao sam # ovaca. Zzz}    }// No sheep, no sleep.$translator->trans('i_am_counting_sheep', ['results' => 0], null, 'en');// I counted one sheep. Still can’t sleep.$translator->trans('i_am_counting_sheep', ['results' => 1], null, 'en');// I counted 2 sheep. Zzz$translator->trans('i_am_counting_sheep', ['results' => 2], null, 'en');// I counted 5 sheep. Zzz$translator->trans('i_am_counting_sheep', ['results' => 5], null, 'en');// Nema ovaca, nema spavanja.$translator->trans('i_am_counting_sheep', ['results' => 0], null, 'hr');// Prebrojao sam jednu ovcu. Još uvijek nema sna.$translator->trans('i_am_counting_sheep', ['results' => 1], null, 'hr');// Prebrojao sam 2 ovce. Zzz$translator->trans('i_am_counting_sheep', ['results' => 2], null, 'hr');// Prebrojao sam 5 ovaca. Zzz$translator->trans('i_am_counting_sheep', ['results' => 5], null, 'hr');

Notice the difference in definition of pluralization rules for the hr locale. These rules can be examined on the language plural rules provided by the Unicode organization. Other than pluralization, gender and similar variables can be used to provide correct translations for various locales.

# messages+intl-icu.en.yamli_am_counting_sheep: >-    {gender, select,        other {{results, plural,            =0 {No sheep, no sleep.}            one {I counted # sheep. Still can't sleep.}            other {I counted # sheep. Zzz}        }}    }# messages+intl-icu.hr.yamli_am_counting_sheep: >-    {gender, select,        male {{results, plural,            =0 {Nema ovaca, nema spavanja.}            =1 {Prebrojao sam jednu ovcu. Još uvijek nema sna.}            one {Prebrojao sam # ovcu. Zzz}            few {Prebrojao sam # ovce. Zzz}            other {Prebrojao sam # ovaca. Zzz}        }}        female {{results, plural,            =0 {Nema ovaca, nema spavanja.}            =1 {Prebrojala sam jednu ovcu. Još uvijek nema sna.}            one {Prebrojala sam # ovcu. Zzz}                   few {Prebrojala sam # ovce. Zzz}            other {Prebrojala sam # ovaca. Zzz}        }}        other {{results, plural,                      =0 {Nema ovaca, nema spavanja.}            =1 {Prebrojana je jedna ovca. Još uvijek nema sna.}            one {Prebrojana je # ovca. Zzz}            few {Prebrojane su # ovce. Zzz}            other {Prebrojano je # ovaca. Zzz}        }}    }// I counted 5 sheep. Zzz$translator->trans(    'i_am_counting_sheep',    [        'gender' => (string) GenderEnum::MALE(),        'results' => 5,    ],    null,    'en',);// Prebrojao sam 5 ovaca. Zzz$translator->trans(    'i_am_counting_sheep',    [        'gender' => (string) GenderEnum::MALE(),        'results' => 5,    ],    null,    'hr',);// Prebrojala sam 5 ovaca. Zzz$translator->trans(    'i_am_counting_sheep',    [        'gender' => (string) GenderEnum::FEMALE(),        'results' => 5,    ],    null,    'hr',);// Prebrojano je 5 ovaca. Zzz$translator->trans(    'i_am_counting_sheep',    [        'gender' => null,        'results' => 5,    ],    null,    'hr',);

Other examples where the ICU message format comes in handy are numbers, currencies, percentages, date and time, etc.

# messages+intl-icu.en.yamlcurrent_date: 'Current date: {now, date, long}'duration: 'Duration: {duration, duration}'currency_usd: '{cost, number, ::currency/USD}'currency_hrk: '{cost, number, ::currency/HRK}'percentage: '{value, number, percent}'# messages+intl-icu.hr.yamlcurrent_date: 'Današnji datum: {now, date, long}'duration: 'Trajanje: {duration, duration}'currency_usd: '{cost, number, ::currency/USD}'currency_hrk: '{cost, number, ::currency/HRK}'percentage: '{value, number, percent}'
// Current date: February 16, 2022$translator->trans('current_date', ['now' => CarbonImmutable::now()], null, 'en');// Današnji datum: 16. veljače 2022.$translator->trans('current_date', ['now' => CarbonImmutable::now()], null, 'hr');// Duration: 10:00$translator->trans('duration', ['duration' => 600], null, 'en');// Trajanje: 600$translator->trans('duration', ['duration' => 600], null, 'hr');// $100.00$translator->trans('currency_usd', ['cost' => 100], null, 'en');// 100,00 USD$translator->trans('currency_usd', ['cost' => 100], null, 'hr');// HRK 100.00$translator->trans('currency_hrk', ['cost' => 100], null, 'en');// 100,00 HRK$translator->trans('currency_hrk', ['cost' => 100], null, 'hr');// 5,000%$translator->trans('percentage', ['value' => 50], null, 'en');// 5.000 %$translator->trans('percentage', ['value' => 50], null, 'hr');

Conclusion

Internationalization and localization can be hard to achieve, but they are worth every penny. By establishing these processes early on in your application, you will be able to represent your product appropriately for the user’s locale — allowing the user to identify with your product in their own language is a game changer.

By using Symfony, you’re already halfway there. The translation package offers support for a wide variety of formats, making it possible to include translators easily into the process, including pluralization and similar translation challenges. Additionally, the support for the ICU message format covers even the most complex translations and automates a lot of manual labor per locale.

To wrap this up, I highly recommend the teams to standardize the translation structure — you will reap the benefits in the long run. I believe you will have lots of fun translating your product with Symfony now that you’ve seen some of its superpowers.

--

--