Step 28: Localizing an Application

Localizing an Application

The first step to internationalize the website is to internationalize the URLs. When translating a website interface, the URL should be different per locale to play nice with HTTP caches (never use the same URL and store the locale in the session).

Use the special route parameter to reference the locale in routes:

patch_file

On the homepage, the locale is now set internally depending on the URL; for instance, if you hit /fr/, $request->getLocale() returns fr.

As you will probably not be able to translate the content in all valid locales, restrict to the ones you want to support:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
  4. $this->bus = $bus;
  5. }
  6. - #[Route('/{_locale}/', name: 'homepage')]
  7. + #[Route('/{_locale<en|fr>}/', name: 'homepage')]
  8. public function index(ConferenceRepository $conferenceRepository): Response
  9. {
  10. $response = new Response($this->twig->render('conference/index.html.twig', [

Each route parameter can be restricted by a regular expression inside < >. The homepage route now only matches when the _locale parameter is en or fr. Try hitting /es/, you should have a 404 as no route matches.

As we will use the same requirement in almost all routes, let’s move it to a container parameter:

patch_file

  1. --- a/config/services.yaml
  2. +++ b/config/services.yaml
  3. @@ -7,6 +7,7 @@ parameters:
  4. default_admin_email:
  5. default_domain: '127.0.0.1'
  6. default_scheme: 'http'
  7. + app.supported_locales: 'en|fr'
  8. router.request_context.host: '%env(default:default_domain:SYMFONY_DEFAULT_ROUTE_HOST)%'
  9. router.request_context.scheme: '%env(default:default_scheme:SYMFONY_DEFAULT_ROUTE_SCHEME)%'
  10. --- a/src/Controller/ConferenceController.php
  11. +++ b/src/Controller/ConferenceController.php
  12. @@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
  13. $this->bus = $bus;
  14. }
  15. - #[Route('/{_locale<en|fr>}/', name: 'homepage')]
  16. + #[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
  17. public function index(ConferenceRepository $conferenceRepository): Response
  18. {
  19. $response = new Response($this->twig->render('conference/index.html.twig', [

Adding a language can be done by updating the app.supported_languages parameter.

Add the same locale route prefix to the other URLs:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -44,7 +44,7 @@ class ConferenceController extends AbstractController
  4. return $response;
  5. }
  6. - #[Route('/conference_header', name: 'conference_header')]
  7. + #[Route('/{_locale<%app.supported_locales%>}/conference_header', name: 'conference_header')]
  8. public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
  9. {
  10. $response = new Response($this->twig->render('conference/header.html.twig', [
  11. @@ -55,7 +55,7 @@ class ConferenceController extends AbstractController
  12. return $response;
  13. }
  14. - #[Route('/conference/{slug}', name: 'conference')]
  15. + #[Route('/{_locale<%app.supported_locales%>}/conference/{slug}', name: 'conference')]
  16. public function show(Request $request, Conference $conference, CommentRepository $commentRepository, NotifierInterface $notifier, string $photoDir): Response
  17. {
  18. $comment = new Comment();

We are almost done. We don’t have a route that matches / anymore. Let’s add it back and make it redirect to /en/:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. +++ b/src/Controller/ConferenceController.php
  3. @@ -33,6 +33,12 @@ class ConferenceController extends AbstractController
  4. $this->bus = $bus;
  5. }
  6. + #[Route('/')]
  7. + public function indexNoLocale(): Response
  8. + {
  9. + return $this->redirectToRoute('homepage', ['_locale' => 'en']);
  10. + }
  11. +
  12. #[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
  13. {

Now that all main routes are locale aware, notice that generated URLs on the pages take the current locale into account automatically.

Adding a Locale Switcher

To allow users to switch from the default en locale to another one, let’s add a switcher in the header:

patch_file

  1. --- a/templates/base.html.twig
  2. +++ b/templates/base.html.twig
  3. @@ -34,6 +34,16 @@
  4. Admin
  5. </a>
  6. </li>
  7. +<li class="nav-item dropdown">
  8. + data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
  9. + English
  10. + </a>
  11. + <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
  12. + <a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a>
  13. + <a class="dropdown-item" href="{{ path('homepage', {_locale: 'fr'}) }}">Français</a>
  14. + </div>
  15. +</li>
  16. </ul>
  17. </div>
  18. </div>

To switch to another locale, we explicitly pass the _locale route parameter to the path() function.

patch_file

app is a global Twig variable that gives access to the current request. To convert the locale to a human readable string, we are using the locale_name Twig filter.

Depending on the locale, the locale name is not always capitalized. To capitalize sentences properly, we need a filter that is Unicode aware, as provided by the Symfony String component and its Twig implementation:

  1. $ symfony composer req twig/string-extra

patch_file

  1. --- a/templates/base.html.twig
  2. +++ b/templates/base.html.twig
  3. @@ -37,7 +37,7 @@
  4. <li class="nav-item dropdown">
  5. <a class="nav-link dropdown-toggle" href="#" id="dropdown-language" role="button"
  6. data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
  7. - {{ app.request.locale|locale_name(app.request.locale) }}
  8. + {{ app.request.locale|locale_name(app.request.locale)|u.title }}
  9. </a>
  10. <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
  11. <a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a>

You can now switch from French to English via the switcher and the whole interface adapts itself quite nicely:

To start translating the website, we need to install the Symfony Translation component:

  1. $ symfony composer req translation

Translating every single sentence on a large website can be tedious, but fortunately, we only have a handful of messages on our website. Let’s start with all the sentences on the homepage:

patch_file

  1. --- a/templates/base.html.twig
  2. +++ b/templates/base.html.twig
  3. @@ -20,7 +20,7 @@
  4. <nav class="navbar navbar-expand-xl navbar-light bg-light">
  5. <div class="container mt-4 mb-3">
  6. <a class="navbar-brand mr-4 pr-2" href="{{ path('homepage') }}">
  7. - &#128217; Conference Guestbook
  8. + &#128217; {{ 'Conference Guestbook'|trans }}
  9. </a>
  10. <button class="navbar-toggler border-0" type="button" data-toggle="collapse" data-target="#header-menu" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Show/Hide navigation">
  11. --- a/templates/conference/index.html.twig
  12. +++ b/templates/conference/index.html.twig
  13. @@ -4,7 +4,7 @@
  14. {% block body %}
  15. <h2 class="mb-5">
  16. - Give your feedback!
  17. + {{ 'Give your feedback!'|trans }}
  18. </h2>
  19. {% for row in conferences|batch(4) %}
  20. @@ -21,7 +21,7 @@
  21. <a href="{{ path('conference', { slug: conference.slug }) }}"
  22. class="btn btn-sm btn-blue stretched-link">
  23. - View
  24. + {{ 'View'|trans }}
  25. </a>
  26. </div>
  27. </div>

The trans Twig filter looks for a translation of the given input to the current locale. If not found, it falls back to the default locale as configured in config/packages/translation.yaml:

  1. framework:
  2. default_locale: en
  3. translator:
  4. default_path: '%kernel.project_dir%/translations'
  5. fallbacks:
  6. - en

Notice that the web debug toolbar translation “tab” has turned red:

Step 28: Localizing an Application - 图2

It tells us that 3 messages are not translated yet.

Click on the “tab” to list all messages for which Symfony did not find a translation:

Providing Translations

As you might have seen in config/packages/translation.yaml, translations are stored under a translations/ root directory, which has been created automatically for us.

Instead of creating the translation files by hand, use the translation:update command:

This command generates a translation file (--force flag) for the fr locale and the messages domain. The messages domain contains all application messages excluding the ones coming from Symfony itself like validation or security errors.

patch_file

  1. --- a/translations/messages+intl-icu.fr.xlf
  2. +++ b/translations/messages+intl-icu.fr.xlf
  3. @@ -7,15 +7,15 @@
  4. <body>
  5. <trans-unit id="LNAVleg" resname="Give your feedback!">
  6. <source>Give your feedback!</source>
  7. - <target>__Give your feedback!</target>
  8. + <target>Donnez votre avis !</target>
  9. </trans-unit>
  10. <trans-unit id="3Mg5pAF" resname="View">
  11. <source>View</source>
  12. - <target>__View</target>
  13. + <target>Sélectionner</target>
  14. <trans-unit id="eOy4.6V" resname="Conference Guestbook">
  15. <source>Conference Guestbook</source>
  16. - <target>__Conference Guestbook</target>
  17. + <target>Livre d'Or pour Conferences</target>
  18. </trans-unit>
  19. </body>
  20. </file>

Note that we won’t translate all templates, but feel free to do so:

Step 28: Localizing an Application - 图4

Form labels are automatically displayed by Symfony via the translation system. Go to a conference page and click on the “Translation” tab of the web debug toolbar; you should see all labels ready for translation:

Localizing Dates

If you switch to French and go to a conference webpage that has some comments, you will notice that the comment dates are automatically localized. This works because we used the format_datetime Twig filter, which is locale-aware ({{ comment.createdAt|format_datetime('medium', 'short') }}).

The localization works for dates, times (format_time), currencies (format_currency), and numbers (format_number) in general (percents, durations, spell out, …).

Managing plurals in translations is one usage of the more general problem of selecting a translation based on a condition.

On a conference page, we display the number of comments: There are 2 comments. For 1 comment, we display There are 1 comments, which is wrong. Modify the template to convert the sentence to a translatable message:

patch_file

  1. --- a/templates/conference/show.html.twig
  2. +++ b/templates/conference/show.html.twig
  3. @@ -44,7 +44,7 @@
  4. </div>
  5. </div>
  6. {% endfor %}
  7. - <div>There are {{ comments|length }} comments.</div>
  8. + <div>{{ 'nb_of_comments'|trans({count: comments|length}) }}</div>
  9. {% if previous >= 0 %}
  10. <a href="{{ path('conference', { slug: conference.slug, offset: previous }) }}">Previous</a>
  11. {% endif %}

For this message, we have used another translation strategy. Instead of keeping the English version in the template, we have replaced it with a unique identifier. That strategy works better for complex and large amount of text.

Update the translation file by adding the new message:

patch_file

  1. --- a/translations/messages+intl-icu.fr.xlf
  2. +++ b/translations/messages+intl-icu.fr.xlf
  3. @@ -17,6 +17,10 @@
  4. <source>Conference Guestbook</source>
  5. <target>Livre d'Or pour Conferences</target>
  6. </trans-unit>
  7. + <trans-unit id="Dg2dPd6" resname="nb_of_comments">
  8. + <source>nb_of_comments</source>
  9. + <target>{count, plural, =0 {Aucun commentaire.} =1 {1 commentaire.} other {# commentaires.}}</target>
  10. + </trans-unit>
  11. </body>
  12. </file>
  13. </xliff>

We have not finished yet as we now need to provide the English translation. Create the translations/messages+intl-icu.en.xlf file:

translations/messages+intl-icu.en.xlf

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
  3. <file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
  4. <header>
  5. <tool tool-id="symfony" tool-name="Symfony"/>
  6. </header>
  7. <body>
  8. <trans-unit id="maMQz7W" resname="nb_of_comments">
  9. <source>nb_of_comments</source>
  10. <target>{count, plural, =0 {There are no comments.} one {There is one comment.} other {There are # comments.}}</target>
  11. </trans-unit>
  12. </body>
  13. </file>
  14. </xliff>

Updating Functional Tests

Don’t forget to update the functional tests to take URLs and content changes into account:

patch_file

  1. --- a/tests/Controller/ConferenceControllerTest.php
  2. +++ b/tests/Controller/ConferenceControllerTest.php
  3. @@ -11,7 +11,7 @@ class ConferenceControllerTest extends WebTestCase
  4. public function testIndex()
  5. {
  6. $client = static::createClient();
  7. - $client->request('GET', '/');
  8. + $client->request('GET', '/en/');
  9. $this->assertResponseIsSuccessful();
  10. $this->assertSelectorTextContains('h2', 'Give your feedback');
  11. @@ -20,7 +20,7 @@ class ConferenceControllerTest extends WebTestCase
  12. public function testCommentSubmission()
  13. {
  14. $client = static::createClient();
  15. - $client->request('GET', '/conference/amsterdam-2019');
  16. + $client->request('GET', '/en/conference/amsterdam-2019');
  17. $client->submitForm('Submit', [
  18. 'comment_form[author]' => 'Fabien',
  19. 'comment_form[text]' => 'Some feedback from an automated functional test',
  20. @@ -41,7 +41,7 @@ class ConferenceControllerTest extends WebTestCase
  21. public function testConferencePage()
  22. {
  23. $client = static::createClient();
  24. - $crawler = $client->request('GET', '/');
  25. + $crawler = $client->request('GET', '/en/');
  26. $this->assertCount(2, $crawler->filter('h4'));
  27. @@ -50,6 +50,6 @@ class ConferenceControllerTest extends WebTestCase
  28. $this->assertPageTitleContains('Amsterdam');
  29. $this->assertResponseIsSuccessful();
  30. $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
  31. - $this->assertSelectorExists('div:contains("There are 1 comments")');
  32. + $this->assertSelectorExists('div:contains("There is one comment")');
  33. }

Going Further