Developers

Languages

Every string a component shows a person comes out of an .ini file through Lang::txt(). No component in the tree hard-codes display text, and neither should yours: a hub can override any string without touching your code, and a translator can supply another language without reading it.

The habit costs nothing while you are writing and is expensive to add later, because retrofitting it means finding every sentence in every layout.

One file per client

Language files belong to a client, and the client that is running loads only its own. This is the detail that catches people:

app/components/com_bookings/
    site/language/en-GB/
        en-GB.com_bookings.ini          the site loads this, and only this
    admin/language/en-GB/
        en-GB.com_bookings.ini          the administrator loads this
        en-GB.com_bookings.sys.ini      and this, for lists of extensions

The name is {tag}.{extension}.ini inside a directory named for the tag. The loader looks in {client directory}/language/{tag}/, so the path is fixed once the client is.

Duplicating a handful of strings is the cost of the split. It buys a site that does not carry the administrator's vocabulary into every page it renders.

The format

Key/value pairs, one per line, values in double quotes. Lines beginning with a semicolon are comments:

COM_KB="Knowledge Base"
COM_KB_LOGIN_NOTICE="Please login to continue."
COM_KB_HELPFUL="helpful"
COM_KB_NOT_HELPFUL="not helpful"
COM_KB_CATEGORIES="Categories"
COM_KB_SUBCATEGORIES="Sub Categories"
COM_KB_MOST_POPULAR_ARTICLES="Most Popular Articles"
COM_KB_MOST_RECENT_ARTICLES="Most Recent Articles"
COM_KB_ANSWERS="Answers"
COM_KB_REPORT_PROBLEMS="Report Problems"
COM_KB_ARTICLES="Articles"
COM_KB_NO_ARTICLES_FOR_CATEGORY="There are currently no articles in this category."
COM_KB_NO_ARTICLES="No articles found."
COM_KB_DETAILED_QUESTION="Detailed question"
COM_KB_YOU_FOUND_THIS_ARTICLE="You found this article"
COM_KB_WAS_THIS_HELPFUL="Was this helpful to you?"
COM_KB_YES="Yes"
COM_KB_NO="no"
COM_KB_READ_ARTICLE="Read the full article"
COM_KB_MY_SUPPORT_TICKETS="My Support Tickets"
COM_KB_FOUND_THIS_HELPFUL="%s out of %s people found this helpful."

Keys may contain letters, digits, and underscores, but no spaces. Convention is uppercase, words separated by underscores, prefixed with the component name: COM_BOOKINGS_INSTRUMENT_UNAVAILABLE. Nothing enforces the prefix, but every language file loaded into a request shares one flat table of strings, so an unprefixed SEARCH will collide with somebody else's — and the collision appears only on the pages where both are loaded. See Languages.

Translating

echo Lang::txt('COM_BOOKINGS_INSTRUMENTS');

A key that is not defined is returned unchanged, which is why a missing translation shows up as a screaming uppercase key rather than a blank space. Nothing is logged, so the only way to find the ones you have not exercised is to look:

php tools/lint/undefined-language-keys.php app/components/com_bookings

Any further arguments are passed to sprintf(), so a string can carry placeholders:

COM_BOOKINGS_SLOT_TAKEN="%s is already booked from %s until %s."
echo Lang::txt('COM_BOOKINGS_SLOT_TAKEN', $instrument->title, $from, $until);

Number the placeholders — %1$s, %2$s — whenever a translator might need to reorder them. Getting the count wrong is a PHP warning from sprintf() and a sentence with a hole in it, not an exception.

When files are loaded

Component::render() loads two files before your entry point runs, in this order:

  1. {client directory}/language/{tag}/{tag}.com_bookings.ini — the component's own strings, from the client that is running.
  2. PATH_APP/bootstrap/{client}/language/{tag}/{tag}.com_bookings.ini — the hub's overrides.

The second is loaded after the first, so a hub can redefine any single string by putting just that key in its own file. Nothing is copied and nothing needs to be complete. That is the supported way for a hub to reword your component; tell hub administrators about it rather than letting them edit your .ini, which an update overwrites.

SiteController's constructor loads the component's file from the client directory as well, but only if nothing has loaded it yet — which covers a controller instantiated outside the normal render path, from a module or a plugin.

Requesting a non-default language loads the default language first, so a partial translation falls back to English string by string rather than showing raw keys.

The system file

{tag}.com_bookings.sys.ini in the administrator language directory holds the strings the platform needs when your component is not the one running: its name in the Extensions manager, and the labels for the menu item types it offers. Leave it out and your component appears in the administrator's lists as COM_BOOKINGS.

COM_KB="Knowledge Base"

; Views
COM_KB_ARTICLES_VIEW_DEFAULT_TITLE="Intro"
COM_KB_ARTICLES_VIEW_DEFAULT_OPTION="Intro"
COM_KB_ARTICLES_VIEW_DEFAULT_DESC="Knowledge Base landing page"

COM_KB_ARTICLES_VIEW_ARTICLE_TITLE="Article"
COM_KB_ARTICLES_VIEW_ARTICLE_OPTION="Article"
COM_KB_ARTICLES_VIEW_ARTICLE_DESC="Display a single article"

COM_KB_ARTICLES_VIEW_CATEGORY_TITLE="Category"
COM_KB_ARTICLES_VIEW_CATEGORY_OPTION="Category"

COM_KB is the component's name wherever the platform lists extensions. The COM_KB_ARTICLES_VIEW_DEFAULT_TITLE form is what the menu manager falls back to when a view offers no layout metadata: {COMPONENT}_{VIEW}_VIEW_DEFAULT_TITLE. When the layout does have an .xml file beside it, its title attribute is used instead — and that attribute is itself passed through Lang::txt(), so it may be a key. See Views.

Adding a language

A translation is the same tree with a different tag: fr-FR/fr-FR.com_bookings.ini beside the en-GB directory. The tag is a language and a region joined by a hyphen; the path is built from it literally, so fr_FR produces a file nothing looks for. The general mechanics — the tag list, the .sys.ini convention, plural rules — are covered in Languages and Languages.

Rewritten and checked against 2.4-main @ 348f0057c2 on 2026-09-10.