---
title: "Input and validation — Translatable for Laravel | Roundly"
description: "Hardened write paths, fromInput() normalisation, single-locale checks, validation rules, full-form and partial-update recipes and concurrent edits."
url: https://roundly-consulting.com/open-source/docs/translatable-for-laravel/input-validation
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Translatable for Laravel](https://roundly-consulting.com/open-source/docs/translatable-for-laravel.md)

# 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:

```php
$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:

```php
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:

```php
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:

```php
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:

```php
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:

```php
$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:

```php
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:

```php
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:

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/translatable-for-laravel.md)
