Testing Localization in Laravel: The Assertions That Catch Real Bugs
Localization bugs share a property that makes them unusually expensive: almost all of them fail silently. A missing key falls back to another language. A short plural line returns the singular. A queued job renders in the wrong locale. Nothing throws, nothing logs, and you find out from a user — often months later, often in a language nobody on the team reads.
That makes localization an unusually good fit for automated testing. The tests are cheap, and they catch things no amount of code review will.
Key parity across locales
The highest-value test in this entire post. It catches the single most common failure: a key added to English and nowhere else.
it('has the same translation keys in every locale', function () {
$locales = config('app.available_locales');
$reference = null;
$referenceLocale = null;
foreach ($locales as $locale) {
$keys = collect(File::files(lang_path($locale)))
->filter(fn ($file) => $file->getExtension() === 'php')
->flatMap(fn ($file) => collect(Arr::dot(require $file->getPathname()))
->keys()
->map(fn ($key) => "{$file->getBasename('.php')}.{$key}"))
->sort()
->values()
->all();
if ($reference === null) {
[$reference, $referenceLocale] = [$keys, $locale];
continue;
}
expect(array_diff($reference, $keys))->toBe(
[], "Locale [{$locale}] is missing keys present in [{$referenceLocale}]"
);
expect(array_diff($keys, $reference))->toBe(
[], "Locale [{$locale}] has keys not present in [{$referenceLocale}]"
);
}
});
Three things make this work well in practice.
Arr::dot() flattens nested groups, so you compare real leaf keys — validation.size.array, validation.custom.email.unique — rather than just top-level names. Without it, a flattened size key looks identical to a correct nested one.
Reporting both directions matters. Missing keys are the obvious failure; extra keys usually mean a rename that only landed in one locale, which leaves dead translations and a broken key in the others.
And array_diff in the failure message tells you exactly which keys, so the fix is mechanical rather than a hunt.
Files that load and have the right shape
Cheap insurance against a malformed file reaching production:
it('loads every locale file without error', function () {
foreach (config('app.available_locales') as $locale) {
foreach (File::files(lang_path($locale)) as $file) {
expect(fn () => require $file->getPathname())->not->toThrow(Throwable::class);
expect(require $file->getPathname())->toBeArray();
}
}
});
it('keeps nested validation rules as arrays', function () {
// size, min, max and the comparison rules are keyed by type.
// Flattening one to a string throws only when that rule fires.
foreach (config('app.available_locales') as $locale) {
$validation = require lang_path("{$locale}/validation.php");
foreach (['size', 'min', 'max', 'between', 'gt', 'gte', 'lt', 'lte'] as $rule) {
expect($validation[$rule])->toBeArray("[{$locale}] validation.{$rule} must be an array");
expect($validation[$rule])->toHaveKeys(['array', 'file', 'numeric', 'string']);
}
}
});
The second test targets a specific, real bug: pass validation.php through a naive converter or spreadsheet and those nested arrays get flattened to strings. Validation then throws at runtime, only for the rules affected, only in the affected language.
Plural forms
Laravel picks plural forms with its own per-locale rules, and supplying too few segments falls back silently to the first. Test the boundaries, not just 1 and 2:
it('pluralizes cart items correctly in polish', function () {
$cases = [
1 => '1 produkt',
2 => '2 produkty',
4 => '4 produkty',
5 => '5 produktów',
12 => '12 produktów',
22 => '22 produkty',
25 => '25 produktów',
];
foreach ($cases as $count => $expected) {
expect(trans_choice('cart.items', $count, [], 'pl'))->toBe($expected);
}
});
The fourth argument to trans_choice() is the locale, so you don't need to switch the app locale.
22 and 12 are the cases that matter — they catch a rule copied from another Slavic language, and they're exactly the numbers nobody tries by hand. For why Polish needs three forms under Laravel while CLDR defines four, see the pluralization deep dive.
A generic version that catches the silent fallback across all languages, without you knowing each language's grammar:
it('supplies enough plural forms for every locale', function () {
$pluralKeys = ['cart.items', 'notifications.count', 'search.results'];
foreach (config('app.available_locales') as $locale) {
foreach ($pluralKeys as $key) {
$singular = trans_choice($key, 1, [], $locale);
$many = trans_choice($key, 137, [], $locale);
// If these match, the locale rule asked for an index that
// doesn't exist and Laravel fell back to the first segment.
expect($many)->not->toBe(
$singular,
"[{$locale}] {$key} returns the singular for 137 — too few forms?"
);
}
}
});
This won't hold for genuinely single-form languages like Japanese, so exclude those explicitly rather than deleting the test — an exclusion list you had to think about is more useful than no check.
No untranslated keys leaking to users
Lang::has() lets you assert a key resolves in a given locale:
it('has translations for every enum label in every locale', function () {
foreach (config('app.available_locales') as $locale) {
foreach (OrderStatus::cases() as $status) {
expect(Lang::has("orders.status.{$status->value}", $locale))
->toBeTrue("[{$locale}] missing orders.status.{$status->value}");
}
}
});
This pattern generalises to anything enumerable in code — enum cases, notification types, permission names, plan tiers. Those are the keys that get added in code and forgotten in the lang files, because nothing renders them until a particular state occurs.
An important caveat: Lang::has() respects the fallback locale, so a key present only in English can report true for Spanish. When you specifically want "is this translated in Spanish", check the file rather than the resolver:
$spanish = Arr::dot(require lang_path('es/orders.php'));
expect($spanish)->toHaveKey("status.{$status->value}");
That distinction is why the key-parity test at the top of this post compares files directly instead of using Lang::has().
Rendered pages
Feature tests catch the plumbing — middleware, locale resolution, dir attributes:
it('renders the dashboard in the user locale', function () {
$user = User::factory()->create(['locale' => 'es']);
$this->actingAs($user)
->get('/dashboard')
->assertOk()
->assertSee(__('dashboard.heading', [], 'es'))
->assertDontSee(__('dashboard.heading', [], 'en'));
});
it('sets dir=rtl for arabic', function () {
$this->get('/ar/pricing')
->assertOk()
->assertSee('dir="rtl"', escape: false);
});
The assertDontSee on the English string is the part that gives this test teeth. Without it, the test passes when nothing is translated at all — because a missing Spanish key falls back to English, and assertSee on a fallback string succeeds. Asserting the English version is absent is what proves translation actually happened. (Skip that assertion where the two languages share a word.)
The queue locale leak
Three lines, catches a bug that's nearly impossible to diagnose in production:
it('restores the locale after a queued job', function () {
app()->setLocale('en');
ProcessOrder::dispatchSync(Order::factory()->create(), 'es');
expect(app()->getLocale())->toBe('en');
});
A worker is a long-lived process. A job that calls App::setLocale() without restoring leaks that locale into every subsequent job on that worker, so some users get the wrong language depending on job ordering. This test fails against the naive implementation and passes with the Localizable trait — see the emails and notifications post.
Hardcoded strings
You can assert against regressions in templates you've already internationalised:
it('has no hardcoded text in the checkout views', function () {
$suspicious = [];
foreach (File::allFiles(resource_path('views/checkout')) as $file) {
$contents = File::get($file->getPathname());
// Text between tags that isn't a Blade expression.
preg_match_all('/>([A-Z][a-zA-Z][^<>{}@]{4,})</', $contents, $matches);
foreach ($matches[1] as $match) {
if (trim($match) !== '') {
$suspicious[] = "{$file->getRelativePathname()}: ".trim($match);
}
}
}
expect($suspicious)->toBe([]);
});
Be honest about what this is: a heuristic. It will produce false positives, and the right response to those is usually narrowing the directory rather than loosening the pattern. Scope it to directories you've finished internationalising and it works as a ratchet — those views can't regress. Pointing it at your whole views/ directory on a legacy app produces noise nobody will act on.
Our Blade scanner does a better job of the detection itself, including attributes, if you want to check a template interactively rather than in CI.
What not to test
Some restraint is worth having:
Don't assert exact translated copy for every string. A test asserting __('cart.checkout') === 'Finalizar compra' fails when a translator improves the wording — that's a correct change breaking a test, and after it happens twice people stop trusting the suite. Assert structure (the key resolves, placeholders are present, forms differ by count) rather than wording.
Don't test that translations are good. No test tells you a Spanish string is idiomatic. That's review by someone who reads the language, and pretending a test covers it is worse than admitting it doesn't.
Do test placeholders survive, since a translator dropping :count is a real and silent failure:
it('keeps required placeholders in every locale', function () {
$required = [
'cart.items_for' => [':name', ':count'],
'mail.welcome.greeting' => [':name'],
];
foreach (config('app.available_locales') as $locale) {
foreach ($required as $key => $placeholders) {
$line = Lang::get($key, [], $locale);
foreach ($placeholders as $placeholder) {
expect($line)->toContain($placeholder, "[{$locale}] {$key} lost {$placeholder}");
}
}
}
});
Use Lang::get() rather than __() here so you get the raw line with placeholders intact, instead of a string with substitutions already applied.
Summary
- Key parity across locales is the single highest-value test. Use
Arr::dot()and check both directions. - Assert
validation.php's nested rules are still arrays — flattening them fails only at runtime. - Test plural boundaries (1, 2, 5, 12, 22), and add a generic check that a large count doesn't return the singular.
Lang::has()respects fallbacks; compare files directly when you mean "translated in this language".- Pair
assertSeewithassertDontSeeon the source language, or the test passes when nothing is translated. - Test that a job restores the locale afterwards.
- Assert placeholders survive translation, using
Lang::get()for raw lines. - Test structure, not wording. Don't pretend tests cover translation quality.
Tests tell you a key is missing. They don't fill it in, and the fix is still an edit and a deploy. LangSyncer makes the gap visible before CI does — coverage per language across every key — fills it with AI for review, and publishes to production in seconds.