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
/**
* Translate a message string using gettext with optional placeholder replacement.
*
* This function serves as a wrapper around gettext() for internationalization (i18n).
* It supports sprintf-style placeholders for dynamic content insertion.
*
* Example usage:
* __('The %s contains %d monkeys and %d birds.', 'tree', 5, 3);
* // Returns: "The tree contains 5 monkeys and 3 birds."
*
* @param string|null $message The message to be translated. May contain sprintf placeholders
* (e.g., %s for strings, %d for integers). Null returns empty string.
* @param mixed ...$context Optional values to replace placeholders in the translated string.
* Values are passed to vsprintf() in the order provided.
*
* @return string The translated string with placeholders replaced, or empty string if the message is null
*/
function __(?string $message, ...$context): string
{
if ($message === null) {
return '';
}
$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.
.pofiles (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..mofiles (Machine Object files) are binary files that are generated from.pofiles. 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\Module\Localization\Infrastructure\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 coming from the server can be translated before it's sent to the client.
PHP templates also have access to the __() function, but
elements that are dynamically added to the dom 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.
Passing translations to JavaScript via PHP template
The preferred way to handle translations in JavaScript is to pass them from the PHP template to the frontend. This avoids making extra Ajax requests and ensures translations are available as soon as the scripts run.
1. Add translations in the PHP template
In the .html.php template file, addTranslationsArray() function can be used within a <script> tag.
This function translates an array of strings and encodes them as JSON.
Example: templates/user/user-list.html.php
<script id="page-translations" type="application/json">
<?= addTranslationsArray([
'Edit user', 'User updated successfully.', 'Cancel', 'Save'
]) ?>
</script>
The addTranslationsArray() function (defined in config/functions.php) creates an associative array where the keys
are the original English strings and the values are their translations.
2. Initialization
The translations from the JSON script tags are automatically loaded into window.translations by
public/assets/general-js/initialization.js when the page loads.
It scans for script tags with the ID page-translations or layout-translations.
// "DOMContentLoaded" is fired when the initial HTML document has been completely loaded and parsed,
// without waiting for stylesheets, images, etc. to finish loading
window.addEventListener("DOMContentLoaded", function (event) {
loadTranslations();
});
function loadTranslations() {
// Global translations object
window.translations = {};
const translationElements = document.querySelectorAll('script[type="application/json"][id$="-translations"]');
translationElements.forEach(element => {
try {
const translationsArray = JSON.parse(element.textContent);
Object.assign(window.translations, translationsArray);
} catch (e) {
console.error("Could not parse translations from " + element.id, e);
}
});
}
3. Accessing translations in JavaScript
In the JavaScript file, the __ function from functions.js looks up the translation in the window.translations
object (populated during initialization) and returns it. If no translation is found, it returns the original key.
The function name __ is important, because it helps the program Poedit identify it as a translation string.
Example: public/assets/user/list/user-list-dom.js
import {__} from "../../general-js/functions.js";
export function addUserToDom(user) {
// ...
const editBtnLabel = __('Edit user');
// ...
}
The __() function in JavaScript also supports basic placeholder replacement (e.g., __('Hello %s', 'World')).