Feature Wiki

Information about planned and released features

Tabs

PO-Files for improving language handling

1 Initial Problem

The language handling mechanism of ILIAS – i.e. the management of all translated interface texts – relies on a very outdated scheme: language variables are maintained in proprietary .lang files and imported into the database at deployment time, from where the application reads them at runtime. This approach is maintenance-intensive, is hardly compatible with common translation tooling, and provides no structured solution for texts that differ depending on quantity (singular/plural) or for language variants such as dialects.

Modern approaches now exist to solve exactly this problem. gettext, a well-established industry standard built around template, translation and compiled files (POT, PO, MO), offers a considerably more future-proof and maintainable foundation for the language service.

2 Conceptual Summary

The .lang files are replaced by standard gettext files (POT, PO, MO), one set per module, stored in the `lang/` folder of the component that owns the module. ILIAS reads them with the PHP library `gettext/gettext`; the native PHP function `gettext()` is not used (reasons in 2.2). `txt()` stays the only API for existing code, a new method `ntxt()` adds singular/plural handling.

The migration runs module by module. Local changes of an installation (GUI edits, customizing files) are no longer written into the database or into shipped files, but into a small overlay file per module and language in the client data directory. During the transition, the database tables are still written in parallel, so that not yet migrated modules, plugins and a rollback keep working. Once all modules are migrated, the database is removed as storage and runtime source for language variables.

The ILIAS GUI for editing language variables remains available with the same functions; it now reads the shipped files plus the overlay and writes to the overlay.

A pilot implementation covers two modules: `tos` (TermsOfService) and `poll` (Poll, first module with plural forms).

2.1 Advantages over the current scheme

  • gettext is a widely adopted industry standard with broad tool support (e.g. Poedit, Weblate, Crowdin) that translators can work with comfortably. This noticeably reduces maintenance effort compared to today's .lang/database workflow.
  • It solves the problem of wording that differs depending on quantity (singular/plural) in a structured, automatic way – see 2.2.
  • Translation files are plain text per module and language. Changes to translations can be versioned in Git and reviewed like code – something that is not possible for database content today.
  • Shipped translations are compiled into binary MO files during the build. At runtime a module is read from one compiled, fast-to-read file instead of from the database, so language strings are loaded considerably more efficiently.
  • Every PO file carries the plural rule of its language. A new language is a further set of PO files created from the same templates.
  • Language variants (e.g. an "easy language" version of German or Swiss German) only contain the texts that differ from their base language; everything else falls back to the base language automatically – see 2.7.

2.2 Handling of singular and plural forms

One file per module and language, the key stays the key. Each module has a template (`poll.pot`) and one translation file per language (`poll_de.po`, `poll_pl.po`, …) in `components/ILIAS/Poll/lang/`. The `msgid` is the existing language key, exactly the string that is passed to `txt()` today. No context (`msgctxt`) is used.

A simple entry looks like this (German file `poll_de.po`):

# msgid "poll_add"
# msgstr "Abstimmung anlegen"

In words: the text for the key `poll_add` in German is "Abstimmung anlegen". This is the same information as the former .lang line `poll#:#poll_add#:#Abstimmung anlegen`; the module is given by the file instead of being repeated in every line.

File header. Every PO file starts with a header that describes the file (German file `poll_de.po`, shortened):

# "Language: de\n"
# "Language-Team: German\n"#
"Plural-Forms: nplurals=2; plural=n != 1;\n"

  • `Language` is the language key. It stays the two-letter code, because translation tools (Poedit, Weblate, `msgfmt`) derive the language and its plural rule from it.
  • `Language-Team` names the language in English, so a reader of the file sees the language without knowing the code. The conversion tool takes the name from the existing language names of ILIAS (`meta_l_<code>` in the English language file). ILIAS itself does not evaluate this header.

Plural forms. The `Plural-Forms` line of the header states how many forms the language has and which number selects which form (the plural rule). German and English have two forms, Polish has three, Arabic six.

A quantity-dependent entry has one text per form (German, from the pilot module `poll`):

# msgid "poll_population"
# msgid_plural "poll_population_plural"
# msgstr[0] "Eine Stimmabgabe"
# msgstr[1] "%s Stimmabgaben"

In words: for one vote the text is "Eine Stimmabgabe", for any other number "%s Stimmabgaben". The Polish file has three texts `msgstr[0]`–`msgstr[2]` for the same key, following the Polish rule. The calling code only passes the number:

# $lng->ntxt('poll_population', $count) // picks the form according to the rule of the user's language

