terranet/translatable
A Laravel package for adding multilingual attributes to Eloquent models. Store translations per locale, retrieve values with automatic fallback, and manage translated fields without duplicating your schema—useful for building translatable applications quickly.
Install and pin first. The package was last released in March 2019 and has no dependents, so before writing any code confirm it will even install against your Laravel version:
composer require terranet/translatable
composer show terranet/translatable # check requires: laravel/framework, php
If Composer resolves to an old illuminate/* constraint, either run it on a matching legacy app or fork it and bump the constraints — do not try to shoehorn it into Laravel 10+ without reading composer.json first.
Where to look first, in this order:
src/ for the main trait (likely Translatable or HasTranslations) — this is the whole API surface you'll use daily.extra.laravel.providers) or needs manual registration in config/app.php (very likely for a 2019 package).config/translatable.php and database/migrations/ stubs — publish them before touching models:php artisan vendor:publish --provider="Terranet\Translatable\TranslatableServiceProvider"
First use case: pick one model with two or three short text fields (a Page with title and body is ideal), add the trait, declare the translatable attributes, run the migration, and verify you can write and read the same field in two locales. Only then wire it into forms.
Trait + attribute list. The core pattern is the same as every Laravel translatable package: a trait on the model and a whitelist of translated columns.
use Terranet\Translatable\Translatable;
class Page extends Model
{
use Translatable;
protected $translatable = ['title', 'body'];
}
Confirm the exact property name from the trait source — $translatable vs $translatedAttributes is the single most common integration mistake, and getting it wrong fails silently (you just get the raw column back).
Two storage models to plan around. Decide early whether translations live in JSON columns on the parent table or in a side translations table. JSON columns mean simpler migrations and no joins, but you lose indexability and per-locale constraints. Side tables mean a join and a whereHas('translations', ...) scope for every filtered query, but they scale better and let you add slug uniqueness per locale. The package's migration stub will reveal which one it assumes.
Locale switching belongs in middleware, not in controllers. Set the locale once in a SetLocale middleware (app()->setLocale(...) from session/user/header), then let models resolve translations implicitly. Avoid calling ->translate('ro') in random controllers — that's how you end up with mixed-locale output in one response.
Admin Architect integration. If you're on Admin Architect, the payoff is form rendering: you declare the field once and the package generates per-locale inputs (usually as tabs). Check the form/filter classes it ships and use its field types rather than hand-building <input name="title[ro]"> markup, otherwise you'll fight the mass-assignment and validation conventions it expects.
Validation per locale. Build a FormRequest that expands rules across your supported locales instead of writing them three times:
protected function rules(): array
{
$rules = ['slug' => 'required|alpha_dash'];
foreach (config('app.locales') as $locale) {
$rules["title.$locale"] = 'required|string|max:255';
$rules["body.$locale"] = 'nullable|string';
}
return $rules;
}
API output. For a JSON API, either flatten with fallback ($page->title after setting locale) or expose the whole translation map ($page->translations) so the frontend can switch languages client-side. Pick one and be consistent; mixing the two creates subtle caching bugs in HTTP responses.
Eager loading. If translations are a relation, add with('translations') to index queries — a 25-row listing with lazy-loaded translations is 26 queries, which is exactly the kind of thing that ships to production unnoticed.
dd($model->getAttributes()) immediately after save() to see whether the write happened at all.null, some return the fallback-locale string, some return ''. Decide what your UI does with an empty translation ('' usually means "show fallback", null means "not translated yet") and enforce it in one place, not in every Blade template.TEXT. If you must support both, use LONGTEXT with a json cast — but then you can't use whereJsonContains.LIKE '%term%' against a JSON blob works but is slow and locale-blind. For real search, index into Scout/Meilisearch with one document per locale, or add generated columns for the locales you actually query on.app()->getLocale() is whatever the process default is — a queued job that renders a translated email will render it in config('app.locale') regardless of the user's preference. Pass the locale into the job and set it explicitly; same in feature tests, where you should assert against a locale you set yourself rather than the app default.vendor/. Given the package's age, you will need a local change eventually — make it a fork, not a vendor/ edit that disappears on the next composer install.How can I help you explore Laravel packages today?