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>]=…, 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')));
// 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 supportedValidation 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.”) — 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];
}PATCH-style updates
Translatable::apply() merges a TranslationChanges set onto a model, touching only the supplied locales — ideal for partial updates from an API. 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();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 cryptoBy 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.