Internationalisation - edmozley/freeitsm GitHub Wiki

Internationalisation (i18n)

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.

Supported locales (23)

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.

How it works

  • Lang files live at lang/<locale>/<module>.php and return a nested PHP array. The first dot-segment of a t() 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') (or htmlspecialchars(t(...)) for HTML output). Defined in includes/i18n.php.
  • JavaScript calls window.t('cmdb.nav.browse'). The host page injects window.translations from I18n::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_language preference โ†’ browser Accept-Language (best supported match, primary-subtag fallback) โ†’ en. Changing it in Preferences persists to user_preferences and 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.

Module coverage

๐Ÿ‡ง๐Ÿ‡ท 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.

Converting a module

The pattern is mechanical and mirrors any already-converted module (Tasks is the canonical reference):

  1. Extract every user-facing string into lang/en/<module>.php as a grouped nested array (nav, list, settings, toast, help, โ€ฆ).
  2. 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 the window.translations + i18n.js block in <head>.
  3. Replace literals โ€” PHP <?php echo htmlspecialchars(t('<module>.key')); ?>, JS window.t('<module>.key', {param}).
  4. Add the locale files lang/<locale>/<module>.php for 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.

Page titles: pageTitle(), never a string with the product in it

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 own nav.help / nav.settings label, 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).

Files

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)
โš ๏ธ **GitHub.com Fallback** โš ๏ธ