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 Model Translation Laravel Package

we-are-neopix/laravel-model-translation

Add multi-language attributes to Eloquent models with we-are-neopix/laravel-model-translation. Store per-locale values, fall back to defaults, and retrieve translations via simple accessors—no extra tables or schema changes required.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal install path. The package is Composer-only and Laravel-only; there is nothing to compile and no frontend asset. The sequence on a fresh model is: require the package, publish the config, run the migrations for the DB driver, add the trait, declare the translatable attributes, and confirm which driver is active.

composer require we-are-neopix/laravel-model-translation
php artisan vendor:publish --provider="Neopix\ModelTranslation\Providers\ModelTranslationServiceProvider"

# DB driver only — check the README for the exact migration tag
php artisan vendor:publish --tag=model-translation-migrations
php artisan migrate

Pin a version constraint immediately ("we-are-neopix/laravel-model-translation": "1.*") rather than floating on dev-main. With 40 stars and a single agency maintainer, an unpinned dependency is the one upgrade-risk you can eliminate for free in the first five minutes.

Where to look first, in this order:

  1. config/model-translation.php — the driver binding, the fallback locale, and (depending on version) the translations table name. This is the file that decides almost every behavioral question you'll have later.
  2. The driver contract — the interface the DB and JSON drivers implement. Reading the contract is faster than reading either driver, and it tells you exactly what a custom driver owes. This is the package's only real differentiator, so understand it before you write application code against the trait.
  3. The published migration — confirm the schema, and specifically confirm whether there is a composite unique constraint and a covering index on the lookup tuple (model_type, model_id, locale, key). If they're absent, add them in your own follow-up migration before you seed a single production row. You will not add them later, and the table grows as models × attributes × locales.
  4. The trait — the accessor/mutator surface. This is the single point of coupling between your application and the package; everything in the "exit strategy" below hangs off keeping that surface thin.

First use case to spike: one high-read, low-write content model — a Page, Category, or Product — with exactly one translatable attribute. Not a full catalog. The point of the spike is to answer, with real numbers, whether reading $page->title inside a Blade loop triggers a query per row. Do that before you touch a second model.

use Neopix\ModelTranslation\Traits\HasTranslations;

class Page extends Model
{
    use HasTranslations;

    protected $translatable = ['title', 'body'];
}
App::setLocale('de');
$page->title;                 // 'de' value, or fallback per config
$page->title = 'Überschrift'; // writes to the 'de' translation
$page->save();

If those three lines don't behave as expected, stop and read the contract before writing more code. Nearly every downstream problem in this package traces back to an assumption about locale resolution or persistence timing.


Implementation Patterns

1. Wrap translation access before the second model

The package's trait is the only thing your application should know about. Route reads and writes through a thin accessor or repository per model so replacement is a one-class edit rather than a grep across views.

class Page extends Model
{
    use HasTranslations;

    protected $translatable = ['title', 'body'];

    // Application-level door. Swap this body for spatie/astrotomic/in-house
    // and nothing outside this class changes.
    public function translated(string $attribute, ?string $locale = null): ?string
    {
        return $this->getTranslation($attribute, $locale ?? app()->getLocale());
    }
}

Verify the exact method name (getTranslation / translate / magic property) against the installed version on day one and keep the call inside this one method. Views and API resources should call $page->translated('title'), never the package API directly.

2. Admin edit form: read every locale at once

The classic day-to-day use case is a CMS form with one tab per locale. Don't loop App::setLocale() — that mutates global state and leaks between requests in long-running workers (Octane, queue workers). Read the full map and pass it to the view.

public function edit(Page $page)
{
    return view('admin.pages.edit', [
        'page' => $page,
        'translations' => collect(config('app.locales'))
            ->mapWithKeys(fn ($label, $code) => [
                $code => collect($page->translatable)
                    ->mapWithKeys(fn ($attr) => [$attr => $page->getTranslation($attr, $code)])
                    ->all(),
            ]),
    ]);
}

On save, write per locale explicitly rather than relying on the ambient locale:

foreach ($request->input('translations', []) as $locale => $fields) {
    $page->setTranslations($fields, $locale); // verify signature on install
}
$page->save();

Whatever the exact API, the pattern holds: writes are always locale-explicit; only reads use ambient locale. Ambient-locale writes are the fastest way to corrupt an admin panel.

3. Eager-load or batch-resolve in listings — measure, don't assume

This is the make-or-break integration pattern. A trait accessor hides I/O behind $page->title, so a 50-row index page plus a toArray() serializer is a classic N+1. Before shipping any listing page:

DB::enableQueryLog();
$pages = Page::paginate(50);
$payload = $pages->toArray();   // if your resource serializes translated fields
dump(count(DB::getQueryLog())); // expect a small constant, not 50+

If the count scales with rows, you have three options, in order of preference: find the package's batch/eager-load hook if one exists; resolve translations for the page of IDs in one explicit query and hydrate a map into your resource layer; or add a request-scoped cache keyed by model+attribute+locale. Do this before the feature ships, not after the first production complaint.

4. Serialize through resources, not toArray()

Because translated attributes are resolved lazily, letting Eloquent serialize the model means the resolution happens inside toArray() with no visibility and no cache. Use an API Resource that names exactly which locales you need:

class PageResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id'    => $this->id,
            'slug'  => $this->slug,
            'title' => [
                'en' => $this->getTranslation('title', 'en'),
                'de' => $this->getTranslation('title', 'de'),
            ],
        ];
    }
}

