Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Translation Loader Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The service provider — confirm it is auto-discovered (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.
  2. The loader class — read its 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.
  3. The published config — connection name, table name, model class, cache TTL, and whether a fallback file loader is enabled.
  4. The migration — check column names, lengths, and the unique index on (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.

Implementation Patterns

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.

Gotchas and Tips

  • The fork risk is the headline gotcha. One star, zero dependents, and a single-maintainer adaptation of an upstream package that has changed its API over time. Pin an exact version in 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.
  • Index length on utf8mb4. A unique index over (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.
  • Case sensitivity. 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.
  • Namespaced and vendor translations. __('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.
  • Pluralization needs the pipe syntax preserved. The text column must store "{0} None|{1} One|[2,*] Many" verbatim; a UI that "nicely formats" this will break trans_choice().
  • Missing keys return the key itself, not an empty string. Any "translation coverage" report must compare the resolved value to the key, and must account for the file fallback — a DB-empty row and a genuinely missing string look identical from the outside.
  • Deploy ordering. Run 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.
  • Packages that read 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.
  • Debugging tip: 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".
  • Don't route SEO/meta copy through this if you have per-locale URL or sitemap generation that runs at build time; build-time processes won't see DB rows added after deploy.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource