
Global business
Software localization
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
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:
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:
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.
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:
Let’s see some examples below:
Locale::setDefault('el'); $allLocales = Intl::getLocaleBundle()->getLocaleNames(); // array('en' => 'Αγγλικά', 'en_SH' => 'Αγγλικά (Αγία Ελένη)', ...)
Locale::setDefault('el'); $allCurrencies = Intl::getCurrencyBundle()->getCurrencyNames(); // array('ALK' => 'Albanian Lek (1946–1965)', 'AOR' => 'Angolan Readjusted Kwanza (1995–1999)', ...)
Locale::setDefault('el'); $countries = Intl::getRegionBundle()->getCountryNames(); // array('SH' => 'Αγία Ελένη', 'LC' => 'Αγία Λουκία', ...)
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();
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.
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.
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!
Last updated on January 11, 2023.