# Speaking other languages

2026-08-10.

Ernest: *"We do need to make Convoro fully translatable."* It is job zero —
ahead of porting any of the 43 Invision applications, because every app ported
before the layer exists is a retrofit, and 43 retrofits is a different project
from 43 ports.

---

## What was actually there

Nothing. Not "partial", not "English-only by default" — no language files
anywhere, no `@lang` in the compiler, no translator in the container. Every
string in Convoro is English typed into a `.cvr` or a `.php`.

The handoff estimated ~474 strings in templates from a grep for capitalised
multi-word text. Measured properly with `tests/support/untranslated.php`:

| | |
|---|---|
| User-facing strings in templates | **1,762** |
| Templates containing at least one | **137 of 143** |
| Worst single file | `system/admin/settings.cvr`, 76 |

That number is conservative by construction — the scanner skips single words
outside attribute and button positions, and skips PHP entirely. The real figure
is higher. It is not a number anybody converts in one sitting, and pretending
otherwise is how a migration ends up half-done with no way to tell which half.

---

## The shape

Five pieces, and the reason each one is where it is.

### 1. The translator is in the engine, not in System

`engine/Support/Lang/Translator.php`, bound as `translator`.

Every other registry built lately — content types, widget types, widget areas —
lives in System, and this one deliberately does not. `engine/Console` is 28
files of user-facing output and the installer runs **before any module is
registered at all**. A translator owned by a module cannot serve either, and
"the installer speaks English but the site does not" is the kind of seam that
never gets closed later.

### 2. A module owns its strings, exactly like its templates

`ModuleManager::registerLanguagePaths()` mirrors `registerTemplatePaths()` line
for line — every module's `Lang/` directory is registered under its own key, so
`forum.reply` resolves to `modules/forum/Lang/en.php`. An extension gets this
for free by being a module, which is the whole reason extensions were built as
modules.

A language file is a **flat PHP array**, `'key' => 'English'`:

```php
return [
    'reply' => 'Reply',
    'replies' => '{count} reply|{count} replies',
];
```

Flat, and not nested, on purpose: the 43 IPS apps already store their strings
as a flat `'key' => "English"` array in `dev/lang.php`, and a compatible shape
turns each port's language file into a rename rather than a restructure. That
is 43 decisions bought with one.

### 3. Interpolation is `{name}`; plurals are pipes with real rules

```
'welcome'  => 'Welcome back, {name}.'
'replies'  => '{count} reply|{count} replies'
```

🚨 **The plural rule is per-locale and comes from a table, not from
`$n === 1`.** Polish needs three forms and Arabic six, and a two-form
assumption bakes English grammar into the engine where no translator can reach
it. `PluralRules` covers the common families and documents its default. Getting
this wrong is not a bug anybody reports — it is a language that reads slightly
broken forever.

### 4. Overrides live in the database, because the operator has no shell

`translations` (locale, key, value), unique on the pair, read once per request
and layered over the files. Editing a file to change one word is not available
to somebody on shared hosting, and it would be overwritten by the next upgrade
anyway. This is the same argument the web installer settled.

Resolution order, most specific first: **database override → the locale's file
→ the fallback locale's file → the key itself.** A missing key renders the key,
visibly and harmlessly — never a blank, which reads as a broken layout and
sends somebody looking for the wrong bug.

### 5. The locale is per-person, and the global is set on every request

`users.locale` has existed since the first migration with nothing reading it —
their own trap, *"a column is not a feature until something reads it"*. Now the
chain is member's locale → site default → `en`.

🚨 `Lang::use()` sets process-wide state and `ApplyLocale` middleware therefore
sets it on **every** request including a guest's, for the same reason
`ApplyTimezone` does: under a long-lived process, one member's language would
otherwise still be in place for whoever came next. That failure mode is
invisible in testing and obvious in production.

---

## The ratchet, and why the number is not zero today

1,762 strings cannot be converted in the same change that builds the machinery,
and a migration with no enforcement drifts back the moment somebody is in a
hurry. So this follows the precedent `design-audit.js` already set here:

**`tests/Engine/TranslationContractTest.php` fails if the count goes up.**

Down is always free and lowers the ceiling. `--accept` moves it deliberately.
New work is therefore translatable from the first line — the ceiling makes
untranslated English literally unshippable — while the existing 1,762 come down
as files are touched for other reasons.

`php tools/convoro lang:report` lists them worst-file-first, so the next
tranche is always obvious.

🚨 **Do not "finish" the conversion in one sweep.** The 27-template admin
stylesheet migration is the cautionary precedent in the other direction: a
mechanical sweep across files nobody is otherwise touching produced a lot of
churn and one silent breakage per unreviewed screen. Convert what you are
already working in.

---

## What this deliberately does not do

- **No translation UI for end members.** Admin → Languages edits overrides;
  members pick a language and nothing else.
- **No shipped translations.** English only. A half-finished Spanish is worse
  than none, because it reads as neglect rather than absence, and the machinery
  is the part that cannot be contributed by somebody else.
- **Console output is translated but the installer's is not.** The installer
  runs before a database exists, so it has no overrides and no member — it
  reads files only, and it says so in the code.
