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 For Laravel Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

  1. 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).

  2. Look at these three places first, in order:

    • the service provider — confirms what gets extended (Blueprint::macro('translatable', ...)), which tells you the macro is available anywhere Schema is used;
    • the shipped model trait/concern — the translation accessor/fallback logic lives there, not in a magic cast;
    • the config file (publish it if present) — locale list, fallback locale, and column/JSON strategy decisions.
  3. 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.

Implementation Patterns

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:

  • Scopes — a scopeTranslatedTo($query, $locale) that filters whereNotNull("title->{$locale}") keeps "only show content actually available in this locale" out of controllers.
  • API Resources — project locale in the resource, never return the raw map to consumers unless the endpoint is explicitly an editor endpoint.
  • Filament/Nova/custom admin — bind one tab per locale off the same config array; the schema macro makes the DB side trivial, the UI side is where the time goes.
  • Factory states — a ->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.

Gotchas and Tips

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:

  • package development under Testbench (no host app to discover into),
  • the provider listed under dont-discover,
  • the provider registered by hand in a non-standard provider.

There, the fix is the shipped PHPStan extension, not a @method docblock hack on your own Blueprint subclass.

PHPStan extension loading — two paths:

  • With phpstan/extension-installer present, it loads automatically via extra.phpstan.includes. Do nothing.
  • Without it, add it explicitly:
    # 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.

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.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core