The usual way to make Eloquent models multilingual is a shadow table: posts plus post_translations, one row per locale, joined on read. It is the pattern most packages reach for, and for some workloads it is right.
We went the other way. Translatable columns on this project are jsonb holding {"en": "…", "ar": "…"}, via spatie/laravel-translatable.
What the join actually costs
A marketing site reads the same handful of rows constantly and writes them a few times a week. Every listing page — the mega menu with 25 services, the blog index, the work index — becomes a join against a table with two rows per record for no benefit, because you always want exactly one locale and you know which one before you query.
With a JSON column, fetching a page in a locale is a plain single-table select. One row per entity, no joins, and the row you cached is the whole entity in every language.
What you give up
This is a real trade, not a free win. You cannot cheaply index or query inside a translation. WHERE title->>'ar' LIKE '%…%' will not use a normal B-tree index, so full-text search across translations needs a GIN index or a dedicated search service.
For us that does not matter yet: site search is a later phase and will go through a search index rather than LIKE against a jsonb column either way. If your app's core feature were searching user-generated content in forty languages, the shadow table would be the better call.
Slugs stay in one language
One deliberate exception: slug is not translatable. A single Latin slug keeps routing and hreflang pairing simple, and the locale already lives in the path prefix — /ar/services/ai-automation. Per-locale Arabic slugs would help Arabic search a little and complicate every route resolution a lot. That is a decision worth revisiting only when Arabic organic traffic justifies the cost.
What it looks like to an editor
The storage decision leaks into the admin panel, so it has to be handled there rather than left as a database detail. Every translatable field in this project renders as one tab per locale, English first because it is the fallback.
The failure mode worth guarding against is specific: an editor opens a service, edits only the English tab, saves — and the Arabic value is gone, because the form submitted the whole JSON object with one key missing. It is silent, it is discovered weeks later by an Arabic-speaking visitor, and it is the single reason we have a round-trip test asserting that saving one locale leaves the other untouched.
Fallbacks are a product decision, not a default
A missing Arabic value falls back to English. That is the right behaviour — a page in the wrong language beats an empty page — but it has a consequence: a half-translated site looks finished. Nothing errors, nothing looks broken, and the gap is invisible to whoever is not reading in that language.
So completeness has to be asserted rather than assumed. Our content tests walk every published record and fail if a body or an SEO field is missing in either locale. It is a cheap test and it is the difference between shipping bilingual and shipping English with an Arabic veneer.
What the query layer gives you back
The one thing people assume they lose with a JSON column is the ability to filter on a translation, and that is only partly true. whereJsonContainsLocale and its siblings cover exact matching per locale, which handles most admin filtering — find every service with no Arabic body, list the pages published in one language only.
What genuinely does not work is ranked full-text search across translations. That needs a search index whichever storage shape you picked, so it is not really a point of difference between the two approaches — it is a separate piece of infrastructure either way.
The rule of thumb
Choose the JSON column when you read whole entities in one known locale and write rarely. Choose the shadow table when you query across translations, need per-locale indexes, or have enough locales that carrying them all in every row is genuinely wasteful.
