Schritt 28: Eine Anwendung lokalisieren

5.0 version
Maintained

Eine Anwendung lokalisieren

Mit seinem internationalen Nutzerkreis ist Symfony seit jeher in der Lage, Internationalisierung (i18n) und Lokalisierung (l10n) ohne weiteres zu bewältigen. Bei der Lokalisierung einer Anwendung geht es nicht nur um die Übersetzung der Benutzeroberfläche, sondern auch um Mehrzahlformen, Datums- und Währungsformatierung, URLs und mehr.

URLs internationalisieren

Der erste Schritt zur Internationalisierung der Website ist die Internationalisierung der URLs. Bei der Übersetzung einer Website sollten die URLs pro Sprache unterschiedlich sein, damit HTTP-Caches problemlos funktionieren (verwende niemals die gleiche URL und speichere die Sprache in der Session).

Nutze den speziellen Routen-Parameter _locale, um die Sprache in Routen zu verwenden:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -34,7 +34,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/", name="homepage")
+     * @Route("/{_locale}/", name="homepage")
      */
     public function index(ConferenceRepository $conferenceRepository)
     {

Auf der Homepage wird die Sprache nun intern abhängig von der URL gesetzt; z. B. gibt $request->getLocale() uns fr zurück, wenn Du /fr/ eintippst.

Da Du den Inhalt wahrscheinlich nicht in allen Sprachen übersetzen kannst, beschränke die zulässigen _locale-Werte auf die Sprachen, die Du unterstützen möchtest:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -34,7 +34,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/{_locale}/", name="homepage")
+     * @Route("/{_locale<en|fr>}/", name="homepage")
      */
     public function index(ConferenceRepository $conferenceRepository)
     {

Jeder Routen-Parameter kann durch einen regulären Ausdruck innerhalb < > eingeschränkt werden. Die homepage-Route passt jetzt nur noch, wenn der Routen-Parameter _locale en oder fr ist. Versuche /es/ einzutippen, Du solltest einen 404-Fehler bekommen, da keine Route passt.

Da wir die gleiche Anforderung in fast allen Routen verwenden werden, verschieben wir sie in einen Container-Parameter:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
--- 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_domain:SYMFONY_DEFAULT_ROUTE_SCHEME)%'
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -34,7 +34,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/{_locale<en|fr>}/", name="homepage")
+     * @Route("/{_locale<%app.supported_locales%>}/", name="homepage")
      */
     public function index(ConferenceRepository $conferenceRepository)
     {

Das Hinzufügen einer Sprache kann durch Aktualisieren des app.supported_languages-Parameters erfolgen.

Füge den anderen URLs das gleiche lokale Routenpräfix hinzu:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -47,7 +47,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/conference_header", name="conference_header")
+     * @Route("/{_locale<%app.supported_locales%>}/conference_header", name="conference_header")
      */
     public function conferenceHeader(ConferenceRepository $conferenceRepository)
     {
@@ -60,7 +60,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @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)
     {

Wir sind fast fertig. Wir haben keine Route mehr, die zu / passt. Fügen wir sie wieder ein und leiten sie nach /en/ weiter:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,6 +33,14 @@ class ConferenceController extends AbstractController
         $this->bus = $bus;
     }

+    /**
+     * @Route("/")
+     */
+    public function indexNoLocale()
+    {
+        return $this->redirectToRoute('homepage', ['_locale' => 'en']);
+    }
+
     /**
      * @Route("/{_locale<%app.supported_locales%>}/", name="homepage")
      */

Da nun alle Hauptrouten einen _locale`-Parameter haben, sieht man, dass die generierten URLs auf den Seiten automatisch die aktuelle Sprache berücksichtigen.

Einen Sprachwechsler hinzufügen

Damit Benutzer*innen von der Standard-Sprache en zu einer anderen wechseln können, fügen wir einen Sprachwechsler im Header hinzu:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
--- 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>

Um zu einem anderen Sprache zu wechseln, übergeben wir explizit den Routen-Parameter _locale an die path()-Funktion.

Aktualisiere das Template, um den aktuellen Sprach-Namen anstelle des fest geschriebenen „English“ anzuzeigen:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- 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 ist eine globale Twig-Variable, die den Zugriff auf den aktuellen Request ermöglicht. Um das Sprachkürzel in ein lesbares Wort zu konvertieren, verwenden wir den Twig-Filter locale_name.

Abhängig von der Sprache wird der Name der Sprache nicht immer großgeschrieben. Um Sätze richtig groß zu schreiben, benötigen wir einen entsprechenden Filter, der mit Unicode umgehen kann, wie ihn die Symfony String-Komponente und ihre Twig-Implementierung bereitstellen:

1
$ symfony composer req twig/string-extra
patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- 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>

Du kannst nun über den Sprachwechsler von Französisch auf Englisch umschalten und die gesamte Oberfläche passt sich schön an:

Das Interface übersetzen

Um mit der Übersetzung der Website zu beginnen, müssen wir die Symfony Translation-Komponente installieren:

1
$ symfony composer req translation

Die Übersetzung jedes einzelnen Satzes auf einer großen Website kann mühsam sein, aber glücklicherweise haben wir nur eine Handvoll Nachrichten (messages) auf unserer Website. Beginnen wir mit allen Sätzen auf der Homepage:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
--- 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') }}">
-                        &#128217; Conference Guestbook
+                        &#128217; {{ '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="Afficher/Cacher la 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>

Der Twig-Filter trans sucht nach einer Übersetzung der gegebenen Eingabe in die aktuelle Sprache. Wenn sie nicht gefunden wird, wird sie auf die Standard-Sprache zurückgesetzt, die in config/packages/translation.yaml konfiguriert ist:

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

Beachte, dass der Translation-„Tab“ in der Web-Debug-Toolbar rot geworden ist:

Das sagt uns, dass 3 Nachrichten noch nicht übersetzt sind.

Klicke auf den „Tab“, um alle Nachrichten aufzulisten, für die Symfony keine Übersetzung gefunden hat:

Übersetzungen erstellen

Wie Du vielleicht schon in config/packages/translation.yaml gesehen hast, werden Übersetzungen in einem translations/-Stammverzeichnis gespeichert, das automatisch für uns erstellt wurde.

Verwende den translation:update-Befehl, anstatt die Übersetzungsdateien manuell zu erstellen:

1
$ symfony console translation:update fr --force --domain=messages

Dieser Befehl erzeugt eine Übersetzungsdatei (--force Flag) für die Sprache fr und die messages-Domäne. Die messages-Domäne enthält alle Anwendungsmeldungen, die nicht von Symfony selbst kommen, wie beispielsweise Validierungs- oder Sicherheitsfehler.

Bearbeite die translations/messages+intl-icu.fr.xlf-Datei und übersetze die Nachrichten auf Französisch. Sprichst Du kein Französisch? Ich kann Dir helfen:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
--- 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>

Beachte, dass wir nicht alle Vorlagen übersetzen werden, aber zögere nicht, dies zu tun:

Formulare übersetzen

Form-Labels werden von Symfony automatisch über das Übersetzungssystem angezeigt. Gehe auf eine Konferenzseite und klicke auf den Tab „Translation“ in der Web-Debug-Toolbar; Du solltest alle Labels sehen, die übersetzt werden:

Das Datum übersetzen

Wenn Du zu Französisch wechselst und zu einer Konferenz Website gehst, die ein paar Kommentare hat, wirst Du merken dass das Datum eines Kommentares automatisch angepasst wurde. Das funktioniert, weil wir den Twig-Filter format_datetime benutzen, der die Sprache berücksichtigt ({{ comment.createdAt|format_datetime('medium', 'short') }}).

Die Lokalisierung funktioniert für Datum, Zeiten (format_time), Währungen (format_currency) und Zahlen (format_number) im Allgemeinen (Prozent, Dauer, Buchstabieren, …).

Mehrzahlformen übersetzen

Die Verwaltung von Mehrzahlformen in Übersetzungen ist Teil des üblichen Problems, eine Übersetzung basierend auf einer Bedingung auszuwählen.

Auf einer Konferenzseite wird die Anzahl der Kommentare angezeigt: There are 2 comments. Für 1 Kommentar zeigen wir There are 1 comments an, was falsch ist. Ändere die Vorlage, um den Satz in eine übersetzbare Nachricht zu konvertieren:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -37,7 +37,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 %}

Für diese Nachricht haben wir eine andere Übersetzungsstrategie gewählt. Anstatt die englische Version in der Vorlage zu behalten, haben wir sie durch einen eindeutigen Identifier ersetzt. Diese Strategie funktioniert bei komplexen und großen Textmengen besser.

Aktualisiere die Übersetzungsdatei, indem Du die neue Nachricht hinzufügst:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
--- a/translations/messages+intl-icu.fr.xlf
+++ b/translations/messages+intl-icu.fr.xlf
@@ -17,6 +17,10 @@
         <source>View</source>
         <target>Sélectionner</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>

Wir sind noch nicht fertig, da wir nun die englische Übersetzung zur Verfügung stellen müssen. Erstelle die translations/messages+intl-icu.en.xlf-Datei:

translations/messages+intl-icu.en.xlf
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?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>

Funktionale Tests aktualisieren

Vergiss nicht, die funktionalen Tests zu aktualisieren, um URLs und Inhaltsänderungen aufzunehmen:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
--- 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")');
     }
 }

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.