Internationalization
Every user-facing string lives in a per-language JSON file and is read through NLS,
so the same code renders in whatever language the request is in.
Configuration
IntlConfig is where the layer is set up. In a config file it
sets the default language and points at your copy directories — strings, plus the
email and notification content:
use Framework\Intl\IntlConfig;
IntlConfig::setDefaultLanguage("en");
IntlConfig::setStringsDir("nls/strings");
IntlConfig::setEmailsDir("nls/emails");
IntlConfig::setNotificationsDir("nls/notifications");The defaults are those same nls/… paths, so you only call the setters to move them. The default
language is the fallback used whenever no language is set on the request.
Two more take the directories the check reads: addScriptDir() for the ones where your own apps keep
their strings, and addSourceDir() for the ones where the strings are
used. Nothing else reads either of them.
The string files
The languages live inside the strings directory set in the config, and nowhere else. Each is one JSON
file named by its code, and the code is the file name: en.json is the language
en. The NAME key holds the name of the language, the one a picker shows, and the
rest of the keys are shared across the files — you translate the values:
{
"NAME": "English",
"GENERAL_ACCEPT": "Accept",
"PRODUCT_CREATED": "The product was created",
"PRODUCT_COUNT_SINGULAR": "{0} product",
"PRODUCT_COUNT_PLURAL": "{0} products"
}{
"NAME": "Español",
"GENERAL_ACCEPT": "Aceptar",
"PRODUCT_CREATED": "El producto fue creado",
"PRODUCT_COUNT_SINGULAR": "{0} producto",
"PRODUCT_COUNT_PLURAL": "{0} productos"
}Only the files with a NAME key become languages. A file without one can still be read
through NLS by naming its code — strings for a tool, or a variant not offered to
anyone — it just is not listed as a language anywhere.
The files are written to mirror each other, key for key and line for line, so a key sits at the same place in all of them. Checking them is what says whether they still do.
The Language class
Language is generated, not written. At build time the
framework lists the files of the strings directory and reads each one, and every file with a
NAME key becomes a language: the file name is the code, and the NAME is the name.
The result is written as a class of your app, so the languages it offers are known at compile time rather
than kept in a list someone has to maintain:
public static function getRootCode(): string {
return "en";
}
public static function getAll(): array {
return [
"en" => "English",
"es" => "Español",
];
}
Three rules decide what comes out:
- The root is the default language of the config. If no file carries that code, the first language found becomes the root instead.
- The order puts the root first and sorts the rest by name, so a picker opens on the language most people want.
- With no languages at all — an empty directory, or files that all lack a
NAME— the list is created with English alone, so there is always one to answer with.
Rebuild after adding a file, renaming one, or changing a NAME: until then the class still
holds the languages of the last build. What the class offers:
use Framework\System\Language;
Language::getAll(); // [ "en" => "English", "es" => "Español" ]
Language::getRootCode(); // the root language code
Language::isValid("es"); // whether it is one of the languages
Language::getCode("es"); // the code, or the root one for a code not listed
Language::getSelect(); // a Select list, ready for a dropdown
getSelect() is the quickest way to render a language picker; feed the chosen code back into the
credential with Credential::setLanguage() and every later
request follows it.
The current language
You normally never set the language yourself. When a request is authenticated,
Auth reads the language stored on the signed-in
credential and calls NLS::setLanguage() with it — so from that
point on every string resolves in the user's language automatically. While an admin is
signed in as another user, the admin's language wins, so the
interface stays in the language they read. With no user, the configured default applies.
use Framework\Intl\NLS;
NLS::getLanguage(); // the language in effect for this request
NLS::setLanguage("es"); // override it — rarely needed
The catch is that this only happens when there is a user. An API request authenticates with a static token and carries no credential, and the same is true of a console command or a cron run — so nothing sets a language and every string falls back to the configured default. In those contexts pass the language explicitly, either per call or once for the whole run:
// Per call — the second argument of every read method
NLS::getString("PRODUCT_CREATED", $language);
NLS::format("HOME_WELCOME", [ $name ], $language);
// …or set it once, from whatever the API request carries
NLS::setLanguage($request->getString("language"));
xLangcode, which
Framework::execute() applies for you. An API request has no such
step — decide where the language comes from (a request field, the account you are acting for) and set it
yourself.Reading strings
NLS is the class you read through — every method is static and takes an optional language as its
last argument. NLS::getString() resolves a key in the current language, so most calls take just the
key:
use Framework\Intl\NLS;
NLS::getString("GENERAL_ACCEPT"); // "Accept"
NLS::getString("GENERAL_ACCEPT", "es"); // force a language
Keys can hold more than a single string:
| Method | Returns |
|---|---|
NLS::getString() | One translated string. |
NLS::getList() | A key holding an array of strings. |
NLS::getIndex() | One entry of such a list — how enums resolve their labels. |
NLS::getMap() | A key holding a map of values. |
NLS::getSelect() | A list turned into Select options. |
NLS::getAll() | The raw value behind a key. |
Placeholders
Strings interpolate with numbered {0} placeholders, filled in order by NLS::format().
Numbering them means a translation can reorder the values when its grammar needs a different order:
// en: "Welcome back, {0}. You have {1} messages."
// es: "Tiene {1} mensajes, {0}."
NLS::format("HOME_WELCOME", [ $name, $count ]);
Two more helpers finish a value before it reaches a string — NLS::toYesNo() renders a boolean in
the user's language, and NLS::formatNumber() formats a number for it:
NLS::toYesNo(true); // "Yes" — "Sí" in Spanish
NLS::formatNumber(1234.5); // the number in the language's format
// …then drop them into a string
NLS::format("ORDER_SUMMARY", [ NLS::formatNumber($total), NLS::toYesNo($isPaid) ]);
Plurals
Declare a _SINGULAR and a _PLURAL key, and NLS::pluralize() picks between
them by count — passing the count in as {0}, so you never build the sentence by hand:
// "PRODUCT_COUNT_SINGULAR": "{0} product"
// "PRODUCT_COUNT_PLURAL": "{0} products"
NLS::pluralize("PRODUCT_COUNT", 1); // "1 product"
NLS::pluralize("PRODUCT_COUNT", 7); // "7 products"
When the count comes from a list you already have, NLS::pluralizeList() takes the list itself and
uses its size, passing the joined values in as the placeholder:
// "TAG_ADDED_SINGULAR": "Added the tag {0}"
// "TAG_ADDED_PLURAL": "Added the tags {0}"
NLS::pluralizeList("TAG_ADDED", [ "New" ]); // "Added the tag New"
NLS::pluralizeList("TAG_ADDED", [ "New", "Hot", "Sale" ]); // "Added the tags New, Hot and Sale"
Joining values
Listing values in a sentence is language-specific — the separator and the final conjunction change.
NLS::join() builds that list for you:
NLS::join([ "New", "Hot", "Sale" ]); // "New, Hot and Sale"
NLS::joinWithAnd([ "New", "Hot" ]); // "New and Hot"
NLS::joinWithOr([ "New", "Hot" ]); // "New or Hot"
To put that list inside a string, NLS::formatJoin() does both steps at once — it joins the values
and drops the result into the key as {0}:
// "FILTER_APPLIED": "Filtering by {0}"
NLS::formatJoin("FILTER_APPLIED", [ "New", "Hot", "Sale" ]);
// "Filtering by New, Hot and Sale"
NLS::formatJoin("FILTER_APPLIED", $tags, useOr: true);
// "Filtering by New, Hot or Sale"
Localized urls
NLS::url() and NLS::urlPath() build a url whose segments are themselves translated
keys, so a link reads naturally in each language:
NLS::url([ "URL_PRODUCTS", "URL_NEW" ]); // /productos/nuevo in Spanish
Checking the files
The files drift apart with use: a key is added to one and forgotten in the other, a line moves, a Select loses an option. The nlsCheck command compares the files of a directory against each other and fails when they no longer match, so it can run alongside the other checks before merging:
./framework nlsCheck
It reads the three directories of the config, plus every app directory added to it. What is compared depends on what the files hold:
| Directory | Compared by |
|---|---|
| Strings | The keys, the keys inside each of them, and the line each key is written at. |
| Emails and notifications | The keys, the keys inside each of them, and the order of the blocks. |
| App directories | The same as the strings, since they are written the same way. |
The keys inside a key are the options of a Select, the entries of a list, and the subject and
message an email is made of. The lines are compared where the files mirror each other line for line,
and the order where a block is as long as its copy needs. The NAME key is skipped, since each strings
file names its own language.
No file is taken to be the right one. The error is reported for the files that are on their own, against the ones that agree, and when only two of them disagree the file of the default language is the one taken to be right. There is a section per directory, naming the path it read, and a directory with no files is left out:
Strings: nls/strings
- Found 2 errors in es.json
- GENERAL_ACCEPT is missing, and is in en.json and pt.json
- SELECT_STATUS is a string, and a Select in en.json and pt.json
- Found 1 error in pt.json
- PRODUCT_CREATED is at line 49, and at line 48 in en.json and es.json
Emails: nls/emails
- Found 1 error in es.json
- position 3 holds Reset, and Welcome in en.json
Notifications: nls/notifications
- Compared 2 files, there are no errors
The keys are what everything else rests on, so the lines and the order are only reported once they match: a key that only one file holds moves every key below it in that file, and reporting those as well would bury the one thing to fix.
The keys that are used
The files agreeing with each other says nothing about whether the code still asks for what they hold. Point the check at the directories where the strings are used and it answers two more questions: which keys nothing uses, and which keys the code asks for that no file defines. Nothing below runs until at least one is added:
IntlConfig::addSourceDir("src");
IntlConfig::addSourceDir("../desktop/src");Every .php, .js, .jsx, .mjs, .ts and
.tsx file under them is read, the language files themselves excluded. The framework's own source is
always read as well, since a key like GENERAL_AND is used by NLS::join() without
your app ever naming it.
A key counts as used wherever its name shows up — in a call, in an attribute of a component, or
on its own, since the answer of the server is often a key the app renders. Two conventions are followed: a
plural pair is used by the key both halves are written from, so
NLS::pluralize("PRODUCT_COUNT") covers PRODUCT_COUNT_SINGULAR and
PRODUCT_COUNT_PLURAL; and a key the code completes is only ever named by its start, so
"AUTOMATION_ERROR_" . $type covers every key of that family.
A key is asked for by name in the places that take one, and those are what a missing key is reported from:
| Where | What is read |
|---|---|
NLS::getString() and every other method of NLS, in both languages | Every key given to it, a list of them included. |
Response::success(), warning() and error() | The key of the message. |
$errors->field = "KEY" and $errors->add() | The key of the error, which the app renders. |
<Button message="KEY" />, in an app file | Only when the key holds an underscore and its family already exists. |
That last rule is what keeps the report readable: an attribute takes an action of the component as readily as a
key — action="DELETE" — and a bare word is not enough to tell them apart. A key that would
start a family of its own is the price of that, and it shows up as an unused key on the other side instead.
The keys nothing uses are reported under the directory that defines them, and the ones nothing defines under the sources that ask for them, with the file and line of the first one:
Strings: nls/strings
- Found 2 keys that no source uses
- CAMPAIGN_ERROR_EXTERNAL_ID
- GENERAL_APPLY
Sources: src and ../desktop/src
- Found 2 keys that no file defines
- GENERA_ERROR_STATUS, used in src/Flow/Node/AssignDataNode.php:315
- GENERAL_PHONE, used in ../desktop/src/Components/App/Client/User/UserDetails.jsx:57
Both count as errors, so the command fails on them the way it does on a key two files disagree on.
The app files
The apps around the backend — a front end, an admin — keep their own strings beside their source, one file per language written as a script that exports an object. Add each directory by name, and the check reads it as one more section. Nothing else reads them, since the app that owns them is the one that does:
use Framework\Intl\IntlConfig;
IntlConfig::addScriptDir("Desktop Strings", "../desktop/src/NLS/Strings");
IntlConfig::addScriptDir("Desktop Urls", "../desktop/src/NLS/Urls");The name is what heads the section of the report, and the directory is read from the app base, so a path that
climbs out of it with ../ is how a sibling app is reached. The files are named by language code the same
way, en.js beside es.js:
const strings = {
// General
GENERAL_ACCEPT : "Accept",
GENERAL_CANCEL : "Cancel",
SELECT_STATUS : {
"active" : "Active",
"paused" : "Paused",
},
DATE_TIME_DAYS : [
"Sunday",
"Monday",
],
};
export default strings;A file is either a JSON or a script like that one, and both are read the same way — the difference is only in what a script is allowed to write: a key without quotes, a comma after the last one, and comments. What surrounds the object is ignored, so how it is declared and exported is up to the app.