# 0005. Translations in a JSON column

- **Status:** Accepted
- **Date:** 2026-06-09

## Context

The site had to become trilingual (Hungarian, English, German) about a year into development, with roughly
twenty models carrying translatable text. The Hungarian data already existed and had to keep working
untouched.

## Decision

Store the **default-language value in the real column** and every other language in a `translations` JSON
column on the same row, keyed by language then attribute:

```json
{ "en": { "name": "Storage unit", "slug": "storage-unit" },
  "de": { "name": "Lagerraum" } }
```

A model declares `protected array $multilingualAttributes = [ 'name', 'description' ]`; `ActiveRecord`
contributes the column, and `populateRecord()` / `beforeSave()` swap the values in and out transparently.

## Alternatives

- **A translation table per model** (`storage_translation` …). The conventional answer. Rejected: twenty new
  tables, a join on every read, and the existing Hungarian rows would have had to be migrated out of their
  columns.
- **One polymorphic translation table.** One join instead of twenty tables, but a wide, hot table and no
  referential integrity.
- **A column per language** (`name_en`, `name_de`). Adding a language becomes a migration across every table.

## Consequences

- **Nothing downstream is language-aware.** A model hydrated in German carries German values in its ordinary
  properties. Controllers, widgets and views never mention languages.
- **Adding a language is configuration**, not a migration — one entry in `APP_LANGUAGES`.
- **The Hungarian data never moved.** Existing rows kept working the day the feature shipped.
- **Anything that bypasses `populateRecord()` gets no translation** — raw SQL, `createCommand()`, `asArray()`.
- Querying a translated value needs `JSON_EXTRACT`. `findBySlug()` does exactly that, with a
  `CONVERT( … USING utf8mb4 )` on both sides because otherwise the JSON extraction and the bound parameter
  end up in different collations and never match.
- **Untranslated values fall back to the source**, and untranslated slugs stay reachable under the Hungarian
  slug in every language. A half-translated record is a working page, not a 404.
- The column is `json()` with a `text()` fallback if the driver rejects it.
