roundly-consulting/translatable-for-laravel
A Laravel package that brings locale-aware translations to Eloquent models, letting you store and retrieve attribute values per language with fallback support. It simplifies building multilingual applications while staying close to Laravel conventions and reducing boilerplate.
Install and let discovery do its job.
composer require roundly-consulting/translatable-for-laravel
The service provider is auto-discovered; it is the thing that registers the Blueprint macro, so nothing works until it boots. Don't add it to dont-discover unless you're deliberately registering it by hand (see Gotchas).
Look at these three places first, in order:
Blueprint::macro('translatable', ...)), which tells you the macro is available anywhere Schema is used;First use case — add a translated field to an existing model. Write the migration using the macro rather than a bare json() column, so the intent (and, post-1.1.2, the static type) is explicit:
Schema::table('posts', function (Blueprint $table) {
$table->translatable('title'); // returns ColumnDefinition, so ->nullable() etc. still chain
$table->translatable('excerpt')->nullable();
});
Then add the field to the model's $translatable list and use the trait. A translatable migration plus a trait on one model is the smallest end-to-end slice — do that before touching validation or queries.
Migration + model is the unit of work. Every translatable attribute shows up twice: once as $table->translatable('field') and once in the model's $translatable array. Keeping the two adjacent in review (same PR, same commit) prevents the classic "column exists but the model doesn't know it" bug where writes silently go to a scalar path.
Write the whole locale map, read one locale. Treat the stored JSON as an admin-facing document and the rendered output as a single-locale projection:
// Admin form: fill every configured locale
$post->fill([
'title' => ['en' => 'Hello', 'fr' => 'Bonjour'],
]);
// API resource: project the active locale with fallback
'title' => $post->translate('title', app()->getLocale()),
Validate per locale, not per column. Generate rules from the config's locale list so adding a locale doesn't require editing every FormRequest:
$rules = collect(config('translatable.locales'))
->mapWithKeys(fn ($l) => ["title.$l" => ['required', 'string', 'max:255']]);
Combine with 'title' => ['array'] and an array_keys rule if you need to reject unknown locales.
Query the JSON paths directly for filters and sorts, and keep the driver in mind:
Post::where('title->en', 'like', "%{$term}%")->get();
For anything hot, add a generated/virtual column or a dedicated index rather than sorting on a JSON path at scale.
Integration points that pay off early:
scopeTranslatedTo($query, $locale) that filters whereNotNull("title->{$locale}") keeps "only show content actually available in this locale" out of controllers.->translated() state that fills all configured locales saves a lot of test noise.Run static analysis in CI. As of 1.1.2 the schema macro is typed, so a migration that mistypes the column or chains an unsupported modifier is caught by Larastan/PHPStan instead of at migrate time. This is the cheapest regression net for schema changes.
The macro only exists once the provider boots. This is the single biggest source of confusion and the thing 1.1.2 addresses. Larastan boots an auto-discovering host app and usually finds the macro — so most applications see nothing wrong. The failure ("Call to an undefined method ...::translatable()") appears specifically when the provider isn't booted during analysis:
dont-discover,There, the fix is the shipped PHPStan extension, not a @method docblock hack on your own Blueprint subclass.
PHPStan extension loading — two paths:
phpstan/extension-installer present, it loads automatically via extra.phpstan.includes. Do nothing.# phpstan.neon
includes:
- vendor/roundly-consulting/translatable-for-laravel/extension.neon
Symptom that you missed this: PHPStan passes locally (installer present) and fails in a minimal CI image or a package's own phpstan.neon (installer absent). Pin the include in shared config to keep both green.translatable() returns a ColumnDefinition — chain ->nullable(), ->default(), ->after() as usual. Don't wrap it in json() as well; you'll get two columns or a type mismatch.
Empty string vs. missing key vs. null. Decide once, in the trait's fallback path, what "not translated" means. {'en': '', 'fr': 'Bonjour'} behaving differently from {'fr': 'Bonjour'} is a recurring bug in admin UIs that save the whole form. Normalise on write (drop empty strings) or on read (treat '' as absent) — but pick one and test it.
Dirty tracking and array casts. Mutating a nested key ($post->title['en'] = 'x') may not mark the attribute dirty depending on the cast implementation; assign the whole array back ($post->title = [...$post->title, 'en' => 'x']) if you see saves silently skipped.
Config caching. New locales added to the config after config:cache in a deploy won't appear until the cache is rebuilt. Put locale changes in the same deploy step as config:cache, and never read the locale list from env() at runtime.
dont-discover is a trap here. If you disable discovery for this package, you must both register the provider and keep the PHPStan include — two separate things that both fail quietly.
Extension point worth knowing: since the schema side is a macro and the model side is a trait, the natural customisation seam is your own base model / base migration helper that wraps both, so app-wide conventions (column naming, default locale, fallback) live in one place rather than in every model that opts in.
How can I help you explore Laravel packages today?