Joomla Language Files and Internationalisation

This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production.

This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages.

Every string that an extension shows to a person is a candidate for translation. Joomla solves this with language constants: the PHP code and the layouts never contain the visible words, only keys such as COM_TALLPINE_TRAIL_DISTANCE_LABEL, and each installed language supplies a plain text file that maps the keys to the words. This page is the developer’s view of that mechanism: the format and location of the .ini files, how they are registered in a manifest, the functions of the Text class (Text::_(), Text::alt(), Text::sprintf(), Text::plural(), Text::script()), strings for JavaScript, language overrides, the cache that parses the files in Joomla 6.0 and later, and how language files and translation packs are packaged.

The page was checked against the Joomla Developer Manual and the 6.2 source tree on 2026-10-30. It refers to Joomla 6.2 (the current release, requiring PHP 8.3 or newer). The language system has been stable since Joomla 4 and the text marks the differences between 3.x, 4.x, 5.x and 6.x. Joomla 5.4 only receives security fixes, and 4.x and 3.x are end of life (see Versions, Release Cycle and Support). The site builder’s side (installing languages, content languages, the language switcher, associations) is described in Joomla Multilingual Sites. The plain-PHP functions that the examples rely on (sprintf, string handling and Unicode) are covered in PHP strings.

The examples belong to the Tallpine Hiking Club, a fictitious mountain club described in Joomla Introduction and Architecture. Its package pkg_tallpine contains the component com_tallpine, the module mod_tallpine_nexthikes and the plugin plg_content_tallpine_conditions. The site is published in English (en-GB, the default) and German (de-DE).

The idea: constants instead of words

A language constant is an upper-case key. The lookup is case-insensitive in the sense that the language class converts the requested key with strtoupper() before it consults its table, but by convention keys are written in upper case in both the files and the code. A key is made of:

  • a prefix that names the extension: COM_TALLPINE_, MOD_TALLPINE_NEXTHIKES_, PLG_CONTENT_TALLPINE_CONDITIONS_, TPL_CASSIOPEIA_TALLPINE_, or the core prefixes JGLOBAL_, JERROR_, JLIB_ and the short J... constants such as JYES, JNO, JALL, JSAVE

  • a section that says where the text is used: FIELD_, TRAIL_, MSG_, ERROR_

  • a suffix that says what the text is: _LABEL for a form label, _DESC for its tooltip, _TITLE, _MSG, _HEADING

Using the extension prefix keeps keys unique, because all language files loaded during a request are merged into one table. Using the same key in two files does not raise an error, the file loaded later simply wins. Never reuse another extension’s prefix, and never use a key that is a reserved word of the INI format (YES, NO, NULL, FALSE, ON, OFF, NONE, TRUE), because the debug checker of the language class reports them as problems.

Language file format

A language file is a PHP-style INI file, parsed with parse_ini_file() in its raw mode, so that no value is interpreted (yes, 1 or on stay strings) and no PHP constants are expanded. The rules are small:

  • one KEY="Translation" pair per line, with the value always in double quotes

  • a line that starts with a semicolon is a comment, and blank lines are ignored

  • keys contain A-Z, 0-9 and underscores, and start with a letter (the debug checker matches the pattern ^[A-Z][A-Z0-9_:\*\-\.]*\s*=\s*".*")

  • a double quote inside a value is written \"

  • a percent sign that must appear literally is written %%, because the strings are used as sprintf formats

  • the file is UTF-8 without a byte order mark

  • \n and \t inside a value are converted to a line feed and a tab by Text::_() (when its third argument is true, which is the default), and \\ becomes a single backslash

; language/en-GB/com_tallpine.ini
; Tallpine Hiking Club, site and administrator strings of com_tallpine
COM_TALLPINE="Tallpine trails"
COM_TALLPINE_TRAIL_DISTANCE_LABEL="Distance (km)"
COM_TALLPINE_TRAIL_DISTANCE_DESC="Length of the trail in kilometres, one way."
COM_TALLPINE_TRAIL_DIFFICULTY_LABEL="Difficulty"
COM_TALLPINE_TRAIL_MSG_SAVED="Trail \"%s\" was saved."
COM_TALLPINE_TRAIL_MSG_COMPLETION="You completed %d%% of the route."
COM_TALLPINE_ERROR_TRAIL_NOT_FOUND="The trail could not be found."
COM_TALLPINE_TRAIL_N_HIKES_COUNT="%d hikes planned."
COM_TALLPINE_TRAIL_N_HIKES_COUNT_0="No hikes planned."
COM_TALLPINE_TRAIL_N_HIKES_COUNT_1="One hike planned."
; language/de-DE/com_tallpine.ini
COM_TALLPINE="Tallpine Touren"
COM_TALLPINE_TRAIL_DISTANCE_LABEL="Distanz (km)"
COM_TALLPINE_TRAIL_DISTANCE_DESC="Länge der Tour in Kilometern, einfache Strecke."
COM_TALLPINE_TRAIL_DIFFICULTY_LABEL="Schwierigkeit"
COM_TALLPINE_TRAIL_MSG_SAVED="Die Tour \"%s\" wurde gespeichert."
COM_TALLPINE_TRAIL_MSG_COMPLETION="Du hast %d%% der Route geschafft."
COM_TALLPINE_ERROR_TRAIL_NOT_FOUND="Die Tour wurde nicht gefunden."
COM_TALLPINE_TRAIL_N_HIKES_COUNT="%d Wanderungen geplant."
COM_TALLPINE_TRAIL_N_HIKES_COUNT_0="Keine Wanderungen geplant."
COM_TALLPINE_TRAIL_N_HIKES_COUNT_1="Eine Wanderung geplant."