The calling code does not need to know the plural rules of any language. `ntxt()` is the ILIAS counterpart to `ngettext()`.

  • The plural rules of all 31 ILIAS languages follow the current CLDR release (48.2), e.g. three forms for Spanish, French, Italian and Portuguese.
  • The rule is evaluated by the Language component itself: a small parser for the gettext formula syntax, no `eval`, the formula is validated like any other input. A missing or invalid rule falls back to the two-form rule and is logged.
  • `txt()` keeps working for plural entries and returns the most general form (the last one, CLDR "other"), so existing callers do not change.

Why not the native PHP `gettext()`?

  • It needs the PHP extension plus a generated operating-system locale for every language. Generating locales usually requires root access, which many ILIAS hosting environments do not grant.
  • Locale and text domain are global per process. ILIAS reads several languages within one request (e.g. `txtlng()`, mails to users with another language), which does not fit a single global locale.
  • Native gettext caches loaded MO files per process. Changes made in the ILIAS GUI would only become visible after restarting PHP-FPM or the web server.

The file formats stay standard PO/POT/MO, so translation tools work unchanged. Native gettext may be added later as an optional acceleration with fallback, only if a measured performance problem requires it.

2.3 Migration of existing language data

Module by module. A module is migrated by converting its .lang lines into a template and one PO file per language, and by registering the module's language folder in the component (`$contribute[LanguageFileDirectory::class]` in the component class). Not yet migrated modules keep working as before. Modules without a component of their own (e.g. `common`) move into the Language component.

  • A conversion tool writes the POT/PO files from the current .lang files. It takes every value over 1:1 and checks this by reading the written files back. Placeholder entries ("new variable", not yet translated) are marked as fuzzy.
  • For plural entries, the tool only distributes existing texts (e.g. singular text and former text); it never invents translations. Where a form could only be filled by copying the existing translation, the entry is marked as fuzzy, so translators see that the forms still have to be distinguished.
  • Call sites that switch between singular and plural manually are identified during migration. Switching them to `ntxt()` is proposed to the maintainers of the respective component; the Language component does not change code of other components.

Local changes of an installation. Shipped files are never written at runtime. GUI edits and values from customizing files are stored in an overlay (a PO/MO pair per module and language below the client data directory) that contains only the entries differing from the shipped state. At runtime the overlay is applied on top of the shipped translations. On update, a three-way comparison (local value, previously shipped value, newly shipped value) keeps local changes and takes over changes of the shipped text wherever the entry was not changed locally.

Exception, as today for the .lang files: in translation mode, the maintenance action "merge local changes into the global language file" writes the local changes of a language into the shipped PO files, so that translations made in the GUI can be committed. The previous file is copied to `lang/customizing/` first.

MO files are build artifacts. They are not versioned; `setup build` (run by `composer install`) compiles the shipped PO files reproducibly. After changing a shipped PO file, `setup build` is part of the deployment.

Database and rollback. During the transition, the tables `lng_data` and `lng_modules` are still written for migrated modules. They therefore always hold a current state, and a rollback means removing files or code (per module, per language or completely), never restoring data. The tables are switched off only after all modules are migrated and the remaining direct database accesses outside the Language component (Notifications, DataCollection) have been changed.

2.4 Uniqueness of language variables

Within a module, uniqueness is guaranteed per file: an identifier assigned twice is reported as an error during conversion instead of silently overwriting the other one.

Across modules, the rule "the last loaded module wins" stays in place. `txt()` still receives only the identifier and has no module parameter (see 2.5), and the loaded modules are merged into one list at runtime, as before. The migration does not change which text is displayed.

  • Collisions become visible: if two migrated modules deliver the same identifier with different texts, a warning is logged per module pair.
  • Current state (2026-09-30): 241 of 17,968 identifiers exist in more than one module; for 85 of them the English texts already differ in meaning (e.g. `sorting_3` in `bibl` and `irss`). A report lists all cases.
  • A collision can only be resolved by renaming one of the keys together with its call sites. This is recommended to the maintainers of the affected components during the rollout.

