organi/translatables
Organi Translatables brings multilingual attribute support to Laravel Eloquent models. Attach a simple trait to store and retrieve translated values per locale, with locale fallbacks and straightforward setup for building multi-language applications.
Verify before you build. This package has no stars and no dependents, so treat the README as the only contract and read the source before committing to it. Start here, in this order:
composer.json — check the illuminate/* constraint against your Laravel version. Small packages like this often pin only one or two major versions.config/translatables.php (after publishing) — locales list, fallback locale, storage strategy (JSON column vs. separate translations table), and any cache settings.getTranslation, setTranslation, translate, or similar) and which Eloquent events it hooks.Minimal setup:
composer require organi/translatables
php artisan vendor:publish --provider="Organi\Translatables\TranslatablesServiceProvider"
php artisan migrate
use Organi\Translatables\Traits\Translatable;
class Product extends Model
{
use Translatable;
protected $translatable = ['name', 'description', 'slug'];
}
First use case: a two-locale catalog (e.g. en/nl) where editors fill in per-locale titles and body copy on the same form. Get one model working end-to-end — migration, admin form, front-end render with fallback — before touching a second model. The value of the package is that you don't hand-roll name_en/name_nl columns; confirm that payoff on one model first.
Storage strategy drives everything. Determine early whether the package writes to a *_translations side table or to JSON columns on the parent row. This decides your indexing, your query builder usage, and whether whereHas vs. JSON path expressions are available. If it's a side table, you get real indexes and unique constraints per locale; if it's JSON, you get simpler writes but weaker query options.
Model + form pattern. The trait usually exposes translations as a locale-keyed array. Bind that shape directly to your admin form so a single PUT updates all locales:
// Controller
$product->update($request->validated());
// Form payload
[
'translations' => [
'en' => ['name' => 'Widget', 'description' => '...'],
'nl' => ['name' => 'Artikel', 'description' => '...'],
],
]
Validate per locale with wildcards rather than duplicating rules:
'translations.*.name' => ['required', 'string', 'max:255'],
'translations.*.slug' => ['required', 'string', 'distinct'],
Note distinct on slugs — uniqueness across locales, not just within one, is a common requirement you'll have to enforce yourself.
Always eager load. Whatever the relation is called (translations, translationsRelation, etc.), add it to a base query scope or $with on read-heavy models. A listing page of 50 products without eager loading is 50 extra queries, and translation tables are exactly where this creeps in unnoticed.
Fallback handling. Decide whether the package's fallback (usually config('app.fallback_locale')) is enough or whether you need per-field fallback. Marketing copy often has description in one locale only, while name should always exist. Write a small accessor wrapper so the rule lives in one place:
public function displayName(?string $locale = null): string
{
return $this->getTranslation('name', $locale ?? app()->getLocale())
?: $this->getTranslation('name', config('app.fallback_locale'))
?: '';
}
Querying translated content. For search, build a scope that constrains to the current locale and falls back when empty. If it's a side table:
$query->whereHas('translations', fn ($q) => $q
->where('locale', app()->getLocale())
->where('name', 'like', "%{$term}%"));
If it's JSON, you'll need the driver-specific JSON path operators — test on the same DB engine as production (SQLite in tests will not behave like MySQL or Postgres here).
API resources. Keep translation exposure explicit. Returning the whole translations array leaks unpublished locales and bloats payloads. Map to the requested locale in the resource, and accept ?locale= with validation against the configured list.
Import/export and seeders. Seeders are where this package pays off most — one array per record covering all locales keeps fixtures readable. Do the same for CSV imports: one row per entity, columns per locale, rather than one row per translation.
saved/deleting event hooks and confirm orphaned translation rows are cleaned up on delete. If they aren't, add a deleting observer yourself.fill(). If your $translatable fields are also in $fillable, you can end up writing the parent column and the translation row. Check which one wins.slug unique index on the parent table is wrong once slugs are per-locale. Make sure the index is (locale, slug). This is the single most common source of SQLSTATE[23000] after switching to a translatable package.used, you can override its methods in the model. If behavior lives in a repository/service class bound in the provider, you can swap it via container binding. Prefer the latter for anything you'd otherwise copy-paste across models.DB::listen() or Laravel Telescope will show immediately whether reads are hitting the translation table per row. Do this once on a listing page before shipping.spatie/laravel-translatable or astrotomic/laravel-translatable, the swap is contained to those wrappers plus the migration.whereTranslation, and sluggable integration. Choose organi/translatables only if its storage model matches your schema better or you're already inside the Organi ecosystem — otherwise you're taking on maintenance risk for parity functionality.How can I help you explore Laravel packages today?