Skip to main content

Documentation

No results found.
Features

Translations & Multilanguage Support

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 la...

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}.json files and the dashboard_language session 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 translateRunner in resources/js/app.js) — one network request per (record, lang) chunk, so any single request stays well under PHP's max_execution_time and 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:

  1. The visitor hits the language-prefixed URL (/es/about). The catch-all route in routes/cms.php validates the prefix against site.languages, sets config(['cms.current_language' => 'es']), and re-dispatches to the underlying route (/about) so the same page file renders.
  2. 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.
  3. Inside the page, every editable spot calls content($slug, $key, ...). When cms.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 the Show the original language instead render 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.
  4. 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

  1. Add a translations JSON nullable column via migration.
  2. use HasTranslations; on the model, add 'translations' to $fillable, cast to array.
  3. Declare translatableKeys(): array (and optionally richtextKeys() for HTML content).
  4. Override getTranslatableSource(string $key): string only if values live outside the model's column attributes (e.g. nested JSON).
  5. In public views, replace $model->name with $model->localized('name').
  6. 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.
  7. 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 via translatableKeys() / richtextKeys()).
  8. In the dashboard index page, mirror the Auto Translate runner from resources/views/pages/dashboard/content/⚡index.blade.php — needs planXxxTranslations(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:status instead 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_translations rides 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

  1. Wrap user-facing strings in Blade with __('Source string').
  2. Add an entry to lang/{code}.json: "Source string": "Translated string".
  3. Strings discovered with :placeholders should 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 — so es-ES and pt-BR missed 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. PluralSelector normalizes the separator and is bound onto the translator in AppServiceProvider.

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-model translations JSON.
  • app()->getLocale() is whatever the public-site locale is (separate logic, not the admin middleware).

An admin viewing the dashboard:

  • dashboard_language session → app()->setLocale('es') → __() returns Spanish chrome from lang/es.json.
  • frontend_preview_language (or fallback to dashboard_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:

  1. Explicit flag in the predefined catalogue — Arabic (ar), Hebrew (he), Persian/Farsi (fa), and Urdu (ur) are marked with 'rtl' => true.
  2. 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: and ltr: variants become live utilities — design library row authors and shared row authors can write rtl:rotate-180 on a chevron-right icon, rtl:flex-row-reverse on a card, or rtl:text-right overrides 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.