2.5 Consequences for developers

  • Existing callers of `txt()` require no code changes. `txt()` remains the API; it reads the compiled files instead of the database.
  • Quantity-dependent texts use the new method `ntxt($key, $number)` going forward. It is added to `ilLanguage` and not to the interface `ILIAS\Language\Language`, so that existing implementations of the interface do not break. Migrating existing code that resolves plural cases manually is not part of this FR.
  • New language variables of a migrated module are added to the POT and PO files of the module (directly or via the ILIAS GUI in translation mode plus "merge"), followed by `setup build`. Without a build, ILIAS compiles a changed PO file on the fly, so nothing breaks.
  • Translation values may only contain an allowlist of HTML tags and attributes (see 4.4).
  • Developers of new components get binding documentation on how to provide templates and translation files and how to register them.
  • Plugins can already ship PO files instead of .lang files; they are still read into the database as before. Plugins get the full scheme once they become components (Component Revision).

2.6 Documentation (language.md)

The existing language.md still fully describes the old .lang/database structure and is revised as part of this FR. Going forward, it is to describe, among other things:

  • The structure and storage location of the translation files per component and of the local overlay.
  • A step-by-step guide for correctly creating a new language entry under the new scheme, including plural entries and `ntxt()`.
  • Migration notes for developers whose code or documentation still refers to the old scheme.
  • The conventions for key assignment and cross-module collisions as described in 2.4.
  • How to provide a language variant (2.7).

2.7 Language variants

A language variant is a version of an existing language that differs only in some texts, e.g. an "easy language" version of German or Swiss German. It is not a new language, but a layer on top of its base language.

Files. A variant ships, per module, only the entries that differ from the base language, in a PO file next to the base file (e.g. `poll_de.po` plus a variant file such as `poll_de@easy.po`; the naming scheme is settled during implementation). The variant uses the plural rule of its base language.

Runtime. For a user with a variant, a module is read in three layers, each one replacing entries of the previous one:

  1. shipped base language (`de`),
  2. shipped variant,
  3. local changes of the installation (overlay, see 2.3).

If the variant has no text for a key, the text of the base language is shown – per key and automatically.

The language key stays the base language. Everywhere outside the Language component, a user with a variant still has the language `de`: `getLangKey()` returns `de`, and all settings, database columns and content languages keep using the two-letter key. Only the Language component knows the variant. This keeps the change inside the Language component: ILIAS identifies languages by two-letter keys in several hundred places and in many database columns, and regional keys such as `de_CH` would require changes across many components.

Selecting a variant.

  • For the whole installation: a setting in the language administration selects the variant of a language that is shown instead of the base language.
  • Per user: an additional user preference next to the language. Offering the selection in the personal settings belongs to the User component and is proposed to its maintainers.

Limits. A variant is only used for the interface texts of the current user. Mails to other users (`txtlng()`), the HTML `lang` attribute and content languages keep using the base language. Variants are not written to the database tables during the transition; being new, they need no rollback state. A language with its own ISO code remains a new language (a further set of PO files), not a variant.

3 User Interface Modifications

3.1 List of Affected Views

  • Administration > Languages > Edit (list of language variables)
  • Administration > Languages > Maintenance (action "merge")
  • Administration > Languages > Settings (selection of a language variant, see 2.7)

3.2 User Interface Details

No new views and no changed workflows. Small changes in the existing views:

  • A plural entry is shown as one row per form, e.g. `poll_population [0]`, `poll_population [1]`.
  • Entries marked as fuzzy in the shipped file show the note "Not translated" in the comment column.
  • Saving a value with disallowed markup is rejected; the affected keys are named.
  • In translation mode, "merge" also writes the local changes of migrated modules into their shipped PO files.
  • A new setting selects the variant of a language that is shown for the whole installation (2.7).

3.3 New User Interface Concepts

No new UI concepts.

3.4 Accessibility Implications

No changes regarding accessibility.

4 Additional Information

4.1 Involved Authorities

4.2 Technical Aspects

The following are brief explanations of the terms gettext, POT file, PO file and MO file:

  • gettext: A widely used standard for translating software. It defines the file formats below and a set of functions (NLS, Native Language Support) for reading them.
  • POT file (.pot): Stands for Portable Object Template. It is the blank master template of a module. It contains the keys but no translations.
  • PO file (.po): Stands for Portable Object. It is created from the POT file for a specific language. It is a simple text file containing pairs of key (msgid) and translation (msgstr); its header names the language (code and English name) and its plural rule. It is easy for humans to read and edit.
  • MO file (.mo): Stands for Machine Object. It is the compiled, binary version of the PO file, which the program reads at runtime.

Reading and writing of these files is encapsulated in the Language component behind a narrow adapter around the library `gettext/gettext`, so a later change of library stays local. `txt()` and `ntxt()` read the compiled MO files plus the local overlay (see 2.2, 2.3).

4.3 Privacy

No changes.

