Most internationalisation projects start the same way. Someone wraps every string in __('Save'), ships a German file with 60% of the keys filled in, and the other 40% render as English mixed into the German UI — or worse, render as the raw key name common.save showing through. A year later there's tooling debt, a translation file nobody quite trusts, and the project quietly defaults back to English-only.
FreeITSM's i18n layer is built around one design choice that fixes this: fallback is per key, not per file.
The Process Mapper module — FreeITSM's swimlane-and-flowchart builder — is now translated into 18 languages, end-to-end. Toolbar, sidebar, detail panel, every label, every placeholder, every dynamic title, the lot. Pick a language in System → Preferences and the UI re-renders in that language on the next page load.
Here's how the layer is structured, and why the design lets you add a language in about ten minutes.
Namespaced keys, file-per-module
Every translatable string has a key like process-mapper.toolbar.export. The first segment maps to a file (lang/<locale>/process-mapper.php); everything after navigates a nested PHP array inside that file. The same call works in PHP and JavaScript:
// PHP
<?php echo htmlspecialchars(t('process-mapper.toolbar.export')); ?>
// JavaScript
t('process-mapper.toolbar.export')
The translation file is just a returned array:
// lang/de/process-mapper.php
return [
'toolbar' => [
'process' => 'Prozess',
'decision' => 'Entscheidung',
'export' => 'Exportieren',
],
'autosave' => [
'saved' => 'Gespeichert',
'saving' => 'Speichern…',
],
];
Splitting by module rather than dumping everything in one giant file means translators can own specific modules, files stay small enough to read, and pages only need to load the namespaces they actually use. The Process Mapper page declares ['common', 'process-mapper'] and that's what gets emitted to JavaScript.
Per-key fallback — the bit that matters
When a German user hits a string that's not in lang/de/process-mapper.php, the system falls back to the matching key in lang/en/process-mapper.php. The whole file doesn't fall back — just the missing key. So if German has 80% of the keys translated, you get those 80% in German and the other 20% in English, mixed in seamlessly.
The point of per-key fallback is that "partially translated" is a usable state, not a broken one.
Last-resort behaviour: if even English doesn't have the key, return the key itself (process-mapper.toolbar.export) so missing strings are visible during development rather than silently rendering as empty space.
The JS bridge
The JavaScript side reads from window.translations, which PHP injects into the page at render time. The catch: PHP already merges the English fallback into the active locale per key before serialising, so the browser receives a flat object where every key is filled in (with English for anything the active locale missed). The JS t() helper then just walks the dotted path — no fallback chain needed on the client.
That keeps the JS layer small, and means PHP and JS are guaranteed to produce identical output for the same key.
Locale detection
The active locale gets resolved in this order on every request:
- The logged-in analyst's
interface_languageuser preference (set via System → Preferences) - The browser's
Accept-Languageheader — with primary-subtag matching, so a browser asking forptresolves topt-BR - English as the default
The supported-locale list is a hard-coded PHP constant. Adding a new language is two changes: add a line to I18n::SUPPORTED_LOCALES (with the native-script display name so the dropdown reads correctly), and create lang/<code>/<namespace>.php. That's it — the System → Preferences dropdown picks the new locale up automatically.
What's not in scope (yet)
This is phase 1, deliberately scoped. A few things are conscious deferrals to phase 3:
Pluralisation. Russian has three plural forms (1, 2-4, 5+). Arabic has six. The current system doesn't handle plural-aware translation — but every string translated so far is a static label (Save, Cancel, Process), which dodges the issue entirely. Phase 3 will add a tn(key, count) helper for dynamic counts.
RTL languages. Translation files for Arabic or Hebrew would work today, but the CSS layout isn't direction-aware — flipping the whole UI mirror-wise is a separate engineering project, not just a translation question.
The other 23 modules. Only Process Mapper has been swept end-to-end as the pilot. Tickets, CMDB, Contracts, Watchtower, all the rest — their UI is still hardcoded English. The next phase is converting them module by module, which is mostly mechanical now that the infrastructure is proven.
The honest tradeoff
The translations themselves were AI-assisted, then I reviewed them where I could. For static button labels like Save / Cancel / Delete that's robust enough — the strings are short, context is unambiguous. For the Indic-language entries in particular, a native speaker review is the right next step before relying on them in production. Community contributions through GitHub PRs are the natural channel.
But the point of phase 1 isn't to ship perfect translations. It's to ship the infrastructure — the t() helpers, the per-key fallback, the JS bridge, the locale persistence. With those in place, swapping a French label or adding a fifteenth language is a content change, not a code change. The hard problem is done.