Older tutorials and the Joomla 3 wiki write a double quote inside a value as the token "_QQ_". The Joomla 6.2 language class and its INI reader contain no handling of that token. The reader only restores the escaped quote \", so new files must use \". A file that comes from a Joomla 3 extension and still contains "_QQ_" shows the token literally, and must be edited.

The two kinds of file: .ini and .sys.ini

Each extension normally ships two files per language:

File Content

com_tallpine.ini

The strings that the running extension uses: form labels, messages, layout text, the names of views. It is loaded when the extension is used.

com_tallpine.sys.ini

The system strings: the extension’s name and description in the installer and in the Extensions: Manage list, the menu item types of a component (title and description of each view shown in the menu item dialog), the title of a module or plugin in the lists, and the access-control section names. Joomla loads it in the administrator in places where the extension itself is not running.

; language/en-GB/com_tallpine.sys.ini
COM_TALLPINE="Tallpine trails"
COM_TALLPINE_XML_DESCRIPTION="Trail reports and planned hikes of the Tallpine Hiking Club."
COM_TALLPINE_TRAILS_VIEW_DEFAULT_TITLE="List of trails"
COM_TALLPINE_TRAILS_VIEW_DEFAULT_DESC="Shows all published trails of a category."
COM_TALLPINE_TRAIL_VIEW_DEFAULT_TITLE="Single trail"

A frequent mistake is to put a key in com_tallpine.ini that the installer or the menu item dialog needs. Those screens only load the .sys.ini file, so the raw constant is displayed there. Another is to put every string in the .sys.ini file: it is loaded in the administrator on pages that list extensions, so it should stay small.

File names and locations

Joomla 4 changed the file naming. Joomla 3 prefixed each file with the language tag (en-GB.com_tallpine.ini). Since Joomla 4 the name is just <extension>.ini, and the language is given by the folder:

com_tallpine/
  admin/
    language/
      en-GB/
        com_tallpine.ini
        com_tallpine.sys.ini
      de-DE/
        com_tallpine.ini
        com_tallpine.sys.ini
  site/
    language/
      en-GB/com_tallpine.ini
      de-DE/com_tallpine.ini
mod_tallpine_nexthikes/
  language/
    en-GB/
      mod_tallpine_nexthikes.ini
      mod_tallpine_nexthikes.sys.ini
plg_content_tallpine_conditions/
  language/
    en-GB/
      plg_content_tallpine_conditions.ini
      plg_content_tallpine_conditions.sys.ini

The loader still accepts the old name with the language prefix (it tries <extension>.ini first and then <lang>.<extension>.ini), which is why Joomla 3 extensions keep working, but new code uses the short name. At installation the files are copied to one of two roots:

Root Used by Contents

language/<tag>/

The site (front end)

Strings of site components, modules, plugins and templates that run in the front end

administrator/language/<tag>/

The administrator

Strings of administrator components, administrator modules, plugins and templates that run in the back end

Third root: the Web Services API application (api/language/, the JPATH_API path) exists for the API client. Strings of API responses come from the com_tallpine files that the API application loads; see Web Services API.

Only the folder of the extension in the package decides the destination. An administrator-only module that is translated into German must carry its files in the administrator client of the package, otherwise the strings are copied to the wrong root and the module’s heading appears as a raw constant.

An extension can also keep its strings in its own folder and load them with an explicit base path, as shown in the loading section below.

Registering language files in the manifest

The installer copies the language files that the extension manifest lists in a <languages> element. Each <language> has a tag attribute and the path of the file inside the package:

<?xml version="1.0" encoding="UTF-8"?>
<extension type="component" method="upgrade">
    <name>com_tallpine</name>
    <namespace path="src">Tallpine\Component\Tallpine</namespace>
    <version>1.0.0</version>
    <description>COM_TALLPINE_XML_DESCRIPTION</description>

    <files folder="site">
        <folder>src</folder>
        <folder>tmpl</folder>
    </files>
    <languages folder="site/language">
        <language tag="en-GB">en-GB/com_tallpine.ini</language>
        <language tag="de-DE">de-DE/com_tallpine.ini</language>
    </languages>

    <administration>
        <menu>COM_TALLPINE</menu>
        <files folder="admin">
            <folder>services</folder>
            <folder>src</folder>
            <folder>tmpl</folder>
        </files>
        <languages folder="admin/language">
            <language tag="en-GB">en-GB/com_tallpine.ini</language>
            <language tag="en-GB">en-GB/com_tallpine.sys.ini</language>
            <language tag="de-DE">de-DE/com_tallpine.ini</language>
            <language tag="de-DE">de-DE/com_tallpine.sys.ini</language>
        </languages>
    </administration>
