BinktermPHP uses a key-based localization system covering Twig templates, PHP route/controller code, and client-side JavaScript. Translation catalogs live under config/i18n/ and are loaded on demand by locale.
config/i18n/
├── en/
│ ├── common.php # UI strings (templates, JS)
│ └── errors.php # API error messages
├── es/
│ ├── common.php
│ └── errors.php
├── overrides/ # Sysop phrase overrides (JSON, applied on top of base catalogs)
│ └── <locale>/
│ └── <namespace>.json
└── hardcoded_allowlist.php # Known-OK English strings exempt from the linter
Each locale directory name becomes a supported locale identifier (e.g. en, es). New locales are added by creating a new directory with common.php and errors.php files.
Note on the bundled Spanish (
es) translation: The Spanish catalog was generated by AI and has not been independently reviewed for accuracy. It may contain errors, awkward phrasing, or incorrect terminology. Community corrections are welcome — see the Translation Contributor Workflow section below.
Loads and caches catalog files, performs key lookup with fallback, and interpolates {param} placeholders.
- Reads
I18N_DEFAULT_LOCALEfrom.env(default:en). - Supported locales are read from
I18N_SUPPORTED_LOCALES(comma-separated), or auto-discovered from theconfig/i18n/directory structure. - On a missing key, falls back to the default locale, then returns the key itself as a last resort.
- Missing keys can be logged by setting
I18N_LOG_MISSING_KEYS=trueand optionallyI18N_MISSING_KEYS_LOG_FILE. - After loading a base
.phpcatalog, automatically merges any sysop overrides fromconfig/i18n/overrides/<locale>/<namespace>.json(see Language Phrase Overrides).
Determines the active locale for a request using this priority order:
- Explicit locale argument (e.g. from a
?locale=query param) - Authenticated user's saved locale preference (
users.localecolumn) binktermphp_localecookieAccept-Languagerequest header (highestqvalue wins)- Default locale from
.env
persistLocale() writes the resolved locale to the cookie for one year.
The t() function is registered globally in Template.php and is available in every template.
{# Basic usage #}
{{ t('ui.login.title', {}, locale, ['common']) }}
{# With parameters #}
{{ t('ui.polls.create.submit', {'cost': poll_cost}, locale, ['common']) }}
{# Errors namespace #}
{{ t('errors.auth.invalid_credentials', {}, locale, ['errors']) }}Arguments: t(key, params, locale, namespaces)
key— dot-separated translation keyparams— object of{placeholder}substitutionslocale— thelocaleTwig global (set per-request byTemplate.php)namespaces— array of catalogs to search; defaults to['common']
The locale and supported_locales globals are automatically available in every template.
Use apiLocalizedText() for strings returned in API responses:
apiLocalizedText('errors.auth.invalid_credentials', 'Invalid credentials');This resolves the current user's locale automatically. An optional $user array, $params, and $namespace can be passed.
All API errors must use the apiError() helper so the frontend can resolve the display text:
apiError('errors.some.key', apiLocalizedText('errors.some.key', 'English fallback'), 400);The response payload shape is:
{
"success": false,
"error_code": "errors.some.key",
"error": "Translated error message"
}The error_code field is the translation key. The error field is the server-side translated string. The frontend can re-translate using the client-side catalog if needed (see below).
Success responses that carry a human-readable message use message_code / message the same way:
{
"success": true,
"message_code": "ui.some.success_key",
"message": "Translated success message"
}Template.php injects window.appLocale, window.appDefaultLocale, and window.appI18nNamespaces into every page via base.twig. On DOMContentLoaded, app.js fetches the catalog for all namespaces (always ['common', 'errors']) from:
GET /api/i18n/catalog?ns=common,errors&locale=<locale>
The response merges the default locale catalog with the active locale catalog so only translated keys need to be provided for non-default locales.
The primary translation function available everywhere:
window.t('ui.polls.create.submit', { cost: 25 }, 'Create Poll (25 credits)')- Looks up
keyin loaded catalogs. - Interpolates
{param}placeholders fromparams. - Returns
fallback(or the key itself) when the key is not found.
Many templates define a local wrapper to handle the case where window.t is not yet available:
function uiT(key, fallback, params = {}) {
if (window.t) {
return window.t(key, params, fallback);
}
return fallback;
}Resolves the display text for an API error payload:
.catch(error => {
showAlert('danger', getApiErrorMessage(error, 'Operation failed'));
});Checks payload.error_code first (looks it up in the client catalog), then payload.error, then fallback.
Same pattern for success messages using message_code / message.
Additional namespaces can be loaded on demand:
loadI18nNamespaces(['common', 'errors']).then(function() {
// catalog is now available
});- Add to
config/i18n/en/common.php(orerrors.phpfor API errors):
'ui.my_feature.some_label' => 'My Label',- Add the same key to
config/i18n/es/common.php:
'ui.my_feature.some_label' => 'Mi etiqueta',- Use it in Twig:
{{ t('ui.my_feature.some_label', {}, locale, ['common']) }}- Use it in JavaScript:
window.t('ui.my_feature.some_label', {}, 'My Label')- Run the validation scripts before committing:
php scripts/check_i18n_hardcoded_strings.php
php scripts/check_i18n_error_keys.php| Prefix | Purpose |
|---|---|
ui.<page>.* |
Template / UI strings for a specific page |
ui.base.* |
Strings in the shared base layout |
ui.common.* |
Strings shared across many pages |
ui.admin.<page>.* |
Admin panel page strings |
errors.<area>.* |
API error messages |
Examples:
ui.login.titleui.compose.echomail_guideline_identityui.admin.bbs_settings.features.enable_webdoorserrors.auth.invalid_credentialserrors.polls.not_found
- Create
config/i18n/<locale>/common.phpandconfig/i18n/<locale>/errors.phpreturning arrays of translated keys. - The locale is auto-discovered from the directory name — no code changes required.
- Optionally, pin the supported locale list explicitly via
I18N_SUPPORTED_LOCALES=en,es,frin.env.
The script scripts/create_translation_catalog.php translates the English catalogs into a new locale automatically using an AI API (OpenAI or Anthropic Claude). It is the fastest way to bootstrap a new locale and produces a complete common.php and errors.php ready for human review.
- OpenAI: set
OPENAI_API_KEYin.env(optionallyOPENAI_API_BASEfor a custom endpoint) - Claude: set
ANTHROPIC_API_KEYin.env(optionallyANTHROPIC_API_BASE)
# Translate into French using whichever API key is configured
php scripts/create_translation_catalog.php --locale=fr --language="French"
# Force a specific provider
php scripts/create_translation_catalog.php --locale=fr --language="French" --provider=claude
php scripts/create_translation_catalog.php --locale=de --language="German" --provider=openai
# Specific model
php scripts/create_translation_catalog.php --locale=ja --language="Japanese" --model=claude-opus-4-6
# Overwrite an existing locale
php scripts/create_translation_catalog.php --locale=es --language="Spanish" --overwrite
# Dry run — translate but do not write files
php scripts/create_translation_catalog.php --locale=fr --language="French" --dry-runThe script selects the provider automatically when --provider is not given:
- If only
ANTHROPIC_API_KEYis set → Claude - Otherwise → OpenAI
| Option | Default | Description |
|---|---|---|
--locale |
(required) | Target locale code, e.g. fr, de, pt-BR |
--language |
(required) | Full language name passed to the model, e.g. French |
--provider |
auto | openai or claude |
--model |
gpt-4o-mini / claude-sonnet-4-6 |
Model to use (default depends on provider) |
--namespaces |
common,errors |
Which catalogs to translate |
--batch-size |
150 |
Translation keys per API request |
--timeout |
120 |
HTTP timeout in seconds per request |
--retries |
3 |
Retries on batch failure |
--pause-ms |
0 |
Milliseconds between batch requests |
--overwrite |
off | Overwrite existing locale files |
--dry-run |
off | Translate but do not write files |
Writes config/i18n/<locale>/common.php and config/i18n/<locale>/errors.php. Any keys where placeholder tokens ({name}, %s, etc.) did not survive translation are kept in English and logged to config/i18n/<locale>/translation_warnings.log.
- Review the output files for quality — AI translations are a starting point, not a finished product.
- Fix any entries in
translation_warnings.log. - Test by setting
?locale=<code>in the browser and browsing key pages. - Commit the new locale directory.
This section describes how to contribute a translation for a new language from start to finish.
Enable missing-key logging against your target locale so the application tells you what's untranslated as you browse:
# .env
I18N_LOG_MISSING_KEYS=true
I18N_MISSING_KEYS_LOG_FILE=/path/to/binkterm/data/logs/i18n-missing.log
I18N_DEFAULT_LOCALE=en
I18N_SUPPORTED_LOCALES=en,fr # add your target localeThen browse the site with your browser's Accept-Language set to the target locale (or append ?locale=fr to any URL). Missing keys accumulate in the log file.
Alternatively, use the English catalog as your full source of truth — every key in config/i18n/en/common.php and config/i18n/en/errors.php needs a counterpart in your locale.
mkdir config/i18n/frCopy the English catalog and translate the values. Keys must stay identical — only values change.
<?php
// config/i18n/fr/common.php
return [
'ui.login.title' => 'Connexion',
'ui.login.username' => 'Nom d\'utilisateur',
'ui.login.password' => 'Mot de passe',
// ... all other keys
];Tips:
- Preserve
{placeholder}tokens exactly — they are substituted at runtime and must not be translated or renamed. - Keep the same key ordering as the English file to make diff reviews easier.
- You do not need to include keys whose English value is acceptable as-is; the system falls back to the default locale for any missing key.
Same process for API error messages:
<?php
// config/i18n/fr/errors.php
return [
'errors.generic' => 'Une erreur inattendue s\'est produite',
'errors.auth.invalid_credentials' => 'Identifiants invalides',
// ... all other keys
];Set your browser's preferred language to the target locale (or use the language selector in user settings) and navigate through the interface. Key areas to check:
- Login, registration, and password reset pages
- Compose (netmail and echomail) — including posting identity guidelines
- Admin panel settings pages
- Error messages (try submitting invalid forms)
- API responses shown in alerts/toasts
php scripts/check_i18n_hardcoded_strings.php
php scripts/check_i18n_error_keys.phpBoth must pass before submitting. They do not check translation quality but do catch missing errors.* keys and newly introduced hardcoded English strings.
Open a pull request against the main branch with only the new locale files (config/i18n/<locale>/). Include a brief note in the PR description about which areas were translated and any strings intentionally left in English.
- Placeholders like
{cost},{system_name},{count}must appear verbatim in translated strings — the system replaces them at runtime. - HTML is not used inside catalog strings. Do not add markup.
- Gendered / plural forms are not currently supported — choose a neutral phrasing where the language requires it.
- Missing keys fall back to English automatically, so a partial translation ships gracefully without breaking the interface.
Sysops can customize individual phrases for any locale without editing the base translation files. Overrides are layered on top of the base catalog at runtime — only the keys you define in an override file are affected; everything else falls through to the base catalog as normal.
Navigate to Admin → BBS Settings → Language Overrides. Select a locale and catalog, then click Load. Each row shows the translation key, the current base value, and an input field for your override. Leave a field empty to use the base value. Click Save Overrides when done.
Override files are plain JSON stored at config/i18n/overrides/<locale>/<namespace>.json:
{
"ui.terminalserver.server.banner.title": "My BBS Telnet Service",
"ui.nav.home": "Home Base"
}Only include the keys you want to override. Keys not present in the file are unaffected. Saving an empty set of overrides removes the file entirely.
When Translator loads a catalog it checks for a corresponding override file after loading the base .php catalog and merges any matching keys on top. The override is transparent to all callers — t(), window.t(), and API responses all see the overridden values automatically without any code changes.
- Override files are written through the admin daemon — the web process never writes them directly.
- Keys in override files that do not exist in the base catalog are silently ignored at runtime but are still saved in the file.
- Override files are not tracked by the i18n validation scripts (
check_i18n_hardcoded_strings.php,check_i18n_error_keys.php) and do not need to be committed to version control for a production installation.
| Variable | Default | Description |
|---|---|---|
I18N_DEFAULT_LOCALE |
en |
Fallback locale when no user preference or browser locale matches |
I18N_SUPPORTED_LOCALES |
(auto) | Comma-separated list of supported locales; auto-discovered if unset |
I18N_LOG_MISSING_KEYS |
false |
Log a warning when a translation key is not found |
I18N_MISSING_KEYS_LOG_FILE |
(php error log) | Path to write missing-key log entries |
Two scripts keep the catalogs consistent:
Scans templates and JavaScript files for user-visible English strings that should be translation keys. Strings in config/i18n/hardcoded_allowlist.php are exempt (e.g. API fallback strings, internal values).
Verifies that every error_code used in apiError() calls throughout routes and controllers exists in config/i18n/en/errors.php.
Both scripts exit non-zero on failure and are run in CI via .github/workflows/i18n-error-keys.yml.
The i18n catalog is fetched asynchronously on page load. The load sequence is:
- Twig renders the page server-side with the correct locale (always correct)
DOMContentLoadedfiresloadUserSettings()fetches/api/user/settings(async)- On completion,
loadI18nNamespaces()fetches/api/i18n/catalog(async) - Only after step 4 does
window.t()return translated strings
There is no mechanism that queues or defers window.t() calls made before step 4 completes. Any JavaScript that runs during steps 2–4 and tries to set visible text via window.t() or uiT() will receive the English fallback string regardless of user locale.
The primary protection is server-side rendering. Twig handles the initial render correctly, so JavaScript should not re-render already-translated text during initialization.
Rule: When a UI element is already rendered by Twig server-side, do not overwrite it from JavaScript during page initialization. Only use window.t() / uiT() to update text in response to user interactions (dropdown changes, button clicks, etc.) — by which point the catalog is reliably loaded.
If you must translate a string from JS during init (no server-rendered equivalent), defer it until the catalog is ready:
loadI18nNamespaces(['common']).then(function() {
$('#myElement').text(window.t('ui.my_feature.label', {}, 'My Label'));
});