Module Language Files

3 views Markdown

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

layout
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.

lang/en.php
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.

OrderWhat decides the languageApplies when
1The language argument you passedYou named one explicitly
2The registry's shared language markerA module was already loaded in some language this request
3The active interface languageThe marker is still empty
4The installation's default localeNo interface language is selected
5EnglishNothing else answered
then the file, with one fallback
// 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

  1. Create lang/en.php returning an array with name and description.
  2. Copy it to lang/tr.php and translate the values, leaving every key as it was.
  3. Reload the module list. The module now shows its own label instead of its directory name.

Read a String

  1. 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.
  2. 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.
  3. Change the panel language and reload. The same code now gives the other file's value.

Label a Configuration Entry

  1. In a settings field descriptor, put the language value into the name and description entries. That array is built at runtime and can read the property.
  2. 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.
  3. Open the settings screen in both languages and confirm each label follows.

Reference

The Loader

signatures
// 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;
Lang() Returns the array, or an empty array when neither the requested file nor the English file exists. It never returns null, so reading the result is safe.
Config() Returns null when the module has not been loaded, because it only reads the cache. Unlike the language loader, it does not go to disk.
The shared language marker One static value for the whole registry, set to the last language anyone asked for. Passing an explicit language changes it for every later call that omits one.
When a reload happens Only when the requested language differs from the marker, or the module has no cached strings yet. A module already cached is not re-read when the marker moves.

Keys the Platform Reads

name The label in the module list, and everywhere a module is named. Without it: the configuration's name entry, then the directory name.
description The sentence under the label in the module list. One line in the operator's language: what it connects to and what it needs.
{lang.key} Accepted where a server module's configuration file holds a label as static data: a card item, an add-on parameter. Resolved against this module's own array; an unknown key appears as written, not as an empty string.
Sorting side effect A type listing is ordered by the resolved display name, so translating 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 and lang/tr.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',
];
reading them in the class
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'];
}
config.php, where PHP cannot be called
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 missing key does not fall back to English

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.

Asking for a specific language moves a shared marker

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.

Module strings and platform strings are different systems

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.

Do not build a sentence out of fragments

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.

The placeholder form only works where it is resolved

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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.