Module Language Files
Give a module its own translatable strings. One file per language, how the right one is chosen, and why a missing key does not fall back to English.
Overview
A module carries its strings in a lang directory: one PHP file per language code, each returning a flat array. The loader picks exactly one file and hands the whole array to your class as a property.
The fallback is per file, not per key. If the active language has a file, that file is all you get, and any key it does not define is absent. Keeping every file on the same key list is what makes the model safe. Measured here: of 297 modules that ship an English and a Turkish file, 295 pairs carry identical top level keys. The other two are Turkish files holding keys English never defines, which is the failure this article is about.
Prerequisites
- A module directory that already loads; the language file is read on every load, class or not.
- An English file: the last resort for every language with no file of its own.
- Knowing which strings are yours and which belong to the platform, covered in Translations and Language Files.
Structure
The Directory
coremio/modules/{Type}/{Name}/lang/
├── en.php # the fallback for every language without a file of its own
├── tr.php # same keys, translated
└── de.php # add a file per language you support; the code is the file name
Every file returns a flat array. Two keys are read by the platform; the rest are yours to name.
return [
// Read by the platform: the label and the blurb in the module list.
'name' => 'Acme Domains',
'description' => 'Domain registration through the Acme API. An API key is required.',
// Yours. Group them with a prefix so a long file stays navigable.
'username' => 'Username',
'username-desc' => 'The API account this installation connects with.',
'api-key' => 'API Key',
'test-mode' => 'Test Mode',
'error-no-domain' => 'No domain name is bound to this service.',
'error-refused' => 'The provider refused the request.',
// Placeholders are positional, and the caller passes the values.
'error-locked' => 'The domain %s is locked and cannot be transferred.',
];
How the File Is Chosen
The language is resolved first, then the file. The two steps are separate, and only the second has a fallback.
| Order | What decides the language | Applies when |
|---|---|---|
| 1 | The language argument you passed | You named one explicitly |
| 2 | The registry's shared language marker | A module was already loaded in some language this request |
| 3 | The active interface language | The marker is still empty |
| 4 | The installation's default locale | No interface language is selected |
| 5 | English | Nothing else answered |
// Exactly one file is included. There is no per-key merge with English.
if (file_exists($path . 'lang' . DS . $lang . '.php'))
$strings = include $path . 'lang' . DS . $lang . '.php';
elseif (file_exists($path . 'lang' . DS . 'en.php'))
$strings = include $path . 'lang' . DS . 'en.php';
// Neither present: the property is an empty array, and every read falls to its default.
Walkthrough
Add the Files
- Create
lang/en.phpreturning an array withnameanddescription. - Copy it to
lang/tr.phpand translate the values, leaving every key as it was. - Reload the module list. The module now shows its own label instead of its directory name.
Read a String
- Inside the class, read from the language property with a null-safe default. The key may be missing on an installation running an older translation.
- For a string with a value in it, keep the placeholder in the language file. Format at the call site, so translators see the whole sentence.
- Change the panel language and reload. The same code now gives the other file's value.
Label a Configuration Entry
- In a settings field descriptor, put the language value into the
nameanddescriptionentries. That array is built at runtime and can read the property. - Where a server module's configuration file holds static data rather than code, write the placeholder form
{lang.key}instead. The module resolves it against the same array. - Open the settings screen in both languages and confirm each label follows.
Reference
The Loader
// Module strings. Loads the file itself, so no prior load call is needed.
// $lang is a language code such as 'en' or 'tr'; empty means "resolve it".
public static function Lang($type, $module, $lang = '');
// Module configuration. Reads the static cache ONLY: returns null when nothing loaded it.
// This asymmetry with Lang() is the single most surprising thing about the pair.
public static function Config($type, $module);
// The display label: lang['name'], then config['name'], then the directory name.
// It performs the load for you.
public static function getName(string $type, string $module): string;
// Platform strings, NOT module strings. A module uses these for shared wording only.
public static function g($key = '', $replaces = [], $slang = ''): array|string|int|bool;
public static function gc($name = '', $replaces = [], $slang = ''): array|string|int|bool;
public static function selected(): string;
Keys the Platform Reads
name also moves the module in that language's list.
Example
Both files, then the two places the strings are read: the class, and a configuration entry that cannot call PHP.
// lang/en.php
return [
'name' => 'Acme Domains',
'description' => 'Domain registration through the Acme API.',
'api-key' => 'API Key',
'api-key-desc' => 'Found under Account, API in the provider panel.',
'error-locked' => 'The domain %s is locked and cannot be transferred.',
'addon-privacy' => 'WHOIS Privacy',
];
// lang/tr.php : the SAME keys, in the same order, values translated.
return [
'name' => 'Acme Alan Adları',
'description' => 'Acme API üzerinden alan adı kaydı.',
'api-key' => 'API Anahtarı',
'api-key-desc' => 'Sağlayıcı panelinde Hesap, API altında bulunur.',
'error-locked' => '%s alan adı kilitli ve transfer edilemez.',
'addon-privacy' => 'WHOIS Gizliliği',
];
public function config_fields($data = []): array
{
return [
'apiKey' => [
// Always with a default: a translation shipped before this key existed
// would otherwise render an empty label.
'name' => $this->lang['api-key'] ?? 'API Key',
'description' => $this->lang['api-key-desc'] ?? '',
'type' => 'password',
'value' => $data['apiKey'] ?? '',
],
];
}
public function transfer(): array|bool
{
if ($this->is_locked()) {
// The placeholder lives in the language file; the value is applied here,
// so a translator sees the whole sentence rather than two fragments.
$message = sprintf(
$this->lang['error-locked'] ?? 'The domain %s is locked.',
(string) ($this->service['domain'] ?? ''),
);
throw new \Exception($message);
}
// A platform string, not a module string: the wording is shared with the rest
// of the panel and does not belong in this module's files.
if (!$this->credentials()['apiKey'])
throw new \Exception(\Language::gc("admin/modules/error-missing-credentials"));
return ['status' => 'SUCCESS'];
}
return [
'addon-params' => [
// Static data, resolved against lang/ when the screen renders it.
'whois_privacy' => [
'label' => '{lang.addon-privacy}',
'description' => '{lang.addon-privacy-desc}',
'type' => 'toggle',
],
],
];
Pitfalls
A key present in English and absent from the active language is absent, full stop. It shows whatever default your read supplied. Add a key to every language file in the same change, and always read with a default.
The registry keeps one language marker for the whole request, and requesting a module in another language sets it. Measured: a module already cached kept returning its first language after the marker moved. Do not request a specific language on a display path unless the whole page is in it.
Your own wording lives in the module's files, read from the language property. Wording shared with the panel comes from the platform's translation helpers. A module cannot add keys to those, so anything you invent lives in your own files.
Concatenating two keys around a value gives word order that works in one language only. Keep the whole sentence in one key with a positional placeholder, and apply the value at the call site.
Writing a language placeholder into an arbitrary configuration value does nothing. It is expanded only where the resolver is called: a server module's card item and add-on parameter labels. Anywhere else it reaches the screen as literal text.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.