Explicit locale lists make the query count predictable and make a future driver swap a non-event.

5. Environment-scoped driver selection

The JSON driver is a genuine convenience for local development and tests, where you want a clean translation state without seeding a table. Select it by environment and make the DB driver non-negotiable everywhere else.

// config/model-translation.php
'driver' => env('MODEL_TRANSLATION_DRIVER', 'database'),
// phpunit.xml
<env name="MODEL_TRANSLATION_DRIVER" value="json"/>

Do not let this be overridable into production. See the gotchas section for why.

6. Custom driver: the one place this package earns its keep

The justifying scenario for adopting this package over a ~150-line in-house trait is real storage variance — moving from files to DB over a phased migration, per-tenant storage, or an external TMS. When you need it, implement the contract once and register it:

class TenantTranslationDriver implements TranslationDriverContract
{
    public function get(Model $model, string $key, string $locale): ?string { /* ... */ }
    public function set(Model $model, string $key, string $locale, $value): void { /* ... */ }
    public function forget(Model $model, string $key, string $locale): void { /* ... */ }
}

// In a service provider
$this->app->bind('model-translation.driver.tenant', TenantTranslationDriver::class);

The value here is that the tenant driver and the DB driver produce identical model-facing behavior, so the migration is a config flip. Write a contract test once and run it against every driver you register — including the DB driver — so a driver swap can't silently change fallback behavior.

7. Test the fallback path explicitly

Fallback is a product decision that ships as a library detail. Write a test that asserts the exact behavior on a missing translation, because "silent null" and "default-locale substitution" produce very different user-facing bugs:

public function test_missing_translation_follows_documented_fallback()
{
    $page = Page::factory()->create(['title' => 'Hello']); // en
    App::setLocale('de');

    $this->assertSame('Hello', $page->title); // or assertNull — pick ONE and pin it
}

Pick one, document it in the model's docblock, and never let it drift.

8. Seed with explicit locales

Factories and seeders should always write locale-explicitly, or your fixtures become dependent on whatever config('app.locale') happens to be in CI:

$page->setTranslations(['title' => 'Hello'], 'en');
$page->setTranslations(['title' => 'Hallo'], 'de');

Gotchas and Tips

The N+1 is real and it's the main day-to-day hazard. Lazy trait accessors behind property reads are invisible in code review and only show up as a query count. Make DB::getQueryLog() on a listing page part of your definition of done for every translatable model. If the package has no batch-resolution story, build one in your resource layer before the second listing page exists.

Verify the DB driver's indexes before you seed. The translations table grows as models × attributes × locales — a 3-attribute model across 10 locales is 30 rows per record. Without a composite unique constraint on (model_type, model_id, locale, key) you get silent duplicate rows on a race; without a covering index the lookup is a table scan. Read the published migration, and if either is missing, ship your own migration to add them. This becomes a permanent operational cost you own, not the package.

Test whether translated values survive where and orderBy. This is the abstraction's leak point. If you cannot filter or sort on a translated column without dropping into driver-specific SQL, then the driver abstraction has bought you nothing and you're paying adoption risk for a translations table you could have written yourself. Answer this during the spike; an unfavourable answer is a legitimate reason to stop.

Fallback semantics are a user-visible product decision. Missing translation resolving to null means blank headings in a live UI; default-locale substitution means a German page with English body copy. Neither is wrong, but you must know which you have and write the test. Bonus: check whether fallback is configurable per locale chain or only global.

Exclude the JSON driver from production by policy, not by hope. File writes to storage_path break on read-only or ephemeral container filesystems, diverge across horizontally scaled instances, and race under concurrent writes unless implemented with atomic rename or locking. Also check whether it writes one global file or one per model — a single global file means every translation write rewrites the whole corpus. Treat it as a local-dev and test convenience.

Ambient-locale writes are a bug factory. Anything that calls App::setLocale() to force a write will behave differently under Octane or in queue workers where locale state persists across jobs. Always pass the locale explicitly on writes; reset locale in a middleware or finally block if you must set it.

Check the declared framework constraints against your LTS before you start. One composer show answers whether this is a spike or a dead end. Trait-based packages touching Eloquent internals typically need a patch cycle per Laravel major — budget one to two engineer-days per upgrade and own it via a fork if the upstream goes quiet.

Plan the exit before the first trait goes on a model. Because the trait is the single coupling point, the exit is cheap if you never let package-specific calls leak into views, controllers, or resources. One accessor method per model is the whole discipline. Per-model rollback flag: keep a config array of models still using the package so you can revert one model without a branch.

Debugging tips. Add a temporary DB::listen() or log inside your wrapper method to see which locales are actually being resolved and how often. If $model->title returns the wrong language, check the middleware order — anything that sets locale after the model is resolved but before the view renders is a classic source of "works in tinker, wrong in Blade."

Don't put it on a critical path. Auth, billing, audit logs, compliance text — none of these belong here. Zero dependents means there is no public evidence of this running in production anywhere, and an unanswered GitHub issue shouldn't be able to block a release. Contained content features only.

Extension points worth knowing. The driver contract is the documented extension point; the trait is the supported coupling point. Anything else — subclassing the driver, overriding the resolution internals — is unsupported surface, and with a single-maintainer agency project you should assume it breaks on the next minor. If you fork, keep the fork additive (indexes, caching, batch resolution) rather than rewriting driver internals, so you can rebase onto upstream tags cheaply.

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.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox