Translation bugs are quiet. Nothing throws, tests pass, and the interface says something slightly wrong to half your users. You find out when one of them tells you, if they bother.
I ship bilingual software in French and English. These are the six that keep happening.
1. The placeholder got translated
The worst one, because the placeholder name looks like a word:
// en
'welcome' => 'Hello :name, you have :count messages',
// fr, translated by someone being thorough
'welcome' => 'Bonjour :nom, vous avez :count messages',
:nom is not a placeholder. Nothing substitutes it, and the user sees the literal characters :nom in the middle of a sentence.
Easy mistake, easy to catch mechanically: compare the placeholder sets on both sides of every key and fail if they differ. Same rule for ICU and next-intl, where {name} stays {name} and only the surrounding text changes.
This one belongs in CI. It has no false positives.
2. Zero is plural in English and singular in French
| Count | English | French |
|---|---|---|
| 0 | 0 item*s* | 0 article |
| 1 | 1 item | 1 article |
| 2 | 2 item*s* | 2 article*s* |
English treats zero as plural. French treats it as singular. Every French interface saying "0 articles" got there this way.
Laravel's MessageSelector already implements the French rule, so trans_choice picks correctly with no work from you. The bug appears when someone builds the string by hand:
// Wrong: the English rule, applied to French.
$label = $count === 1 ? 'article' : 'articles';
// Right.
trans_choice('messages.articles', $count);
// lang/fr/messages.php
'articles' => 'article|articles',
This generalises past French. A ternary on a count encodes one language's rule into your control flow, where no translation file can reach it.
3. ICU categories do not map across languages
On next-intl or formatjs, plural categories are per language and copying the English structure does not work:
{count, plural, =0 {Aucun article} one {# article} other {# articles}}
Arabic has six plural categories. Russian and Polish have three. Missing categories make the formatter fall back to other silently, producing grammatically wrong output with no error anywhere.
If you target any of those languages, read the CLDR plural rules for them.
4. An empty string is worse than a missing key
{ "nav.settings": "" }
A missing key usually triggers fallback to the source locale, so the user sees English. Readable, if not ideal.
An empty string often does not, because fallback logic checks whether the key exists. The user gets a blank button.
So a parity check has to treat empty values as failures instead of as present keys. People forget this case precisely because the key is technically there.
5. French typography is not English typography with accents
Native speakers notice these instantly and reviewers miss all of them.
- Narrow non-breaking space before
; : ! ?and inside« ». It isBonjour !, notBonjour!. The character is U+202F, with U+00A0 as the widely supported fallback. With a plain space the punctuation can wrap to the next line by itself. - Guillemets for quotations.
« comme ceci », not"like this". - Decimal comma, space for thousands.
1 234,56, not1,234.56. UseIntl.NumberFormat('fr-FR')or PHP'sNumberFormatter, never string manipulation. - Lowercase months and weekdays.
7 septembre 2026, not7 Septembre 2026. - Currency after the amount.
12,50 €, not€12.50. - Sentence case in titles.
Paramètres du compte, notParamètres Du Compte.
A key-by-key diff catches none of these, so a checker is necessary and not sufficient.
6. The string that was never extracted
Keys present in both locales is half the job. A hardcoded string in a template is never missing from a locale file, because it was never in one.
Parity checks compare files against each other, and a hardcoded string is in neither.
What to automate and what to review
Split them by whether a machine can be certain.
Mechanical, belongs in CI, fails the build: missing keys, empty values, placeholder mismatches, plural branch count mismatches. Each is a definite defect with no legitimate reading.
Warns but does not block: extra keys in the target, and values identical across locales. Sometimes a string genuinely is the same in both languages, and failing the build on "Email" being spelled the same way in French trains people to disable the check.
That split matters more than the checks themselves. A parity checker that fails on things which are sometimes fine gets switched off, taking the ones that are never fine with it.
I packaged this as an agent skill rather than only a script, because the second half of the list is judgement rather than comparison. The checker handles the mechanical part:
node skills/i18n-parity/scripts/check-parity.mjs
It auto-detects Laravel lang/{locale}/*.php, Laravel JSON, next-intl messages/{locale}.json and react-i18next locales/{locale}/*.json, exits 1 on anything a user would see, and warns on the rest.
The skill then carries the rules a diff cannot check, so an agent reviewing a translation pull request knows that a ternary on a count is suspect and that Bonjour! is missing a space.
npx github:catidegla/skillbelt add i18n-parity
skillbelt also carries Laravel and Next.js security review skills and a secrets audit, and installs into Claude Code, Codex, Cursor, Gemini CLI and Antigravity, which all read the same SKILL.md format and disagree about which directory it lives in.
Top comments (0)