How Laravel Actually Loads Translations (And Why There's No lang:cache)
php artisan optimize caches your configuration, routes, events and Blade views. It does not cache translations, and there is no lang:cache command. That surprises people who assume lang files get the same treatment as config, and it leads to two opposite mistakes: worrying about a cost that doesn't exist, and missing one that does.
Here's what actually happens when you call __().
The resolution path
A call like __('cart.checkout') goes through roughly these steps:
- The key is parsed into namespace, group and item — here namespace
*, groupcart, itemcheckout. - The translator checks whether that group is already loaded for the current locale. If it is, resolution is an array lookup.
- If not, the loader reads
lang/{locale}/cart.phpfrom disk and the result is stored in an in-memory array on the translator. - If the key isn't found, the same happens for the fallback locale.
- If still not found, the key string itself is returned.
Step 3 is per group, which is to say per file. Referencing cart.checkout loads cart.php and nothing else. A request that touches cart, validation and nav reads three files.
Step 2 is the important optimisation and it's in-memory only:
if ($this->isLoaded($namespace, $group, $locale)) {
return;
}
That array lives on the translator instance, which lives for one request. Nothing is written to disk, nothing survives to the next request. Every request that renders a translated string reads the relevant lang files again.
Why that's usually fine
Two reasons this rarely shows up in a profile.
PHP lang files are PHP. require on a .php file hits opcache, so after the first request the file isn't parsed again — the compiled opcodes are reused. What you pay per request is executing a return [...] statement, which is cheap.
The OS caches the files. Even without opcache, these are small files read repeatedly, so they sit in the page cache.
This is the real reason there's no lang:cache: for the PHP-array format, opcache already is the cache. A combined cache file would mostly move work around, and would introduce the usual staleness problem for a format that already benefits from the runtime's own caching.
Where it does cost something
Three situations where translation loading becomes measurable.
JSON files. These are loaded whole, and JSON is not opcached — it's file_get_contents plus json_decode on every request:
if ($this->files->exists($full = "{$path}/{$locale}.json")) {
$decoded = json_decode($this->files->get($full), true);
if (is_null($decoded) || json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException("Translation file [{$full}] contains an invalid JSON structure.");
}
$output = array_merge($output, $decoded);
}
A 5,000-key es.json gets read and decoded on every request that resolves a single string key. That's the one genuine performance argument for PHP files over JSON at scale, and it's covered in more depth in JSON vs PHP lang files.
Very large single groups. A messages.php with 4,000 keys is loaded entirely the moment you reference any one of them. Splitting by feature — cart.php, billing.php, admin.php — means a request only loads what it uses. This is the actual practical benefit of group organisation, beyond tidiness.
Fallback misses. Every unresolved key triggers a second lookup against the fallback locale, including loading that locale's group file. An app with substantial gaps in a locale is loading roughly twice the files on those pages. It's not usually dramatic, but it means incomplete translations have a small ongoing cost as well as a correctness one.
Measuring rather than guessing
Before optimising anything here, check whether it's on your critical path:
// A quick probe in a service provider or a route.
$before = memory_get_usage();
$start = hrtime(true);
__('cart.checkout');
logger()->info('First translation resolution', [
'ms' => (hrtime(true) - $start) / 1_000_000,
'kb' => (memory_get_usage() - $before) / 1024,
]);
For most apps this comes in low enough that the answer is "leave it alone." Translation loading is rarely the bottleneck; database queries and view rendering almost always dominate. Optimise it because you measured it, not because this article mentioned it.
If you genuinely need to cache
For the JSON case at scale, or an unusual setup where files aren't local, you can wrap the loader. Laravel lets you swap the translation loader implementation:
final class CachedTranslationLoader implements Loader
{
public function __construct(
private readonly Loader $inner,
private readonly Repository $cache,
) {}
public function load($locale, $group, $namespace = null): array
{
$key = "translations:{$locale}:{$namespace}:{$group}";
return $this->cache->rememberForever(
$key,
fn () => $this->inner->load($locale, $group, $namespace),
);
}
public function addNamespace($namespace, $hint): void
{
$this->inner->addNamespace($namespace, $hint);
}
public function addJsonPath($path): void
{
$this->inner->addJsonPath($path);
}
public function namespaces(): array
{
return $this->inner->namespaces();
}
}
// A service provider
$this->app->extend('translation.loader', function (Loader $loader, $app) {
return new CachedTranslationLoader($loader, $app['cache']->store());
});
Be clear-eyed about the trade you've made. You now own an invalidation problem: editing a lang file no longer takes effect until the cache is cleared, which will confuse someone on your team and will bite you in a deploy. Only do this if you measured a real cost, and wire the clear into your deploy script next to config:cache.
The four methods above are the whole of Illuminate\Contracts\Translation\Loader in Laravel 12 — load, addNamespace, addJsonPath and namespaces — so delegating all four covers the contract. Check it again if you upgrade across a major version.
Octane changes the picture
Under Octane the translator instance survives between requests, which flips two of the assumptions above.
The good news: lang files are loaded once per worker rather than once per request. The in-memory $this->loaded cache stays warm, so translation resolution after the first request on a worker is pure array lookup. Whatever cost you measured under FPM largely disappears.
The bad news is the same fact from the other side. That persistent cache means:
Editing a lang file has no effect until workers restart. A developer changes a string, reloads, sees nothing, and concludes translations are broken. php artisan octane:reload is the answer, and it belongs in your deploy script.
Anything that mutates the translator leaks. Lang::addLines() writes directly into the loaded-lines array:
Arr::set($this->loaded, "$namespace.$group.$locale.$item", $value);
Under FPM that dies with the request. Under Octane it persists for every subsequent request on that worker. If you use addLines() for per-user or per-tenant overrides, you have a cross-request data leak — one tenant's custom copy showing up for another. That failure mode and its fix are covered in the multi-tenant localization post; the short version is to flush the translator on RequestReceived or avoid mutating it at all.
A locale set and not restored persists too. Same reasoning as queue workers: use the Localizable trait's withLocale(), which restores in a finally, rather than a bare App::setLocale().
If you're on Octane, the practical checklist is: reload workers on deploy, never mutate the translator, and always restore the locale.
Memory, not just time
One more dimension worth a thought, because it's the constraint that actually bites at scale.
Every loaded group stays in memory for the life of the request — or the life of the worker under Octane. An app with 6,000 keys across many languages, where a request touches several locales (an admin viewing customer content, a digest job rendering per-recipient), holds all of it simultaneously.
That's rarely a problem at typical sizes. It becomes one in two situations: queue workers processing many locales in sequence under Octane-style persistence, and any code that loops over locales:
// Loads every group for every locale, and keeps them all.
foreach (config('app.available_locales') as $locale) {
$subject[$locale] = __('mail.digest.subject', [], $locale);
}
With 30 locales that's 30 copies of every group you touch. If you genuinely need to render many locales in one process — building a multi-language export, say — measure memory rather than assuming, and consider chunking by locale across separate jobs.
The cost you should actually care about
There's a bigger number in this discussion than milliseconds, and it isn't about the loader.
Every lang file is in your repository. Changing a translation means a commit, a review, a CI run and a deploy. For a typo in a Spanish button label, that's a pipeline — often 10 to 30 minutes of wall-clock and a context switch for whoever runs it, to fix a string a non-developer noticed and could have corrected in seconds.
That's the cost worth engineering away, and it's what live translation delivery addresses: strings resolve from a CDN-backed source that can be updated independently of your application deploys. Load time stays comparable; the deploy disappears from the edit loop. It's also what makes a non-developer able to fix their own copy, which is usually a bigger organisational win than the time saved.
Practical guidance
- Split lang files by feature, so a request loads only the groups it uses. This is the highest-value structural choice.
- Prefer PHP arrays over JSON for large catalogues — opcache handles them, JSON is re-decoded every request.
- Keep locales complete: every fallback miss costs a second group load as well as showing the wrong language.
- Don't build a translation cache before profiling. Opcache is doing most of the work already.
- If you do cache, wire invalidation into your deploy and treat it as a real maintenance obligation.
- The expensive part of file-based translations isn't load time. It's that every edit is a deploy.
If the deploy-per-typo loop is what actually costs you, that's what LangSyncer removes: import your existing lang files, edit and translate in a dashboard, publish to a global CDN in seconds. Coverage per language is visible, so fallback misses stop being invisible — see the free tools if you'd like to check your current state first.
More on Architecture & Tooling
-
Laravel Translation Manager Packages: What They Solve and Where They Stop
A database-backed admin UI over your lang files solves the non-developer problem and creates three new ones. Here's the honest accounting before you install one.
-
JSON vs PHP Lang Files in Laravel: Performance, Tooling and When to Use Each
PHP files load one group at a time; a JSON file loads entirely on the first lookup. Nested JSON silently doesn't work. Here's what actually differs, verified against the framework.
-
spatie/laravel-translatable vs Lang Files vs a TMS: What to Use for Which Content
These three aren't competitors — they solve different problems. Here's the line between interface text and database content, and why picking wrong is expensive to undo.