The Ultimate Symfony Tutorial on Internationalization

Symfony i18n tutorial

Symfony is one of the most popular PHP frameworks, also consisting of reusable components for cases in which we want to use only certain services. When it comes to internationalization, there are a lot of options such as the Translation Component for handling translation messages, the Intl component for displaying locale-aware information and localized Routing configurations for handling URL paths to translated pages. In this Symfony tutorial, we are going to leverage in full the provided abstractions with some advanced examples of those components in action.

There is an excellent introduction to the basics of translating Symfony 3 applications, where you can learn how to use the Translation Component. In this Symfony tutorial, we will step up even further and use this library to the max to show some practical and more advanced examples of i18n. For the purposes of this tutorial, we are using PHP 7 and the Symfony framework with either versions 4 or 5. All the code examples are available on GitHub. The quickest way to run the bundled application is to clone the repository and using docker-compose run the following command:

 docker-compose up -d

Localized Routing

Symfony’s latest version offers, among other improvements, the ability to add unique paths per locale. That way we can show a different URL pathname for each language. If you couple that with an annotation-specific configuration, we can have our routes map to each locale without much effort. Let’s see how we can do that…

First, expose some locale parameters in the services.yaml file.

File: phrase-app-symfony4/phrase-app/config/services.yaml

# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
    locale: 'en'
    # This parameter defines the codes of the locales (languages) enabled in the application
    app_locales: en|fr|de|el
 
services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
        public: false       # Allows optimizing the container by removing unused services; this also means
                            # fetching services directly from the container via $container->get() won't work.
                            # The best practice is to be explicit about your dependencies anyway.
        bind:
            $locales: '%app_locales%'
            $defaultLocale: '%locale%'

Here, app_locales is a list of supported locales for our app. We also used bind to wire them as parameters in our controllers.

Next, add the annotation configuration to prefix our routes with locale information:

File: phrase-app-symfony4/phrase-app/config/routes/annotations.yaml

controllers:
    resource: ../../src/Controller/
    type: annotation
    prefix: /{_locale}
    requirements:
        _locale: '%app_locales%'
    defaults:
        _locale: '%locale%'

With that in place, all our routes need to have a _locale prefix, even for the index route.

We can also further customize each route in the controller.

File: phrase-app-symfony4/phrase-app/src/Controller/DefaultController.php

<?php
 
namespace App\Controller;
 
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Translation\TranslatorInterface;
 
class DefaultController extends AbstractController
{
    /**
     * @Route({
        "el": "/giasas",
     *  "en": "/hello"
     * }, name="homepage")
     */
    public function index(TranslatorInterface $translator, $locales, $defaultLocale)
    {
        return $this->render('index.html.twig', [
                        'name' => "Theo",
                    ]);
    }
}

File: phrase-app-symfony4/phrase-app/templates/index.html.twig

{% extends 'base.html.twig' %}
{% block body %}<h1>{{ 'hello'|trans }} {{ name }}</h1>{% endblock %}

Based on those configuration values, we can visit the following routes for the Greek locale:

  • /el/giasas
  • /el/hello

and have the same page translated into Greek.

Note that if we start the development server, the debug toolbar will show any missing translation strings as depicted in the screenshots below:

Twig Extensions

If you are building web apps using Twig templates, then you will benefit from having to include the Twig extensions library, as it’s bundled with some useful tools that enhance the existing functionality. For example, the i18n extension adds Gettext support and if you are working with .PO  and .MO files, then it’s really handy.

To install them, just use composer first:

composer require twig/extensions

And make sure you enable them in the twig_extensions.yaml

File: phrase-app-symfony4/phrase-app/config/packages/twig_extensions.yaml

services:
    _defaults:
        public: false
        autowire: true
        autoconfigure: true
 
    #Twig\Extensions\ArrayExtension: ~
    #Twig\Extensions\DateExtension: ~
    Twig\Extensions\IntlExtension: ~
    Twig\Extensions\I18nExtension: ~
    #Twig\Extensions\TextExtension: ~

