Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Translatable Laravel Package

fsi/translatable

FSI Translatable adds simple multilingual support to Laravel Eloquent models. Store translated attributes, switch locales, use fallbacks, and retrieve localized values with minimal configuration and clean, familiar model syntax.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish:

composer require fsi/translatable
php artisan vendor:publish --provider="FSi\Translatable\TranslatableServiceProvider"
php artisan migrate

This gives you a translations table (polymorphic: translatable_type, translatable_id, locale, key, value) and a config file where you define supported locales and a fallback_locale.

Where to look first, in order:

  1. config/translatable.php — locales, fallback, and the translation model class you can override.
  2. src/TranslatableTrait.php — this is the real API surface. The trait is thin; read it before writing any accessors so you use the exact method names (getTranslation / setTranslation style helpers vs. filling the translations relation manually).
  3. The published migration — check the unique index on (translatable_type, translatable_id, locale, key). That constraint drives most of your write logic.

First use case — a model with translatable attributes:

use FSi\Translatable\TranslatableInterface;
use FSi\Translatable\TranslatableTrait;

class Product extends Model implements TranslatableInterface
{
    use TranslatableTrait;

    protected $fillable = ['sku', 'price'];
}
$product = Product::create(['sku' => 'ABC-1', 'price' => 19.99]);

$product->setTranslation('name', 'Shirt', 'en');
$product->setTranslation('name', 'Hemd', 'de');
$product->save();

$product->getTranslation('name', 'de'); // "Hemd"

The non-translated columns stay on products; only the localizable text lands in translations. That split is the whole mental model — get it right early and the rest follows.

Implementation Patterns

Accessor with fallback. Don't scatter ?? $product->name through Blade. Wrap it once:

public function translatedName(?string $locale = null): ?string
{
    $locale ??= app()->getLocale();

    return $this->getTranslation('name', $locale)
        ?: $this->getTranslation('name', config('translatable.fallback_locale'));
}

Locale switching. Resolve the locale once per request and never read app()->getLocale() inside the model. A small middleware or a LocaleProvider binding keeps admin-preview (edit German while browsing in English) working:

// middleware
app()->setLocale($request->user()?->preferred_locale ?? config('app.locale'));

Admin form pattern. Render one tab per configured locale and write in a loop — this is the highest-value integration:

foreach (config('translatable.locales') as $locale) {
    foreach ($request->input("translations.$locale", []) as $key => $value) {
        $product->setTranslation($key, $value, $locale);
    }
}
$product->save();

Validate as translations.*.* => nullable|string|max:255, and decide explicitly whether an empty string means "delete the row" or "store empty" — see Gotchas.

Eager loading. The relation is a separate query per model otherwise:

Product::with('translations')->paginate(25);

For index pages that show one locale only, consider a constrained eager load rather than pulling every locale for every row.

API resources. Pick one strategy and stay consistent: either emit {"name": {"en": "...", "de": "..."}} for editors, or flatten to the current locale for public consumers. Mixing the two across endpoints is the most common source of frontend bugs.

Querying translated values. Filtering/sorting on a translated column means joining translations and constraining locale + key:

Product::query()
    ->join('translations', function ($join) {
        $join->on('translations.translatable_id', '=', 'products.id')
             ->where('translations.translatable_type', Product::class)
             ->where('translations.locale', app()->getLocale())
             ->where('translations.key', 'name');
    })
    ->orderBy('translations.value');

Wrap that in a query scope so the join stays in one place.

Morph map. Register a morph map in a service provider so translatable_type stores product instead of the FQCN. It keeps the table readable and survives namespace refactors:

Relation::morphMap(['product' => Product::class]);

This must be in place before you write production rows; migrating existing type strings later is a manual UPDATE.

Seeding and factories. Create the model first, then attach translations — setTranslation before save() on an unsaved model is a common ordering mistake. In factories use afterCreating to add a default-locale set.

Gotchas and Tips

  • The unique index will throw, not upsert. Repeated setTranslation on the same key/locale must be an update, not an insert. If you see integrity constraint violations in a loop, your write path is bypassing the trait. Use updateOrCreate on the relation for bulk imports.
  • Empty string is not null. A stored '' will defeat ?: fallback logic only if you use ??. Use ?: or normalize on write (delete the row when the value is blank) — choose one, document it.
  • toArray() / JSON responses won't include translations unless you touch the relation first or add it to $with. $appends on a computed accessor triggers a query per model if the relation isn't loaded — pair $appends with $with, always.
  • No cascade delete. The package doesn't add a DB-level foreign key on a polymorphic relation, so deleting a parent leaves orphan translation rows. Add a deleting model event (or a cleanup command) if the table grows.
  • Method names are the contract. With near-zero adoption there is very little Stack Overflow coverage; the trait source is the documentation. Before building accessors, confirm the exact signatures in src/TranslatableTrait.php rather than trusting a blog post or an older README snippet.
  • Locale strings must match exactly. en_US vs en mismatches silently return null and fall through to fallback — which looks like "translation missing" when it's really a config mismatch. Validate the locale input against config('translatable.locales').
  • No built-in per-locale slugs. If you need localized URLs, generate and store the slug as just another translation key, and remember uniqueness must be enforced per locale in your own validation.
  • Search is the weak spot. LIKE across all locales means a join-per-locale or a whereHas subquery; for anything non-trivial, denormalize a search index column on the parent rather than fighting the join.
  • Maintenance risk. Zero stars and zero dependents means no community safety net and no guarantee of Laravel-version compatibility. The surface area is small (one trait, one relation, one migration), so if you hit a wall, forking the package or lifting the trait into app/Support is a realistic escape hatch — do that rather than patching vendor/. Pin the version in composer.json and re-run composer update for this package deliberately, not incidentally.
  • Caching. The translations relation is a good candidate for a per-model cache keyed by locale if you render the same entities on every request; invalidate in the model's `sav
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle