Symfony translation: A step-by-step guide with examples

In this article, we are going to discuss Symfony translation and localization and learn how to translate Symfony apps into multiple languages. We will cover all the important concepts and discuss various examples to help you get started with Symfony i18n.

The source code for this article can be found on GitHub.

Huge thanks to my colleague Ondřej Frei for all the assistance and technical insights.

    Prerequisites

    To follow this tutorial, you’ll need to have the below software installed:

    Creating a new Symfony app

    Let’s get started by creating a new Symfony application called blog:

    symfony new blog --webapp

    You can run it using the following command:

    symfony serve

    Then, navigate to localhost:8000 and make sure everything is running smoothly.

    Performing translations in Twig templates

    Now we’ll create a homepage for our website and see how to translate plain text in Symfony:

    symfony console make:controller Homepage

    Open the src/Controller/HomepageController.php file and replace the /homepage route with just /:

    #[Route('/', name: 'app_homepage')]

    Next, let’s see how to translate the text inside Twig templates. Open the src/templates/homepage/index.html.twig file and replace the block body with the following:

    <h1>
      {{ "Welcome to the app!"|trans }}
    </h1>

    trans is a special filter that will search for the corresponding key in your translation files. Currently, the "Welcome to the app!" string acts as a translation key but it might not be desirable, especially if you are using a format like YAML to store translations.

    Thus, you can utilize a shorter and more concise version of the key:

    <h1>
      {{ "homepage_welcome"|trans }}
    </h1>

    Please note that by default all translated text is escaped. Therefore, if you would like to output HTML markup, you’ll need to provide the raw filter in the following way:

    <h1>
      {{ "homepage_welcome"|trans|raw }}
    </h1>

    However, if you run the app now, you’ll get a warning saying that the key does not have a corresponding translation:

    Well, this is expected, so let’s proceed to the next section and see how to solve this problem!

    Extracting translations in Symfony

    So, at this point our Twig template contains the homepage_welcome key but there’s no corresponding message. These messages should be stored in separate translation files that by default are hosted in the translations folder. You can control this behavior by opening the config/packages/translation.yaml file and tweaking the following line:

    default_path: '%kernel.project_dir%/translations'

    While you could create a translation file manually and extract all the relevant content inside, it’s a very tedious task, especially for larger projects. Luckily for us, Symfony comes with a special extractor tool. For example, if you would like to scan your project for all translations that could potentially be extracted for the English locale (en), run:

    php bin/console translation:extract --dump-messages en --domain messages

    en is the language code and messages is the default domain that acts as a namespace for your translations.

    The translation:extract command will search for any missing translations inside your templates and PHP files found in the src directory.

    You’ll get the following output:

    Messages extracted for domain "messages" (1 message)
    ----------------------------------------------------
    
     * homepage_welcome

    Cool, right? Of course, this command can create translation files for you, but first of all we should decide which file format to utilize.

    Translation files

    Currently, Symfony supports a handful of translation file formats, including YAML, JSON, and XLIFF. The choice is yours, but in general we use YAML for smaller projects and XLIFF for complex apps.

    In this tutorial, I’ll stick to the YAML format but you can find more information on the XLIFF files and JSON file format in our blog as well.

    The default format used by the extractor is XLIFF, so you’ll need to override it when running the translation:extract command. Also, you’ll need to provide the target locale and the domain:

    php bin/console translation:extract --force en --domain messages --format yaml

    After running this command, you’ll find a new .en.yaml file inside the translations directory. The homepage_welcome will contain placeholder text, so let’s provide the actual translation now:

    homepage_welcome: "Welcome to the app!"

    Reload your app and make sure the warning message is now gone and the welcome text is displaying properly. Great job!

    Adding support for multiple locales in Symfony

    Currently our app only supports the English locale, but you’ll probably want to translate your website into multiple languages. Let’s see how to achieve this.

    First, it’s important to mention that the default locale is always set to en, but you can modify this behavior by opening the config/packages/translation.yaml file and modifying this line:

    default_locale: en

    Next, we will need to modify our homepage route to include the desired locale. Open the src/Controller/HomepageController.php file and modify the route in the following way:

    #[Route('/{_locale}', name: 'app_homepage', locale: 'en')]

    The _locale parameter gets special treatment in Symfony as it is automatically passed to the translator object, so we don’t need to do anything else than to add it in the URL. It has the default value 'en', so we can still visit the homepage without having to specify /en in the URL.

    If we want to always have the locale in the URL explicitly, we can remove the locale: 'en' default value from the route definition. Then, you’ll need to add the below route:

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

    Adding a new language

    Reload your app, navigate to the localhost:8000, and note that you are automatically redirected to the /en path. Let’s try requesting a different language, for example, Latvian by opening the localhost:8000/lv. Unfortunately, you will still see the English text on the home page. Why? Well, because Symfony has a special fallback mechanism that displays English text by default whenever translations for the requested locale cannot be found.

    You’ll see the following notification in the debugger:

    You can adjust the fallback mechanism in the config/packages/translation.yaml file. For instance, if your default locale is French and you would like this language to act as a fallback, use this code:

    fallbacks:
      - fr

    Having a fallback is nice, but you will likely want to provide a proper translation for the welcome message. Let’s take advantage of the extractor again:

    php bin/console translation:extract --force lv --domain messages --format yaml

    Of course, you can use any other locale of your choice.

    Open the newly created YAML file inside the translations directory and provide the translation for the homepage_welcome key, for example:

    homepage_welcome: "Laipni lūdzam lietotnē!"

    Now reload the page and make sure the newly added translation is displayed. Well done!

    Performing pluralization in Symfony

    Pluralization is a very common task when translating a Symfony app, so let’s check out how to do it.

    Add some more content to the block body of the homepage template:

    {% for i in range(0, 5) %}
      <p>{{ 'i_have_apples'|trans({"%apples%": i}) }}</p>
    {% endfor %}

    Let’s suppose we would like to display the text “I have X apples”, where X is a number. When X equals 1, the text should transform to “I have 1 apple” (or even “I have one apple”), but for all other values we should use the “apples” form. English pluralization rules are quite simple, but the situation might be more complex in other languages.

    Therefore, we will need to provide so-called “plural forms” when performing translations. The Unicode website has a dedicated document that lists plural forms for different languages. For example, English has two plural forms: one and other. Latvian has three forms (zero, one, and other), whereas some other languages can even have four forms.

    So, we’ll extract our newly added translation key for both locales:

    php bin/console translation:extract --force en --domain messages --format yaml
    
    php bin/console translation:extract --force lv --domain messages --format yaml

    Then, open the English translation file and provide the following translation:

    i_have_apples: >-
        {apples, plural,
            =0    {I have no apples.}
            =1    {I have an apple.}
            other {I have # apples!}
        }

    What we can see here is an ICU expression called plural. Depending on the value of the apples variable, we display one of the texts. So if the value equals 0, we display the first text, if it is 1, we render the second text. In all other cases, we use the third text that contains interpolation represented as #.

    However, you might ask: why do we provide three plural forms here if English supports only two? Well, it appears that these two plural forms are mandatory, but we can specify more forms as needed. Specifically, to tackle the case when the number equals 0, we introduce the zero form.

    Next, let’s also provide the Latvian translations:

    i_have_apples: >-
        {apples, plural,
            =0    {Man nav ābolu.}
            =1    {Man ir viens ābols.}
            other {Man ir # āboli.}
        }

    Now you can reload the app and make sure the texts are properly pluralized.

    Working with gender information

    Displaying different texts based on the user’s gender is also a very common task. Let’s imagine we’d like to display some text written by a user but adjust the introductory message based on their chosen gender:

    <p>{{ 'they_say'|trans({"%gender%": 'female', "%text%": "Hello!"}) }}</p>

    The %gender% variable can be adjusted accordingly. The %text% variable contains the actual message.

    Now we’ll use the select ICU switch in our translations:

    they_say: >-
        {gender, select,
            female {She {text}}
            male {He {text}}
            other {They {text}}
        }

    So, based on the value of gender, we display one of three messages. The text variable is also interpolated into the rendered translation.

    Awesome!

    Translating Symfony forms

    Next up, let’s see how to perform translations in forms. For example, create a new contact form:

    symfony console make:form Contact

    When prompted for the entity name, simply hit Enter to choose “none”.

    Modifying the controller

    Open your Homepage controller and modify the index action to process the form:

        #[Route('/{_locale}', name: 'app_homepage')]
        public function index(Request $request): Response
        {
           $form = $this->createFormBuilder()->create('contactForm', ContactType::class)->getForm();
            
           $form->handleRequest($request);
    
            if ($form->isSubmitted() && $form->isValid()) {
                $this->addFlash('success', new TranslatableMessage('i_have_apples', ['%apples%' => $form->getData()['applesCount']]));
    
                return $this->redirectToRoute('app_homepage');
            }
    
            return $this->render('homepage/index.html.twig', [
                'controller_name' => 'HomepageController',
                'contact_form' => $form->createView()
            ]);
        }

    Also add the following imports into your controller:

    use App\Form\ContactType;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\Translation\TranslatableMessage;

    Tweaking views

    Add your form inside the index.html.twig file:

    {{ form(contact_form) }}

    Then, open src/Form/ContactType.php and modify it like so:

    namespace App\Form;
    
    use Symfony\Component\Form\AbstractType;
    use Symfony\Component\Form\FormBuilderInterface;
    use Symfony\Component\OptionsResolver\OptionsResolver;
    use Symfony\Component\Form\Extension\Core\Type\NumberType;
    use Symfony\Component\Form\Extension\Core\Type\SubmitType;
    use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
    use Symfony\Component\Validator\Constraints\LessThanOrEqual;
    
    class ContactType extends AbstractType
    {
        public function buildForm(FormBuilderInterface $builder, array $options): void
        {
            $builder
                ->add('applesCount', NumberType::class, [
                    'constraints' => [
                        new GreaterThanOrEqual(1),
                        new LessThanOrEqual(10),
                    ],
                    'label' => 'apples_count'
                ]);
            
            $builder->add('submit', SubmitType::class, [
                'label' => 'request_apples'
            ]);
        }
    
        public function configureOptions(OptionsResolver $resolver): void
        {
            $resolver->setDefaults([
                'translation_domain' => 'forms'
            ]);
        }
    }

    Here, we are adding constraints to make sure that the number of apples stays within the [1, 10] range.

    We use the apples_count as the label. This is the key name and we’ll provide a translation for it as well.

    Also note that within the configureOptions function we are providing a new translation domain called forms. While you can use the default domain, it might be a good idea to separate your messages into multiple domains to make working with them easier.

    To display a confirmation message to the user, we need to add flash messages. Open the templates/base.html.twig file and modify body in the following way:

        <body>
            {% for message in app.flashes('success') %}
                <div class="alert alert-success">
                    {{ message | trans }}
                </div>
            {% endfor %}
            {% block body %}{% endblock %}
        </body>

    We don’t need to pass any arguments to |trans because they are stored in the TranslatableMessage instance.

    Adding translation files

    Unfortunately, the extractor does not seem to detect translations properly in this case, so you’ll need to create files inside the translations directory manually by following this pattern: forms+intl-icu.LANG_ISO.yaml where LANG_ISO is the locale code.

    Here are the English translations for the newly added keys:

    apples_count: 'Number of apples'
    request_apples: 'Request apples!'

    Now you can reload the app, proceed to the main page, enter any number from 1 to 10 in the form, and make sure that the flash message is properly pluralized!

    Using the Lokalise TMS

    At this point, our app is localized but there’s a problem: what if I would like to invite professional translators to work on the texts? Should I send them YAML files directly? Well, of course it is possible but perhaps not too convenient. Moreover, if you have many people working on the same project it will quickly turn to chaos.

    A translation management system can streamline this process, helping you manage collaboration effectively. On top of that, you might need to introduce quality assurance or take advantage of machine translation.

    Is there a solution that can do the heavy lifting for you? Meet Lokalise, a translation management system for agile teams that is here to save the day! It offers all the basic translation features with the ability to work in teams, and advanced tools and integrations with dozens of third-party services including Amazon S3, GitHub, Figma, and many others.

    So, let’s add Lokalise to the app as a translation provider by running:

    symfony composer require symfony/lokalise-translation-provider

    Open the .env file and uncomment the following line:

    LOKALISE_DSN=lokalise://PROJECT_ID:API_KEY@default

    Here you’ll need to provide two values: The Lokalise project ID and the translation key.

    First, navigate to app.lokalise.com/signup and grab your free 14-day trial.

    Enter your details and follow the wizard to create a new project. A project is basically a collection of your keys and translations along with contributing users, settings, and other useful things.

    Once you’ve navigated to the newly created project, click More > Settings and take note of the project ID.

    Next, click on your avatar in the bottom left corner and choose Profile settings. Switch to the API tokens tab and generate a new read-write token.

    Now provide both the project ID and the token in the .env file:

    LOKALISE_DSN=lokalise://123.abc:5678secret9876@default

    Next, open the config/packages/translation.yaml file and paste the following:

    framework:
        default_locale: en
        translator:
            default_path: '%kernel.project_dir%/translations'
            fallbacks:
                - en
            providers:
                lokalise:
                    dsn: '%env(LOKALISE_DSN)%'
                    domains: ['messages', 'forms', 'security', 'validators']
                    locales: ['en', 'lv']

    Replace the locales with the actual locale codes used in your app. Please note that the domains option contains more domains than we’ve mentioned in this article. This is because Symfony has some built-in domains with system messages.

    Now run the following command to push your translations to Lokalise:

    symfony console translation:push lokalise  --force

    The --force switch is needed for the very first export. For any subsequent ones, only use it if you want to overwrite existing keys.

    Now, return to your Lokalise project and make sure the translations have been uploaded!

    You can tweak them as needed and then download them back to the Symfony app by running:

    symfony console translation:pull lokalise --format yml --force

    Conclusion

    In this article, we have seen how to translate Symfony apps into multiple locales. We have covered translation basics, pluralization, working with gender information, and translating forms. We’ve also added support for the second locale and integrated Lokalise into the workflow.

    Thank you for staying with me today, and until next time!

    Further reading

    Related articles
    Stop wasting time with manual localization tasks. 

    Launch global products days from now.