</extension>

A module or plugin has a single <languages> element at the top level of its manifest, as the core plugin plg_content_joomla does:

<languages>
    <language tag="en-GB">language/en-GB/mod_tallpine_nexthikes.ini</language>
    <language tag="en-GB">language/en-GB/mod_tallpine_nexthikes.sys.ini</language>
    <language tag="de-DE">language/de-DE/mod_tallpine_nexthikes.ini</language>
    <language tag="de-DE">language/de-DE/mod_tallpine_nexthikes.sys.ini</language>
</languages>

The <name> and <description> elements and the menu entries of a manifest hold constants too, as in <description>COM_TALLPINE_XML_DESCRIPTION</description>. The installer translates them with the .sys.ini file of the extension. See Extension Types, Manifests and Packaging for the rest of the manifest.

A file that is missing from <languages> is not copied, so a translation added to the source tree but not to the manifest works on the developer’s machine and disappears in the installed package. The same applies to a plugin: it is a frequent source of "works locally, raw constants in production" reports.

Loading the language files

Registering the files makes them available; something must also load them. Joomla loads them in these places:

  • Core language files (joomla.ini and the files of the core extensions that the application always needs) are loaded by the application at the start of the request.

  • Components are loaded by the component dispatcher. In the MVC code of Joomla 4 and later the ComponentDispatcher loads the component’s .ini file for the current client before it dispatches the controller, so Text::_() works in every controller, model and layout of the component.

  • Modules are loaded by the module renderer before the dispatcher runs, using the module’s own folder as a source.

  • Templates load tpl_<name>.ini when the template is initialised.

  • Plugins must ask for their files. A plugin sets the property $autoloadLanguage to true in its class to have its plg_<group>_<name>.ini loaded when the plugin object is created. In Joomla 6.0 that load became a final method of CMSPlugin (autoloadLanguage()) that guards against loading twice and falls back to the global application if the plugin has none. A plugin that has no language strings of its own must not set the flag, because it costs a file load on every request.

<?php

namespace Tallpine\Plugin\Content\TallpineConditions\Extension;

use Joomla\CMS\Language\Text;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
use Joomla\CMS\Event\Content\ContentPrepareEvent;

\defined('_JEXEC') or die;

final class TallpineConditions extends CMSPlugin implements SubscriberInterface
{
    /**
     * Load plg_content_tallpine_conditions.ini for the current client.
     */
    protected $autoloadLanguage = true;

    public static function getSubscribedEvents(): array
    {
        return ['onContentPrepare' => 'onContentPrepare'];
    }

    public function onContentPrepare(ContentPrepareEvent $event): void
    {
        $item = $event->getItem();

        if (!isset($item->text) || !str_contains($item->text, '{conditions}')) {
            return;
        }

        $item->text = str_replace(
            '{conditions}',
            '<p class="trail-conditions">' . Text::_('PLG_CONTENT_TALLPINE_CONDITIONS_NOTICE') . '</p>',
            $item->text
        );
    }
}

For explicit loading, use the language object of the application and give it the extension and a base path. A code path that does not run inside the component dispatcher (a console command, a scheduled task, a helper called from another extension) needs this:

<?php

use Joomla\CMS\Factory;

\defined('_JEXEC') or die;

// Inside a class that has the application (the Aware traits give $this->getApplication()).
$language = $this->getApplication()->getLanguage();

// Load administrator/language/<tag>/com_tallpine.ini, and the site or default language first as fallback.
$language->load('com_tallpine', JPATH_ADMINISTRATOR);

// Load strings that sit in the component's own folder instead of the global language folder.
$language->load('com_tallpine', JPATH_ADMINISTRATOR . '/components/com_tallpine');

Older books such as Developing Extensions for Joomla! 5 (2023) obtain the language object with Factory::getLanguage(). In Joomla 6 prefer the language of the application ($app->getLanguage()) or the object injected by the framework, in line with the dependency-injection style described in Namespaces, Autoloading and Dependency Injection. The static Text methods used below still work and call the language object internally.

The order in which strings are merged

The Language::load() method fills one table of constants per request. For a language that is not the default one, and when debugging is off, it loads in this order, each step overwriting keys of the previous steps:

  1. the default language of the site (en-GB for Tallpine), so that no constant stays raw when the German file lacks a key

  2. in Joomla 6.2, the fallback chain of the requested language, weakest first (for example de-DE as the fallback of de-CH), see Joomla Multilingual Sites

  3. the file of the requested language, <extension>.ini or, if it does not exist, <lang>.<extension>.ini

  4. the override file for the client and language, which is merged last so that it wins over everything