The i18nExtension adds Gettext support and you may find useful our guide to the Gettext tools.

The IntlExtension has a very useful filter for printing localizable date string from timestamps or DateTime instances, localizable numbers and localizable currencies. Let’s see an example below:

Define a new controller:

File: phrase-app-symfony4/phrase-app/src/Controller/ExampleController.php

<?php
 
namespace App\Controller;
 
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Translation\TranslatorInterface;
 
 
class ExampleController extends AbstractController
{
    /**
     * @Route(/example, name="homepage")
     */
    public function index(TranslatorInterface $translator, $locales, $defaultLocale)
    {
        return $this->render('example.html.twig', [
                        'date' => date(DATE_RFC2822),
                        'number' => 13123123,
                        'price' => 1500
                    ]);
    }
}

Add the following template:

File: phrase-app-symfony4/phrase-app/templates/example.html.twig

{% extends 'base.html.twig' %}
{% block body %}
    <p>{{ date|localizeddate('medium', 'none') }}</p>
    <p>{{ number|localizednumber }}</p>
    <p>{{ price|localizedcurrency('EUR') }}</p>
{% endblock %}

Then navigate to a few supported locale routes and see the results. I’ve included some examples for Greek, German and French.

Basic Intl Component

If you want to store and operate on data using a locale-independent binary representation and operations, then your best bet is to use the Intl Component. This is an extension that adds locale-specific tools for special cases.

To install it, invoke the following command:

$ composer require symfony/intl

Some useful use cases for this library are:

  • Writing or reading locale-specific information to files; you can use the https://symfony.com/doc/current/components/intl.html#phpbundlewriter, for example, to write PHP resources in a locale-specific format for later use or for archiving.
  • Retrieving locale information: You have a variety of methods to retrieve the list of languages, scripts, locales, country names, currencies or currency symbols for use. Those prove very handy as you won’t have to hardcopy anything in your code.

Let’s see some examples below:

Printing the list of locales based on the current locale:

Locale::setDefault('el');
$allLocales = Intl::getLocaleBundle()->getLocaleNames();
// array('en' => 'Αγγλικά', 'en_SH' => 'Αγγλικά (Αγία Ελένη)', ...)

Printing the list of currencies based on the current locale:

Locale::setDefault('el');
$allCurrencies = Intl::getCurrencyBundle()->getCurrencyNames();
// array('ALK' => 'Albanian Lek (1946–1965)', 'AOR' => 'Angolan Readjusted Kwanza (1995–1999)', ...)

Printing the list of countries based on the current locale:

Locale::setDefault('el');
$countries = Intl::getRegionBundle()->getCountryNames();
// array('SH' => 'Αγία Ελένη', 'LC' => 'Αγία Λουκία', ...)

Database Translations

In most cases, you might be storing your content in a database using Docturine ORM. Typically, if you want to have your content translated to different locales, then you want to have the content stored in a separate column or table where you can reference.

For example, if you have an Entity called Product with following fields:

Price
Name
Description

and you want to give translations for the Description field, you can add a few fields, such as Description_el or Description_de to store the Greek and German translations. 

This solution is suited only if you plan only to support a couple of locales. A more scalable approach is to include a separate table for the model that represent its Translations, such as in that case we would need the ProductTranslation where we can refer to each Product Id with a list of translations. In order to retrieve the model with the relevant translations, you will need to do a LEFT OUTER JOIN on the Product and ProductTranslation entities.

You can either choose to carry out your own repository manager for that or use a bundle that does that for you. There is a well-known bundle called DocturineBehaviors from KnpLabs that adds a few interesting traits and one of them is the translatable trait. Let’s see how we can use that…

First, require the library and add it to the list of loaded bundles:

composer require knplabs/doctrine-behaviors

