Software localization

Symfony Internationalization: A Step-by-Step Guide

Discover the power of Symfony, one of the trendiest PHP frameworks, and unlock its potential to expand your app's global user base by implementing robust multilingual support.
Software localization blog category featured image | Phrase

Symfony is one of the oldest and most mature PHP Web Frameworks, with a solid user base and extensive documentation. Symfony also has first-class i18n (internationalization) support built right in. However, Symfony i18n can a bit daunting in the beginning. No worries, though. We’re here to help.

In this guide we will build a demo app and localize it using the official Symfony Translations module. Additionally, we will walk through translating your own data in the database layer using the Doctrine Behaviors bundle. By the end of this guide you will become a Symfony 6 i18n sensei. Let’s get started.

Bundles used in this guide

We’ll use the following bundles to build our demo (versions in parentheses):

  • symfony/translation (v6.2) — Adds support for Translation messages in Symfony.
  • annotations (v2.0) — Allows you to use PHP comment annotations in code when declaring routes and paths instead of instead of editing yaml files. We’ll use this for localized routing.
  • We will be using twig for view templates, and we need a couple of twig add-on bundles to handle i18n filters and functions:
    • twig (v3.0)
    • twig/string-extra (v3.5)
    • twig/intl-extra (v3.5)
  • symfony/webpack-encore-bundle (v1.16) — For adding Tailwind CSS to our project for styling the website.
  • knplabs/doctrine-behaviors (v2.6) — This is needed for database translations.

🔗 Resource » The code for this guide will be available on GitHub.

Our demo app

We are building an Indie Games blog that hosts reviews of popular Independent video games. Here is a sneak peak of the final design:

 

Indie game blog final design screen | Phrase

Using the Symfony-CLI, let’s create the site using the following command:

$ symfony new indiegamesblog --version="6.3.*@dev" --webappCode language: Bash (bash)

Setting up the blog

We’ll use Composer to install the base bundles that we need for now. We will be installing the rest as we go along:

$ composer require twig symfony/webpack-encore-bundle annotationsCode language: Bash (bash)

🗒️ Note » If you’re coding along with us, you might want to install and configure Tailwind CSS at this point. Again, this is only for styling, and it’s an optional step.

Creating our home page controller

Let’s create our home page. Create a HomeController.php file inside the /src/Controller folder, and add the following code to it.

<?php

// src/Controller/HomeController.php

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class HomeController extends AbstractController
{
    #[Route('/', name: 'home')]
    public function index(): Response
    {         
        $messages = [
            'title' => 'Welcome to Indie Games Blog',
						'subtitle' => 'Honest Indie game reviews since 2010',
            'heading' => 'Recent Game Reviews',
            'visitors' => 'There are 156 visitors online',
        ];
        

        return $this->render('home.html.twig', [
            'messages' => $messages,
        ]);
    }
}Code language: PHP (php)

We will also need a home template to render the page. You can create one inside the /templates folder. We will hard-code some mock game reviews until we get the database sorted a bit later.

