statikbe/laravel-chained-translator
Chain multiple translation providers in Laravel to automatically fill missing translation keys. Configure a sequence of translators with fallbacks so untranslated strings are resolved across services and locales, keeping your language files complete with minimal manual effort.
1. Check your Laravel version before anything else
2.6.0 adds Laravel 12 support and targets Laravel 13, and ships "a major refactor of the translation manager." That combination usually means the supported-version floor moved. Before composer require, confirm your app's Laravel constraint is inside the package's composer.json range:
composer require statikbe/laravel-chained-translator:^2.6
composer show statikbe/laravel-chained-translator --all | grep -A5 require
If you're on Laravel 10 or 11, expect to either stay on ^2.5 or upgrade Laravel first. Read the compare link in the release notes (2.5.0...2.6.0) and diff src/ — the refactor touched the manager, so any custom code that subclassed or type-hinted the old classes needs a look.
2. Install and let discovery do its job
Laravel's package discovery registers the service provider automatically. The provider's job is to swap Laravel's translation.loader binding (the FileLoader by default) for the package's chained loader, so every __(), trans(), trans_choice(), @lang and Blade {{ __('...') }} call resolves through the chain. No application code changes required.
3. Publish and read the config — do not trust old snippets
php artisan vendor:publish --provider="Statikbe\ChainedTranslator\ChainedTranslatorServiceProvider"
The file you care about is config/chained-translator.php. Because 2.6.0 is a manager refactor, read the shipped config and the ChainedLoader source in vendor/ before copying any config example from a blog post or from a 2.5-era project — key names and the loader list shape are exactly the kind of thing a refactor renames. Confirm the ordered list of loaders, then read the loader to see how it decides to fall through.
4. Start with the smallest useful case
The canonical first use case is file translations with database overrides: keep lang/en/*.php as the source of truth, let a translations table override rows per key without redeploying. Configure DB-first, file-second (verify key names against the published config):
// config/chained-translator.php
'loaders' => [
\App\Translation\DatabaseLoader::class, // override layer
\Illuminate\Translation\FileLoader::class, // baseline from lang/
],
5. Verify it works
php artisan optimize:clear
php artisan tinker
>>> __('messages.welcome');
Insert a row for messages.welcome in the DB and repeat — the DB value should win. If it doesn't, dump app('translation.loader') and confirm it is the chained loader and not something else (see Gotchas).
The most conservative pattern for teams migrating off lang/ files: files stay authoritative, the DB loader sits after them. Ship the table, backfill it, prove coverage, then flip the order. Have the DB loader swallow its own exceptions and return [] so the chain degrades to files if the database is down.
The opposite order — DB first, files second — gives editors a live override layer while missing keys still resolve from versioned files. This is the pattern that justifies the package most clearly: a runtime-overridable dictionary without abandoning file-based translations for CI, tests, or fresh installs.
Implement Illuminate\Contracts\Translation\Loader and register it in the chain:
class CmsLoader implements \Illuminate\Contracts\Translation\Loader
{
public function load($locale, $group, $namespace = null): array
{
// Must return [] on miss — never null, never throw.
return $this->client->fetch($locale, $namespace, $group) ?? [];
}
public function addNamespace($namespace, $hint) {}
public function addJsonPath($path) {}
public function namespaces() { return []; }
}
Keep load() cheap and idempotent; Laravel calls it once per locale/group/namespace triple per request (cached in Translator::$loaded), but the chain multiplies that by the number of loaders. After 2.6.0, re-run php artisan vendor:publish --force comparison on any loader interface stubs the package ships, in case the refactor widened or narrowed the contract.
Because the chain is just config, compose it per environment in a service provider:
// AppServiceProvider::register()
if (app()->environment('local')) {
config(['chained-translator.loaders' => [
\App\Translation\DebugLoader::class,
\Illuminate\Translation\FileLoader::class,
]]);
}
Effective for adding a "dump every lookup" loader in local/staging without touching production. If the refactored manager resolves the chain earlier than before (e.g. in a singleton resolved before your provider's register()), move this into bootstrap/app.php or a booting callback.
lang/ with an artisan command that reads the loader output and upserts.Treat the "major refactor of the translation manager" as a breaking change until proven otherwise, even at a minor version number:
vendor:publish --force into a temp dir and compare).ChainedLoader, any manager class) and check they still exist.* group handling.Bind a deterministic in-memory loader rather than hitting the real chain:
$this->app->bind(\Illuminate\Contracts\Translation\Loader::class, fn () => new ArrayLoader([
'en' => ['messages' => ['welcome' => 'Hi']],
]));
If assertions flake across tests, clear the translator's in-memory cache: app('translator')->setLoaded([]);. After the 2.6.0 test refactor upstream, don't assume your local test helper still matches the package's own fixtures — they moved.
composer.json pins laravel/framework below 12, you cannot take this release; stay on ^2.5 and plan the framework upgrade.[], not null, not false. The chain falls through only on an empty array. A loader returning ['key' => null] or ['key' => ''] is a hit and stops the chain. Single most common "my fallback isn't working" bug.* pseudo-group. __('Welcome') with no dot calls load($locale, '*', '*'). Your loader must handle that explicitly or JSON translations silently bypass the chain.Lang::addNamespace('admin', ...) and package-namespaced translations only reach children if the chained loader forwards addNamespace() / addJsonPath() to every child. If namespaced keys resolve to the raw key string, the proxy is broken — check the loader source before blaming config.Lang::addLines() is not part of the chain. Runtime-added lines live on the Translator, above the loader, and always win. Handy for tests; dangerous if you think your DB loader is authoritative.Translator iterates fallback_locale itself, so each loader receives both primary and fallback locale. A loader that throws on an unexpected locale breaks fallback in a hard-to-trace way. This is the first thing to test after the manager refactor.config:cache is mandatory after editing the chain. php artisan config:cache bakes the loader list; forgetting it means deploy changes that "did nothing." Use php artisan optimize:clear locally when debugging.translation.loader (translation managers, some multitenancy packages) fights yours. If translations suddenly stop overriding, dump app('translation.loader') and confirm it's the chained loader.(locale, namespace, group, key). Without it every request re-queries once per group touched — and the chain doubles that by falling through on misses.dd(). A LoggingLoader wrapping each child, logging locale/namespace/group plus hit/miss and elapsed time, is the fastest way to understand a surprising resolution. Keep it out of production.overridden = true).How can I help you explore Laravel packages today?