4.4 Security

  • No scripts may be integrated via the GUI. Translation values may only contain an allowlist of HTML tags and attributes (no scripts, no event handlers, no `javascript:` URLs). Changed values that violate it are rejected in the GUI and on import; shipped values are sanitized during the build.
  • Shipped files in the code directory are not written at runtime (except "merge" in translation mode, as today for .lang files), so the web server needs no general write access to the code directory.

4.5 Contact

Person to be contacted in case of questions about the feature or for funding offers:  Auerbach, Jeanine [jeanine.auerbach]

4.6 Funding

Funding status and funding parties are listed in the block 'Status of Feature' in the right column of this page.

If you are interested to give funding for this feature, please get into contact with the person mentioned above as 'Contact'.

5 Discussion

JourFixe, ILIAS [jourfixe], 05 OCT 2026: We highly appreciate this suggestion and accept the feature for trunk. Nico will check if a native gettext usage is possible. In addition, please use ENUMs instead of strings as arguments when translating variables. To keep backwards compatibility, we can use a union type for string.

Roeser, Nico [nicoroeser], 2026-10-05:

Thanks for this feature request! ☺

It covers translations. Fine.

Things which are not about the language, but still locale-specific (paper formats, currency symbols, measurement units, sorting order (collation), etc.) belong in a different feature request. They are typically supported in some way by a system library, most often the C library. If we like to use PHP functions for them and have everything work , it may matter against which C library PHP is linked. Further down, I’ll assume the GNU C Library (which is applicable in many, many, many use cases for our ILIAS administrators).

About gettext (system-wide or not):

As far as I can tell (from a quick peek), gettext/gettext is an implementation of gettext-like functions in PHP; particularly it contains loaders for MO files, and it can also handle other formats like JSON. The GNU gettext manual explains about the language implementor’s view that a language (like PHP, obviously) can either link against the gettext library or re-implement the support in a compatible way (like gettext/gettext does). For our work on ILIAS, the programmer’s (and yes, also the translator’s) view are most important, and both of them do not depend on write access to any system directory or such.

Just for completeness: gettext implementations in PHP (i.e. not delegating calls to C library code) will probably run slower than native implementations. This is not a showstopper for this feature though, even if not using the PHP gettext functions.

Now, is gettext support available in PHP? Almost every system out there that might be running ILIAS is probably providing gettext functionality through its system C library already. And I’d say that most installations of PHP offer gettext. I cannot tell on how many systems it is not possible to trivially get a gettext-enabled PHP. You can find out whether gettext support is available by using php -m (for the CLI) or e.g. calling print_r(get_loaded_extensions()) or phpinfo() (always). We can simply add gettext to the list of required PHP extensions, which should be no issue for almost all our users (and if there really were large-scale installations of embedded systems running ILIAS, their administrators should know how to handle such things).

When we like to load translated strings from a MO file with gettext functions, we first call bindtextdomain(3) (see e.g. bindtextdomain in the GNU C Library 2.44 manual); in PHP we also use bindtextdomain (PHP). This function receives the base path of our translation files so we do not depend on a system-wide location which people installing ILIAS possibly cannot write to. We simply point to the installed ILIAS tree.

As explained above, things like currency symbols and paper format are different; they are not handled in MO files. Instead, locales are defined (and, in the GNU C Library case, compiled) in different files. The input files can probably be found in /usr/share/i18n/ on your system, and the compiled output is usually located in /usr/lib/locale/. See localedef(1), locale(5), and so on for further defails. And read the documentation of your C library if you are interested. Not relevant to this feature request, but even for locales we can (assuming GNU libc again) override where the compiled files are searched (and we might just putenv a value for it and after that call the locale-specific functions—have neither tried it nor read the corresponding source code of PHP), that’s LOCPATH, see locale(7) and localedef(1).

Altogether, I do not see why we should not use the gettext (and at some later date, locale) support included in and provided by PHP. Especially as we still use our own wrapping functions (like txt()) for now.

Further reading (some links rather broad and generic):

6 Implementation

Feature has been implemented by {Please add related profile link of this person}

6.1 Description and Screenshots

{ Description of the final implementation and screenshots if possible. }

6.2 Test Cases

Test cases completed at {date} by {user}

  • {Test case number linked to Testrail} : {test case title}

6.3 Privacy

Information in privacy.md of component: updated at {date} by {user} | no change required

6.4 Approval

Approved at {date} by {user}.

Last edited: 5. Oct 2026, 19:02, Roeser, Nico [nicoroeser]