flowchart LR A[Text underscore call with a KEY] --> B{KEY in merged table?} B -- yes --> C[Return translation] B -- no --> D[Return the key itself] subgraph Merge order of one request E[1 default language en-GB] --> F[2 fallback languages 6.2] F --> G[3 requested language de-DE] G --> H[4 language overrides] end H --> B

When Debug Language is on, the merge of default and fallback languages is skipped on purpose: a missing German string must not be hidden by the English one.

Text::_(): translating a key

Joomla\CMS\Language\Text is the static facade of the language system. Its central method translates a key:

<?php

use Joomla\CMS\Language\Text;

\defined('_JEXEC') or die;

echo Text::_('COM_TALLPINE_TRAIL_DISTANCE_LABEL');   // Distance (km) / Distanz (km)
echo Text::_('JYES');                                  // core constant, translated by the core language file

The signature is Text::_($string, $jsSafe = false, $interpretBackSlashes = true, $script = false). What the arguments do:

Argument Effect

$string

The key. It is converted to upper case before the lookup. If the key is not in the merged table, the string is returned unchanged, so a missing translation shows the raw constant, for instance COM_TALLPINE_TRAIL_DISTANCE_LABEL.

$jsSafe

When true, the result is passed through addslashes() so it can sit inside a JavaScript string. In this case the backslash interpretation of the next argument is skipped.

$interpretBackSlashes

When true (the default) and $jsSafe is false, the sequences \n, \t and \\ are replaced by a line feed, a tab and a backslash.

$script

When true, the translated text is stored in the JavaScript string store and the key is returned (see JavaScript strings).

The method also has a quirk that was added in Joomla 3.4.4: if the key passed to Text::_() contains a comma, the text is split at the commas, the first part is used as a sprintf format and the others as its arguments. That behaviour exists for old code; new code must call Text::sprintf() explicitly and never build a key from user input, because the user could then choose which translations are shown.

Text::alt(): a variant of a key

Text::alt($string, $alt) uses the key $string . '_' . $alt if it exists and the key $string if not. It is used for alternative wordings that depend on a context, for instance a different button text on a small screen:

COM_TALLPINE_SAVE="Save trail report"
COM_TALLPINE_SAVE_MOBILE="Save"
<?php
echo Text::alt('COM_TALLPINE_SAVE', 'mobile');   // "Save"; falls back to "Save trail report" if no _MOBILE key exists

Text::sprintf(): values inside a translation

Hard-coding a sentence in code and concatenating a value to it makes translation impossible, because the word order differs between languages. A translation file holds the whole sentence with placeholders and the code passes the values. Text::sprintf() translates the key and calls the PHP function sprintf() with the remaining arguments:

<?php

use Joomla\CMS\Language\Text;

\defined('_JEXEC') or die;

$this->getApplication()->enqueueMessage(
    Text::sprintf('COM_TALLPINE_TRAIL_MSG_SAVED', $trail->title),
    'success'
);
// en-GB: Trail "Hochalm Loop" was saved.
// de-DE: Die Tour "Hochalm Loop" wurde gespeichert.

echo Text::sprintf('COM_TALLPINE_TRAIL_MSG_COMPLETION', 80);   // You completed 80% of the route.

Text::printf() does the same but prints the result instead of returning it. It does not support the JavaScript or placeholder options of sprintf().

Placeholders and positions

The placeholders are those of PHP’s sprintf(): %s for a string, %d for an integer, %f or %.1f for a floating-point number, %05d for zero-padded numbers, and %% for a literal percent sign. When the translation needs a different word order, a positional specifier numbers the arguments, so the translator can reorder them without a code change:

; en-GB
MOD_TALLPINE_NEXTHIKES_ROUTE="Hike %1$s starts at %2$s."
; de-DE: the order of the two values is reversed in German.
MOD_TALLPINE_NEXTHIKES_ROUTE="Ab %2$s startet die Wanderung %1$s."
<?php
echo Text::sprintf('MOD_TALLPINE_NEXTHIKES_ROUTE', $hike->title, $hike->meetingTime);

The Manual lists the specifiers b, c, d, e, u, f, F, o, s, x and X, and the order of a directive: the percent sign, an optional sign, alignment (- for left-justified), padding (a zero or a character after a single quote), width and precision, then the type. HTML collapses repeated spaces, so padding only shows in a preformatted element.

Argument count

Too many arguments are ignored. Too few arguments are a fatal error: PHP 8 throws an ArgumentCountError ("2 arguments are required, 1 given"), and Joomla does not intercept it. This is the classic way a bad translation breaks a site: a translator who adds a second %s to the German file ("%s von %s") while the code passes one value makes the whole page fail in German only. The quality checks for a translation pack must therefore compare the number of placeholders of every key between the source and the target language. Placeholders must never appear in a translation of a key that the code uses with Text::_(), because Text::_() does not run sprintf(): a %s would be displayed literally.

