laravel-lang/json-fallback
Adds a JSON fallback translator to Laravel so missing JSON translation keys resolve to your fallback locale instead of showing raw keys. Drop-in service provider, no config required.
Install the package via Composer:
composer require laravel-lang/json-fallback
The service provider is auto-discovered, so there is nothing to register manually. On the next request, the package swaps Laravel's default FileLoader (JSON side) for its fallback-aware loader. Confirm it is active by dumping the loader:
dd(app('translator')->getLoader());
// You should see the LaravelLang loader, not the default one
Prerequisites to check first:
config/app.php → 'fallback_locale' => 'en' (or whatever your base language is). This is the locale the package falls back to.lang/{locale}.json (or resources/lang/{locale}.json on Laravel 10 with the classic layout). If you use a custom path, verify it in config/app.php → 'paths' or the loader's $paths.{
"auth.login.title": "Sign in",
"checkout.submit": "Place order"
}
First use case: put a key in lang/en.json but omit it from lang/fr.json. Then:
app()->setLocale('fr');
echo __('auth.login.title'); // "Sign in" — pulled from en.json
Where to look in the source if something does not work: src/ServiceProvider.php (the binding that replaces the loader), and the loader class that wraps Laravel's FileLoader and re-checks the fallback locale when get() returns the raw key.
Drop-in default (recommended). Most teams install and forget. Because the package overrides the translator's loader rather than adding a macro, existing __() calls keep working exactly as before — you simply get a fallback string instead of seeing auth.login.title leaked into the UI.
Confirm the fallback tier in a test. Lock the behavior with a feature test so an accidental config change does not silently break the contract:
public function test_json_fallback_resolves_missing_french_key(): void
{
app()->setLocale('fr');
$this->assertSame('Sign in', __('auth.login.title'));
}
Keep a canary key in lang/en.json that is never translated, and assert the fallback returns it in every locale. This catches loader regressions cheaply.
Custom TranslationServiceProvider. If you have overridden the translator registration (common with translation-loader packages, DB-driven locales, or testing harnesses), the package's binding can lose the race. In those cases, extend the default TranslationServiceProvider and set the loader after parent::register(), or bind the language loader explicitly in your own provider's register():
$this->app->extend('translation.loader', function ($loader, $app) {
return new \LaravelLang\JsonFallback\...Loader($app['files'], $app['path.lang']);
});
Check the package's own ServiceProvider to copy the exact class name and constructor signature — the loader's $paths argument matters if you moved lang/.
Frontend/SPA delivery. Pair this with a small endpoint that dumps a locale's JSON so your Vue/React/Inertia bundle can render strings. Because fallback is resolved server-side, the JSON you ship to the client is already complete — no JavaScript ?? 'default' guards needed in components. Alternatively, keep using Blade + __() and let the fallback handle it at render time.
Translation workflow. Keep publishing base strings via laravel-lang/common or php artisan lang:add, then let translators work in lang/{locale}.json incrementally. Every partially translated locale stays shippable — you can merge a half-finished de.json to main without a UI regression. Use a CI step (jq . lang/*.json or jsonlint) to make sure no file is malformed, since a broken JSON file aborts the loader entirely.
Cache invalidation. If you cache translations (Laravel's native translation cache in bootstrap/cache/translations.php on 11+, or laravel-lang/lang's lang:cache), rebuild or clear after adding keys:
php artisan cache:clear
php artisan lang:clear # only if using laravel-lang/lang's commands
Deploy pipelines that cache during build should invalidate the cache whenever any lang/*.json changes.
__('messages.welcome') where lang/fr/messages.php exists) already have Laravel fallback behavior natively — the package does nothing there. Do not expect it to chain into package namespaces or vendor translations either.config('app.fallback_locale') once. There is no fr-CA → fr-FR → en chain. If you need multi-level fallbacks, subclass the loader and override the resolution logic, or normalize the locale at request time (setLocale('fr-FR') → look up fr) before resolving.__(). Nested JSON objects ({"auth": {"login": "..."}}) are never traversed. If your frontend already uses dotted a.b.c keys, you are fine; if it emits real nested objects, flatten them at build time.lang/fr.json short-circuits before lookup; some behaviors are driven by Laravel's FileLoader returning the key itself. Test both cases so you know whether your fallback comes from the package or from fallback_locale at the outer Translator level.fr-ca.json and fr_CA.json are not interchangeable; the loader constructs the filename from the locale string verbatim. Keep your config/app.php available_locales (if you have one) and file names byte-identical.translation.loader after this one (translation caching, DB loaders, spatie/laravel-translation-loader, testing helpers) will silently disable the fallback. Add the loader assertion test above to your CI matrix so this never ships unnoticed.dd(app('translator')->getLoader()) tells you instantly whether the package is active. dd(app('translator')->get($key, [], 'fr')) isolates whether the loader or the outer Translator produced the raw key back.jq -e . lang/*.json > /dev/null).vendor:publish — behavior is entirely driven by config('app.fallback_locale') and your language path config. Do not go looking for a config/json-fallback.php.composer show laravel-lang/json-fallback after install confirms you got the expected tag.get(), and rebind translation.loader in your own service provider. That is the cleanest way to add analytics without forking.How can I help you explore Laravel packages today?