Translations
Module localization: Messages files and the 29-language translation workflow.
Every string a MikoPBX module shows to a user — page titles, field labels, validation prompts, REST endpoint descriptions — is a translation key, not a hard-coded string. The module ships a Messages/ folder with one PHP file per locale; the Core merges those files into a single global translation array at request time and resolves keys on the server (Volt, models) and in the browser (JavaScript).
Throughout this page the running example is ModuleBlackList (config class BlackListConf, main class BlackListMain, model BlackListNumbers, table m_ModuleBlackList_BlackListNumbers, front-end assets named per action — module-black-list-index.js, module-black-list-modify.js). Its translation keys use the prefix module_black_list_.
The Messages/ directory
A feature module keeps its translations in a flat directory — one file per locale, each returning a key => translation array:
Extensions/ModuleBlackList/Messages/
├── ru.php ⭐ source of truth — edit ONLY this
├── en.php
├── de.php
├── es.php
├── ...
├── zh_Hans.php
└── languages.php key scaffold (see below) — not a runtime localeThis flat layout is the standard structure for an ordinary feature module. Every shipped example uses it:
Extensions/ModuleTemplate/Messages/— the canonical starter set.Extensions/EXAMPLES/WebInterface/ModuleExampleForm/Messages/— a web-interface module (shipsru.php+en.php).Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Messages/— a REST module (shipsru.php+en.php).Extensions/ModuleUsersGroups/Messages/— a full production module with the complete locale set.
Anatomy of a locale file
A locale file is a plain PHP file that returns an associative array. Here is ru.php for ModuleBlackList — the source of truth you edit by hand:
The matching en.php has identical keys, with the values translated:
See the real, copy-ready pair in Extensions/ModuleTemplate/Messages/ru.php and Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Messages/en.php.
languages.php is a key scaffold, not a locale
Messages/languages.php looks like a locale file, but it is not loaded at runtime. The loader (MessagesProvider::loadModuleTranslations()) only reads Messages/<langcode>.php for codes the Core actually knows about, and languages is not a language code — so it is silently ignored when building the translation array.
What it actually is: a developer-facing template listing every translation key the module defines, with empty string values:
It serves as a quick "what keys exist" index and a scaffold to copy when seeding a new locale. It does not affect what the user sees.
Where keys are referenced
The merged translation array is exposed in three places. Use the same key string in all of them.
1. Volt templates — t._('key')
In .volt views, translate with the t (translation) service:
Real usage: Extensions/EXAMPLES/WebInterface/ModuleExampleForm/App/Views/AdditionalPage/index.volt.
2. JavaScript — globalTranslate.key
The Core injects the active locale's translation array into the page as the global globalTranslate object. Reference any key as a property:
Real usage: Extensions/EXAMPLES/WebInterface/ModuleExampleForm/public/assets/js/module-example-form-modify.js.
globalTranslate is a JavaScript object: globalTranslate.someKey resolves to the string, and a missing key is undefined, not the key name. Declare it in the file's /* global ... */ comment so the linter does not flag it.
3. Models — rep… representation keys
A module model's human-readable label (shown in lists, deletion warnings, related-record links) comes from a rep<ModuleUniqueID> key resolved through the model's getRepresent() method. Module models extend ModulesModelsBase, which overrides getRepresent() in Core/src/Modules/Models/ModulesModelsBase.php. When called with a link (getRepresent(true)), it resolves:
So for ModuleBlackList (UniqueID ModuleBlackList) the key is repModuleBlackList — keyed on the module's UniqueID, not on the model or any field. The %represent% placeholder is filled with the linked module name (an <a> tag), producing e.g. Black list - <a href="…">Black list</a>. Without a link, getRepresent() returns the mo_<ModuleUniqueID> title instead and the rep… key is not used.
Use the placeholder spelled %represent% — that is the name the Core passes (['represent' => …]). Some shipped starter files (ModuleTemplate, ModuleUsersGroups) contain the misspelling %repesent% and even leftover demo text like "Module amoCRM"; do not copy those into your module. Match the placeholder name the Core supplies, or the substitution will not happen.
The same starter files also ship the model-title key with a doubled prefix — mo_ModuleModuleTemplate, mo_ModuleModuleUsersGroups. The Core reads mo_ + moduleUniqueID (ModulesModelsBase::getRepresent(), line 75), i.e. mo_ModuleTemplate / mo_ModuleUsersGroups, so the doubled keys are dead weight and the key that is actually looked up is missing. Use the single form.
Key naming conventions
module_{feature}_{Name}
UI labels, buttons, validation prompts
module_black_list_AddNewRecord
mo_{ModuleUniqueID}
Model title used by getRepresent()
mo_ModuleBlackList
Breadcrumb{Page}
Breadcrumb / page heading — and the left-menu caption for Breadcrumb{ModuleUniqueID}
BreadcrumbModuleBlackList
SubHeader{Page}
Sub-header descriptive text
SubHeaderModuleBlackList
rep{ModuleUniqueID}
Model representation (with %represent%)
repModuleBlackList
module_{feature}_Validate{...}
Validation messages read from JS
module_black_list_ValidateNumberEmpty
The prefix convention module_{feature}_ keeps a module's keys namespaced so they never collide with the Core's keys or another module's. For ModuleBlackList the feature prefix is module_black_list_. REST-API modules add their own families (rest_, rest_tag_, rest_param_, rest_schema_, …) — see the full set in Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Messages/en.php.
The /translations workflow (29 languages)
MikoPBX ships interface translations for a broad set of languages. The authoritative active set is the constant LanguageProvider::AVAILABLE_LANGUAGES in Core/src/Common/Providers/LanguageProvider.php — currently these 26 codes:
The starter ModuleTemplate/Messages/ directory ships 28 locale files (the 26 above plus he and fa, which are harmless extras kept for historical reasons) alongside the languages.php scaffold. When the translation skill talks about "29 languages," that figure counts the full shipped file set, not the active languages the UI offers — for what is actually rendered, trust AVAILABLE_LANGUAGES. New locales are added to a module by adding the matching Messages/<code>.php file for any code present in that constant.
The end-to-end process is owned by the /translations skill (skills/translations/SKILL.md in mikopbx/agent-skills):
Collect keys in
ru.phpfirst. Add or change every key in Russian only, with the correctmodule_{feature}_prefix. Russian is the baseline against which all other files are validated.Translate file-by-file, language-by-language. Complete one locale fully (analyse → translate the missing keys → merge → validate) before starting the next. Never edit several files at once.
Preserve existing translations. Only the missing keys are translated; correct existing values are left untouched.
Validate key count and structure after every locale. Each locale file must have the exact same key count, the same keys, and the same array structure as
ru.php. If Russian has 47 keys, every other locale must have exactly 47. A mismatch stops the process.Keep
%placeholder%names identical. Placeholders use the%name%form (e.g.%represent%,%length%); the placeholder names must match the Russian source verbatim in every locale.Do not translate technical terms. Keep
SIP,IAX,AMI,PJSIP,NAT,STUN,TURN,RTP,CDR,IVR,DID,CID,DTMF,codec,trunk,extension,IP,DNS,VPNunchanged in every language.Escape quotes for PHP. A single quote inside a single-quoted value must be escaped:
'He said: "Don\'t"'.
Common mistakes
How the Core assembles translations
You do not call any loader yourself — MessagesProvider in Core/src/Common/Providers/MessagesProvider.php does it on each request:
Loads the Core's English strings as a base.
Merges the Core's strings for the active language (if not English).
Merges English strings from every module's
Messages/(so an untranslated key always has some value).Merges the active language's strings from every module's
Messages/.Merges the static language-name array from
LanguageProvider(theex_English,ex_Russian, … keys).Caches the result (
ManagedCache, keyLocalisationArray:<versionHash>:<lang>), but only for web requests — under CLI (php_sapi_name() === 'cli') no cache key is built and the array is rebuilt on every run.
Because step 3 always loads module English first, en.php is your safety net: any key missing from a locale falls back to English rather than showing a raw key. Keep en.php complete.
Translations are cached. After editing Messages/*.php you must clear the cache before the change appears, then hard-refresh the browser (Ctrl+Shift+R / Cmd+Shift+R).
The managed cache lives in Redis database 4 (ManagedCacheProvider::DATABASE_INDEX), not database 0, so you must pass -n 4 — a bare redis-cli FLUSHDB targets database 0 and clears nothing:
The cached array is also keyed on the module version hash, so bumping the module version invalidates it. Restarting php-fpm (monit restart php-fpm) does not clear the cache — the entries live in Redis, not in the PHP process.
Checklist before shipping
Related pages
Module interface (Messages folder) — where the
Messages/directory sits in the overall module layout.What the AI generator produces — the generator chains the
/translationsskill as its final step, emittingru.phpfirst and then the full locale set automatically.
Last updated
Was this helpful?