An options array

The last argument of Text::sprintf() and of Text::plural() can be an array with the options jsSafe (default false), interpretBackSlashes (default true) and script (default false), the same flags as the arguments of Text::_().

<?php
$text = Text::sprintf('COM_TALLPINE_TRAIL_MSG_SAVED', $trail->title, ['jsSafe' => true]);

Text::plural(): counts

English distinguishes one from many, but Polish, Russian, Arabic and Welsh have more forms, and a sentence like "1 hikes" is wrong in every language. Text::plural($key, $n) chooses a key according to the number and runs sprintf() with the number as the first argument:

<?php

use Joomla\CMS\Language\Text;

\defined('_JEXEC') or die;

echo Text::plural('COM_TALLPINE_TRAIL_N_HIKES_COUNT', count($hikes));
// 0 -> "No hikes planned."   1 -> "One hike planned."   5 -> "5 hikes planned."

How the key is chosen

The base key is the fallback. Text::plural() builds a list of candidate suffixes and uses the first candidate key that exists in the language table:

  1. If $n is not 1, the number itself is the first candidate. A key ..._5 or ..._12 therefore wins for exactly that number. The number is not added for 1, because several languages use the same form for 1 and for 21.

  2. Then the suffixes that the language provides. They come from the localise class of the language (the file localise.php in the language folder, the method getPluralSuffixes($count)), and without such a class the only suffix is the number.

  3. If no candidate key exists, the base key is used.

The English language pack returns ['0'] for zero, ['ONE', '1'] for one, and ['OTHER', 'MORE'] for anything else. For the Tallpine file above this means: count 0 uses _0, count 1 uses _ONE if it exists and _1 otherwise, and for a count of 5 the order is _5, _OTHER, _MORE, then the base key "%d hikes planned.". That is why the file defines the generic form on the base key and only the special cases with suffixes.

; a number-specific form and the catch-all forms
MOD_TALLPINE_NEXTHIKES_N_HUTS="Huts"
MOD_TALLPINE_NEXTHIKES_N_HUTS_5="Exactly five huts are open."
MOD_TALLPINE_NEXTHIKES_N_HUTS_MORE="%d huts are open."
MOD_TALLPINE_NEXTHIKES_N_HUTS_OTHER="Many huts are open."

When both _OTHER and _MORE exist, _OTHER is tried first. Removing _OTHER makes _MORE apply to the counts that have no exact key.

Languages with more plural forms

A language pack that has more forms ships a localise file whose getPluralSuffixes() returns other names. The shape of the core class is a static method:

<?php

// language/pl-PL/localise.php (shape of a localise class)
abstract class Pl_PLLocalise
{
    public static function getPluralSuffixes($count)
    {
        if ($count == 1) {
            return ['1'];
        }

        // two to four (but not twelve to fourteen) use the "few" form
        $last = $count % 10;
        $tens = $count % 100;

        if ($last >= 2 && $last <= 4 && ($tens < 12 || $tens > 14)) {
            return ['FEW'];
        }

        return ['MANY'];
    }
}

The class name is the language tag with its hyphen removed and the case adapted (En_GBLocalise for en-GB), and the method is looked up by the Language constructor. A translator who works on a language with several forms does not edit PHP; the pack already contains the class, and the translator adds the keys with the suffixes that the class returns (_FEW, _MANY in the example). The Tallpine extension should provide at least the base key plus _0 and _1, and give translators a note in a comment of the .ini file on which suffixes the target language uses.

Writing translatable strings

A few rules avoid most translation problems:

  • Never build a sentence from pieces. Text::_('A') . ' ' . $name . ' ' . Text::_('B') assumes a word order. Use one key with a placeholder.

  • Do not translate fragments that are language-dependent, such as plurals added with "s". Use Text::plural().

  • Put dynamic parts in placeholders, and keep markup out of the value wherever possible. If a message needs a link, pass the already-escaped link as an argument instead of translating the HTML.

  • Escape on output. A translation is trusted content, but the values that Text::sprintf() inserts (a trail title, a user name) are not. Escape them with htmlspecialchars() or the layout’s $this->escape() before they reach a message or a layout, see Security in Extensions. In a layout, $this->escape(Text::_(...)) is the usual form for text placed inside an attribute.

  • Choose precise keys. A "Date" label for a form and a "Date" column of a list may need different words in German; use two keys.

  • Provide a description (_DESC) for every form field, since it becomes the tooltip. Forms refer to labels by key, see Forms and Form Fields.

<?php

use Joomla\CMS\Language\Text;

\defined('_JEXEC') or die;
/** @var \Tallpine\Component\Tallpine\Site\View\Trail\HtmlView $this */
?>
<h2><?php echo $this->escape($this->item->title); ?></h2>
<dl>
    <dt><?php echo Text::_('COM_TALLPINE_TRAIL_DISTANCE_LABEL'); ?></dt>
    <dd><?php echo (float) $this->item->distance; ?></dd>
