JSON vs PHP Lang Files in Laravel: Performance, Tooling and When to Use Each
Laravel supports two translation file formats and the documentation presents them as a stylistic choice: PHP arrays with short keys, or JSON with the English string as the key. In practice they behave differently in ways that matter — how much gets loaded per request, what happens when a file is malformed, and whether nesting works at all.
Here's what actually differs, checked against the framework's loader rather than inferred.
The two formats
PHP files live in lang/{locale}/{group}.php and return an array. The filename is the group:
// lang/es/cart.php
return [
'checkout' => 'Finalizar compra',
'empty' => 'Tu carrito está vacío',
'items' => ':count artículo|:count artículos',
];
__('cart.checkout'); // group "cart", key "checkout"
JSON files live in lang/{locale}.json — one file per locale, no group — and conventionally use the source string as the key:
{
"Checkout": "Finalizar compra",
"Your cart is empty": "Tu carrito está vacío"
}
__('Checkout');
The appeal of JSON is that your templates read as English, and a missing translation degrades to readable source text rather than a key like cart.checkout leaking into the page.
Loading: the real difference
PHP files are loaded per group, on demand. Calling __('cart.checkout') loads lang/es/cart.php and nothing else. Reference validation.* and you additionally load validation.php. A request that only touches two groups reads two files.
JSON is loaded whole. The first time you resolve any JSON-keyed string, the loader reads the entire lang/es.json and merges it into memory:
protected function loadJsonPaths($locale)
{
return (new Collection(array_merge($this->jsonPaths, $this->paths)))
->reduce(function ($output, $path) use ($locale) {
if ($this->files->exists($full = "{$path}/{$locale}.json")) {
$decoded = json_decode($this->files->get($full), true);
// ...
$output = array_merge($output, $decoded);
}
return $output;
}, []);
}
For a few hundred keys this is irrelevant — it's one file read and a json_decode, and it happens once per request. At several thousand keys it becomes a measurable cost paid on every request that renders any translated string, including ones that need three keys.
This is the practical argument for PHP files on a large app: granularity. Your admin panel's 2,000 keys don't get parsed to render the login page.
Nested JSON silently doesn't work
This is the trap worth knowing before you commit to JSON. It's natural to organise a large JSON file with nested objects:
{
"cart": {
"checkout": "Finalizar compra"
}
}
And it looks like __('cart.checkout') should find it. It doesn't:
__('cart.checkout'); // returns "cart.checkout" — unresolved
The loader merges the decoded array as-is, and lookup against the JSON group is a flat key match, not a dot-path traversal. Nested objects are unreachable. There's no error — you just get the key back, which renders as visible text on your page.
What does work is a literal dotted key in a flat structure:
{
"cart.checkout": "Finalizar compra"
}
__('cart.checkout'); // "Finalizar compra"
So you can have dot-namespaced keys in JSON. You just can't have actual nesting. If you're exporting JSON from a tool that produces nested output — most i18n platforms default to it, since that's what JavaScript frameworks expect — you need it flattened for Laravel. Our JSON to PHP converter handles this in both directions, which is precisely the case generic converters get wrong.
Malformed files: one throws, one is worse
JSON fails loudly. The loader checks json_last_error() and throws:
RuntimeException: Translation file [/app/lang/es.json] contains an invalid JSON structure.
Every page using translations 500s. That's unpleasant, but it's honest — you find out immediately, and in CI rather than from a user.
A broken PHP file behaves according to whatever PHP does with it: a syntax error is a fatal error, but a semantically wrong file — say a trailing key overwriting an earlier one, or a nested array where a string was expected — fails at the point of use. The size rule in validation.php is the classic case: flatten that array to a string and validation throws only when someone triggers a size rule.
The lesson isn't that one format is safer. It's that both want a CI check that loads every locale file and compares key sets, which I'd recommend regardless of format:
it('loads every locale file and has matching keys', function () {
$reference = null;
foreach (config('app.available_locales') as $locale) {
$keys = collect(File::files(lang_path($locale)))
->flatMap(fn ($file) => array_keys(
Arr::dot(require $file->getPathname())
))
->sort()
->values()
->all();
$reference ??= $keys;
expect($keys)->toEqual($reference, "Locale {$locale} has drifted");
}
});
Arr::dot() is doing the important work — it flattens nested groups so you compare real leaf keys.
Tooling and ecosystem
PHP files are what the ecosystem assumes. php artisan lang:publish writes PHP. Laravel-Lang/lang distributes PHP. Laravel's own validation.php, auth.php and passwords.php are PHP, and their nested structures (size.array, custom.email.unique) can't be expressed in a flat JSON file without dotted keys.
That last point is decisive for one case: you cannot move Laravel's stock validation messages to JSON cleanly, because they rely on nesting. In practice apps that use JSON still keep PHP files for validation, so you end up with both formats anyway.
Static analysis and IDE support also lean PHP. A short key is greppable; a natural-language key containing punctuation is harder to search reliably, and renaming source copy means renaming the key everywhere.
The maintenance argument against string keys
This is the strongest practical case against JSON's convention, and it takes a while to show up.
When the key is the English text, changing the English text changes the key. Fixing a typo in a button label orphans every translation of it:
// lang/es.json
{
"Recieve notifications": "Recibir notificaciones"
}
Fix the spelling in your Blade template to "Receive notifications" and the Spanish translation is now unreachable. The key no longer exists; Spanish users see English. Nothing errors. You've silently untranslated a string by correcting a typo.
With a short key, copy edits are free:
// lang/en/settings.php
'receive_notifications' => 'Receive notifications', // edit freely
// lang/es/settings.php
'receive_notifications' => 'Recibir notificaciones', // unaffected
The same problem applies to any copy change — tone tweaks, shortening for mobile, adding a full stop. On an app where marketing iterates on wording, this is a recurring source of regressions.
There's a related duplication issue: identical English strings in different contexts share one key, so "Open" as a button label and "Open" as an order status get the same translation, even in languages where they're different words. Short keys let you distinguish them.
When JSON is the right call
I don't want to be one-sided about it. JSON genuinely fits some cases:
- Small sites — a marketing site with 150 strings and two languages. The whole-file load is irrelevant and readable templates are a real benefit.
- Sharing strings with a JS frontend — if an Inertia or Vue layer needs the same catalogue, a JSON file is directly consumable.
- Prototypes — writing English inline and extracting later is faster than inventing a key taxonomy up front.
- Prose-heavy content where a short key adds nothing:
__('We could not process your payment. Please check your card details and try again.')is arguably clearer at the call site than__('billing.errors.card_declined').
The pragmatic answer
Most substantial apps end up with PHP files for interface text and validation, because of the copy-edit problem and because Laravel's own files are PHP anyway. JSON shows up for the frontend catalogue when there is one.
If you're starting out and unsure, PHP with short keys is the lower-regret default — it's easier to move from PHP to JSON later than the other way around, since generating natural-language keys from short ones is impossible but the reverse is mechanical.
If you already have JSON and want to move, the shape of the job is: pick a key taxonomy, write a script mapping each English string to a key, rewrite call sites, then regenerate each locale's file. It's mechanical but touches everything. Our JSON to PHP converter handles the file half, preserving placeholders and plural pipes; the call sites are the part that needs care.
Summary
- PHP loads per group on demand; JSON loads the whole locale file on first use. Granularity matters as key counts grow.
- Nested JSON objects are unreachable. Use flat keys, dotted if you want namespacing.
- Malformed JSON throws a
RuntimeException; broken PHP fails at point of use. Diff key sets in CI either way. - Laravel's
validation.phpneeds nesting, so JSON-only isn't really achievable. - With string keys, editing English copy orphans every translation. This is the main long-term cost.
- JSON is a good fit for small sites, shared JS catalogues and prototypes.
Both formats have the same underlying problem: the files are in your repository, so changing a translation means a deploy. LangSyncer imports either format, shows coverage per language so drift is visible instead of silent, fills gaps with AI and publishes to a CDN in seconds. Format conversion in both directions is free at our converters.