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.
.po
files (Portable Object files) are text files that contain the original text and the translated text for each string in the application. They are human-readable and editable..mo
files (Machine Object files) are binary files that are generated from.po
files. They are used by the gettext library during runtime to quickly look up translations and are not meant to be manually edited.
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:
src/
templates/
public/
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);
}
}