</dl>
<p><?php echo Text::plural('COM_TALLPINE_TRAIL_N_HIKES_COUNT', (int) $this->item->hikeCount); ?></p>

JavaScript language strings

JavaScript cannot read the PHP language table. Joomla transfers a chosen set of strings to the page as script options and exposes them through Joomla.Text. It is a two-step process: the PHP code registers a key, and the script reads it:

<?php

use Joomla\CMS\Language\Text;
use Joomla\CMS\HTML\HTMLHelper;

\defined('_JEXEC') or die;

// In HtmlView::display(), a layout or a module dispatcher, before the page is rendered.
Text::script('COM_TALLPINE_CONFIRM_DELETE_TRAIL');
Text::script('COM_TALLPINE_ERROR_GPX_TOO_LARGE');

$wa = $this->getDocument()->getWebAssetManager();
$wa->getRegistry()->addRegistryFile('media/com_tallpine/joomla.asset.json');
$wa->useScript('com_tallpine.trail-form');
COM_TALLPINE_CONFIRM_DELETE_TRAIL="Delete this trail report permanently?"
COM_TALLPINE_ERROR_GPX_TOO_LARGE="The GPX file is larger than 5 MB."
// media/com_tallpine/js/trail-form.js
document.querySelector('#delete-trail').addEventListener('click', (event) => {
  if (!window.confirm(Joomla.Text._('COM_TALLPINE_CONFIRM_DELETE_TRAIL'))) {
    event.preventDefault();
  }
});

What Text::script() does in the 6.2 source: it uppercases the key, translates it, stores the result in the static string table Text::getScriptStrings() returns, and writes the table into the document’s script options under the name joomla.jtext (it also makes sure the core web asset is loaded). Joomla.Text._('KEY') then reads that table in the browser. Consequences:

  • A key must be registered before the document head is rendered. Calling Text::script() after the page output has been sent has no effect.

  • The script only receives strings that were registered, so a script that asks for an unregistered key gets an empty value. Registering is cheap, but each registered string increases the HTML of every page that uses it, so register keys where the script is used, not globally.

  • There is no sprintf in JavaScript. Put a placeholder in the string ({count}) and replace it in the script, or build the string in PHP and register it with the options array (['script' => true]), which stores the formatted text.

  • Text::script() called with no argument is deprecated since Joomla 3.7 and raises a deprecation notice; Joomla 7.0 does not support it. Use Text::getScriptStrings() to read the registered strings.

  • Data that is not a translation, such as a URL or a limit, goes through script options ($document->addScriptOptions('com_tallpine.options', [...]) and Joomla.getOptions()), not through language strings.

The asset registration in the example is the subject of Web Asset Manager.

Language overrides

An override replaces a string without touching the extension, so that an update does not undo the change. Overrides are stored in language/overrides/<tag>.override.ini (site) and administrator/language/overrides/<tag>.override.ini (administrator), one file for each language, and are edited under System > Manage > Language Overrides. The loader merges the override file last, so it wins over the extension’s own file and over the default language; it also reads the override file of the default language once for a non-default language, so that a key that exists only in the default override does not fall back to a raw constant. The detail of the screen is in Joomla Multilingual Sites.

; language/overrides/en-GB.override.ini
COM_TALLPINE_TRAIL_DISTANCE_LABEL="Route length (km)"

; language/overrides/de-DE.override.ini
COM_TALLPINE_TRAIL_DISTANCE_LABEL="Streckenlänge (km)"

For extension developers, the practical consequences are:

  • Do not edit the files of an installed extension to change a word. The next update replaces them.

  • A key that the code builds dynamically (for example 'COM_TALLPINE_DIFFICULTY_' . strtoupper($value)) works with overrides, but the override editor cannot search for it. Document the full list of generated keys for the site builder.

  • An override can break a message that is used with Text::sprintf() if it changes the placeholders. Keep every %s and %1$s of the original.

  • Overrides are loaded for the whole site, so an override of a core key such as JYES changes it everywhere.

Finding missing and broken strings

To debug a translation open System > Global Configuration > System and set Debug Language to Yes. With Language display on Value the output marks the strings:

  • **text** (shown between asterisks) is translated from a language file

  • ??text?? (shown between question marks) was requested but no file defines it

  • text without a marker is not passed through a language function at all

The second kind is the one a developer looks for. A string without a marker is a hard-coded text in a layout. Enable the System - Debug plugin with Debug System on to get panel tabs that list the loaded language files and the constants that were requested but not found, with the file and line of the caller. The debug mode also checks the syntax of each file that is parsed: it reports lines whose quotes are unbalanced, lines that do not match the key pattern and the reserved keys. Keep the debug options off in production (see Debugging).

A practical check for a build pipeline is to compare the key sets of en-GB and de-DE files, and to lint each file with php -r 'var_dump(parse_ini_file($argv[1], false, INI_SCANNER_RAW));' file.ini, which fails for unbalanced quotes. The build and CI side is in Build and CI for Extensions.

