Internationalisation - edmozley/freeitsm GitHub Wiki
FreeITSM has native multi-language support built in โ no framework, no build step, no external translation service. UI strings are looked up at render time through a tiny t('namespace.key') helper that works identically in PHP and JavaScript, with automatic per-key fallback to English.
The active language follows the logged-in analyst's preference (set in System โ Preferences), so two analysts on the same install can use the product in different languages.
en English |
af Afrikaans |
fr Franรงais |
de Deutsch |
es Espaรฑol |
pt-BR Portuguรชs (BR) |
nl Nederlands |
it Italiano |
pl Polski |
ru ะ ัััะบะธะน |
uk ะฃะบัะฐัะฝััะบะฐ |
id Bahasa Indonesia |
nb Norsk bokmรฅl |
nn Norsk nynorsk |
hi เคนเคฟเคจเฅเคฆเฅ |
bn เฆฌเฆพเฆเฆฒเฆพ |
ta เฎคเฎฎเฎฟเฎดเฏ |
te เฐคเฑเฐฒเฑเฐเฑ |
mr เคฎเคฐเคพเค เฅ |
kn เฒเฒจเณเฒจเฒก |
ml เดฎเดฒเดฏเดพเดณเด |
gu เชเซเชเชฐเชพเชคเซ |
pa เจชเฉฐเจเจพเจฌเฉ |
Norwegian ships as both written standards. Bokmรฅl and Nynorsk are separate locales rather than one "Norwegian", and they are listed under their native names because a Nynorsk reader offered only "Norsk" cannot tell which one they will get.
Adding a language is a code change (add it to I18n::SUPPORTED_LOCALES and create the lang/<code>/ folder) โ deliberate, so the locale parameter can never be used for path traversal. Locale codes follow BCP 47 (the same form used in the HTML lang attribute).
Doing it in practice: see Adding a Language โ developer guide for the fan-out workflow that translates all 23 modules, and the four verification checks that catch what the silent per-key fallback hides.
-
Lang files live at
lang/<locale>/<module>.phpand return a nested PHP array. The first dot-segment of at()key is the filename; the rest walks the array.t('cmdb.nav.browse')โlang/<locale>/cmdb.phpโ['nav']['browse']. -
PHP calls
t('cmdb.nav.browse')(orhtmlspecialchars(t(...))for HTML output). Defined inincludes/i18n.php. -
JavaScript calls
window.t('cmdb.nav.browse'). The host page injectswindow.translationsfromI18n::exportForJs([...])with the English fallback already merged in per key; the JS bridge (assets/js/i18n.js) just walks the dotted path. - Per-key fallback. If a locale file is missing a key (or doesn't exist at all), that single string falls back to English โ everything else in the locale still applies. A completely unfilled key surfaces as the literal key, so gaps are visible during development rather than rendering blank.
-
Interpolation.
t('common.welcome', ['name' => 'Ed'])substitutes{name}. Unknown placeholders are left intact. -
Locale resolution (priority order): analyst's
interface_languagepreference โ browserAccept-Language(best supported match, primary-subtag fallback) โen. Changing it in Preferences persists touser_preferencesand reloads the page. -
Security. Namespace identifiers are regex-validated; locale codes are checked against the supported list before being used as a path; the JS bridge JSON is emitted with
JSON_HEX_*flags to prevent script-tag injection from a translation value.
๐ง๐ท Portuguรชs (Brasil) โ 100% complete (2026-06-28). pt-BR has been brought to full key parity with English across all 23 modules: the six modules that had no pt-BR file (System, Contracts, Network Mapper, Self-Service, System Wiki, Setup) were translated, and every later-added key in the already-translated modules (notably the Tickets/Tasks/Workflows/Process-Mapper in-app help & SLA documentation) was filled in. Verified lint-clean with zero missing keys and zero placeholder mismatches. The per-locale tracker below still reflects the broader multi-locale rollout.
Internationalising a module happens in two phases: first its pages are wired (strings extracted to keys, the t() / window.t() bridge added), then the 19 locale files are generated. The tracker below shows both at a glance.
| Module | ๐ง Pages wired | ๐ Translations (19 locales) |
|---|---|---|
| โ Watchtower |
โโโโโ 2 / 2 |
โโโโโ 19 / 19 |
| โ Tickets |
โโโโโ 11 / 11 |
โโโโโ 19 / 19 |
| โ Tasks |
โโโโโ 6 / 6 |
โโโโโ 19 / 19 |
| โ Assets |
โโโโโ 7 / 7 |
โโโโโ 19 / 19 |
| โ Knowledge |
โโโโโ 4 / 4 |
โโโโโ 19 / 19 |
| โ Change Management |
โโโโโ 6 / 6 |
โโโโโ 19 / 19 |
| โ Calendar |
โโโโโ 4 / 4 |
โโโโโ 19 / 19 |
| โ Morning Checks |
โโโโโ 3 / 3 |
โโโโโ 19 / 19 |
| โ Reporting |
โโโโโ 5 / 5 |
โโโโโ 19 / 19 |
| ๐ก Software |
โโโโโ 6 / 6 |
โโโโโ 18 / 19 |
| ๐ก Forms |
โโโโโ 6 / 6 |
โโโโโ 17 / 19 |
| โ Service Status |
โโโโโ 3 / 3 |
โโโโโ 19 / 19 |
| โ LMS |
โโโโโ 3 / 3 |
โโโโโ 19 / 19 |
| โ Process Mapper |
โโโโโ 3 / 3 |
โโโโโ 19 / 19 |
| โ CMDB |
โโโโโ 4 / 4 |
โโโโโ 19 / 19 |
| โ Workflows |
โโโโโ 4 / 4 |
โโโโโ 19 / 19 |
| ๐ก System |
โโโโโ 11 / 11 |
โโโโโ 0 / 19 |
| ๐ก Network Mapper |
โโโโโ 3 / 3 |
โโโโโ 0 / 19 |
| ๐ก Self-Service |
โโโโโ 6 / 6 |
โโโโโ 0 / 19 |
| ๐ก Contracts |
โโโโโ 21 / 21 |
โโโโโ 0 / 19 |
| ๐ก System Wiki |
โโโโโ 7 / 7 |
โโโโโ 0 / 19 |
| ๐ก Setup |
โโโโโ 1 / 1 |
โโโโโ 0 / 19 |
Legend โ โ
fully done ยท ๐ก in progress ยท โฌ not started. Pages wired counts full-document pages initialised for i18n (data pages such as uploaded course/SCORM content are excluded); Translations counts the non-English lang/<locale>/<module>.php files present, out of 19. Per-key English fallback means any gap simply shows English, so nothing ever renders blank. Updated 2026-06-03 โ English page-wiring is now complete for every module. Contracts, System Wiki and the Setup installer were the final three to convert. The only remaining i18n work is translations: System, Network Mapper, Self-Service, Contracts, System Wiki and Setup still need their 19 locale files, and Software/Forms have 1โ2 Indic locales left.
What is not translated, by design: user-authored and configurable data โ CMDB CI values and user-defined class/property names, knowledge-article content, supplier/contract records, incident update text, etc. These are data, not UI chrome, so they always display as entered.
The pattern is mechanical and mirrors any already-converted module (Tasks is the canonical reference):
-
Extract every user-facing string into
lang/en/<module>.phpas a grouped nested array (nav,list,settings,toast,help, โฆ). -
Wire each full-HTML page:
require_once 'includes/i18n.php'; I18n::initFromSession();, set$translationNamespaces = ['common', '<module>'], set<html lang="<?php echo htmlspecialchars(I18n::getLocale()); ?>">, and emit thewindow.translations+i18n.jsblock in<head>. -
Replace literals โ PHP
<?php echo htmlspecialchars(t('<module>.key')); ?>, JSwindow.t('<module>.key', {param}). -
Add the locale files
lang/<locale>/<module>.phpfor the other 19 languages. Missing keys fall back to English, so a partial file is always valid โ you can ship incrementally.
Reuse the shared common.* namespace for cross-cutting strings (buttons, calendar month/weekday names) rather than duplicating them per module.
Every analyst page's browser tab reads <system name> - <module> - <page> (#173), built by one helper in includes/system_name.php:
<title><?php echo htmlspecialchars(pageTitle('contracts', t('contracts.edit.page_title_edit'))); ?></title>
<title><?php echo htmlspecialchars(pageTitle('contracts')); ?></title> // a module's front page
<title><?php echo htmlspecialchars(pageTitle(null, tr('sign_in', 'Sign In'))); ?></title> // outside any module-
The first argument is the module's key in
common.modules(the names the waffle menu shows), so the module part is translated once. Help and settings pages pass the module's ownnav.help/nav.settingslabel, as the header buttons do. - A title string holds only the page's own words - "Edit contract", never "Service Desk - Edit contract". The system name is the administrator's setting (System -> Branding), and a string carrying it is frozen English in every language. Before #173, about 60 English strings (449 across the locales) had it baked in; they were stripped in every language at once, keeping each translation.
- A part equal to one already in the title is dropped, so a front page whose heading is the module's own name does not say it twice.
-
A record's name set from JavaScript is appended to the server's title, captured once on the document so a repaint never stacks names:
document.title = (document.titleBase = document.titleBase || document.title) + ' - ' + name;(domains, projects, diagrams, RFPs, form submissions, manager access). - Deliberately not in this shape: the self-service portal (its own, customer-facing site), the public CSAT survey, and documents whose title becomes a PDF's file name (contract equipment report, merged-ticket PDF).
| Path | Purpose |
|---|---|
includes/i18n.php |
I18n class + global t(); locale resolution, fallback, interpolation, exportForJs()
|
assets/js/i18n.js |
Client-side window.t() mirroring the PHP contract |
lang/<locale>/<module>.php |
Per-locale, per-module translation arrays |
lang/<locale>/common.php |
Shared strings (buttons, calendar primitives, module names, account menu) |