Backend & architecture
Translatable content in Laravel with JSON columns and Filament
6 min read
Translatable content in Laravel with JSON columns and Filament
The starting point
My portfolio already served its interface in Spanish, English and Chinese, but the content itself (projects, experience, my own presentation) was plain text in a single language. Choosing Chinese translated the menu and left every project description in English. I wanted each public text to be editable per language from the admin panel, with a predictable fallback when a translation is missing, and without turning a small site into a localisation platform.
Why not a package or translation tables
There are two common options. One is a package such as spatie/laravel-translatable. The other is a translation table per model, for example project_translations with a locale column and one row per language. Both are perfectly valid. For this project I chose a small trait of my own over JSON columns:
- The content volume is tiny: a few dozen projects and a handful of posts. I never need to query or sort by a translated value in SQL.
- One row per entity keeps the admin form, the content loader and the tests simple: a translatable field is just an array
{"en": "...", "es": "...", "zh": "..."}. - The behaviour I needed (an explicit fallback order and ignoring blank values) fits in about forty lines. Owning it means no surprises when the framework is upgraded.
The tradeoff is real: you cannot index or sort efficiently by a translated value, and adding a fourth language means touching data in every row instead of inserting new rows. At this scale that is acceptable. With thousands of rows and locale-specific queries I would use translation tables.
The trait
Each model declares which fields are translatable, and the trait registers the cast and the accessors:
trait HasTranslations
{
public function initializeHasTranslations(): void
{
foreach ($this->translatable as $field) {
$this->casts[$field] = 'json:unicode';
}
}
public function t(string $field, ?string $locale = null): ?string
{
$resolved = $this->localeOf($field, $locale);
return $resolved ? trim(((array) $this->getAttribute($field))[$resolved]) : null;
}
public function localeOf(string $field, ?string $locale = null): ?string
{
$values = (array) $this->getAttribute($field);
foreach ([$locale ?? app()->getLocale(), ...Locales::FALLBACK] as $candidate) {
if (filled($values[$candidate] ?? null)) {
return $candidate;
}
}
return null;
}
}
A few details matter more than they look:
json:unicodestores Chinese characters and accents as readable text instead of\uescapes. That makes database dumps and diffs reviewable by a human.- The fallback order is the requested language first, then English, Spanish and Chinese. It is a constant, not configuration scattered across views.
filled()ignores empty strings and whitespace, so an empty Spanish tab in the admin never wins over a real English text.- If no language has text,
t()returnsnull, and the views simply skip that element instead of printing an empty box or a translation key. localeOf()tells me which language was actually used. The blog relies on it to show an "only available in English" notice when a post falls back.
One tab per language in Filament
In the admin panel every translatable field becomes a set of tabs, one per language. A small helper builds them:
public static function make(string $field, Closure $component): Tabs
{
return Tabs::make($field)
->tabs(array_map(
fn (string $locale) => Tab::make(self::LABELS[$locale] ?? $locale)
->schema([$component("{$field}.{$locale}")]),
Locales::ALL,
))
->columnSpanFull();
}
The closure receives a name such as title.es, and Filament maps that path to the nested array automatically thanks to the JSON cast. There is no custom state handling: the same helper works for a text input, a textarea or a Markdown editor. For tables, a column resolves the value for the current language with the same fallback through getStateUsing(fn ($record) => $record->t($field)).
Migrating existing data without losing anything
The requirement was to keep all existing content and treat it as the English version. The migration changes the column type and then wraps every value, and it is written to be safe to run more than once:
private function wrap(?string $value): ?string
{
if ($value === null) {
return null;
}
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded) && ! array_is_list($decoded)) {
return $value; // already {"en": ...}
}
return json_encode(['en' => $value], JSON_UNESCAPED_UNICODE);
}
Two lessons came out of this step.
Changing a column type on SQLite rebuilds the whole table. Laravel creates a temporary table, copies the data, drops the original and renames the copy. If another table has a foreign key with ON DELETE CASCADE pointing at it, that intermediate drop fires the cascade and deletes the dependent rows. Disabling foreign keys does not help inside a transaction, which is exactly how the test suite runs. The fix was to alter a column only when its type is not already the right one, so a second run never rebuilds anything.
Wrapping legacy text as English is correct, but it creates hidden work. Those values stay English-only until someone translates them, and the fallback makes the gap invisible: the Spanish and Chinese pages quietly show English. On this site, twenty-one older project write-ups stayed that way after the migration, because nothing else ever touched that field. A short script that lists every translatable field missing Spanish or Chinese found all of them in minutes. I now run that audit after any change of this kind.
Search that ignores accents but not Chinese
Projects and posts are searchable. Typing "migracion" must find "Migración", and typing "喉部" must find the Chinese title of the larynx project.
My first attempt used iconv with ASCII//TRANSLIT//IGNORE. On the server's platform it turned "Migración" into "Migraci'on": the transliteration inserted an apostrophe where the accent had been, so the comparison failed. It would also have destroyed any Chinese text. The final version relies on Unicode decomposition:
public static function normalize(string $text): string
{
$text = Normalizer::normalize(trim($text), Normalizer::FORM_D) ?: $text;
$text = preg_replace('/\p{Mn}/u', '', $text);
$text = mb_strtolower($text);
return trim(preg_replace('/\s+/u', ' ', $text));
}
NFD splits "ó" into "o" plus a combining accent, and \p{Mn} removes those non-spacing marks. Chinese characters do not decompose this way, so they pass through untouched. Lowercasing handles case, and a plain substring match works for Chinese, which does not separate words with spaces. The same normalisation is applied to the query and to the indexed text, on the server and again in the client-side filter, so both always agree.
What I would keep
- Small, explicit fallback rules beat clever magic. Anyone can read the trait in a minute.
- Migrations that change data should be idempotent and should check before altering anything.
- After migrating content, audit which fields only exist in the fallback language. The fallback is a safety net, not a translation.