The language cache (Joomla 6.0)

Parsing an INI file for every request is wasteful, because a front-end request loads tens of language files. Joomla 6.0 added a language file cache (pull request "Cache language files", merged in April 2025 as part of the 6.0 line). Two follow-up fixes in the 6.0 series improved its error handling: a corrupt cache file is detected and regenerated, and a failure to write the cache is logged instead of raising an exception.

How it works, from LanguageHelper::parseIniFile():

  1. The first time a language file is loaded, the INI file is parsed and the resulting array is written to a PHP file in JPATH_CACHE/language/ (cache for the site and administrator/cache for the administrator). The file name is made of the path of the source file relative to the site root with the slashes turned into dashes, then the modification time of the source file, and the extension .php. For example the file for the German component strings is named like language-de-DE-com_tallpine.ini.<mtime>.php.

  2. The cache file is a small script: a guard defined('_JEXEC') or die; and return of the array as PHP code.

  3. On later requests the cache file is loaded with include. This avoids the INI parser, and as the file is plain PHP, OPcache keeps the array in memory too.

  4. When someone edits the INI file its modification time changes, the name of the cache file changes and a new file is written. Old cache files are not deleted, so a site that is updated often accumulates a few files per language file.

  5. If the cache file does not return an array, it is deleted and the INI file is parsed again.

  6. If the cache cannot be written (a read-only cache folder, a full disk), the strings are still returned from the parse and a warning is logged in the language category.

This cache is independent of the System > Clear Cache storage (the Cache classes with file or Redis handlers) and of the page cache. The other caches in the language area are separate too: the list of installed and content languages (getInstalledLanguages(), getContentLanguages()) is cached in the com_languages output cache group. See Caching and Performance for those.

Practical consequences:

  • The cache folder must be writable by the web server user, and on a read-only container filesystem it should be a writable volume. Otherwise every request re-parses the files and logs a warning.

  • A deployment that replaces language files in place with the same modification time (some archive extractors and image builds preserve the original timestamps) can keep serving an older cache file if the timestamp is identical. Normal installs change the time and are not affected. After a deployment that touches language files, clear the folder cache/language or the Joomla cache.

  • The cache needs a writable cache folder on every web node; with several nodes behind a load balancer each node fills its own folder, which is correct and needs no sharing.

  • Older books and tutorials describe language files being parsed on every request and recommend turning off unused languages for speed. With the cache the effect is much smaller, but unused languages and extensions are still worth removing.

Packaging language packs

There are two things to package, and they are not the same.

Translations that belong to an extension

The normal case: the German strings of com_tallpine are part of the extension package. The files sit in the extension’s folders as shown above and are listed in the manifest. One ZIP carries all languages, and the installer copies each file into the right place. No separate installation is needed, and the translation is updated together with the extension. This is the approach for the Tallpine package:

<?xml version="1.0" encoding="UTF-8"?>
<extension type="package" method="upgrade">
    <name>pkg_tallpine</name>
    <packagename>tallpine</packagename>
    <version>1.0.0</version>
    <files>
        <file type="component" id="com_tallpine">com_tallpine.zip</file>
        <file type="module" client="site" id="mod_tallpine_nexthikes">mod_tallpine_nexthikes.zip</file>
        <file type="plugin" group="content" id="tallpine_conditions">plg_content_tallpine_conditions.zip</file>
    </files>
</extension>

Translations as a separate language pack

A language pack is an extension of type language. It is what the Install Languages screen downloads for the core interface, and a club or an agency can make one for a third-party extension, or for a regional variant. The core English pack describes the shape of its manifest: type="language", a client of site or administrator, and the language tag.

<?xml version="1.0" encoding="UTF-8"?>
<extension client="site" type="language" method="upgrade">
    <name>Deutsch (de-DE) for Tallpine</name>
    <tag>de-DE</tag>
    <version>1.0.0</version>
    <creationDate>2026-10</creationDate>
    <author>Tallpine Hiking Club</author>
    <description>German strings for the Tallpine package.</description>
    <files>
        <filename>com_tallpine.ini</filename>
        <filename>mod_tallpine_nexthikes.ini</filename>
    </files>
</extension>

A full language, like the German pack of the project, additionally contains the metadata file langmetadata.xml with the fields the language manager reads: name, native name, tag, right-to-left flag, locale list, first day of the week, weekend days and calendar. The English administrator file contains <tag>en-GB</tag>, <rtl>0</rtl>, <locale> and <firstDay>0</firstDay>, <weekEnd>0,6</weekEnd>, <calendar>gregorian</calendar> in its <metadata> element. Older packs use <tag>.xml (for example en-GB.xml) instead of langmetadata.xml, and the installer still looks for that name. A pack for a language that needs special plural forms or transliteration also contains the localise.php file with the static methods described above (getPluralSuffixes(), and the optional transliterator, ignored search words and search length limits callbacks).

