Implement a "Read in your language" link with Symfony

Published on 2019-04-18 • Modified on 2019-04-19

In this post, we will see how to implement a "Read in your language" link in your pages. The goal will be to detect the user browser preferred language and show him a link if the current page is available in his language. Let's go! 😎

Configuration

The code snippets you see in this post are used by this website, so you are sure "It works β„’". This project uses the following components:

  • Symfony 4.4.0-BETA2
  • PHP 7.2

i18n setup

The posts you are reading on this blog are all available in both English and French. First, let's have a look at the parameters related to i18n (internationalization) in this project:

# config/services.yaml
imports:
  - { resource: posts/29.yaml }
  - { resource: posts/48.yaml }
  - { resource: snippets/49.yaml }

parameters:
    # i18n β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”
    locale: 'en'                         # default locale
    activated_locales: ['%locale%','fr'] # to get as an array
    locales_requirements: '%locale%|fr'  # to inject in controller routes requirements

We have three related parameters in our config/services.yaml file. The default locale that we will use as the reference. Then we build two parameters that will help us to get the list of available locales. Now, let's have a look at the main blog controller:

<?php declare(strict_types=1);

// src/Controller/BlogController.php

namespace App\Controller;

use App\Controller\Post\Post13Trait;
use App\Controller\Post\Post26Trait;
use App\Controller\Post\Post51Trait;
use App\Data\ArticleData;
use App\Entity\Article;
use App\Twig\Extension\SlugExtension;
use App\Utility\BreadcrumbsHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * @Route("/{_locale}/blog", name="blog_", requirements={"_locale"="%locales_requirements%"})
 */
class BlogController extends AbstractController

In the route annotation, we can see two main things. First the locale slug /{_locale} will prefix all URLs of the blog so it's easy to identify the locale of a given resource. (we have two separate trees: /en and /fr) Then, we have a requirement that limits the available locales. So in this case we inject the locales_requirements parameter we have seen in the previous section. It contains: en|fr. Try to access /es (spanish), you will get a 404 error as this locale is not available. (Javier, if you read me! 😁) Now, let's see how to build our switch language link.

Getting the user preferred language

The first thing to do is to detect the user language. Symfony already provides a function for that, it can be accessed through the Request object. Let's see the main application controller that handles the root URL of this website:

<?php declare(strict_types=1);

namespace App\Controller;

use App\Entity\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * App generic actions and locales root handling.
 */
class AppController extends AbstractController
{
    /**
     * @Route("/", name="root")
     */
    public function root(Request $request): Response
    {
        $locale = $request->getPreferredLanguage($this->getParameter('activated_locales'));

        return $this->redirectToRoute('homepage', ['_locale' => $locale]);
    }

As you can see, we are getting the preferred language of the user. We are passing the activated locales as an argument. If the user preferred language matches one of theses values then it will return it, otherwise it will return the default locale. Then a redirection is made to the locale homepage: /en or /fr.
This was an example to show how the preferred language function works. Now let's have a look on how to use this inside templates. You can use the following functions directly on the request object:

Variable Twig call Result Doc
The user preferred locale {{ app.request.preferredLanguage(activated_locales) }} en API
The current locale {{ app.request.locale }} en API
The default locale {{ app.request.defaultLocale }} en API

To avoid manually calling these functions, we will introduce a Twig extension to ease the job:

<?php declare(strict_types=1);

// src/Twig/Extension/LangExtension.php

namespace App\Twig\Extension;

use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;

/**
 * Locale pre-computed values.
 */
class LangExtension extends AbstractExtension implements GlobalsInterface
{
    private const LANG_FR = 'fr';

    protected $requestStack;
    protected $activatedLocales;
    protected $defaultLocale;

    public function __construct(RequestStack $requestStack, array $activatedLocales)
    {
        $this->requestStack = $requestStack;
        $this->activatedLocales = $activatedLocales;
        $this->defaultLocale = $this->activatedLocales[0];
    }

    public function getGlobals(): array
    {
        $request = $this->requestStack->getMasterRequest();
        $route = $request ? $request->attributes->get('_route') : '';
        $locale = $request ? $request->getLocale() : $this->defaultLocale;
        $userLocale = $request ? $request->getPreferredLanguage($this->activatedLocales) : $this->defaultLocale;

        $globals = [
            'route' => $route,
            'locale' => $locale,
            'alt_locale' => $locale === $this->defaultLocale ? self::LANG_FR : $this->defaultLocale,
            'user_locale' => $userLocale,
        ];

        return $globals;
    }
}

We introduce several pre-computed global variables: the current route, the current page locale, the alternative locale and the user preferred locale. The alternative locale variable will be the opposite of the current language as the blog only handles two. The user preferred locale uses the same call we used before, we have to pass the activated locales argument. Now, we can call these global variables in all our templates:

Twig global Twig call Result
The current route {{ route }} blog_show
The current locale {{ locale }} en
The alternate locale {{ alt_locale }} fr
The user preferred locale {{ user_locale }} en

Now that we have these variables, we can check if we should propose a link. It fact we will show it if the alternate language matches the user's locale. Let's look at the code:

{# templates/_read_in_your_lang.html.twig #}

{% trans_default_domain 'locale' %}

{% if alt_locale == user_locale or app.request.get('force') %}
    {% set alt_slug = ('slug_'~slug)|trans({}, 'blog', alt_locale) %}
    {% set alternate_url = path('blog_show', {'slug': alt_slug, '_locale': alt_locale}, alt_locale) %}
    <br/>
    <a name="alt-box"></a>
    <div class="card card-nav-tabs text-center">
        <div class="card-header card-header-primary">
            <p class="h4">{{ 'alt_lang_detected'|trans({}, 'locale', alt_locale) }}</p>
        </div>

        <div class="card-body">
            <h4 class="card-title">&nbsp; {{ 'read_in_your_lang'|trans({}, 'locale', alt_locale)|raw }}</h4>
            <a href="{{ alternate_url }}" class="btn btn-primary">{{ 'read_in_your_lang_button'|trans({}, 'locale', alt_locale) }}</a>
        </div>
    </div>
{% endif %}

Some explanations. First we use a specific translation domain "locale". I am using this so I can show you the content of two translation files I am using. (see below). The main test is explicit. I have added an "or" condition to force the box to be displayed with a get parameter. After that, we set two variables: the first one will contain the translated slug of the article and the second one will contain the alternate URL. Note that we pass "alt_locale" as an argument to the trans Twig function because we don't want to use the current locale of the page but the user's preferred one to encourage them to click. Then we create the link with some explanation texts. There are two cases: you can see the box, or (if you don’t see it) click on the link below. It will add the special get parameter to force its display.

You don't see the link box because your lang is en and the current article locale in en. (or you don't have the ?force=1 get parameter in the url). To see it, click on the link below.

 Show the link

And as promised the two translations files used in this post (click here to see).
# translations/locale.en.yaml
read_in_your_lang: >
  We noticed that your browser is using English.
  Do you want to read this post in this language?

alt_lang_detected: English language detected! πŸ‡¬πŸ‡§

read_in_your_lang_button: Read the english version πŸ‡¬πŸ‡§
# translations/locale.fr.yaml
read_in_your_lang: >
  Nous avons remarqué que votre navigateur utilise le français,
  voulez-vous lire cet article dans cette langue ?

alt_lang_detected: Langue franΓ§aise detectΓ©e ! πŸ‡«πŸ‡·

read_in_your_lang_button: Lire en franΓ§ais πŸ‡«πŸ‡·

That's it! I hope you like it. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😁

 The Symfony doc

They gave feedback and helped me to fix errors and typos in this article, many thanks to TimoBakx, Lynn, Pierstoval, jmsche. πŸ‘

» Published in "A week of Symfony 642" (15-21 April 2019).


» Comments

Privacy-focused with Commento. (Comment system added on 2019-11-18: be the first! πŸ₯‡)

» Call to action

Did you like this post? You can help me back in several ways: (use the box above to comment or the Tweet on the right to contact me )

  • Report any error/typo.
  • Report something that could be improved.
  • Like and retweet!
  • Follow me on Twitter
  • Subscribe to the RSS feed.

Thank you for reading! And see you soon on Strangebuzz! πŸ˜‰

COil