File: phrase-app-symfony4/phrase-app/config/bundles.php

<?php

return [
Knp\DoctrineBehaviors\Bundle\DoctrineBehaviorsBundle::class => ['all' => true],
...

Create a new entity either from the generator or by hand called Product and add some fields.

File: phrase-app-symfony4/phrase-app/src/Entity/Product.php

/**
 * @ORM\Entity(repositoryClass="App\Repository\ProductRepository")
 */
class Product
{
    use ORMBehaviors\Translatable\Translatable;
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue(strategy="NONE")
     * @ORM\Column(type="integer")
     */
    private $id;

    public function getId()
    {
        return $this->id;
    }

    public function setId($id): void
    {
        $this->id = $id;
    }

    /**
     * @ORM\Column(type="integer")
     */
    private $price;

    /**
     * @return mixed
     */
    public function getPrice()
    {
        return $this->price;
    }

    /**
     * @param mixed $price
     */
    public function setPrice($price): void
    {
        $this->price = $price;
    }

}

File: phrase-app-symfony4/phrase-app/src/Entity/ProductTranslation.php

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Model as ORMBehaviors;

/**
 * @ORM\Entity
 */
class ProductTranslation
{
    use ORMBehaviors\Translatable\Translation;

    /**
     * @ORM\Column(type="string", length=255)
     */
    protected $name;

    /**
     * @ORM\Column(type="string", length=255)
     */
    protected $description;

    /**
     * @return string
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * @param  string
     * @return null
     */
    public function setName($name)
    {
        $this->name = $name;
    }

    /**
     * @return string
     */
    public function getDescription()
    {
        return $this->description;
    }

    /**
     * @param  string
     * @return null
     */
    public function setDescription($description)
    {
        $this->description = $description;
    }
}

Create a ProductRepository class as a base manager.

File: phrase-app-symfony4/phrase-app/src/Repository/ProductRepository.php

<?php

namespace App\Repository;

use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Symfony\Bridge\Doctrine\RegistryInterface;

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

    /**
     * @return Product[] Returns an array of Product objects
     */
    /*
    public function findByExampleField($value)
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.exampleField = :val')
            ->setParameter('val', $value)
            ->orderBy('p.id', 'ASC')
            ->setMaxResults(10)
            ->getQuery()
            ->getResult()
        ;
    }
    */

    /*
    public function findOneBySomeField($value): ?Product
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.exampleField = :val')
            ->setParameter('val', $value)
            ->getQuery()
            ->getOneOrNullResult()
        ;
    }
    */
}

Now, if you haven’t set up the database, you can do it now…

php bin/console doctrine:database:create

and then

bin/console make:migration

The last command will create all the necessary migrations for the table schemas.

Now run the migrations to apply them in the database.

php bin/console doctrine:migrations:migrate

If you have the database explorer on, you can see there are two tables created one for the Product and one for the ProductTranslation:

create table product
(
id INTEGER not null
primary key,
price INTEGER not null
);

create table product_translation
(
id INTEGER not null
primary key
autoincrement,
translatable_id INTEGER default NULL,
name VARCHAR(255) not null,
description VARCHAR(255) not null,
locale VARCHAR(255) not null
);

create index IDX_1846DB702C2AC5D3
on product_translation (translatable_id);

create unique index product_translation_unique_translation
on product_translation (translatable_id, locale);

To populate the database, you can use the database explorer itself or use the Product Entity itself:

$entityManager = $this->getDoctrine()->getManager();
$product = new \App\Entity\Product();
$product->setId(1000);
$product->setPrice(1000);
$product->translate('de')->setName('Schuhe');
$product->translate('de')->setDescription('Schuhe');
$product->translate('en')->setName('Shoes');
$product->translate('en')->setDescription('Shoes');
$entityManager->persist($product);

// In order to persist new translations, call mergeNewTranslations method, before flush
$product->mergeNewTranslations();
$entityManager->flush();