{# templates/home.html.twig #}

{% extends "base.html.twig" %}

{% block body %}
<div class="container mx-sm-auto">
  <section>
    <div>
      <h1>{{ messages['title'] }}</h1>
      <p>{{ messages['subtitle'] }}</p>
    </div>
  </section>
  <h2>{{ messages['heading'] }}</h2>
  <ul>
    <li>
      <div>
        <div>
          <div
            style="background-image: url('https://media.indiedb.com/images/members/5/4686/4685285/profile/Logo.png'); background-position: center center; background-blend-mode: multiply; background-size: cover;"></div>
          <div>
            <div>
              <span>Strategy</span>
            </div>

            <h2>Candy Shop Rush</h2>

            <p>
              A candy shop assistant simulation game, where your goal 
              is to fulfill all the customers' requests as quickly as
              possible!
            </p>
            <a rel="noopener noreferrer" href="#">
            <span>Read more</span>
            </a>
            <div>
              <div>
                <span>by Theo Despoudis</span>
              </div>
              <span>3 min read</span>
            </div>
          </div>
        </div>
      </div>
    </li>
  </ul>
</div>
{% endblock %}

{% block footer %}
    <ul>
        <li>{{ messages['visitors'] |trans }}</li>
    </ul>
{% endblock %}Code language: HTML, XML (xml)

🗒️ Note » We’ve removed most CSS styles from our code listings for brevity. Get the entire demo code, including CSS, from our GitHub repo.

Let’s also modify the base.html.twig to include header and footer sections:

{# templates/base.html.twig #}

 <body>
   {% include "Layout/header.html.twig" %}
   <main>
     {% block body %}{% endblock %}
   </main>
   <footer>
     {% block footer %}{% endblock %}
   </footer>
 </body>Code language: HTML, XML (xml)

Our header is just a simple nav bar for now:

{# templates/Layout/header.html.twig #}
<header class="bg-black">
  <nav aria-label="Global">
    <div>
      <a href="#">Games</a>
      <a href="#">Reviews</a>
      <a href="#">About</a>
    </div>
    <div >
      <a href="#" href="{{ path('home') }}">
        <span class="sr-only">Indie Games Blog</span>
        <img src="{{ asset('build/images/console-controller.png') }}" alt="...">
      </a>
    </div>
  </nav>
</header>Code language: HTML, XML (xml)

You should now be able to start the server and see the page rendering as expected:

Indie game blog final design screen | Phrase

Now let’s start setting up our locales and translations.

 

How do I set up the Symfony Translations bundle?

First, we install the bundle via composer:

$ composer require symfony/translationCode language: PHP (php)

Configuration: Active locale

The symfony/translations bundle allows us to specify the active locale on load: when a visitor first navigates to a page, they’ll see the page translated in this language. This is configured in config/packages/translation.yml.

# config/packages/translation.yaml

framework:
    default_locale: en
    translator:
        default_path: '%kernel.project_dir%/translations'Code language: Bash (bash)

The default_path parameter here is used to configure the location of the translation messages. The framework will expand the variable %kernel.project_dir% (which is defined as the root project path) and look inside the /translations path for any defined translations. You can also specify multiple paths to look for using a paths field that accepts a list of path values.

 

How do I work with basic translations?

Now that we have the locales configured, we can start marking our strings for translation. We use the translator service to wrap all the strings with the trans method.

// src/Controller/HomeController.php

// 👇 This service is used to make the strings discoverable 
//    and translatable inside templates.
use Symfony\Contracts\Translation\TranslatorInterface;

// ...

// We inject the service interface as a parameter 👇
public function index(TranslatorInterface $translator)
{
    $messages = [
        'title' => $translator->trans('Welcome to Indie Games Blog'),
        'subtitle' => $translator->trans('Honest Indie game reviews since 2010'),
        // ...
    ];

    return $this->render('home.html.twig', [
        'messages' => $messages,
    ]);
}Code language: PHP (php)

We then add the twig trans filter after each message string. Let’s update top section of the blog page as an example:

{# /templates/home.html.twig #}

<section>
    <div>
		    <h1>{{ messages['title'] | trans }}</h1>
		    <p>{{ messages['subtitle'] |trans }}</p>
    </div>
</section>Code language: HTML, XML (xml)

You shouldn’t see any difference in the site now, but underneath the hood we’ve just internationalized the blog’s front-end. We can provide a translation file (with messages) for each locale, and see these translations at runtime. We’ll do this shortly.

First, let’s see the different ways to use the TranslatorInterface we just saw.

How do I use the TranslatorInterface?

Symfony’s TranslatorInteface gives us a few handy ways to define translation strings, and allows to force a locale for a given message.

Using keywords

We can use any kinds of string as message keys. For best practices it’s better if you use dot (.) separated strings.

// In the controller:
[
    'title' => $translator->trans('home.title'),
    'heading' => $translator->trans('home.heading'),
]Code language: PHP (php)

The dot (.) syntax allows you to declare messages with nested IDs to avoid repeating yourself:

# In the translation message file:
home: 
	title: Welcome to Indie Games Blog
  heading: Honest Indie game reviews since 2010Code language: YAML (yaml)

🗒️ Note » We’ll look at translation message files in more detail in the next section.

Using variables

You can construct a string that mixes string literals and variables:

// In the controller:
$visitorsCount = 12382;

[
    'visitors' => $translator->trans($visitorsCount . ' people online'),
]Code language: PHP (php)

When using concatenated strings like the example above, the translator will only pick up the part of the string without the variable and use it as a key. It will then prepend a double underscore (_ _) in the value part to signify that this message needs a proper translation.

# In the translation message file:
' people online': '__ people online'Code language: JavaScript (javascript)

✋ Heads up » Defining translations messages with concatenated strings like this is not recommended. Symfony can’t detect these messages in code properly, so it will be harder to translate properly. Also, languages don’t all share the same grammar; a variable like $visitorCount isn’t always placed in the same position in a translation. So hard-coded interpolation is just bad practice.

// In the controller
[
    'visitors' => $translator->trans(
        'There are %visitors% visitors online', ['%visitors%' => 156]),
]Code language: PHP (php)
# In the translation message file:
'There are %visitors% visitors online': 'There are %visitors% visitors online'Code language: YAML (yaml)

🗒️ Note » We cover interpolation in detail after looking at ICU messages later.

Forcing a locale

You can specify a locale directly as the second parameter to trans. This is useful if you want to pin a specific translation for a string but have the rest of the messages render in the current locale:

[
    'heading' => $translator->trans(
        'Welcome to Indie Games Blog', locale: 'de');
]Code language: PHP (php)

With this newfound knowledge, let’s start translating our site to German.

How do I work with translation files?

Symfony provides two formats to setup translation files: normal messages and ICU messages. We’ll deal with ICU messages a bit later. Let’s look at normal messages for now.

Extracting translations from code

You can use the following CLI commands to generate translation message files:

$ php bin/console translation:extract --force en
$ php bin/console translation:extract --force deCode language: Bash (bash)

This will detect all the usages of the TranslatorInterface in your code and place them into the associated files:

  • /translations/messages.en.yml
  • /translations/messages.de.yml

The files will contain key-value pairs, where both key and value contain the string we passed to trans(). For example, we have code like this in our home controller:

$translator->trans('Indie Games Blog');Code language: PHP (php)

When you run the translation:extract command, it will detect the trans() call in our code, generating this liine in each of our translation files:

'Indie Games Blog': 'Indie Games Blog'Code language: YAML (yaml)

Variables are surrounded by percentage symbols (%).

'app.visitors': 'There are %visitors% visitors online'Code language: JavaScript (javascript)

To create such a message string, you need to add a translation key and then edit the message in the file with the appropriate translation:

$translator->trans('app.visitors');Code language: Bash (bash)
/translations/messages.en.yml
'app.visitors': '__app.visitors'

'app.visitors': 'There are %visitors% visitors online'Code language: YAML (yaml)

🔗 Resource » The Symfony translation bundle covers many formats outside of YAML, including CSV and JSON. See the entire list of supported translation file formats in the official docs.

Go ahead and provide all the required translation strings for each locale.

# /translations/messages.en.yml

brand_name: 'Indie Games Blog'
home.title: 'Welcome to Indie Games Blog'
# ...
app.visitors: 'There are %visitors% visitors online'
# ...Code language: YAML (yaml)
# /translations/messages.de.yml

brand_name: 'Indie Games Blog'
home.title: 'Willkommen beim Indie-Games-Blog'
# ...
app.visitors: 'Es sind %visitors% Besucher online'
# ...Code language: YAML (yaml)

Now let’s check our German translations by setting up the default locale as de:

# config/packages/translation.yaml

framework:
    default_locale: de
    translator:
        default_path: '%kernel.project_dir%/translations'Code language: PHP (php)

Now try to load the site and navigate to the following path

localhost:8000

You should be able to see the translated to German:

Site in Deutsch | Phrase


{# Simple example #} 
{% trans %}Welcome to Indie Games Blog{% endtrans %}

{# Example with variables #} 
{% trans with {'%visitors%': 156} %}There are %visitors% visitors online{% endtrans %}Code language: HTML, XML (xml)

You can also force the locale for a given message:

{# Force German Locale #} 
{% trans with {'%visitors%': 156} into 'de' %}There are %visitors% visitors online{% endtrans %}Code language: HTML, XML (xml)

✋ Heads up » Using the Twig {% trans %} is more verbose and does not escape the variables in templates by default. You need to manually escape the translated output using the escape filter otherwise this can expose your visitors to XSS (Cross-site Scripting) attacks.

🔗 Resource » You can find all information about escaping rules and filters in the relevant Twig documentation.

Manually escaping html content works by using the raw filter. This is useful when your translation message contains HTML tags:

{# /templates/home.html.twig #}

{% set copyright = '<p>©2023 Build in Planet Earth</p>' %}
...

{% block footer %}
    <ul>
        <li>{{ messages['visitors'] |trans }}</li>
        <li>{{ copyright|trans|raw }}</li>
    </ul>
{% endblock %}Code language: HTML, XML (xml)

✋ Heads up » Again, be careful with the raw filter: Make sure that you trust the HTML in the incoming translation message or you expose your visitors to XSS attacks.

How do I work with ICU messages?

ICU (International Components for Unicode) is a set of widely used message formats in translation software and i18n libraries. It provides services and standards to format localized messages. This includes basic messages, pluralization, numbers, dates, and much, much more.

🤿 Go Deeper » Our Missing Guide to the ICU Message Format covers the ins and outs.

To include ICU MessageFormat translations in our blog we need to make a couple of updates to our project. First you need to change the file extension of the message files to have a +intl-icu suffix:

  • messages.en.yamlmessages+intl-icu.en.yaml
  • messages.de.yamlmessages+intl-icu.de.yaml

Then you need to change all the variables to be surrounded by brackets ({) instead of percentage symbols (%):

There are %visitors% visitors onlineThere are {visitors} visitors online

🗒️ Note » Once you have configured the filenames to use ICU, the translation:extract command will automatically extract string into ICU messages.

That’s about it for refactoring. Now let’s explore the power ICU provides.

How do I add dynamic values to translation messages?

With ICU, we can inject dynamic values at runtime using the {variable} syntax in our message strings. We will have to provide a matching value using the second parameter to trans():

Let’s define these messages in English:

# /translations/messages+intl-icu.en.yaml

'Hello {username}': 'Hello {username}'
app.visitors: There are {visitors} visitors online.Code language: YAML (yaml)

And in German:

# /translations/messages+intl-icu.de.yaml

'Hello {username}': 'Hallo {username}'
app.visitors: Es sind {visitors} Besucher online.Code language: YAML (yaml)

Then, using the translator interface, pass the variables as a key-value parameters.

$translator->trans('Hello {username}', ['username' => 'Alex']); 
$translator->trans('app.visitors', ['visitors' => 156]);Code language: PHP (php)

Then the output in English will be:

// en
Hello Alex
There are 156 visitors onlineCode language: plaintext (plaintext)

And German:

// de
Hallo Alex
Es sind 156 Besucher online.Code language: JavaScript (javascript)

You can also use dynamic values in twig templates. You can’t use the trans tag, however. You will need to use the full form of the interpolated message with the brackets:

<li>{{ messages['visitors'] | trans({'{visitors}': 100}) }}</li> Code language: PHP (php)

✋ Heads up » If you use variable interpolations in both twig templates and in the TranslatorInterface the latter will always override the former whether you use ICU or normal message formats.

How do I work with plural messages?

ICU is excellent at handling plurals. Let’s add a German plural message to show number of visitors in our site:

# /translations/messages+intl-icu.de.yaml

app.visitors: >-
    {visitors, plural,
        =0 {Es sind keine anderen Besucher hier.}
        one {Sie sind der einzige Besucher hier.}
        other {Hier sind # Besucher hier.}
    }Code language: YAML (yaml)

Notice that we define the count variable, visitors, followed by the plural directive. We then specify each of our locale’s plural forms. German has two: one and other. It’s like having a switch statement to match visitors with use cases.

We can also use =<number> lines to provide a message for explicit counts. We’re using =0 to define a message when visitors has a value of zero. This will override the regular other form in this case.

✋ Heads up » The other plural form is required. If you do not provide one, you will get an error like the following:

Symfony error message | Phrase

🔗 Resource » You can find all plural form keywords, like one and other, for your supported locales in the official Unicode CLDR Language Plural Rules chart.

With the message above, we get a different plural form output depending on the value of visitors:


// Assuming German (de) is the active locale:

$translator->trans('app.visitors', ['visitors' => 0]);
// => Es sind keine anderen Besucher hier.

$translator->trans('app.visitors', ['visitors' => 1]);
// => Sie sind der einzige Besucher hier.

$translator->trans('app.visitors', ['visitors' => 100]);
// => Hier sind 100 Besucher hier.Code language: PHP (php)

You can also have variables within plural forms:

# /translations/messages+intl-icu.de.yaml

app.visitors: >-
    {visitors, plural,
        =0 {Hallo {username}. Es sind keine anderen Besucher hier.}
        one {Hallo {username}. Sie sind der einzige Besucher hier.}
        other {Hallo {username}. Hier sind # Besucher.}
    }Code language: YAML (yaml)
// Assuming German (de) is the active locale:

$translator->trans('app.visitors', ['visitors' => 0, 'username' => 'Theo']);
// => Hallo Theo. Es sind keine anderen Besucher hier.

$translator->trans('app.visitors', ['visitors' => 1, 'username' => 'Theo']);
// => Hallo Theo. Sie sind der einzige Besucher hier.

$translator->trans('app.visitors', ['visitors' => 100, 'username' => 'Theo']);
// => Hallo Theo. Hier sind 100 Besucher hier.Code language: PHP (php)

🗒️ Note » In addition to the plural directive there is a select directive. select is suitable for gender rules and any other selection based on non-numeric choices. We cover this in detail in our ICU guide.One visitor in German screenshot | Phrase

Multiple visitors in German screenshot | Phrase

Complex plurals

German and English each have two plural forms, one (”We’ve had 1 visitor”) and other (”We’ve had 12 visitors”). Other languages have more forms. Russian has four, Arabic six. The good news is that ICU messages can handle complex plural forms with ease. Let’s look at an example in Arabic:

 app.visitors: >-
    {visitors, plural,
        zero {لم يزرنا أحد}
        one {زارنا شخص واحد}
        two {زارنا شخصان}
        few {زارنا # أشخاص}
        many {زارنا # شخصًا}
        other {زارنا # شخص}
    }Code language: YAML (yaml)
$translator->trans('app.visitors', ['visitors' => 0]);
// => لم يزرنا أحد

$translator->trans('app.visitors', ['visitors' => 1]);
// => زارنا شخص واحد

$translator->trans('app.visitors', ['visitors' => 2]);
// => زارنا شخصان

// The `few` form in Arabic can include 3-10
$translator->trans('app.visitors', ['visitors' => 5]);
// => زارنا 5 أشخاص

// The `many` form in Arabic can include 11-26
$translator->trans('app.visitors', ['visitors' => 13]);
// => زارنا 13 شخصًا

// The `other` form in Arabic can include 100-102
$translator->trans('app.visitors', ['visitors' => 101]);
// => زارنا 101 شخصCode language: PHP (php)

How do I localize dates?

The ICU message format can localize dates using a variety of options:

# /translations/messages+intl-icu.en.yaml

today: 'Today is: {today, date, long}'Code language: YAML (yaml)
# /translations/messages+intl-icu.de.yaml

today: 'Heute ist: {today, date, long}'Code language: YAML (yaml)

The format for rendering date format for a country is defined within brackets. The today parameter specifies the name, the date specifies that it is a date type and the long specifies that it should be printed in full form: January 12, 1952 3:30:32pm.

// In our controllers:

// Assuming German is active
$translator->trans('today', ['today' => new \DateTime('2023-01-25 11:00:00')]);
// => Heute ist: 25. Januar 2023Code language: PHP (php)

Twig also comes with special filters that format localized dates. To make full use of them you need to install some extra packages first:

$ composer require twig/intl-extra twig/string-extraCode language: Bash (bash)
// Assuming the active locale is German

{{ '2023-01-25 11:00:00'| format_datetime('none', 'short') }}
{# => 11:00 #}

{{ '2023-01-25 11:00:00'| format_datetime('full', 'full') }}
{# => Mittwoch, 25. Januar 2023 um 11:00:00 Koordinierte Weltzeit #}

{{ '2023-01-25 11:00:00'| format_datetime(pattern="hh 'oclock' a, zzzz") }}
{# => 11 oclock AM, Koordinierte Weltzeit #}Code language: HTML, XML (xml)

The format_datetime accepts different kinds of parameters. The first one is the format of the date part and the second one is the format of the time part. Each of the parameters can be none, short, full or a custom dateFormat string. The last example showcases the usage of a custom pattern parameter for the whole datetime string instead of its date and time parts.

🔗 Resource »  Learn more about the configuration parameters of format_datetime in the official docs.

How do I localize numbers?

Localized numbers and currency formatting is performed via the number directive in ICU:

# /translations/messages+intl-icu.en.yaml

game.price: 'Price: {price, number, ::currency/USD}'Code language: YAML (yaml)
# /translations/messages+intl-icu.de.yaml

game.price: 'Preis: {price, number, ::currency/EUR}'Code language: YAML (yaml)
// English active:
$translator->trans('game.price', ['price' => 50]);
// => Price: $50.00

// German active:
$translator->trans('game.price', ['price' => 60]);
// => Preis: 60,00 €Code language: PHP (php)

The syntax for this is similar to plurals. You use brackets to declare a formatted number parameter. In this case the price is the parameter name, the number declares that it should be formated as a number and the ::currency/USD tag declares that is should be formated as a USD currency.

Using Twig templates, you can use format_number filter that allows formating of numbers. By default, the filter uses the current locale if you do not provide a locale parameter:


{{ '600'|format_duration_number(locale="en") }}
{# => 10:00 #}

{{ '60'|format_spellout_number(locale="de") }}
{# => sechzig #}

{{ '80'|format_percentage_number(locale="de") }}
{# => achzig #}

{{ '100'|format_percent_number(locale="de") }}
{# => 10.000 #}Code language: HTML, XML (xml)

🔗 Resource » Learn more about the different configuration parameters of format_number in the official docs.

For currency, Twig offers a special filter, format_currency:

{{ '50'|format_currency('EUR', locale='de') }
{# => 50,00 € #}Code language: HTML, XML (xml)

🔗 Resource » Learn more about the configuration parameters of format_currency in the official docs.

How do I get or set the active locale?

We can get or set the locale inside a controller or a service by injecting and using Symfony’s LocaleSwitcher:

// src/Controller/HomeController.php

use Symfony\Component\Translation\LocaleSwitcher;

class HomeController extends AbstractController
{
    #[Route('/', name: 'home')]
    public function index(
        TranslatorInterface $translator,
        LocaleSwitcher $localeSwitcher): Response
    {   
        // Set the active locale to German
        $localeSwitcher->setLocale('de'); 

        // Retrieve the active locale
        $localeSwitcher->getLocale(); // => 'de'
		}
}Code language: PHP (php)

How do I configure fallback locales?

Fallback locales are a list of locales that the server will try to use if a requested locale is not supported, or if translations in the active locale are missing. Let’s add fallbacks in our config file:

# config/packages/translation.yaml

framework:
    default_locale: en
    translator:
        default_path: '%kernel.project_dir%/translations'
        fallbacks: 
            - en
            - deCode language: YAML (yaml)

Here we added two lines for fallbacks corresponding to the English (en) and German (de) language tags. Let’s explore how this works in action.

Say we removed the home.subtitle message from the German translations. You will see the following screen when you visit the Home page with active locale as German:

Homepage screenshot in German | Phrase

Here the home.subtitle will fallback to the English translation since it’s the one available but the rest of the messages will translate fine.

Symfony’s translation bundle will also automatically resolve locale fallback based on the specificity of the requested locale. Say we’re detecting the locale from the visitor’s browser (more on that later). Let’s assume our visitor has set her preferred browser locale to German (Germany) ie. de-DE. With the above configuration, Symfony will fall back to the parent locale of de-DE, which is de.

🗒️ Note » At the end of the fallback cascade, if Symfony can’t find a suitable locale, it always falls back to our default locale (en in our case).

🔗 Resource » We’re specifying our locales using their ISO 639-1 codes (en, de). You can find a list of all these codes on Wikipedia.

How do I support right-to-left languages?

It’s important to remember to support right-to-left (RTL) languages like Arabic and Hebrew. We can leverage browser features to make a web page render RTL content. The first step is to add dir="rtl" to the <html> tag for RTL locales. We can modify the base.html.twig template to add this value whenever we switch to that locale:

// templates/base.html.twig

{% set locale = app.request.attributes.get('_locale') %}

<html dir="{% if locale == 'ar' %}rtl{% else %}ltr{% endif %}" lang="{{locale}}">Code language: PHP (php)

We get the active locale through app.request.attributes.get('_locale'). The code above will product <html dir='rtl'> when the active locale is Arabic. For every other locale, we’ll get <html dir='ltr'>.

For brevity, we won’t go through the steps of adding Arabic to our app here. Feel free to follow the previous configuration, extraction, and translation steps to do so. Assuming all that’s done, setting our default_locale to ar would yield the home page rendered right-to-left:

Homepage screenshot in Arabic | Phrase

🤿 Go Deeper » The above if statement inside the twig template does not scale well. If you have multiple RTL languages in your app, you might want to create a custom Twig extension that encapsulates your conditional logic.

How do I create localized routes?

Until now we’ve been viewing a particular locale by setting our default_locale setting to that language tag.

We would like visitors to be able to navigate to a page and automatically detect the user locale based on their browser language preferences or the current url parameters.

We have a variety of options to customise the routing behaviour based on the selected locale. We take a deeper look at each one of them.

First, let’s look at basic localized routing in Symfony.

What is a localized route?

A localized route is a route that defines a different URL per each translation locale. For example we can associate a route with a particular locale:

# config/routes.yaml

about:
    path:
        en: /about
        de: /Über-unsCode language: YAML (yaml)

You can generate a localized route in twig using the path function passing the _locale as a parameter:

{{ path('about', {_locale: 'en'}) }} {# /about #}
{{ path('about', {_locale: 'de'}) }} {# /Über-uns #}Code language: HTML, XML (xml)

Using a locale prefix

A common way to generate localized routes is to prefix each route with a locale code. We can use the special _locale parameter in our route annotations to accomplish this. During runtime, the framework will automatically set the active locale to the value of _locale:

class ReviewsController extends AbstractController
{
    #[Route('/{_locale}/reviews')]
    public function index(TranslatorInterface $translator): Response
    {
        // ...
    }
}Code language: PHP (php)

For example:

http://localhost:8000/en/reviews → switches locale to English. http://localhost:8000/de/reviews → switches locale to German.

🗒️ Note » It is recommended to follow a consistent scheme for locale paths so that the users can bookmark links for future navigation. Having for example the _locale parameter in a different place, e.g. /blog/{_locale}/, is possible but not consistent.

Using a different path per locale

You can provide an array of paths when using the Route annotation, allowing you to set a different path for each locale, all resolving to the same controller action:

// src/Controller/ReviewsController.php
class ReviewsController extends AbstractController
{
    #[Route([
        'en' => '/reviews',
        'de' => '/rezensionen'
    ], name: 'reviews')]
    public function index(TranslatorInterface $translator): Response
    {
        // ...
    }
}Code language: PHP (php)

Here if the active locale is German, the navigation will point to /de/rezensionen. If it’s English, it will point to /en/reviews.

🗒️ Note » You might be wondering how the active locale is determined here. We’ll tackle this in the locale determination section a bit later.

If you want to leave the default locale out of the url, just use an empty string:

// src/Controller/ReviewsController.php

class ReviewsController extends AbstractController
{
    #[Route([
        '' => '/reviews',
        'de' => '/rezensionen'
    ], name: 'reviews')]
    public function index(TranslatorInterface $translator): Response
    {
        // ...
    }
}Code language: PHP (php)

Here the path to /reviews will use the default locale (English) and the path to de will use the German locale.

Forcing a locale in a URL

You can generate url for a fixed locale by passing along the _locale parameter:

$this->generateUrl('reviews', ['_locale' => 'de']);
// => /rezensionenCode language: PHP (php)

In twig templates, when you use the path function, it will automatically pick up the active locale from app.request:

{# Assuming the active locale is German: #}

<a href="{{ path('reviews') }}">{{ 'Reviews' | trans }}</a>
{# => <a href="/rezensionen">Rezensionen</a> #}Code language: PHP (php)

Forcing a localized home route

We can create a redirect route that ensures that navigating to / always redirects to /en:

// src/Controller/HomeController.php

class HomeController extends AbstractController {

    // ...

    #[Route('/')]
    public function indexNoLocale(): Response
    {
        return $this->redirectToRoute('home', ['_locale' => 'en']);
    }
}Code language: PHP (php)

🤿 Go deeper » There’s a lot more you can do with localized routes, including limiting the locales that a given route supports.

How do I automatically determine the user’s locale?

We can instruct the Symfony framework to choose the active locale based on the user’s browser language preferences and our app’s supported locales. With each request, the browser will provide an Accept-Language header, matching a weighted set of locale user preferences. Symfony will use the header and try to match the most suitable locale.

Symfony includes this feature by default. You just need to enable it by adding the following lines in your framework.yaml config:

# config/packages/framework.yaml

framework:
    set_locale_from_accept_language: true
    set_content_language_from_locale: trueCode language: YAML (yaml)

✋ Heads up » If using the Language negotiation feature, make sure that you do not annotate the routes with the {_locale} parameter. The {_locale} parameter will always override an Accept-Language header. If you want to support both features you will need to implement a custom Event Listener.

When set_locale_from_accept_language is true the request locale is automatically set based on the Accept-Language value and based on the enabled_locales list.

The set_content_language_from_locale sets the value of the Content-Language HTTP response header based on the request locale. You should have both set_locale_from_accept_language and set_content_language_from_locale options enabled if using this feature so that the framework will send the correct headers to clients.

To test automatic locale detection, you need to configure your browser’s preferred language list. Make sure the language you want your app to resolve to is at the top of the list. Here is my Chrome configuration, for example, which can be found at Settings → Languages:

Chrome configuration screenshot found at settings -> languages | Phrase

Then you need a route with no {_locale} parameter to allow Symfony to automatically resolve the locale:

// src/Controller/HomeController.php

class HomeController extends AbstractController
{
    #[Route('/', name: 'home')]
    public function index(TranslatorInterface $translator): Response
    {
        // ...
    }
}Code language: PHP (php)

When you navigate to /, Symfony will render the page in German, since the locale is preferred by the user per the Accept-Language header:

Homepage screenshot rendered in German | Phrase

How do I create a language switcher?

We often want a way for users to manually switch the active locale. This is surprisingly easy to do in Symfony. Adding the following HTML code inside the header.html.twig will show the current language and a list of available locales for the user to choose from:

// templates/Layout/header.html.twig

<!-- ... -->

    <div>
      <div class="relative group">
        <div data-bs-toggle="dropdown" aria-expanded="false">
						<img 
               src="https://flagcdn.com/{{app.request.locale == 'en' ? 'gb' : app.request.locale}}.svg">
             </img>
				</div>
        
      <div>
        <a href="{{ path('home', {_locale: 'en'}) }}">
				  <img
            src="https://flagcdn.com/gb.svg"
            alt="Flag {{ 'locale.name.en'|trans(domain = 'locale') }}"
          >
					{{ 'English' | trans }}
        </a>
        <a href="{{ path('home', {_locale: 'de'}) }}">
				  <img 
            src="https://flagcdn.com/de.svg"
            alt="Flag {{ 'locale.name.de'|trans(domain = 'locale') }}"
          >
					{{ 'German' | trans }}
        </a>
      </div>
    </div>

<!-- ... -->Code language: PHP (php)

Language drop down screenshot | Phrase

That’s it! Now our visitors can use the drop-down to select the language of their choice.

How do I localize my data?

The last important step left in our blog translations is the actual reviews content. We would like to have an Admin or Publisher write game reviews using a dashboard. Of course, we’ll want to store this content in a database. So how do we allow for different translations of this content?

Let’s quickly setup a database . You will need to spin up a PostgreSQL server so that you can apply database migrations on it. Luckily for us, the project folder has a docker-compose.yaml which we can run with Docker Compose:

$ docker-compose up -dCode language: Bash (bash)

This will create a PostgreSQL container with the following configuration:

  • host: localhost
  • port: 5432
  • database: app
  • password: !ChangeMe!

Next use the make:entity console command to generate some boilerplate code for the Review entity:

$ symfony console make:entity

Class name of the entity to create or update (e.g. GrumpyElephant):

> Review
> 

created: src/Entity/Review.php
created: src/Repository/ReviewRepository.phpCode language: YAML (yaml)

This will create the following Review and ReviewRepository files:

// src/Entity/Review.php

<?php

namespace App\Entity;

use App\Repository\ReviewRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ReviewRepository::class)]
class Review
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    public function getId(): ?int
    {
        return $this->id;
    }Code language: PHP (php)
// src/Repository/ReviewRepository.php
<?php

namespace App\Repository;

use App\Entity\Review;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Review>
 *
 * @method Review|null find($id, $lockMode = null, $lockVersion = null)
 * @method Review|null findOneBy(array $criteria, array $orderBy = null)
 * @method Review[]    findAll()
 * @method Review[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class ReviewRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Review::class);
    }

    public function save(Review $entity, bool $flush = false): void
    {
        $this->getEntityManager()->persist($entity);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    public function remove(Review $entity, bool $flush = false): void
    {
        $this->getEntityManager()->remove($entity);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }Code language: PHP (php)

At this point you can add extra fields in the Review Entity. We want to have two extra fields for updated and created datetime events so go ahead and add those now:

// src/Entity/Review.php

<?php 
   
  // inside class Review
	#[ORM\Column(type: 'datetime')]
	protected $created;

	#[ORM\Column(type: 'datetime', nullable: true)]
	protected $updated;

  #[ORM\PrePersist]
  public function onPrePersist()
  {
      $this->created = new \DateTime("now");
  }
    
  #[ORM\PreUpdate]
  public function onPreUpdate()
  {
      $this->updated = new \DateTime("now");
  }
}//end class ReviewCode language: PHP (php)

Next we’ll use a well known bundle called DoctrineBehaviors from KnpLabs that adds a few interesting traits to our entities; one of these is the TranslatableTrait. This trait allows our models to create easily associate translations with an entity. Let’s see how we can use it for our example project.

First things first, you need to install it:

$ composer require knplabs/doctrine-behaviorsCode language: Bash (bash)

🔗 Resource » Review the different options to configure the Translatable trait in the docs.

Next, we need to make the Review entity implement Doctrine TranslatableInterface and use the TranslatableTrait from the bundle:

// src/Entity/Review.php

<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface;
use Knp\DoctrineBehaviors\Model\Translatable\TranslatableTrait;

use App\Repository\ReviewRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: Review::class)]
class Review implements TranslatableInterface
{
    use TranslatableTrait;
     
    // ...
}Code language: PHP (php)

Then you will need to create another Entity that provides the Translatable fields. This entity should implement the TranslationInterface and use the TranslationTrait (⚠️ not TranslatableTrait). Follow the same process by using the make:entity command to generate the boilerplate files for you:

$ symfony console make:entity

Class name of the entity to create or update (e.g. GrumpyElephant):

> ReviewTranslation
> 

created: src/Entity/ReviewTranslation.php
created: src/Repository/ReviewTranslationRepository.phpCode language: PHP (php)

Delete the ReviewTranslationRepository.php since we are not going to use it.

$ rm src/Repository/ReviewTranslationRepository.phpCode language: PHP (php)

Then add the TranslationInterface and the translatable fields in the ReviewTranslation entity. We need two fields: title of type string and content of type text.

This should look like this:

// src/Entity/ReviewTranslation.php

<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface;
use Knp\DoctrineBehaviors\Model\Translatable\TranslationTrait;

/**
 * @ORM\Entity
 */
class ReviewTranslation implements TranslationInterface
{
    use TranslationTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    protected $id;

		#[ORM\Column(type: 'string', unique: true, length: 255)]
    protected $title;

    #[ORM\Column(type: 'text')]
    protected ?string $content = null;
    public function getTitle()
    {
        return $this->title;
    }
    public function setTitle($title)
    {
        $this->title = $title;
    }
    public function getContent(): ?string
    {
        return $this->content;
    }

    public function setContent(string $content)
    {
        $this->content = $content;
    }
}Code language: PHP (php)

Note that by default there is no real association between the ReviewTranslation and Review entities. Instead the bundle relies on merging new translations to the specified model by using the mergeNewTranslations method on that model. When we add a locale translation to the Review entity we need to subsequently call mergeNewTranslations so that it will create entries in the ReviewTranslation with the primary key of Review.

Run the following from the command line to create database tables:

$ php bin/console doctrine:database:createCode language: Bash (bash)

Then run the following command to create all the necessary migrations for the table schemas.

$ bin/console make:migrationCode language: JavaScript (javascript)

Now run the migrations to apply them in the database.

$ php bin/console doctrine:migrations:migrateCode language: JavaScript (javascript)

Check your database. You should be able to see two tables created, one for the Review and one for the ReviewTranslation:

CREATE TABLE review
(
    id integer NOT NULL,
    created timestamp(0) without time zone NOT NULL,
    updated timestamp(0) without time zone NULL
		price integer NULL,
);

CREATE TABLE review_translation
(
    id integer NOT NULL,
    translatable_id integer NULL,
    title character varying(255) NOT NULL,
    content text NOT NULL,
    locale character varying(5) NOT NULL
);Code language: SQL (Structured Query Language) (sql)

Updating translatable models

When creating or updating the Review entity, you need to use the translate method provided by DoctrineBehavior’s TranslatableInterface. Here is an example interaction for saving a model in the database in a Controller form Action handler:

<?php

// src/Controller/HomeController.php

namespace App\Controller;

// Import declarations

class HomeController extends AbstractController
{

		#[Route('/review/new']
    public function createReview(Request $request): Response
    {
        $review = new Review();
        // Code to create a form
				$form = $this->createForm(new ReviewType(), $review);
        $form->handleRequest($request);

        if ($form->isValid())
        {
						$data = $form->getData();
            $em = $this->getDoctrine()->getManager();
								
					  // Translate content based on specified locale specified in the form
            $review->translate($data['locale'])->setContent($data['content'])
					  $review->translate($data['locale'])->setTitle($data['title'])
				
            $em->persist($review);

					  // ❗️Important❗️ Call this method to assotiate translations
            // with the saved Entity
						$review->mergeNewTranslations();

            $em->flush(); // done!

            return $this->redirect($this->generateUrl('your_next_url'));
         }

         return $this->render('review/create.html.twig', [
	          'form' => $form->createView(),
		     ]);
    }
}Code language: PHP (php)

Here we are using a ReviewType class which contains the form data for the Review Entity. We will show how it looks like in a moment.

Now check your database. You should be able to see both a new row in the review table and associated translation rows in review_translation.

Screenshot of the review table | Phrase

Using the above flow you should be able to translate the rest of the site content with the Game Reviews for all supported locales.

Finally we can populate the list of reviews when switching to the selected locale:

<?php

// src/Controller/HomeController.php

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use App\Entity\Review;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Contracts\Translation\TranslatorInterface;
use App\Model\NotificationBanner;

class HomeController extends AbstractController
{
    #[Route('/{_locale}/', name: 'home')]
    public function index(
        TranslatorInterface $translator,
        EntityManagerInterface $entityManager
    ): Response
    {   
        $reviews = $entityManager->getRepository(Review::class)->findAll();
				
        // ...

				return $this->render('home.html.twig', [
            'messages' => $messages,
            'reviews' => $reviews
        ]);
    }Code language: PHP (php)

Then update the associated Twig template:

{# templates/home.html.twig #}

{% set locale = app.request.getLocale() %}

// ...

<ul>
		{% if reviews|length > 0 %}
			{% for review in reviews %}
				<li>
				  <h1>{{review.translate(locale).getTitle()}}</h1>
					<p>{{review.translate(locale).getContent()}}</p>
	      </li>
			{% endfor %}
    {% endif %}
</ul>
...Code language: PHP (php)

The review data translated to German | Phrase

How do I accept form data for a localized model?

To create the form type for Review that accepts a specific locale translation data, you need to create a custom form named ReviewType. This is how it will look like in code:

<?php

// src/Form/ReviewType.php

namespace App\Form;

use App\Entity\Review;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ReviewType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class, [
                'required' => true,
                'mapped' => false
            ])
            ->add('locale', ChoiceType::class, [
                'required' => true,
                'choices' => ['en' => 'English', 'de' => 'German'],
                'mapped' => false
            ])
            ->add('content', TextareaType::class, ['required' => true, 'mapped' => false]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Review::class,
            'translation_domain' => 'forms',
        ]);
    }
}Code language: PHP (php)

Here we create a custom form with the required Review Entity fields (title and content) and a special locale field that allows the user to choose a language.

Then the user will have to fill the form select the locale to provide translations and submit the POST request.

The controller will take the form data and save the model translations using the previous guide on how to update the Translatable Models.

🤿 Go Deeper » Learn how to create advanced Form Types and how to process forms.

Similar forms for the update actions should be provided for any other entity that you want to provide database translations.

Take Symfony localization to the next level

We hope you enjoyed building this Symfony i18n project with us. If you’re ready to level up your app translation process, check out the Phrase Localization Suite.

With its dedicated software localization solution, Phrase Strings, you can manage translation strings for your web or mobile app more easily and efficiently than ever.

With a robust API to automate translation workflows and integrations with GitHub, GitLab, Bitbucket, and other popular software platforms, Phrase Strings can do the heavy lifting for you to stay focused on your code.

Phrase Strings comes with a full-featured strings editor where translators can pick up the content for translation you had pushed. As soon as they’re done with translation, you can pull the translated content back into your project automatically.

As your software grows and you want to scale, our integrated suite lets you connect Phrase Strings with a cutting-edge translation management system (TMS) to fully leverage traditional CAT tools and AI-powered machine translation capabilities.

Check out all Phrase features for developers and see for yourself how they can streamline your software localization workflows from the get-go.

String Management UI visual | Phrase

Phrase Strings

Take your web or mobile app global without any hassle

Adapt your software, website, or video game for global audiences with the leanest and most realiable software localization platform.

Explore Phrase Strings