Practical points when packaging:

  • Use one ZIP per language for a standalone pack, or a package manifest that contains several. The name of the pack should carry the language tag.

  • Keep the key sets of all languages equal. A missing key falls back to the default language when debugging is off, but a missing placeholder or a wrong placeholder count breaks the page, as described above.

  • The extension version and the pack version should be tracked together; the update server of the extension can list the pack as a separate extension. See Update Servers and the JED.

  • Do not install the translation of a template or module into language/overrides: overrides are site configuration, not distributable files.

  • For the German strings of the core and of common extensions, point site builders to the official translation packs that the Install Languages screen offers, and distribute only your own extension’s strings.

Common errors and pitfalls

Symptom Cause and fix

A raw constant such as COM_TALLPINE_TRAIL_DISTANCE_LABEL appears

The key is missing from the file of the active language and of the default language, or the file was never loaded (a plugin without $autoloadLanguage, a file not listed in the manifest, the string needed in the menu item dialog placed in the .ini instead of the .sys.ini). Turn on Debug Language and look for ??.

The text appears in English on the German site

The German file lacks the key and the default language filled it. Compare key sets, and check that the file really is in de-DE.

A page fails only in one language

A translation has more placeholders than the code passes, so sprintf() throws an ArgumentCountError. Compare the placeholders of every key between languages.

Everything after a quote in a value is missing, or the whole file shows raw constants

An unescaped double quote or an unbalanced quote makes parse_ini_file() fail. In debug mode the error is thrown, otherwise the file silently yields no strings. Escape the quote as \".

The value Yes or On shows as a different word

A key named YES or ON is reserved in INI. Use a prefixed key.

% in a text changes into something else

The text goes through sprintf(). Write %% for a literal percent sign.

A JavaScript string is empty

The key was not registered with Text::script(), or it was registered after the head was rendered.

A changed .ini file has no effect

An override exists for that key, or an older file is cached by OPcache or by a page cache. Clear the cache; on a deploy that preserved file times, remove cache/language.

A Joomla 3 extension shows "_QQ_" in strings

It uses the old quote token. Replace it with \".

Factory::getLanguage() is flagged by a static analyser or marked as legacy

Use the language of the application ($app->getLanguage()); see the DI page. The static Text class still works.

Security considerations

  • Translations are trusted code-adjacent content, user data is not. The string in a language file may contain HTML, and Joomla does not escape it. A pack from an untrusted source can therefore inject markup (a stored script in every page that prints a constant). Install language packs only from the official installer list or from your own build, and review third-party packs before use.

  • Escape inserted values. Text::sprintf() inserts values into the sentence without escaping them. Escape user-supplied parts first, or the translation becomes an XSS carrier.

  • Never build a key from user input (Text::_($_GET['label'])). It allows an attacker to display any registered string and, with the comma behaviour of Text::_(), to feed a format string. Use Text::_() with fixed keys and map user choices through a whitelist.

  • Do not leak data in JavaScript strings. Everything registered with Text::script() is visible in the page source of every visitor. Never register a string that contains a token, a path or a private message.

  • Debug output exposes paths. The language debug panel lists files and call locations; it must be off in production.

  • Override files are written by the administrator and are executable only as INI data, but they are loaded on every request, so restrict the Language Overrides permission to administrators.

Version notes and book corrections

Version Change

Joomla 4.0

File names lose the language prefix (com_tallpine.ini instead of en-GB.com_tallpine.ini); the old name is still read.

Joomla 4.x to 5.x

Text::script() with no argument is deprecated (since 3.7) and is not supported in 7.0; Text::getScriptStrings() replaces it.

Joomla 5.0

Plugins use SubscriberInterface with concrete event classes; the language autoload is still the $autoloadLanguage flag.

Joomla 6.0

Language files are cached as PHP files in cache/language. CMSPlugin::autoloadLanguage() is a final method that avoids loading the plugin’s file twice. The legacy J* class aliases such as JText are only available through the Behaviour - Backward Compatibility 6 plugin, so extensions must import Joomla\CMS\Language\Text.

Joomla 6.2

Language fallbacks: a language can name a fallback (in langmetadata.xml or in the language manager) whose strings fill missing keys before the default language does.

Joomla 7.0 (planned)

Plugin properties $app and $db are no longer detected automatically. Text::script() without an argument is not supported.

Corrections to older material:

  • Developing Extensions for Joomla! 5 (2023) and Joomla! 4 Masterclass target Joomla 4 and 5. They still show Factory::getLanguage() and JText::_() in places. In Joomla 6 use Joomla\CMS\Language\Text and the language of the application; JText exists only with the backward-compatibility plugin.

  • Joomla 3 tutorials use the "_QQ_" token, language-prefixed file names and JText::script() with the old array; these are replaced by \", short file names and Text::script($key).

  • Older sources describe the language .ini parsing as a per-request cost; Joomla 6.0 caches the parse as PHP files.

  • The "language pack" and the "translation inside an extension" are different things: the first is an installable extension of type language, the second is a set of files listed in <languages> of the extension’s manifest.