omaralalwi/lexi-translate
A Laravel package for managing multilingual translations: store and edit language keys in the database, switch locales at runtime, and translate model attributes with a simple trait, so you no longer need to hand-edit lang files.
1. Install and publish
composer require omaralalwi/lexi-translate
php artisan vendor:publish --provider="Omaralalwi\LexiTranslate\LexiTranslateServiceProvider"
php artisan migrate
The publish step gives you a config file (default/fallback locale, cache store + TTL, optional custom Translation model) and a migration for a polymorphic translations table (translatable_type, translatable_id, locale, key, value). Check the migration before migrating — if you already have a translations table from another package, rename or merge.
2. Where to look first
config/lexi-translate.php — locales, fallback, cache settings. Everything you'll want to tweak lives here.HasTranslations trait source in vendor/omaralalwi/lexi-translate/src/ — it's a small package, and the trait is the whole public API. Read it once; it saves you guessing at method names.3. First use case
Add the trait and declare which attributes are translatable:
use Omaralalwi\LexiTranslate\Traits\HasTranslations;
class Post extends Model
{
use HasTranslations;
protected $translatable = ['title', 'body'];
}
Then write and read translations for a locale:
$post = Post::create(['slug' => 'hello-world']);
$post->setTranslation('title', 'en', 'Hello World');
$post->setTranslation('title', 'ar', 'مرحبا بالعالم');
$post->getTranslation('title', 'ar'); // 'مرحبا بالعالم'
$post->getTranslation('title', 'fr'); // falls back per config
That's the 80% case: a model with a handful of short-to-medium text fields, an admin form per locale, and reads that respect app()->setLocale(). Confirm the exact method names against the README of the version you install — the package has iterated on its API and the trait is the source of truth.
Locale-aware reads with an explicit fallback chain. Don't rely on implicit fallback. Wrap reads in a helper on the model so the behaviour is greppable and testable:
public function t(string $key, ?string $locale = null): ?string
{
return $this->getTranslation($key, $locale ?? app()->getLocale())
?? $this->getTranslation($key, config('app.fallback_locale'));
}
Save translations inside a transaction. Admin forms typically submit ['en' => [...], 'ar' => [...]]. Loop and persist, and flush the model's translation cache once at the end rather than per attribute:
DB::transaction(function () use ($post, $data) {
$post->fill($data['base'])->save();
foreach ($data['translations'] as $locale => $fields) {
foreach ($fields as $key => $value) {
$post->setTranslation($key, $locale, $value);
}
}
});
Always eager-load the relation. The translations live in a side table, so any listing page is an N+1 waiting to happen:
$posts = Post::with('translations')->paginate(20);
If the package supports a scoped eager load (e.g. translations constrained to the current locale), use it for index pages and reserve the full load for edit screens.
Build API payloads as a locale map. Consumers usually want everything in one response:
return [
'id' => $post->id,
'slug' => $post->slug,
'translations' => collect(config('lexi-translate.locales'))
->mapWithKeys(fn ($l) => [$l => [
'title' => $post->getTranslation('title', $l),
'body' => $post->getTranslation('body', $l),
]]),
];
Locale switching middleware. Resolve locale from Accept-Language, a ?lang= query, or a session key, then app()->setLocale($locale) — the trait reads from the container. Guard against arbitrary values by validating against config('lexi-translate.locales').
Validation shape. Validate nested locale payloads explicitly so a missing Arabic title surfaces as a form error, not a silent null:
'translations.en.title' => ['required', 'string', 'max:255'],
'translations.ar.title' => ['nullable', 'string', 'max:255'],
'translations.*.body' => ['nullable', 'string'],
Seeding and imports. A seeder that loops setTranslation() is fine for hundreds of rows but slow for tens of thousands — batch-insert directly into the translations table and clear the cache afterwards.
Pair it with a translation service. The same author maintains a Gemini-based translation package; the natural workflow is: editor saves the source locale, a queued job fills missing locales via the API, results land through setTranslation(), and a draft flag on the parent keeps unreviewed machine output out of the public site.
Testing. Add a factory state or helper that seeds a model with en + one other locale. Assert both the happy path and the fallback path, and assert that a cache flush actually happens after an update — that's the bug you'll otherwise ship.
DB::table('translations')->update(...), raw SQL imports, ->update() on the Translation model directly, or another process writing concurrently — will leave stale reads. Expose an artisan command or admin button that flushes translation caches, and call it at the end of every import.$model->title is ambiguous. The trait may or may not hook attribute access, and its behaviour (current locale vs. fallback vs. raw column) is easy to misread. Prefer getTranslation() in application code so intent is explicit, and reserve the magic accessor for Blade templates where terseness matters.Post::create(['title' => ...]) won't populate the side table. Persist the parent first, then attach translations. This trips people up when converting an existing app from JSON-column translation packages.Relation::enforceMorphMap(), the translatable_type values change. Rows written before the morph map will silently stop resolving — write a data migration that rewrites existing type strings.(translatable_type, translatable_id, locale, key) unique to prevent duplicate rows, and a locale index if you query "all posts translated to Arabic" for a completeness dashboard.onDelete('cascade') or add a deleting model event that removes translations. Orphaned rows accumulate quietly and inflate every join.en, ar) and enforce it. Mixing en with en_US produces "missing translation" bugs that look like cache problems but aren't.LIKE. where('title', 'like', ...) on the parent table won't work. Query the relation (whereHas('translations', ...)) and benchmark it — for anything beyond a few thousand rows you'll want a full-text index on value or an external search index.array in phpunit.xml.text columns. Confirm the migration uses text/longText for value; a varchar(255) will truncate article bodies without warning on some MySQL configs.composer.json, read the trait before upgrading, and don't expect Stack Overflow answers. Budget an hour to read the source; it will pay for itself the first time caching misbehaves.status, source, reviewed_by columns there rather than on the parent), configurable cache TTL/store, and the fallback locale chain. If you need per-field fallback (title falls back to en, body doesn't), intercept at your own model helper rather than fighting the package config.How can I help you explore Laravel packages today?