NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Translatable for Laravel

Input and validation

Locale keys are validated on write

Every model write path — $model->name = …, setTranslation(), setTranslations(), mass assignment, including JSON-path keys such as update(['name->de' => …]) — validates its locale keys. A key must look like a BCP-47 code (en, en_US, pt-BR); anything with quotes, spaces, markup or SQL, such as a mass-assigned name[<script>]=…, or a trailing newline ("en\n") is rejected with InvalidLocaleException. Values must be scalars: strings are stored as-is, ints and floats are cast to strings, and a nested array, object or boolean raises InvalidTranslationValueException instead of being stored:

$topic->name = ['en' => 'Investing'];                // ok
$topic->name = ['en' => 42];                         // ok — stored as "42"
$topic->setTranslation('name', 'pt-BR', 'Investir'); // ok — well-formed key

$topic->update(['name->sk' => 'Sporenie']);          // ok — JSON-path key, same guards

$topic->setTranslation('name', '<script>', 'x');     // InvalidLocaleException
$topic->name = ['en' => ['nested' => 'value']];      // InvalidTranslationValueException
$topic->name = true;                                 // InvalidTranslationValueException

// With strict_locales = true and locales ['en', 'sk']:
$topic->setTranslation('name', 'de', 'Investieren'); // InvalidLocaleException (not supported)

Turn on strict_locales to also reject well-formed keys that aren’t in your supported list. A query-builder update such as Topic::query()->update([...]) never builds a model, so it bypasses these guards like any other Eloquent attribute logic.

Normalise raw input with fromInput()

Route raw request maps through Translatable::fromInput(): it drops unsupported and blank locales before they reach the model, so untrusted $request->input('name') never triggers a write-path exception. A bare string becomes the current locale’s value — and is dropped too when that locale isn’t supported:

use RoundlyConsulting\Translatable\Facades\Translatable;

// Supported locales: en, sk — app locale: sk
Translatable::fromInput('Investovanie');                                     // ['sk' => 'Investovanie']
Translatable::fromInput(['en' => 'Investing', 'sk' => '', 'de' => 'Investieren']); // ['en' => 'Investing']
Translatable::fromInput(null);                                               // []

// App locale de (not supported): a bare string is dropped too
Translatable::fromInput('Investieren');                                      // []

$topic->setTranslations('name', Translatable::fromInput($request->input('name')));  // replaces the map with the form's locales
// or validate first with Translatable::rules('name', required: true)

Validating a single locale

Validating one key yourself — a ?locale= query parameter, a route segment — is ensureLocale(), which returns the key or throws InvalidLocaleException; isSupported() gives you a boolean instead:

use RoundlyConsulting\Translatable\Facades\Translatable;

Translatable::isSupported('sk');                   // true — exact, case-sensitive
Translatable::ensureLocale($locale);               // returns it, or throws InvalidLocaleException (malformed)
Translatable::ensureLocale($locale, strict: true); // … also throws when it is not supported

Validation rules

Translatable::rules() builds the rules for a locale-map field: the field must be an array, each supported locale is nullable|string, and when required at least one locale must be filled. Pass extra per-locale value rules through each:

use RoundlyConsulting\Translatable\Facades\Translatable;

$request->validate(Translatable::rules('name', required: true, each: ['max:120']));

// Translatable::rules('name', required: true, each: ['max:120']) returns:
// [
//     'name'    => ['required', 'array', <at-least-one-locale closure>],
//     'name.en' => ['nullable', 'string', 'max:120'],
//     'name.sk' => ['nullable', 'string', 'max:120'],
// ]

// Optional field — no "filled" check:
Translatable::rules('description', required: false);
// ['description' => ['sometimes', 'array'], 'description.en' => ['nullable', 'string'], …]

// Building your own rule list? Take only the "at least one locale is filled" rule:
'name' => ['required', 'array', ...Translatable::filledRule('name')],

The at-least-one-locale check is also available on its own as Translatable::filledRule('name'), shown at the end of the example above. Its message is the translatable::validation.filled language line (“At least one locale must be provided.”, shipped in English and Slovak) — publish translatable-translations to change it.

Form request with a unique slug

Combine the rules with sluggable’s UniqueSlug rule. Extend the slug rules rather than re-declaring slug — a second 'slug' key would silently replace sometimes|array and let a non-map value through:

use RoundlyConsulting\Sluggable\Rules\UniqueSlug;
use RoundlyConsulting\Translatable\Facades\Translatable;

public function rules(): array
{
    // Extend the slug rules rather than re-declaring `slug`: a second `'slug' => […]` key would
    // silently replace `sometimes|array` and let a non-map value through.
    $slug = Translatable::rules('slug', required: false);
    $slug['slug'][] = UniqueSlug::for(Topic::class)->ignore($this->route('topic'));   // sluggable's rule

    return [...Translatable::rules('name', required: true), ...$slug];
}

Full form: replace the map

Validate first, then pick the recipe that matches what the request carries — both route the input through fromInput(). When the form posts every locale, replace the map: a field the editor cleared drops out, which is how a full form removes a locale:

$request->validate(Translatable::rules('name', required: true));

$topic->setTranslations('name', Translatable::fromInput($request->input('name')))->save();

Use it only behind rules(…, required: true), which rejects both risky inputs: an absent field (fromInput(null) is []) would clear the map, and a bare string would keep only the current locale.

Partial updates

Translatable::apply() merges a TranslationChanges set into the model in memory: each supplied locale is set (null or '' removes it) and the model’s other locales are kept. It doesn’t persist, so call save() afterwards:

use RoundlyConsulting\Translatable\DataTransferObjects\TranslationChanges;
use RoundlyConsulting\Translatable\Facades\Translatable;

$topic->getTranslations('name'); // ['en' => 'Investing', 'sk' => 'Investovanie']

Translatable::apply($topic, TranslationChanges::make(['name' => ['sk' => 'Sporenie']]));

$topic->getTranslations('name'); // ['en' => 'Investing', 'sk' => 'Sporenie']
$topic->save();

For a request that carries only the locales it changes — a PATCH, a per-language editor — merge them; the locales it leaves out stay as stored:

Translatable::apply($topic, TranslationChanges::make([
    'name' => Translatable::fromInput($request->input('name')),
]));
$topic->save();

Here an absent field changes nothing and a bare string sets the current locale. A merge never clears a locale, because fromInput() drops blanks — remove one with forgetTranslation().

Concurrent edits

apply(), setTranslation() and name->sk keys merge in memory, and save() writes the whole column. Two requests that load the same row, change different locales and save are last-write-wins: the second save writes back the map it loaded, without the first request’s locale. When editors work on one record in parallel — one per language — serialise the read-merge-write with a row lock:

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($id, $request): void {
    $topic = Topic::query()->lockForUpdate()->findOrFail($id);   // re-read under a row lock

    Translatable::apply($topic, TranslationChanges::make([
        'name' => Translatable::fromInput($request->input('name')),
    ]));

    $topic->save();
});

Model events — TranslationsChanged and sluggable’s slug hooks — fire as on any save.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.