brackets/translatable
brackets/translatable adds translation support to Laravel Eloquent models, letting you store and retrieve translatable attributes per locale without duplicating schema columns. A small, focused package for building multilingual apps on familiar Eloquent workflows.
composer require brackets/translatable
php artisan vendor:publish --provider="Brackets\Translatable\TranslatableServiceProvider"
php artisan migrate
Publishing gives you config/translatable.php and a migration that creates a polymorphic translations table (translatable_type, translatable_id, field, locale, value). That table shape is the single most important thing to understand about this package: translations are side rows, not columns and not JSON. Everything else — querying, eager loading, validation, serialization — follows from that.
First place to look, in order:
config/translatable.php — the locale list and default locale. This is the source of truth for both your admin UI and any locale switcher.src/Traits/HasTranslations.php — the actual public API surface ($translatable property, getTranslation/setTranslation, the accessor that returns the current-locale value).First use case: a BlogPost with a translatable title and content.
use Brackets\Translatable\Traits\HasTranslations;
class BlogPost extends Model
{
use HasTranslations;
protected $translatable = ['title', 'content'];
}
app()->setLocale('hr');
$post->title = 'Hello'; // written to the 'hr' translation row
$post->save();
$post->title; // 'Hello' (current locale)
$post->getTranslation('title', 'en'); // null until you set it
$post->setTranslation('title', 'en', 'Hello there')->save();
If that round-trip works, the package is installed correctly. Verify the exact method names against the README at install time — the package is small and lightly documented, and there is no large community corpus of examples to lean on.
Treat translations as a write-through side table, not attributes. The model's real columns stay locale-agnostic. Any code path that writes translatable content should go through the trait explicitly rather than assigning $model->title in a loop, because the accessor resolves against app()->getLocale() at the moment of assignment. In a service class that is obvious; in a queued job or a console command it is a subtle bug waiting to happen.
class UpdateBlogPost
{
public function handle(BlogPost $post, array $payload): BlogPost
{
$post->fill(Arr::except($payload, ['title', 'content']));
foreach (config('translatable.locales') as $locale) {
if (isset($payload['title'][$locale])) {
$post->setTranslation('title', $locale, $payload['title'][$locale]);
}
if (isset($payload['content'][$locale])) {
$post->setTranslation('content', $locale, $payload['content'][$locale]);
}
}
$post->save();
return $post;
}
}
Admin forms: one tab per locale, keyed payload. The dominant day-to-day pattern is a request shaped like title[en], title[hr], content[en], … which maps cleanly onto the loop above and lets you generate the form from config('translatable.locales') instead of hardcoding.
@foreach (config('translatable.locales') as $locale => $label)
<div class="tab-pane" id="locale-{{ $locale }}">
<input name="title[{{ $locale }}]"
value="{{ old("title.$locale", $post->getTranslation('title', $locale)) }}">
<textarea name="content[{{ $locale }}]">{{ old("content.$locale", $post->getTranslation('content', $locale)) }}</textarea>
</div>
@endforeach
Validate per locale with wildcards, and be explicit about which locales are mandatory. required on the default locale and nullable on the rest is almost always the right policy; requiring every locale blocks publishing.
$rules = [
'title' => ['required', 'array'],
'title.'.config('translatable.default_locale') => ['required', 'string', 'max:255'],
'title.*' => ['nullable', 'string', 'max:255'],
];
Eager load translations anywhere you render a list. Without it you get one or two queries per row per field. The relation is the whole mechanism, so:
$posts = BlogPost::with('translations')->paginate(20);
If the payload is large, constrain the eager load to the locales the page actually renders (with(['translations' => fn ($q) => $q->whereIn('locale', ['en', 'hr'])])) — this is the single highest-leverage optimisation with this package.
Fallback belongs in your app, not your templates. Wrap the accessor once and use it everywhere, so a missing Croatian title degrades to English in one place:
public function translated(string $field): ?string
{
return $this->getTranslation($field, app()->getLocale())
?: $this->getTranslation($field, config('translatable.default_locale'));
}
Sorting and filtering on translated values. ->orderBy('title') and ->where('title', 'x') will not work — title is not a column. Reach for a join when you genuinely need it in SQL:
BlogPost::query()
->join('translations', function ($join) {
$join->on('translations.translatable_id', '=', 'blog_posts.id')
->where('translations.translatable_type', BlogPost::class)
->where('translations.field', 'title')
->where('translations.locale', app()->getLocale());
})
->orderBy('translations.value')
->select('blog_posts.*');
For search, either do it in SQL via the same join with LIKE, or accept PHP-side filtering for small datasets. Decide this early — retrofitting search onto a translations table is a known pain.
Locale switching is plain Laravel: a middleware that reads the route segment or Accept-Language, validates it against config('translatable.locales'), and calls app()->setLocale(). Do not let arbitrary input through to setLocale().
Backfilling and migrating existing content is a one-off Artisan command: read the old column, setTranslation() for the default locale, save, then drop the column in a later deploy. Keep the two steps in separate releases so a rollback is possible.
Testing. app()->setLocale('hr') per test, plus a factory state that seeds at least two locales. Assert on getTranslation() rather than the magic attribute so tests are independent of ambient locale.
null until backfilled. Ship a backfill command alongside any config change, and keep a fallback in place.null. '' is a real translation as far as the package is concerned and will defeat a ?: fallback unless you normalise to null on write. Normalise in your service layer.$model->title = 'x' in a loop is locale-dependent. If you assign the same attribute for three locales without changing app()->getLocale(), you will overwrite one row three times. Always prefer setTranslation() when writing more than one locale.(translatable_type, translatable_id, field, locale) and a unique constraint on it. Without the index, list pages and lookups degrade quickly; without the unique constraint, duplicate rows appear and reads become order-dependent.isDirty('title') reflects an in-memory accessor, not the side rows, and saved/updated model events fire before you may expect relative to translation writes. Anything that reacts to content changes (search indexing, cache busting, revision logging) should be triggered explicitly after the translations are persisted, ideally inside a transaction.toArray()/toJson() will include the loaded translations relation — a full dump of every field in every locale, including unpublished ones. Use $hidden, API Resources, or makeHidden('translations') on public endpoints.TranslatableModel) so that swapping in spatie/laravel-translatable (JSON columns, no side table) or astrotomic/laravel-translatable (per-locale tables) is a contained change rather than a refactor. Pin the version in composer.json.Translation model and point the relation at it rather than patching vendor code.$model->translations before and after save() and check app()->getLocale(). Nine times out of ten it is a locale mismatch or a save that never ran because the parent model was unchanged (save() on an unmodified parent still works, but saveQuietly()/update() paths can surprise you).title.en correctly while the API reads with a stale or wrong locale — one accessor helper on the model, used by both, prevents the whole class of issue.How can I help you explore Laravel packages today?