WebProCMS supports running a fully multilingual site — both the public-facing site (what visitors read) and the dashboard / admin UI (what editors work in) can be translated independently into any of 32 predefined languages or any custom language an admin adds. RTL (right-to-left) languages like Arabic, Hebrew, Persian, and Urdu are auto-detected and the <html dir> attribute is set so browsers, Tailwind rtl: variants, and bidi text rendering all work without manual configuration.
There are two parallel translation systems sharing common infrastructure (AiTranslator, the Languages catalogue, language settings) but solving different problems:
- Public-site translations — per-language overrides for content editors create (locations, blog posts, events, custom content types, forms, menus, header CTAs, page-editor row text). Stored on each model and resolved per-request based on the URL language prefix (e.g.
/es/about). - Admin-UI translations — translations for the dashboard chrome itself (labels, buttons, descriptions). Driven by Laravel's standard
lang/{code}.jsonfiles and thedashboard_languagesession value.
A single Auto Translate workflow throughout the dashboard delegates to the configured AI provider (OpenAI / Anthropic / Google / DeepSeek) to bulk-fill translations across records and languages. Every translatable surface — index pages, edit pages, page-editor rows, headers, menus — exposes the same chunked, cancellable runner so long jobs stay responsive.
Public-site data translations
Stores per-language overrides for content the user creates: locations, blog posts, events, custom content types, forms, menus, header CTAs, page-editor field overrides.
Architecture
| Layer | What |
|---|---|
| Storage | A translations JSON column on each translatable model — content_items (covering locations, blog posts, events, and every other content type), content_taxonomy_terms (categories/tags), forms, and a few smaller models (faqs, content_blocks). Shape: {"es": {"name": "...", "address": "..."}, "fr": {...}}. |
| Model trait | App\Models\Concerns\HasTranslations — localized($key), setTranslation($lang, $key, $val), autoTranslate($lang), translationStatus($langs), plus the provenance readers translationState($lang, $key) / staleTranslationKeys($lang) / confirmTranslations(). Idempotent — autoTranslate() skips fields with a non-empty target translation, unless passed $refreshStale. |
| Per-model declaration | Each model declares translatableKeys(): array and (optionally) richtextKeys(): array. Models with non-column storage (ContentItem's data JSON, Form's nested fields) override getTranslatableSource(key) to read from the right place. |
| Read path | Public views call $model->localized('field') instead of $model->field. Falls back to the base column when no translation exists, when the active language is the default (en), or when the translation is out of date and the install's render policy says to prefer the base — see Keeping translations in step with the base language. |
| Active language | config('cms.current_language') set per-request: by URL prefix /es/about for the public site, by dashboard_language session for admin previews of public content. |
| AI translator | App\Support\Ai\AiTranslator::translate($text, $lang, $isHtml) — single-string. translateBatch($strings, $lang) — bundles short interface strings 20 at a time and translates anything longer (>200 chars, multi-line, or carrying markup) on its own. Provider routed via Setting::get('ai.text_provider') (OpenAI / DeepSeek / Google / Claude). Returns '' on any failure so callers fall back to source. |
| Output quality | Every translation is produced with the shared App\Support\Ai\TranslationPrompt (fidelity rules + the site's knowledge as reference-only) and checked against its source by App\Support\Ai\TranslationGuard before it is written. A rejection is retried once with the specific defect named, then SKIPPED — see "Why translations can come back empty" below. |
UX pattern
Every translatable model's dashboard pages share a common UI:
-
Index page — toolbar Auto Translate button-group: clicking the main button translates every record × every configured non-default language; the chevron dropdown lets the user pick one specific language. Long runs use a chunked client-driven loop (the Alpine
translateRunnerin resources/js/app.js) — one network request per(record, lang)chunk, so any single request stays well under PHP'smax_execution_timeand proxy timeouts. A progress modal (wire:ignore'd so morphdom doesn't touch it) shows current item + cancel button. -
Edit page — a Translations card with per-language tabs and inputs for each translatable key, plus the same Auto Translate button-group. Auto-translate runs against the form's unsaved scratch state via
replicate()so the editor sees the current edits, not the last persisted version.
The Forms model is bulk-translate-only on the index — per-field manual editing UI on the form edit page is a follow-up because of how nested the fields JSON gets (label / placeholder / help_text / option labels per field × per language).
Page-editor row content
Page-builder row text (headlines, subheadlines, button labels, etc.) uses a different storage — content_overrides table rows keyed by {rowSlug}:{key}__{lang}. The page editor's "Translate" tool walks the row's translatable schema fields and writes per-language override rows. The flow is already chunked via planPageTranslation + translateOnePageChunk (in app/Concerns/EditorAiActions.php), driven by the startTranslate() runner in resources/js/manager.js.
Header partials use the same content_overrides shape, with bulk Auto Translate exposed on the templates page's quick-edit panel (HeaderTranslateService).
Menus store translations inline in the navigation JSON blob (label__es siblings); see the Translate dropdown on /dashboard/menus.
Read path: one page sidecar per page, language variants live alongside
Switching language does not load a different page file. There's one set of compiled page blades on disk; saved content (including every language variant) lives in a per-page sidecar PHP file at storage/framework/page-data/pages/{slug}.php, compiled from content_overrides by PageDataCompiler. Translations layer on top of that single sidecar at request time:
- The visitor hits the language-prefixed URL (
/es/about). The catch-all route in routes/cms.php validates the prefix againstsite.languages, setsconfig(['cms.current_language' => 'es']), and re-dispatches to the underlying route (/about) so the same page file renders. - PagePreload
requires the page's sidecar into ContentCache. The sidecar is opcache-resident after the first hit, so this is effectively free; on a fresh miss the compiler runs once to build it. - Inside the page, every editable spot calls
content($slug, $key, ...). Whencms.current_language !== 'en', the helper first checks for the{key}__{lang}entry in the warmed cache. If it exists, it wins; otherwise the helper falls back to the default-language{key}, then to the row's schema default — so any field that hasn't been translated yet automatically renders the English fallback. On theShow the original language insteadrender policy the compiler leaves out-of-date translations out of the sidecar entirely, so this same fallback covers them with no extra work at request time. - For the default language (
en), the__{lang}lookup is skipped entirely.
Repeater item translations (per-row JSON-encoded lists like pricing tiers or feature grids) are stored as __{lang} siblings inside the repeater_* JSON value in the sidecar rather than as separate top-level keys. They're applied at iteration time by Resolver::expand → applyLanguageTranslation, so one JSON blob serves every language.
Cache behaviour
Each language has its own URL (/about vs. /es/about), so Spatie ResponseCache stores them as independent entries automatically — there's no language suffix on the cache key, and none is needed. Saving a translation invalidates the response cache and recompiles the page's sidecar in the same operation, so the next request rebuilds the per-language HTML from the freshly-updated sidecar.
Adding translation support to a new model
- Add a
translationsJSON nullable column via migration. use HasTranslations;on the model, add'translations'to$fillable, cast toarray.- Declare
translatableKeys(): array(and optionallyrichtextKeys()for HTML content). - Override
getTranslatableSource(string $key): stringonly if values live outside the model's column attributes (e.g. nested JSON). - In public views, replace
$model->namewith$model->localized('name'). - Provenance comes for free —
setTranslation()stamps it — but any OTHER path that writes a translation for this model must stamp it too, or the translation becomes undetectably stale later. - In the dashboard edit page, mirror the Translations card pattern from
resources/views/pages/dashboard/content/⚡edit.blade.php(the shared content-type edit page — handles both single-field and multi-field-including-richtext cases viatranslatableKeys()/richtextKeys()). - In the dashboard index page, mirror the Auto Translate runner from
resources/views/pages/dashboard/content/⚡index.blade.php— needsplanXxxTranslations(array $langs),translateOneXxx(int $id, string $lang), and a#[On('xxx-translated')]listener.
Keeping translations in step with the base language
Every reader above prefers a non-empty translation over the base language. That is what makes a translated site work — and it is also the system's sharpest edge: edit the English after a translation was made, and the translation stays in place, still preferred, now saying something the English no longer says. It is worse than a missing translation, which at least degrades to current English, and nothing about the page looks wrong.
The fix is provenance: a fingerprint of the source text is stored beside every translation, so "is this still accurate?" is a string compare — no AI call, no extra query.
The four states
| State | Meaning |
|---|---|
missing |
No translation stored. The reader correctly falls back to the base language. |
current |
The fingerprint matches — this was made from today's base text. |
stale |
The fingerprint differs — the base text was edited after this was translated. |
unverified |
A translation with no recorded fingerprint. Not a defect: it predates provenance tracking and simply hasn't been checked. Never auto-repaired, never hidden. |
Where provenance is stored
Seven surfaces can hold a translation, and each stamps provenance in the same write that stores the copy. TranslationSurfaces is the authoritative list, and a coverage test fails when a file handles __{lang} keys without being registered there.
| Surface | Fingerprint lives in |
|---|---|
| Page rows, layout partials | content_overrides.source_hash column |
| Repeater / grid item sub-keys | reserved items[i].__src.{lang}.{key} bag |
Models with a translations column |
reserved translations.{lang}.__src.{key} bag |
| Menu nodes | reserved node.__src.{lang}.{key} bag |
| Page frontmatter (name, meta title, description) | reserved __src object in the @page marker |
{tag}__{lang} shortcodes |
shortcodes.source_hash column |
| Settings chrome (cookie banner, age gate, business facts) | per-namespace {namespace}.__src Setting |
The two settings
Both live at Settings → Languages → Front-End → Translation upkeep, and only appear once a second language is configured. They answer different questions and compose freely.
While a translation is out of date — what a visitor sees right now:
| Option | Behaviour |
|---|---|
Show the original language instead (default) |
Ignore the stale translation and render the current base text. |
Show the old translation anyway |
Serve it regardless. What every install did before this existed. |
The trade is between two wrongs: out-of-date words in the visitor's own language, or current words in a language they may not read. The base wins when the edit changed meaning (a price, an hours change, an offer) and loses when it was a typo fix — and a fingerprint cannot tell those apart, so the default takes the side where being wrong is recoverable: a visitor reading English on a Spanish page can tell something is off, where a confidently wrong price reads as fact.
This default does change what an existing multi-language install renders, on the update that ships it: any section whose base text was edited after translation stops showing its old translation. That is the intended outcome, and paired with the daily pass it lasts at most until the next run. It never touches a current or unverified translation, so a site translated before provenance tracking is unaffected.
Daily check — what the upkeep pass does about it:
| Option | Behaviour |
|---|---|
Re-translate out-of-date text automatically (default) |
Repairs stale translations once a day using the configured AI provider. Uses its credits. |
Only report it — change nothing |
Counts them and surfaces the number. Costs nothing. |
Only stale is ever acted on. unverified translations are left completely alone by both settings — a fingerprint was never recorded, so there is nothing to compare, and blanking or rewriting them would gut a site that was translated before provenance tracking shipped.
The render policy is applied at compile time for page rows: a stale translation is simply left out of the page's sidecar, so the existing "no translation → base" path handles it and the hot render path gains no branch at all. The policy is folded into the sidecar's change signature, so flipping it invalidates every page on the install the same way a header swap does.
The daily pass
translations:sync runs on LazyCron once a day (off-peak). It scans for stale translations, records the result, and — on the auto policy — repairs them.
php artisan translations:sync # scan; repair if the policy says so
php artisan translations:sync --dry-run # what it would do, writes nothing
php artisan translations:sync --force # repair even on the "report" policy
php artisan translations:sync --limit=100 # raise the per-run repair cap (default 25)
php artisan translations:sync --lang=es --surface=model
Three behaviours worth knowing:
- Report mode still scans. The count is the reason someone chose that mode; only the repair is gated.
- It slices its work. LazyCron runs it inside a visitor's request — after the response is flushed, so nobody waits on it, but a PHP-FPM worker is held for the duration. A single record can cost up to four provider calls, so the web path takes 5 records / 15s with a reserve (it won't start a record it can't finish inside the budget) and extends the time limit; the CLI takes 25 / 300s. An install with a real cron entry (
* * * * * php artisan lazy-cron:run) therefore drains far faster than one relying on visitor traffic. The remainder is reported and picked up next run. - Shadowed copies are skipped. A page-scoped override for one of a page's own rows is merged into the compiled sidecar only as a fallback, so an equivalent global row wins and the page-scoped copy renders nowhere. Those are never re-translated (paying for text no visitor sees) and are listed separately by
translations:statusinstead of inflating the stale count. Page-scoped overrides of shared rows do win, and are treated normally. - The slice also bounds a credit leak. The run writes once, at the end, through the importer — so a process killed mid-loop discards every translation it already paid for and buys them again next run. Keeping the web slice small is what caps that loss.
- It writes through
translations:import. The importer refuses any record whose target no longer holds the base text it was made from, stamps provenance as it writes, and recompiles page data plus clears the response cache. Those checks matter more unattended than they do for a human sweep, so the cron takes the audited path rather than a second one.
A failed translation (no provider, bad key, network error, or output TranslationGuard refused) leaves the record stale and untouched. It is retried next run, and nothing wrong was written meanwhile.
Where staleness surfaces
- Settings → Languages → Front-End — a line under the two controls: how many are still out of date and when the pass last ran.
- Fleet dashboard —
stale_translationsrides the install's health block on the license check-in and shows as a Translations stale badge, so an agency sees it across every install without logging into any of them. - CLI — the full picture, by language and surface:
php artisan translations:status # summary table
php artisan translations:status --state=stale --json # the work list
Reviewing by hand instead
The same machinery drives a human review sweep, which is what you want when the AI's output is not trusted for a particular site:
php artisan translations:status --state=stale --json > work.json # export
# edit the "translation" value on each record
php artisan translations:import work.json # apply + stamp
php artisan translations:confirm --surface=menu # "these are fine as-is"
translations:confirm re-stamps provenance without touching the translation — the dismiss action for a false positive (an English typo fix flags every translation of that string, and the fingerprint cannot know it did not matter). Scope it to what you actually reviewed: it asserts correctness without reading anything, so the scope is the claim.
Admin-UI chrome translations
Different system. Translates the dashboard's labels, buttons, descriptions, and notifications — not user-created content.
Architecture
| Layer | What |
|---|---|
| Storage | Standard Laravel — lang/{code}.json files (e.g. lang/es.json) keyed by source-language string → translation. |
| Lookup | __('Auto Translate') in Blade / PHP. Falls back to the source string when the key isn't translated. |
| Active locale | app()->setLocale($code) per request, applied by ApplyDashboardLanguage middleware reading from the dashboard_language session value. |
| Switcher | SidebarLocaleAppearance Livewire component in the dashboard sidebar — admin picks which language they want the chrome rendered in. Independent of the public site's language. |
| Available languages | Setting::get('dashboard.languages', []) (admin-side allowlist). Settable from Settings → Languages → Admin. The 32 predefined languages come from App\Support\Languages::predefined(); admins can also add custom languages with their own ISO code, label, and flag emoji. |
| Frontend preview | A second session value, frontend_preview_language, lets an admin preview localized()-driven public content in a non-default language without changing the chrome locale. Falls back to dashboard_language when unset, so by default the two are in lock-step. |
The middleware runs on the global web group (so Livewire AJAX requests see the locale too) but skips public-route requests — the public site uses URL-prefix routing (/es/about) instead of session state, since each visitor has their own locale.
Adding chrome translations
- Wrap user-facing strings in Blade with
__('Source string'). - Add an entry to
lang/{code}.json:"Source string": "Translated string". - Strings discovered with
:placeholdersshould keep the placeholder verbatim — Laravel's__()does the substitution, the JSON value just needs the placeholder in place.
The shared translation prompt explicitly tells the model to preserve :placeholder tokens, and TranslationGuard rejects any translation whose token set changed, so bulk-AI-translating chrome strings (a future feature) won't break interpolation.
Plural strings (trans_choice)
A count in the dashboard — "3 pages", "1 contact", "no results" — is a
trans_choice() message: pipe-separated forms, one per grammatical number,
optionally prefixed with an explicit condition ({0}, {1}, [2,*]).
These were invisible to the whole system until 2026-08. The scanner matched
__( and nothing else, so 106 plural strings sat outside the corpus: English in
all 30 locales, unreachable by Auto Translate, and reported by nothing. Both
helpers are now scanned, and DashboardLangScanner::pluralKeys()
records which helper each literal came from — because a | inside a __()
string means punctuation, not plural forms, and the difference is not
recoverable from the string itself. A corpus test fails CI if one appears.
A plural message is the one string on the platform whose shape is load-bearing, and how many forms are right depends entirely on the source:
| Source shape | What a translation must have |
|---|---|
Explicit conditions ({0} none|{1} one|[2,*] :count) |
Exactly the same conditions, in the same order, and the same number of segments. Those conditions are consulted before the locale's plural rule and between them answer every number, so the rule is never reached — and stripConditions() indexes across ALL segments, so an added form would be selected by position rather than by meaning. |
All bare (:count page|:count pages) |
Exactly as many bare forms as the target language distinguishes — three for Russian, Polish, Czech, Romanian and Ukrainian, six for Arabic, and one, meaning no pipe at all, for Japanese, Chinese, Korean, Thai, Vietnamese, Indonesian, Turkish and Filipino. |
That second row is the one place in this codebase where a translation may legitimately say more than its source: there is no way to write correct Russian with two forms. Every other rule — "never add anything", the length ratio, the placeholder multiset — had to learn about it.
PluralMessage owns the grammar, and
its formCount() / formExamples() are probed from Laravel's own
MessageSelector rather than tabulated, so they cannot drift from what picks
the segment at render time. formExamples() is what makes the instruction
usable: "Russian needs three forms" is useless on its own, because the segments
are positional and the order is not guessable — it returns the numbers each slot
answers to (1, 21, 31 … / 2, 3, 4 … / 0, 5, 6 …), and the prompt states
them. Plural messages are excluded from bundled round-trips for the same reason:
that instruction is per-string and per-language, and a shared batch prompt cannot
carry it.
PluralSelector. Laravel's plural table is keyed by underscore locale codes (es_ES), and every code this application stores uses a hyphen — soes-ESandpt-BRmissed every case and fell through to the single-form default, rendering the singular at every number, forever. That is invisible until plural strings are actually translated.PluralSelectornormalizes the separator and is bound onto the translator inAppServiceProvider.
Translation quality: the four levers
The guard below stops mechanical damage. These four are what raise the content quality, and each closes a defect class measured in a full human review of one install's machine Spanish — where 64% of records were defective and every one of them passed the mechanical guard cleanly.
1. The glossary — Dashboard → AI → Knowledge → Translation glossary (appears once a second site language exists). Two kinds of entry:
- Required rendering — "urgent care" must come back as "atención de urgencias", per language.
- Never translate — a brand, product, or service name that survives verbatim in every language.
Unlike the Brief, glossary entries are enforced, not suggested: TranslationGlossary attaches the entries the source actually contains to the prompt, and TranslationGuard rejects a translation that ignored one, which sends it to the retry with the specific term named. Only terms present in the source are ever checked, matching is case- and accent-insensitive (so a stored "atencion" accepts "atención"), and the check runs above the guard's length floor because pinned terms live in headings and buttons that are shorter than it.
This is the only lever that is deterministic — everything else asks a model to behave and then measures whether it did.
2. A stronger model for translation. Translation is the AI task in this application least tolerant of a cheap model and among the cheapest to run, since it only fires on content that changed. AiTextService::TASK_MODEL_FLOORS raises the translation task to Sonnet on the Claude provider. It is a floor, not an override: it applies when the install is sitting on the shipped general-purpose default (which nearly every install is), and never overrides a model an admin deliberately chose or an explicit ai.translation_model. Providers with no named floor are left alone, because naming a model ID a provider doesn't serve turns every call into a 400. On the managed path the floor is applied only when the mothership's signed check-in lists the model as available.
3. Context. A string alone is often ambiguous in ways that have nothing to do with the model's competence — "Book" is a verb on a button and a noun in a library. TranslationContext lets a caller say what the string is on the page and hand over the copy surrounding it as reference (never as material to translate). The daily upkeep pass derives the role from the field key and pulls siblings from the same row; siblings are capped and truncated so reference material can't bury the source.
4. The review pass — the Double-check each re-translation against the original toggle beside the upkeep settings (ai.translation_review, on by default). TranslationReviewer reads the translation against its source and reports the single most serious meaning change: dropped or invented detail, inverted agency ("they had" → "we had"), a trade term narrowed, a changed number, a register switch. It is a critic, never an editor — a clean translation is returned untouched, and a faulted one gets one fresh translation with the defect named, put back through the same mechanical guard. If the repair is itself unusable the original is kept, since it already passed every mechanical check.
It costs one extra call per repaired item and runs only on the unattended daily pass, not the editor's translate buttons: an editor watching a translation appear is the review. It also fails open — no provider, an empty reply, or an unparseable verdict all read as clean, because a reviewer that can't run must never cost the install a translation the guard already accepted.
Why translations can come back empty
AiTranslator refuses to write a translation it can't verify against its source. TranslationGuard compares the pair and rejects on: changed HTML tags, a changed href/src, a changed [[shortcode]] or :placeholder, a missing number of three digits or more (phone numbers, years, prices), a violated glossary entry, a translation far shorter than its source (dropped text), far longer (invented text), or fewer sentences than the source. A rejection is retried once with the failure named in the prompt; a second failure writes nothing.
That is the safe direction, not a bug: every reader falls back to the base language, so a skipped record renders current English, while a written-but-wrong one renders wrong Spanish indefinitely and looks finished. The behaviour exists because an install's entire Spanish site was written by this path and a full review found defects in 64% of its records — whole sentences silently dropped, sentences invented with no source behind them, trade terms narrowed to the wrong system.
A plural message is measured one form against one form, not whole: a
six-form Arabic translation of a two-form English source is three times the
length and carries three times the markup, and comparing whole strings reads
correct output as invented text. Its structure is checked separately
(REASON_PLURAL) against the table above, and its placeholders are compared as a
set rather than a multiset, since the segment count legitimately differs.
Length and sentence checks only apply above 80 visible characters (a short label legitimately changes length dramatically), and languages with compact scripts (Chinese, Japanese, Korean, Thai) are measured against their own band. Two script rules matter for the sentence check, and both existed as bugs that silently rejected correct work: 。!? and the Devanagari danda end a sentence with no following space (Japanese and Chinese simply do not put one there), and Thai, Lao, Khmer, Burmese and Tibetan have no sentence terminator at all, so the check is skipped for them rather than guessed at. If a run reports fewer translations than expected, AiTextService::$lastError names the reason and a Translation rejected after retry warning is logged with the target language and reason — never the copy itself.
How they interact
A user on /es/about:
- URL prefix sets
cms.current_language = 'es'→localized()calls resolve to Spanish content from per-modeltranslationsJSON. app()->getLocale()is whatever the public-site locale is (separate logic, not the admin middleware).
An admin viewing the dashboard:
dashboard_languagesession →app()->setLocale('es')→__()returns Spanish chrome fromlang/es.json.frontend_preview_language(or fallback todashboard_language) →cms.current_language = 'es'→ admin previews of public-site content render in Spanish too.
RTL (right-to-left) support
WebProCMS auto-detects right-to-left languages and sets the document direction so the entire UI flips appropriately — both on the public site and inside the dashboard.
How direction is decided
Every layout (public, editor, dashboard sidebar/header, all three auth layouts, plus the partial preview layout used for editor previews) emits <html lang="…" dir="…"> based on the active locale:
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ \App\Support\Languages::htmlDir() }}">
Languages::htmlDir() returns 'rtl' or 'ltr' for the supplied code (defaults to app()->getLocale()). The detection logic lives in Languages::isRtl() and uses two layers:
- Explicit flag in the predefined catalogue — Arabic (
ar), Hebrew (he), Persian/Farsi (fa), and Urdu (ur) are marked with'rtl' => true. - Prefix fallback — for custom user-added languages or locale variants (
ar-EG,he-IL,fa-IR,ckb,ps,sd,dv,yi, etc.), the bare code or prefix-before--is matched against a list of known-RTL ISO codes.
What this gives you out of the box
- Browsers handle bidi text correctly — paragraph alignment, punctuation placement, and bidirectional embedding all "just work" when
<html dir="rtl">is set. - Tailwind v4
rtl:andltr:variants become live utilities — design library row authors and shared row authors can writertl:rotate-180on achevron-righticon,rtl:flex-row-reverseon a card, orrtl:text-rightoverrides without any extra setup. - Dashboard chrome flips automatically when an admin selects an RTL admin language — the sidebar, dropdowns, and modals reposition because Flux UI components honour
<html dir>natively.
Public site vs. dashboard direction are independent
The public site's direction is driven by app()->getLocale() which is itself driven by the URL prefix (/ar/about → locale ar → dir="rtl"). The dashboard's direction is driven by the same app()->getLocale() value, but on dashboard requests that's set by the ApplyDashboardLanguage middleware reading from the dashboard_language session value. So an admin can be in an RTL dashboard while previewing an LTR public site, or vice versa.
Design library row classes
Tailwind v4's rtl: variants work in any row blade as soon as <html dir="rtl"> is set. Existing rows still ship with directional utilities (ml-*, mr-*, text-left, etc.) — those keep working under LTR and most look acceptable under RTL, but rows that contain horizontally-asymmetric layouts may want explicit rtl: overrides or logical utilities (ms-*/me-*, text-start/text-end) for the cleanest mirroring. Add overrides on a per-row basis when an RTL audit identifies one that needs adjusting.
Picker UX
Both halves of Settings → Languages — Front-End and Admin — use the same searchable language picker. The trigger button shows up to four flag previews and the active count; clicking opens a popover with a search input, the full catalogue (with an RTL badge next to right-to-left languages), and an "Add custom language" form. English is locked as the always-active default; any other predefined or custom language can be toggled.
Cost note
Auto Translate calls the configured AI provider once per (record, language) chunk. With many records × many languages, the run can take minutes; the chunked runner UI shows progress and supports cancellation. Already-translated fields are skipped on subsequent runs (idempotent), so re-running fills only gaps.
The daily upkeep pass costs one call per stale record — two with the review pass on, three when the reviewer finds something — capped at 25 records per run by default. A site whose base language is not being edited costs nothing at all — a healthy install makes zero calls, because nothing is stale. An install that would rather never spend credits unattended sets the daily check to Only report it.