Translations

Table of contents

Loading...

Introduction

To make the application accessible to people who speak different languages, translation is an important feature.

PHP natively supports the localization system gettext, which means there is no need for an external library. It is also rapid and efficient.

For gettext to load, extension=gettext must be enabled in the php.ini file.

All translatable strings are wrapped in a function called __() which is an alias for gettext().

The translation to the available languages are in .po and .mo files stored in the project.

The PHP function setlocale tells gettext which language it should use.

Then, whenever the function __() is used, gettext will automatically take the correct translation based on the locale.

gettext __() function

The __() function is defined in config/functions.php file.
Composer is configured to autoload this file for every request so the function is available globally across the project.

File: config/functions.php

function __(string $message, ...$context): string
{
    $translated = gettext($message);
    if (!empty($context)) {
        // If context is provided, replace placeholders in the translated string
        $translated = vsprintf($translated, $context);
    }

    return $translated;
}

The context parameter is optional and uses the argument unpacking or "splat operator" ... which allows passing an arbitrary number of arguments to the function that will be wrapped in an array.
This means that the following string with variable content could be translated like this:

__('The %s contains %d monkeys and %d birds.', 'tree', 5, 3);

%s is a placeholder for a string and %d for a number. See other specifiers here.

Translation files

The gettext translations are in .po and .mo files.

The translation files are located in the resources/translations/ directory.
Inside is a subdirectory for each language e.g. de_CH, fr_CH and then, inside each language folder another directory LC_MESSAGES where the .po and .mo files are located.
This is how gettext expects the files to be structured.

Creating a new translation file

Download the free Poedit application.

If there is no .po file yet, create a new one by going to File > New... and select the language.

If there is already an existing .po or .pot file, you can go to File > New from POT/PO file and select the .pot file or change the filetype in the explorer window to search also for .po files. This will import all the words that must be translated as well as path configurations.

The default source language is english and doesn't need a translation file.

Then go to File > Save and save the .po file as follows:

resources/translations/[language]_[country]/LC_MESSAGES/messages_[language]_[country].po

[language] is the language code in lowercase and [country] is the country code in uppercase.
E.g. resources/translations/de_CH/LC_MESSAGES/messages_de_CH.po.

Then the paths that should be scanned for translatable strings must be configured.
This can be done by clicking on the + icon in Translations > Properties > Sources paths.
To include frontend JS files, PHP templates, and the backend PHP files, the following paths must be added:

For Poedit to actually detect the translatable strings, we must tell it that every string inside a __() function is translatable.
This is done by going to Translations > Properties > Source Keywords and adding __ to the list of keywords.

Now the list of translatable strings can be refreshed using "Update from code" button in the toolbar.

Every time the file is saved, the binary .mo file is generated automatically.

When translating, I recommend downloading the desktop app DeepL that can automatically translate strings when copying a word twice CTRL + C + C.

Setting the locale in PHP

Configuration

The path to the translations, the available languages and default locale are configured in config/defaults.php:

$settings['locale'] = [
    'translations_path' => $settings['root_dir'] . '/resources/translations',
    'available' => ['en_US', 'de_CH', 'fr_CH'],
    'default' => 'en_US',
];

Set the correct language for each request

The LocaleMiddleware is responsible for setting the correct language for each request.
It is added at the top of the middleware stack right below the body parser middleware.

File: config/middleware.php

return function (App $app) {
    $app->addBodyParsingMiddleware();

    $app->add(\App\Application\Middleware\LocaleMiddleware::class);
    // ...
};

This middleware sets the language of the application based on the user's preference or the browser's language.

It retrieves the authenticated user id from the session, and if a user is logged in, it fetches the user's preferred language from the database.

If no user is logged in, it retrieves the browser's language from the Accept-Language header of the HTTP request.

Then it sets the language with the LocaleConfigurator.

File: src/Application/Middleware/LocaleMiddleware.php

<?php

namespace App\Application\Middleware;

use App\Domain\User\Service\UserFinder;
use App\Infrastructure\Service\LocaleConfigurator;
use Odan\Session\SessionInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final readonly class LocaleMiddleware implements MiddlewareInterface
{
    public function __construct(
        private SessionInterface $session,
        private UserFinder $userFinder,
        private LocaleConfigurator $localeConfigurator,
    ) {
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        // Get authenticated user id from session
        $loggedInUserId = $this->session->get('user_id');
        // If there is an authenticated user, find their language in the database
        $locale = $loggedInUserId ? $this->userFinder->findUserById($loggedInUserId)->language->value : null;
        // Get browser language if no user language is set
        if (!$locale) {
            // Result is something like: en-GB,en;q=0.9,de;q=0.8,de-DE;q=0.7,en-US;q=0.6,pt;q=0.5,fr;q=0.4
            $browserLang = $request->getHeaderLine('Accept-Language');
            // Get the first (main) language code with region e.g.: en-GB
            $locale = explode(',', $browserLang)[0];
        }
        // Set the language to the userLang if available and else to the browser language
        $this->localeConfigurator->setLanguage($locale);

        return $handler->handle($request);
    }
}

Set the language

The language is set in the LocaleConfigurator.
The configuration values are accessed via the utility class Settings.php.

The setLanguage() method sets up the language for the application based on a provided locale or language code.
It prepares the locale string, checks if the language code is available in the configuration with the private function getAvailableLocale(), sets up the gettext translation system, and checks if a translation file exists for the locale.
If the locale is not 'en_US' and no translation file exists, it throws an exception.
The method returns the result of the setlocale function call, which is the new locale string.

It is part of the infrastructure layer, as it provides a technical capability (locale setting) that supports the application and domain layers.

File: src/Infrastructure/Service/LocaleConfigurator.php

