novius/laravel-translation-loader
Load Laravel translations from the database instead of language files, with a custom translation loader that lets you manage and edit strings at runtime. Part of Novius's Laravel packages.
This package replaces Laravel's file-based translation loader with one that reads language lines from a database table (or another source), so __('messages.welcome') resolves from the DB while your Blade/JSON/PHP code stays untouched. It is a fork of spatie/laravel-translation-loader, so the mental model is identical: a service provider rebinds the translation.loader singleton, and the new loader queries a language_lines-style model, typically falling back to the original file loader when no row exists.
Minimal path:
composer require novius/laravel-translation-loader
php artisan vendor:publish --provider="Novius\LaravelTranslationLoader\TranslationLoaderServiceProvider"
php artisan migrate
Then verify what actually got published and registered before writing any code:
extra.laravel.providers in the package composer.json) and see exactly which binding it overrides (translation.loader). If you have another package touching the translator (e.g. a caching or ICU loader), you have a conflict.load() and loadJson() methods. This tells you the lookup order (DB first vs file first), whether it supports namespaced groups (vendor::file), and what it does on a miss.(locale, group, key). You will be living with these.First use case: don't migrate everything at once. Pick one high-churn group — marketing copy, onboarding emails, error messages — and seed just that group into the DB. Everything else keeps resolving from lang/ via the fallback. This gives you a working, reversible proof in one afternoon.
// Confirm the loader is actually the DB one
php artisan tinker
>>> get_class(app('translation.loader'));
>>> app('translator')->get('messages.welcome', [], 'fr');
If get_class() returns Illuminate\Translation\FileLoader, your provider is not registered or is being overridden later in the boot order.
Hybrid file + database (the default recommendation). Keep static, developer-owned strings in lang/ under version control; put editor-owned, frequently changing copy in the DB. The loader's fallback-on-miss behaviour makes this transparent: a missing row silently degrades to the file. Only after the hybrid is stable should you consider moving the bulk of translations into the DB.
Bulk import from existing files. Write a one-off artisan command that walks lang/ with File::allFiles(), requires each PHP file to get the array, flattens with Arr::dot(), and upserts rows keyed on (locale, group, key). Use upsert() with the unique index so the command is idempotent and re-runnable after a teammate adds keys:
LanguageLine::upsert(
$rows, // [['locale','group','key','text','updated_at','created_at'], ...]
['locale', 'group', 'key'], // unique-by
['text', 'updated_at'] // update-on-conflict
);
Admin editing surface. The package gives you storage, not UI. The standard pattern is a Filament/Nova/Backpack resource over the LanguageLine model with filters on locale and group, plus a "missing keys" view. To generate that view, log misses: wrap the loader or add a middleware that calls app('translator')->get($key) for candidate keys and records when the return value equals the key itself.
Cache aggressively, invalidate explicitly. A DB read per translation group per request is a real cost. Wrap the loader in a decorator that caches the assembled group array under translations:{locale}:{group}:{namespace} with a long TTL, and flush those keys from the model's saved/deleted events:
LanguageLine::saved(fn ($line) => Cache::forget("translations:{$line->locale}:{$line->group}"));
Note that Laravel's Translator also memoizes loaded groups in-process ($this->loaded), so a cache flush alone won't affect the current request — see Gotchas.
Multi-source loaders. If your fork exposes a loader contract, register additional sources (JSON files from a CDN, a remote translation service) and chain them: DB → file → remote. Order matters and should be explicit in config, not implied by provider registration order.
Tenant/multi-brand scoping. Add a scope or tenant_id column and a global scope or a resolver in the loader. Do not try to encode tenancy into the group string (tenant1/messages) — it will break trans('messages.welcome') calls and the vendor-namespace parsing.
Testing. In TestCase::setUp(), forget the loader's cache and the translator's loaded groups so tests don't leak translations between cases:
protected function setUp(): void
{
parent::setUp();
app('translator')->setLoaded([]);
Cache::flush();
}
Use RefreshDatabase plus seeders for the rows your assertions need. Prefer asserting on __('key') output rather than hitting the LanguageLine model directly — that tests the real integration.
composer.json, and when you upgrade, diff the fork against the corresponding Spatie release to see what Novius actually changed. If the delta is small (namespace + packaging), you may be better served by depending on upstream directly or vendoring the ~200 lines of loader code into your app — you'll own it either way.group is a reserved word in MySQL. Any raw SQL, DB::table()->where('group', ...), or unquoted migration column will fail. Use the query builder or quote it; verify the published migration already does.(locale, group, key) can exceed the 767/3072-byte limits depending on MySQL version and column lengths. If migrate fails with "Specified key was too long", cap the column lengths explicitly.messages.Welcome and messages.welcome are different keys in MySQL's default collation only if you use a binary collation; with utf8mb4_unicode_ci they collide and your unique index silently merges them. Decide deliberately.Translator memoizes per request. app('translator')->setLoaded([]) (or $translator->loaded = [] on older versions) is required after a runtime write if you expect the same request to see the new value — relevant for admin "save and preview" flows and for tests.__('package::file.key') and JSON translations (__('Welcome back'), where the "key" is the source string) go through different loader methods (load vs loadJson). Confirm the fork handles both; JSON keys are long free text and often blow past a VARCHAR(255) key column — use TEXT with a hash-based unique index if you rely on JSON translations.text column must store "{0} None|{1} One|[2,*] Many" verbatim; a UI that "nicely formats" this will break trans_choice().migrate before anything that warms caches or boots workers, and clear your translation cache as part of the deploy (php artisan cache:clear is heavy-handed; prefer tag-based or prefix-based flushing). Rolling deploys with stale cached groups are the most common "why is production showing old copy" cause.lang/ directly (some validation-message publishers, some test helpers) bypass the loader entirely. If a string refuses to update, check whether it's coming through trans() at all.app('translator')->get('messages.welcome', [], 'fr', false) skips the fallback locale and shows you exactly what the DB/file source returned for that locale — the fastest way to distinguish "row missing" from "wrong locale" from "cache stale".How can I help you explore Laravel packages today?