Step 28: Localizing an Application
Localizing an Application
With an international audience, Symfony has been able to handle internationalization (i18n) and localization (l10n) out of the box since like ever. Localizing an application is not just about translating the interface, it is also about plurals, date and currency formatting, URLs, and more.
Internationalizing URLs
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 _locale
route parameter to reference the locale in routes:
patch_file
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
$this->bus = $bus;
}
- #[Route('/', name: 'homepage')]
+ #[Route('/{_locale}/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
$response = new Response($this->twig->render('conference/index.html.twig', [
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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
$this->bus = $bus;
}
- #[Route('/{_locale}/', name: 'homepage')]
+ #[Route('/{_locale<en|fr>}/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
$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
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -7,6 +7,7 @@ parameters:
default_admin_email: [email protected]
default_domain: '127.0.0.1'
default_scheme: 'http'
+ app.supported_locales: 'en|fr'
router.request_context.host: '%env(default:default_domain:SYMFONY_DEFAULT_ROUTE_HOST)%'
router.request_context.scheme: '%env(default:default_scheme:SYMFONY_DEFAULT_ROUTE_SCHEME)%'
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
$this->bus = $bus;
}
- #[Route('/{_locale<en|fr>}/', name: 'homepage')]
+ #[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
$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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -44,7 +44,7 @@ class ConferenceController extends AbstractController
return $response;
}
- #[Route('/conference_header', name: 'conference_header')]
+ #[Route('/{_locale<%app.supported_locales%>}/conference_header', name: 'conference_header')]
public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
{
$response = new Response($this->twig->render('conference/header.html.twig', [
@@ -55,7 +55,7 @@ class ConferenceController extends AbstractController
return $response;
}
- #[Route('/conference/{slug}', name: 'conference')]
+ #[Route('/{_locale<%app.supported_locales%>}/conference/{slug}', name: 'conference')]
public function show(Request $request, Conference $conference, CommentRepository $commentRepository, NotifierInterface $notifier, string $photoDir): Response
{
$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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,6 +33,12 @@ class ConferenceController extends AbstractController
$this->bus = $bus;
}
+ #[Route('/')]
+ public function indexNoLocale(): Response
+ {
+ return $this->redirectToRoute('homepage', ['_locale' => 'en']);
+ }
+
#[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
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
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -34,6 +34,16 @@
Admin
</a>
</li>
+<li class="nav-item dropdown">
+ <a class="nav-link dropdown-toggle" href="#" id="dropdown-language" role="button"
+ data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ English
+ </a>
+ <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
+ <a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a>
+ <a class="dropdown-item" href="{{ path('homepage', {_locale: 'fr'}) }}">Français</a>
+ </div>
+</li>
</ul>
</div>
</div>
To switch to another locale, we explicitly pass the _locale
route parameter to the path()
function.
Update the template to display the current locale name instead of the hard-coded “English”:
patch_file
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -37,7 +37,7 @@
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="dropdown-language" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
- English
+ {{ app.request.locale|locale_name(app.request.locale) }}
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
<a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a>
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:
$ symfony composer req twig/string-extra
patch_file
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -37,7 +37,7 @@
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="dropdown-language" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
- {{ app.request.locale|locale_name(app.request.locale) }}
+ {{ app.request.locale|locale_name(app.request.locale)|u.title }}
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
<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:
Translating the Interface
To start translating the website, we need to install the Symfony Translation component:
$ 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
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -20,7 +20,7 @@
<nav class="navbar navbar-expand-xl navbar-light bg-light">
<div class="container mt-4 mb-3">
<a class="navbar-brand mr-4 pr-2" href="{{ path('homepage') }}">
- 📙 Conference Guestbook
+ 📙 {{ 'Conference Guestbook'|trans }}
</a>
<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">
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -4,7 +4,7 @@
{% block body %}
<h2 class="mb-5">
- Give your feedback!
+ {{ 'Give your feedback!'|trans }}
</h2>
{% for row in conferences|batch(4) %}
@@ -21,7 +21,7 @@
<a href="{{ path('conference', { slug: conference.slug }) }}"
class="btn btn-sm btn-blue stretched-link">
- View
+ {{ 'View'|trans }}
</a>
</div>
</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
:
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
Notice that the web debug toolbar translation “tab” has turned red:
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:
$ symfony console translation:update fr --force --domain=messages
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.
Edit the translations/messages+intl-icu.fr.xlf
file and translate the messages in French. Don’t speak French? Let me help you:
patch_file
--- a/translations/messages+intl-icu.fr.xlf
+++ b/translations/messages+intl-icu.fr.xlf
@@ -7,15 +7,15 @@
<body>
<trans-unit id="LNAVleg" resname="Give your feedback!">
<source>Give your feedback!</source>
- <target>__Give your feedback!</target>
+ <target>Donnez votre avis !</target>
</trans-unit>
<trans-unit id="3Mg5pAF" resname="View">
<source>View</source>
- <target>__View</target>
+ <target>Sélectionner</target>
</trans-unit>
<trans-unit id="eOy4.6V" resname="Conference Guestbook">
<source>Conference Guestbook</source>
- <target>__Conference Guestbook</target>
+ <target>Livre d'Or pour Conferences</target>
</trans-unit>
</body>
</file>
Note that we won’t translate all templates, but feel free to do so:
Translating Forms
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, …).
Translating Plurals
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
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -44,7 +44,7 @@
</div>
</div>
{% endfor %}
- <div>There are {{ comments|length }} comments.</div>
+ <div>{{ 'nb_of_comments'|trans({count: comments|length}) }}</div>
{% if previous >= 0 %}
<a href="{{ path('conference', { slug: conference.slug, offset: previous }) }}">Previous</a>
{% 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
--- a/translations/messages+intl-icu.fr.xlf
+++ b/translations/messages+intl-icu.fr.xlf
@@ -17,6 +17,10 @@
<source>Conference Guestbook</source>
<target>Livre d'Or pour Conferences</target>
</trans-unit>
+ <trans-unit id="Dg2dPd6" resname="nb_of_comments">
+ <source>nb_of_comments</source>
+ <target>{count, plural, =0 {Aucun commentaire.} =1 {1 commentaire.} other {# commentaires.}}</target>
+ </trans-unit>
</body>
</file>
</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
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="maMQz7W" resname="nb_of_comments">
<source>nb_of_comments</source>
<target>{count, plural, =0 {There are no comments.} one {There is one comment.} other {There are # comments.}}</target>
</trans-unit>
</body>
</file>
</xliff>
Updating Functional Tests
Don’t forget to update the functional tests to take URLs and content changes into account:
patch_file
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -11,7 +11,7 @@ class ConferenceControllerTest extends WebTestCase
public function testIndex()
{
$client = static::createClient();
- $client->request('GET', '/');
+ $client->request('GET', '/en/');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h2', 'Give your feedback');
@@ -20,7 +20,7 @@ class ConferenceControllerTest extends WebTestCase
public function testCommentSubmission()
{
$client = static::createClient();
- $client->request('GET', '/conference/amsterdam-2019');
+ $client->request('GET', '/en/conference/amsterdam-2019');
$client->submitForm('Submit', [
'comment_form[author]' => 'Fabien',
'comment_form[text]' => 'Some feedback from an automated functional test',
@@ -41,7 +41,7 @@ class ConferenceControllerTest extends WebTestCase
public function testConferencePage()
{
$client = static::createClient();
- $crawler = $client->request('GET', '/');
+ $crawler = $client->request('GET', '/en/');
$this->assertCount(2, $crawler->filter('h4'));
@@ -50,6 +50,6 @@ class ConferenceControllerTest extends WebTestCase
$this->assertPageTitleContains('Amsterdam');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h2', 'Amsterdam 2019');
- $this->assertSelectorExists('div:contains("There are 1 comments")');
+ $this->assertSelectorExists('div:contains("There is one comment")');
}
}
Going Further
This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.