Now if you can retrieve the Product Entity using the repository manager and pass the current locale in the translate method to retrieve the correct translations for the field:

$product->translate(locale)->getName();

Integrating Phrase In-Context Editor

If you want to take your internationalization process to the next level, I suggest you take a look at Phrase and specifically its In-Context Editor. It will give you better insights into how the strings get displayed in your software and also help you to manage your translations more easily. Let’s see how can we integrate this into our Symfony app.

I’m using the guidelines from the setup page https://help.phrase.com/en/articles/2265817

Start by creating a new configuration file:

mkdir -p config/packages/translation

Copy the configuration from the dev package to the newly created translation folder.

Make sure to update the bundles to include the new environment.

File: phrase-app-symfony4/phrase-app/config/bundles.php

<?php

return [
    Knp\DoctrineBehaviors\Bundle\DoctrineBehaviorsBundle::class => ['all' => true],
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
    Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle::class => ['all' => true],
    Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
    Doctrine\Bundle\DoctrineCacheBundle\DoctrineCacheBundle::class => ['all' => true],
    Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
    Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
    Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
    Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
    Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true, 'translation' => true],
    Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true, 'translation' => true],
    Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true, 'translation' => true],
    Symfony\Bundle\WebServerBundle\WebServerBundle::class => ['dev' => true, 'translation' => true],
];

Create the PhraseTranslator class as per instructions:

File: phrase-app-symfony4/phrase-app/src/AppBundle/PhraseTranslator.php

<?php

namespace App\AppBundle;


use Symfony\Bundle\FrameworkBundle\Translation\Translator as BaseTranslator;

class PhraseTranslator extends BaseTranslator
{
    public function trans($id, array $parameters = array(), $domain = "messages", $locale = null)
    {
        $prefix = "{{__phrase_";
        $suffix = "__}}";

        if (null === $domain) {
            $domain = 'messages';
        }

        // Return ID of translation key with pre- and suffix for PhraseApp
        return $prefix.$id.$suffix;
    }
}

Add a process method to the Kernel Class to use the PhraseTanslator on this environment:

File: phrase-app-symfony4/phrase-app/src/Kernel.php

public function process(ContainerBuilder $container)
    {
        if ($this->environment == 'translation')
        {
            $definition = $container->getDefinition('translator.default')->setPublic(true);
            $definition->setClass('App\AppBundle\PhraseTranslator');
        }
    }

Then, on your templates include the javascript loader as per instructions:

File: phrase-app-symfony4/phrase-app/templates/base.html.twig

{% if app.environment == 'translation' %}

window.PHRASEAPP_CONFIG = {
   projectId: "YOUR-PROJECT-ID"
};
(function() {
   var phraseapp = document.createElement('script'); phraseapp.type = 'text/javascript'; phraseapp.async = true;
   phraseapp.src = ['https://', 'phraseapp.com/assets/in-context-editor/2.0/app.js?', new Date().getTime()].join('');
   var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(phraseapp, s);
})();

{% endif %}

Navigate to https://app.phrase.com/signup to get a trial version.

Once you set your account up, you can create a project and navigate to Project Setting to find your projectId key.


Now change the environment and restart the server

APP_ENV=translation

When you navigate to the page, you will see the login modal again and once you are authenticated, you will see the translated strings change to include edit buttons next to them. The In-Context Editor panel will also show up.

From there, you can manage your translations more easily.

Conclusion

In this Symfony tutorial, we translated Symfony applications using well-known techniques. We’ve also seen how can we integrate Phrase’s In-Context Editor in our workflow. If you have any other questions left, don’t hesitate to post a comment or drop me a line. Thank you for reading and see you again next time!

4.7 (94%) 30 votes
Comments
close

The Biggest Mistakes to Watch Out For in Localization

Download our FREE INFOGRAPHIC for a strong overview of the crucial mistakes you need to avoid to ensure your localization process has the best outcome possible.