public function setLanguage(string|null|false $locale, string $domain = 'messages'): bool|string
{
    $codeset = 'UTF-8';
    $directory = $this->localeSettings['translations_path'];
    // If locale has hyphen instead of underscore, replace it
    $locale = $locale && str_contains($locale, '-') ? str_replace('-', '_', $locale) : $locale;
    // Get an available locale. Either input locale, the locale for another region or default
    $locale = $this->getAvailableLocale($locale);

    // Get locale with hyphen as an alternative if server doesn't have the one with underscore (windows)
    $localeWithHyphen = str_replace('_', '-', $locale);

    // Set locale information
    $setLocaleResult = setlocale(LC_ALL, $locale, $localeWithHyphen);
    // Check for existing mo file (optional)
    $file = sprintf('%s/%s/LC_MESSAGES/%s_%s.mo', $directory, $locale, $domain, $locale);
    if ($locale !== 'en_US' && !file_exists($file)) {
        throw new \UnexpectedValueException(sprintf('File not found: %s', $file));
    }
    // Generate new text domain
    $textDomain = sprintf('%s_%s', $domain, $locale);
    // Set base directory for all locales
    bindtextdomain($textDomain, $directory);
    // Set domain codeset
    bind_textdomain_codeset($textDomain, $codeset);
    textdomain($textDomain);

    return $setLocaleResult;

    /** 
     * Returns the locale if available, if not searches for the same
     * language with a different region and if not found, 
     * returns the default locale. 
     * @param false|string|null $locale
     * @return string     
     */
    private function getAvailableLocale(null|false|string $locale): string
    {
        // Full code in slim example project LocaleConfigurator.php
    }
}

Translation in JavaScript

JavaScript runs in the browser of the client and thus has no access to the PHP translation system which is in the backend.

Data fetched from the server can be translated before it's sent to the client and PHP templates also have access to the __() function, but non-data elements that are dynamically added via JavaScript like form labels in modal boxes or specific messages coming from the frontend, such as text in confirmation dialoges cannot be translated with PHP.

Appending translated strings to the DOM

It is possible to avoid having to translate anything in JavaScript by appending the text that may be needed to the DOM when it's rendered by PHP and not make it visible.
JavaScript can then access the translated words from the HTML structure.

An appropriate HTML tag for this is <template>. It is used to hold client-side content that should not be rendered immediately when the page is loaded but may be used during runtime by JavaScript.

The <template> tag could contain the entire form or just the text that should be translated.
JavaScript then takes the content it requires from the <template> tag and displays it in the modal box or wherever it's needed.

This is the most straightforward way.

Fetching translations from the server

There is another option to get translated strings in JavaScript: making an Ajax request to the backend which responds with the translations.
For a seamless user experience, the request should be made in the background right after the page has loaded, so they are available instantly when needed.

Creating a __() function for Poedit

Poedit, the application used to translate .po files, has to be configured to scan JavaScript files as well. The public/ path has to be added in the "Source paths" and the strings that should be translated must be wrapped in a __() function for the button "Update from code" to detect them.

A __() function must be defined in JavaScript that returns the string as it is.
File: public/assets/general-js/functions.js

export function __(string) {
    return string;
}

Fetching translation function

The function fetchTranslations in fetch-translation-data.js makes an Ajax request to the backend with the strings that should be translated as an array.

File: public/assets/ajax/fetch-translation-data.js

export function fetchTranslations(wordsToTranslate) {
    const params = new URLSearchParams();
    wordsToTranslate.forEach((value) => {
        params.append('strings[]', value);
    });
    return fetchData(`translate?${params.toString()}`).then(responseJSON => {
        return responseJSON;
    }).catch();
}

The fetchData function makes a GET request to the backend and returns the JSON response.

Example usage

File: public/assets/user/update/change-password-modal.html.js

import {__} from "../../general-js/functions.js";
import {fetchTranslations} from "../../ajax/fetch-translation-data.js";

// List of words that are used in modal box and need to be translated
let wordsToTranslate = [
    __('Change password'),
    __('Old password'),
    __('New password'),
    __('Repeat new password'),
];
// Init translated var by populating it with english values as a default so that all keys are existing
let translated = Object.fromEntries(wordsToTranslate.map(value => [value, value]));
// Fetch translations and replace translated var
fetchTranslations(wordsToTranslate).then(response => {
    // Fill the var with a JSON of the translated words. Key is the original english words and value the translated one
    translated = response;
});

// The translated words can then be accessed via the `translated` object.
export function displayChangePasswordModal() {
    // Using translated string "Change password"
    let header = `<h2>${translated['Change password']}</h2>`;
    // ...
}

Translate PHP action

The PHP action that handles the Ajax request is TranslateAction.
A route is defined that points to this action.

File: config/routes.php

$app->get('/translate', \App\Application\Action\Common\TranslateAction::class)->setName('translate');

The action receives the array of strings that should be translated and calls the PHP __() function that makes the gettext translation. The translated strings are returned as JSON.

<?php

namespace App\Application\Action\Common;

use App\Application\Responder\JsonResponder;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final readonly class TranslateAction
{
    public function __construct(
        private JsonResponder $jsonResponder,
    ) {
    }

    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $queryParams = $request->getQueryParams();
        $translatedStrings = [];
        if (isset($queryParams['strings']) && is_array($queryParams['strings'])) {
            foreach ($queryParams['strings'] as $string) {
                $translatedStrings[$string] = __($string);
            }

            return $this->jsonResponder->encodeAndAddToResponse($response, $translatedStrings);
        }

        return $this->jsonResponder->encodeAndAddToResponse($response, ['error' => 'Wrong request body format.'], 400);
    }
}
^