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

Laravel Translatable Laravel Package

novius/laravel-translatable

A Laravel package that makes Eloquent models translatable using just two fields: locale and locale_parent_id. The trait adds translation relations, a translate() helper, getTranslation() lookup, and a withLocale() query scope.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and verify the trait source first.

composer require novius/laravel-translatable

There is no config file to publish (only optional lang files), so the fastest way to understand the package is to read two files in vendor/novius/laravel-translatable/src: the Translatable trait and TranslatableModelConfig. They are small. Everything the package does is in there — do this before writing any application code, because the package has effectively zero public documentation beyond the README and no community corpus to fall back on.

Optional, only if you rely on the package's own validation/error strings:

php artisan vendor:publish --provider="Novius\LaravelTranslatable\LaravelTranslatableServiceProvider" --tag=lang

2. Add the columns with the migration macro.

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->translatable(); // adds `locale` + nullable `locale_parent_id` FK
    $table->string('title');
    $table->string('slug');
    $table->text('text');
    $table->timestamps();

    $table->index('locale');
    $table->unique(['locale', 'slug']); // slug is only unique per locale
});

Add the locale index yourself — the macro does not index it, and every listing query in your app will filter on it. In down(), use the provided dropTranslatable() macro rather than dropping columns by hand; it handles the foreign key.

3. Attach the trait and make your first translation.

use Novius\LaravelTranslatable\Traits\Translatable;

class Post extends Model
{
    use Translatable;
}
$post = Post::create(['title' => 'Français', 'slug' => 'bonjour', 'locale' => 'fr']);
$post->translate('en', ['title' => 'English', 'slug' => 'hello']);
$post->translate('es', ['title' => 'Español', 'slug' => 'hola']);

$post->translations;              // collection (see gotcha: includes the root record)
$post->getTranslation('en');      // Post|null
Post::withLocale('en')->get();    // scope

That is the entire happy path: two columns, one trait, four API surfaces (translate, getTranslation, translations, withLocale). Your first real use case should be a single document-shaped model — blog posts, CMS pages, product descriptions — where a translation is a full copy of the record, not a per-field overlay. Do not start with a model where only some fields need translating.

4. Decide the root-record convention before you seed data. The package links siblings through locale_parent_id; the original row typically has locale_parent_id = null and children point at it. Nail this down (root = null parent, or root = self-reference) and encode it in a repository/factory, because every other decision — fallback, delete cascades, admin edit routes — depends on it.

Implementation Patterns

Scope every listing query — make it a convention, not a memory exercise. Because all locales share one table, Post::all() returns every locale as a separate row. This is the single biggest day-to-day risk. Pick one of these and enforce it in code review:

// Option A: explicit scope at each call site (safest, most verbose)
Post::withLocale(app()->getLocale())->latest()->paginate();

// Option B: base query class / builder macro
Post::query()->forCurrentLocale()->paginate();

Avoid a blanket addGlobalScope on the model. It will also filter the translations relation and can make getTranslation() return null for locales you actually have — you would then need withoutGlobalScope() calls sprinkled through the trait's own helpers. Scope at the boundary (controllers, API resources, sitemap builders, search indexers), not on the model.

Build your own fallback resolver — the package has none. getTranslation() returns null with no fallback chain. Centralise it so behaviour is consistent across models:

// app/Concerns/HasLocaleFallback.php
public function translated(string $locale): ?static
{
    return $this->getTranslation($locale)
        ?? ($this->translatableConfig()->fallbackLocale ?? null ? $this->getTranslation(config('app.fallback_locale')) : null)
        ?? $this->getTranslation(config('app.fallback_locale'))
        ?? $this->rootTranslation();
}

Whatever you choose, expose it as one method that views and API resources call, and never call getTranslation() directly from a Blade template or a resource class.

Copy shared data deliberately via translateAttributes(). This is the extension point that turns translate() from "a row with a locale" into "a usable duplicate." Use it for media paths, publish dates, categories, and author, and use it to regenerate fields that must not collide:

protected function translateAttributes($parent): void
{
    $this->image_path = $parent->image_path;
    $this->published_at = $parent->published_at;
    $this->author_id = $parent->author_id;
    // slug must NOT be copied verbatim — it collides on (locale, slug)
}

Note that attributes passed as the second argument to translate() should win over anything set here; pass them after and let the explicit array override copied values.

Model the admin edit flow around the sibling row, not the parent. Editors switch locale and edit the row for that locale. Route model binding by primary key already resolves the right row, so the URL pattern is /admin/{locale}/posts/{post} — but you must assert $post->locale === $locale and 404 otherwise, or a user can edit the English row through the French URL.

