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! 😎

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

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 6.4.3
  • PHP 8.3

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:

parameters:
    # i18n β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”
    locale: 'en'                         # default locale
    activated_locales:    []             # This parameter is dynamically injected in src/Kernel.php to get the value of the "framework.translator.enabled_locales" parameter.
    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:


#[Route(path: '/{_locale}/blog', name: 'blog_', requirements: ['_locale' => '%locales_requirements%'])]

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\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

/**
 * App generic actions and locales root handling.
 */
final class AppController extends AbstractController
{
    public function __construct(
        public readonly ArticleRepository $articleRepository,
    ) {
    }

    #[Route(path: '/', 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) }} 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);

namespace App\Twig\Extension;

use App\Enum\Locale;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;

/**
 * Locale pre-computed values.
 */
final class LangExtension extends AbstractExtension implements GlobalsInterface
{
    private RequestStack $requestStack;

    /**
     * @var array<int,string>
     */
    private array $activatedLocales;
    private string $defaultLocale;

    /**
     * @param array<int,string> $activatedLocales
     */
    public function __construct(RequestStack $requestStack, array $activatedLocales)
    {
        $this->requestStack = $requestStack;
        $this->activatedLocales = $activatedLocales;
        $this->defaultLocale = $this->activatedLocales[0];
    }

    /**
     * @return array<string,mixed>
     */
    public function getGlobals(): array
    {
        $request = $this->requestStack->getMainRequest();
        if (!$request instanceof Request) {
            return [];
        }

        $route = $request->attributes->get('_route');
        $locale = $request->getLocale();
        $userLocale = $request->getPreferredLanguage($this->activatedLocales);

        return [
            'route' => $route,
            'locale' => $locale,
            'alt_locale' => $locale === $this->defaultLocale ? Locale::FR->value : $this->defaultLocale,
            'user_locale' => $userLocale,
        ];
    }
}

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' %}

<a id="alt-box"></a>
{% if (article.id == 21 or (not strike)) and article.hasLanguage(alt_locale) %}
    <div v-show="showLangSwitcher" style="display:none">
        {% 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/>
            <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">
                    <p class="card-title h4">&nbsp; {{ 'read_in_your_lang'|trans({}, 'locale', alt_locale)|raw }}</p>
                    <a href="{{ alternate_url }}" class="btn btn-primary">{{ 'read_in_your_lang_button'|trans({}, 'locale', alt_locale) }}</a>
                    <a v-on:click.prevent="disableLangSwitcher" class="btn" href="#">{{ 'close'|trans({}, 'locale', alt_locale) }}</a>
                </div>
            </div>
        {% endif %}
    </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.


Langue franΓ§aise dΓ©tectΓ©e ! πŸ‡«πŸ‡·

  Nous avons remarquΓ© que votre navigateur utilise le franΓ§ais, voulez-vous lire cet article dans cette langue ?

Lire en franΓ§ais πŸ‡«πŸ‡· Fermer

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 πŸ‡¬πŸ‡§

close: Close
# 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 dΓ©tectΓ©e ! πŸ‡«πŸ‡·

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

close: Fermer

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. πŸ‘

  Work with me!


Call to action

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

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

COil