public function resolveRouteBinding($value, $field = null)
{
    $post = parent::resolveRouteBinding($value, $field);
    abort_if($post && $post->locale !== request()->route('locale'), 404);
    return $post;
}

Validation is per-locale. Every unique rule needs a locale constraint, and required fields must be validated against the row being edited, not the root:

'slug' => ['required', Rule::unique('posts', 'slug')
    ->where('locale', $this->route('locale'))
    ->ignore($post?->id)],

Restrict locales and rename columns via translatableConfig() early, not later. If your product only ships fr/en/es, declaring it once prevents bad locale codes reaching the database:

public function translatableConfig(): TranslatableModelConfig
{
    return new TranslatableModelConfig(['fr', 'en', 'es'], 'locale', 'locale_parent_id');
}

Only rename columns if you have a hard legacy constraint — every raw query, index, and unique constraint you write must then use the new names, and the win is small.

Handle soft deletes explicitly. If the model uses SoftDeletes, translationsWithDeleted lets you read trashed siblings, but nothing cascades. When an editor deletes the root record, decide and implement whether siblings go with it:

protected static function booted(): void
{
    static::deleting(function (self $post) {
        if ($post->locale_parent_id === null) {
            $post->translationsWithDeleted()->get()->each->delete();
        }
    });
}

Eager load translations for anything that renders a locale switcher. A list of 50 posts with a "also available in…" badge is 50 queries without with('translations').

Index for the queries you will actually run: locale, locale_parent_id, and a composite (locale, slug) for detail-page lookups. This is not optional at any real volume — row count is records × locales.

Gotchas and Tips

  • translations includes the root record. The README example shows $post->translations containing fr after only translate('en') and translate('es') were called. Treat the collection as "all locale siblings" and verify the exact contents in php artisan tinker for your root-record convention before you build a locale switcher off it. Off-by-one counts here are silent and user-visible.
  • translate() requires a persisted parent. Call it after save()/create(); the child needs a parent id to point at. Chaining (new Post([...]))->translate('en') before saving will not do what you expect.
  • No fallback, no history, no workflow. There is no versioning, no approval state, no "translation is stale" flag. If a PM asks for any of that, it is application-layer work, not configuration.
  • Field-level translation is out of scope. Every column lives on the sibling row, so you cannot translate title but keep slug shared — the whole record is duplicated. Models where only 2 of 15 fields are localizable are a poor fit and will feel awkward immediately.
  • Deleting a parent silently orphans siblings. No FK cascade is configured. translationsWithDeleted helps reads, not hygiene. Add the deleting hook above and consider a scheduled sanity check for rows whose locale_parent_id points at nothing.
  • getTranslation() returns null, not an exception. Release 0.0.5 fixed it for models with no translations at all. Always null-coalesce, and never let null reach a view without a fallback decision.
  • Restrict locales at the config level and validate on write. Nothing stops translate('xx') from inserting an xx row unless you set the locale list in translatableConfig(); even then, validate the locale parameter in your controller so users get a 422 and not a silent bad row.
  • AGPL-3.0 is a gate, not a footnote. Network-use copyleft: a hosted product may trigger source-disclosure obligations for the combined work. Get legal sign-off before the pilot, not after. MIT alternatives exist (spatie/laravel-translatable, astrotomic/laravel-translatable) if the licence blocks you — note both use a different storage model (JSON / separate translation tables), so switching later is a re-architecture, not a composer require.
  • Adoption signal is zero: 0 stars, 0 dependents, single-vendor (Novius) stewardship. There is no Stack Overflow corpus and no community-discovered edge cases. You are the first production user. Pin the version in composer.json, read the trait's source (it is short), and write your own feature tests around the behaviours you depend on — particularly translate(), getTranslation() with soft-deleted siblings, and the withLocale scope combined with your own scopes.
  • Debugging tip: when a locale-filtered page shows duplicates or missing records, the culprit is almost always an unscoped query in a place you forgot — sitemaps, RSS, search indexing, Nova/Filament resource listings, and Model::count() on dashboards. Grep for ::all(, ::count(, and ::paginate( on translatable models and audit each one.
  • Test with a RefreshDatabase factory that sets locale explicitly. Factory defaults that leave locale null produce rows that no withLocale() query will ever find, which fails in confusing ways rather than loudly. Give the factory a forLocale(string $locale) state and use it everywhere.
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.
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk