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, a media library image's alt text and caption (media_items, see media library), 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.

Module paths spelled per language (LocalizedPaths). Step 1 assumes one path per page, recomposed under each prefix. A feature module whose URLs carry words registers a translator with LocalizedPaths::register($owner, $translator, $routeResolver): given an unprefixed path in any language's spelling and a language code, it returns the path as that language spells it, or null when the path isn't the module's. Hreflang::urlFor() (the head alternates, the canonical and the sitemap's language twins), LinkLocalizer and the language switcher all ask it. When a language spells a module's route literal — the first path segment — in its own words, nothing in the route table answers that spelling, so the module also passes the optional third argument, a route resolver: LocalizedPaths::routePath() maps the spelling back to the one the routes serve. The catch-all asks it before re-matching the unprefixed path (the request keeps the URL it came in on), and Breadcrumbs::trail() walks the same spelling so the trail's ancestors don't drop out. routePath() returns null when no module registered a resolver, for any path no module claims, and while the module is off — so the catch-all matches exactly what it did before. A second route at the translated word would instead take that URL from an install's own page on every install, module on or off. Automotive registers both: its Spanish inventory lives at /es/inventario/… (a store's at /es/inventario/agencia/{slug}). Its resolver re-spells only inventario → inventory; agencia and the condition words are route parameters its landing routes already accept, and its translator writes them (see automotive.md → Spanish). Translators and resolvers run for every link on a translated render, so they must stay cheap — no queries (a memoized setting read is fine) — and answer null while their feature is off.

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

Six 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
{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 hourly pass it lasts only until the edit settles and the next run repairs it — usually an hour or two. It never touches a current or unverified translation, so a site translated before provenance tracking is unaffected.

Hourly check — what the upkeep pass does about it:

Option Behaviour
Re-translate out-of-date text automatically (default) Repairs stale translations on the hourly pass, once each edit has settled, 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 hourly pass

translations:sync runs on LazyCron every hour, around the clock (it ran once a day until 2026-10, which left an edited page's Spanish visitors reading English for up to a day). It scans for stale translations, records the result, and — on the auto policy — repairs them. The scan is cheap (well under a second on a 300-page install) and no AI is called unless something is stale; what keeps an hourly repair affordable is that it waits, twice:

  • The settle window. A field is re-translated only once its base text has gone 30 minutes unchanged (StaleTranslationWaits::SETTLE_MINUTES). An owner rewording a page edits the same field several times in an evening; without the wait the pass would buy every draft, and each later edit would make the one it just bought stale again. Visitors lose nothing meanwhile — the default render policy shows them the current base text. "Unchanged" is measured as first seen by a run with these exact words, the one signal every surface has, so a lone edit is repaired one to two runs after it lands.
  • The failure cool-down. A field whose attempt failed waits 24 hours before the next (StaleTranslationWaits::COOL_DOWN_HOURS). Daily, a field that always fails cost one try a day; hourly it would cost twenty-four, each up to four provider calls, on every install. It also stops a block of always-failing fields taking every slot of every run — the work list comes back in the same order each time, so before the cool-down they starved everything behind them.

An edit to the base text restarts both clocks: new copy is owed a fresh wait and a fresh attempt, not a cool-down earned by the old words. The clocks live in the stale_translation_waits table — one row per stale field, deleted once the field is current again — and not in the cache, which every CMS update clears. A run narrowed by --lang / --surface forgets only rows it actually scanned.

php artisan translations:sync                 # scan; repair what has settled, if the policy says so
php artisan translations:sync --now           # skip both waits and repair everything stale right away
php artisan translations:sync --dry-run       # what it would do, writes nothing (not even a "first seen")
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

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.
  • A record its module translates is left to the module. TranslationSync::MODULE_TRANSLATED_MODELS lists models whose own module buys their translations under its own spend policy — today Automotive's vehicle descriptions, translated only by its automotive-translate-vehicles pass within that module's daily cap (Automotive). The repair never buys one: a dealer inventory is thousands of descriptions a feed re-touches nightly, and this pass knows neither the module's cap nor its length limit. They stay in the stale count — a stale one is stale until the module's pass reaches it — and in translations:status, translations:import and translations:confirm.
  • 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, and starts its 24-hour cool-down. So does a translation that came back fine but that translations:import refused to write (its target moved, or it can't be written at all) — it was paid for, and a refusal that repeats would otherwise be bought every hour. Nothing wrong was written meanwhile; --now retries it immediately.

The clocks are kept on both policies. On Only report it nothing is repaired, but each run still notes what it found, so a later translations:sync --force treats a field edited weeks ago as settled rather than as just seen.

The nightly gap filler

translations:sync repairs translations that exist. Text that has no translation at all is filled by translations:fill, which runs nightly (after 4 a.m.) when Translate new content as it is published is on. It covers content items, the other translatable records (forms, FAQs, taxonomy terms, media alts…), page copy, shared rows and header/footer partials, repeater items and menus, but only what changed since the last completed night (its watermark). It skips the models in TranslationSync::MODULE_TRANSLATED_MODELS (vehicle descriptions): their module fills them within its own daily cap, which an uncapped nightly cycle would bypass.

The nightly run is a background task (translations-fill, started by translations:fill-nightly), and it finishes every night. Each step is one uncapped translations:fill --cycle slice, bounded only by its time. On the fleet's servers the task runs as a detached process with no visitor waiting on it; on a host that allows neither a worker nor a detached process it resumes slice by slice from Lazy Cron's task drain. The watermark moves only when a night's cycle is complete, to when it started.

Within a night, each target is tried once. A target the translator could not fill is not tried again that night unless its text changes, so it can no longer sit at the head of the list and keep everything behind it waiting. It is tried again the next night.

Until 2026-10 the nightly run was one capped run (25 targets, only 3 inside a visitor's request) that kept its old watermark whenever it stopped early. Anything it could never fill came back first every night, and the pages behind it were never reached. Two of those never-fillable kinds no longer count as gaps at all:

  • A data token with a format, such as {item.auto.date:M j, Y} on a listing row. The record supplies the text, and a model asked to translate M j, Y would break the date.
  • A short name or term that reads the same in Spanish, such as a reviewer's name or a brand. Up to six words returned unchanged now count as translated and are stored. A longer sentence returned unchanged is treated as a failed translation and stays a gap.
  • A shared row seen through a page. A page embeds a shared row (the CTA banner, the page-title band) as an @include stub with no components, and the field parser fell back to the design-library template's fields for it. Every page embedding a CTA reported the template's "Join thousands of satisfied customers today.", even on banners with no subheadline at all, and a fill would have written a Spanish "Ready to Get Started?" over the real banner. Shared rows are now read only from their own files, through the same walk as header and footer partials.
php artisan translations:fill-nightly     # start tonight's task (what Lazy Cron runs)
php artisan translations:fill --dry-run   # what a sweep would translate, writes nothing
php artisan translations:fill --all --limit=0 --budget=3600   # the whole backlog, in the foreground

Records that belong to another record

An owned record — an FAQ added "Only on this item" to a service or town page, a location's own reviews — never gets a translation run of its own: it is part of its owner's page, so it is translated with its owner. When an item is translated on publish (the translate-item task) or by the daily gap filler (translations:fill), its published owned records are translated in the same run, one language at a time, gaps only, with provenance stamped as usual. Publishing a new owned record queues its owner's run, which costs nothing for text already translated. The gap filler treats an owner as a target when any of its owned records has a gap, including an owned record added or edited since the last run.

Before this, nothing translated owned records — autoTranslate() reaches only the owner's own fields — so a translated page could show its FAQs in the base language. php artisan translations:fill --all is the catch-up for records published before the fix.

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. Fields still settling count here — they are out of date.
  • 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. Fields only waiting out the settle window are left out (the report records them as settling): the next run repairs them with nobody's help, and counting them would badge every install for an hour after every edit.
  • 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.
Scripts Words the dashboard's JavaScript writes itself (toasts, alerts, picker labels) use __('…') / trans_choice('…', n) from resources/js/shared/i18n.js, keyed by the English text like a blade. The lang scan reads resources/js, and JsTranslations prints the current language's translations of those keys as one JSON block in the dashboard <head> (nothing on an English dashboard).
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.

Chinese, Korean and Japanese in PDFs

The PDFs the site renders (form submissions, invoices, contracts, proposals, donation statements, gift cards, certificates, accessibility and analytics reports) use dompdf, whose built-in DejaVu Sans has no Chinese, Korean or Japanese characters — without help, a visitor's Chinese name or a Korean message prints as empty boxes. The faces that fix it, Noto Sans SC and Noto Sans KR (static Regular, SIL Open Font License, about 17 MB), are an opt-in download, not part of every release:

  • They live in the repo at public/fonts/cjk/ but are left out of the release zip and delivered through the asset-bundle channel as the fonts-cjk bundle — the same channel as the language packs.
  • An install fetches them only when Chinese, Korean or Japanese is turned on (in the admin languages or the site languages, any zh / zh-* / ko / ja code). Adding one in Settings → Languages starts a background task (PdfFontsFetchTask) — never a download inside the save, which a 17 MB fetch could time out; the installer fetches them inline (CLI); the daily asset-bundle heal (asset-bundles:ensure) retries after that. Every other install never downloads them.
  • Every renderer builds dompdf through PdfFonts::dompdf(). dompdf picks one font per element with no per-character fallback, so it wraps each run of CJK text in a span set in the matching face (Hangul → Korean, Han and kana → Chinese) with overflow-wrap: anywhere — dompdf otherwise breaks lines only at spaces, and a Chinese sentence has none. Each document's own typography is left alone; a PDF with no CJK text, or an install without the fonts, is unchanged.
  • dompdf registers a face the first time it is used (~1 s, ~100 MB for these faces), so the fetch task and the daily heal warm the faces as soon as they arrive (PdfFonts::warm()) — a visitor's form submission never pays for it. Warming also deletes the cached copies older bundle versions or paths left behind.
  • Text in these scripts prints at regular weight even inside bold text (no bold face is shipped). dompdf's font cache is storage/framework/cache/dompdf-fonts (cms.pdf_font_cache_path moves it); the family names carry the bundle version, so a new version is never served from an old cached copy.
  • A new PDF renderer must call PdfFonts::dompdf($html, $options) instead of new Dompdf($options) — PdfCjkFontsTest fails any bare new Dompdf. To change the faces, replace the files in public/fonts/cjk/, bump PdfFonts::BUNDLE_VERSION, and release: the release script republishes every bundle on the mothership.

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 hourly 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), fewer sentences than the source, a plain-text translation with more blank-line paragraphs than its source (the model added a note, a correction or a second version), or accented letters and punctuation that came back garbled — UTF-8 read as another character set, so "¡Llámenos!" arrives as "ÂĄLlĂĄmenos!" (MisdecodedText). A rejection is retried once with the failure named in the prompt; a second failure writes nothing.

The garbled-text check exists because it happened: on 2026-10-19 about half of the Spanish translations on the managed translation lane (Claude Sonnet 5.5) came back that way, and every other check passes such a translation — its tokens, numbers and length are all intact. The cause was a request parameter, not the model on its own: AiTextService sent every Sonnet 5.5 call thinking: {type: between_tools}, and replaying the translator's exact request to the API garbled 2–3 calls in 6 with it and none without. A call with no tools now gets adaptive thinking instead (AiTextService::anthropicThinkingConfig()). The check stays as the backstop, and it refuses rather than repairs: the garbled bytes are not always a faithful misreading ("¡" came back as "ÂĄ", "ÂĦ" and "·"), and reading the last one back gives a valid but wrong "·" that would look finished. Correct text in any language — Polish capitals, Portuguese, Romanian, Vietnamese, Catalan "l·l" — is never mistaken for it.

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. The paragraph check is structural and runs at any length — a one-line special-offer excerpt once came back as its translation, the model's own "Correction to register: use formal." and a second translation; HTML sources are left to the markup check, since whitespace between tags is formatting. 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.

Language names: dashboard, switcher, translator

A predefined language has two names in Languages. The English label with its dialect ("Spanish (Mexican)") is what the dashboard shows and what every AI translation prompt targets, because the dialect steers the translation. The native name ("Español") is what visitors read in the public language switcher, from Languages::switcherLabel(). es is Mexican Spanish, the variant US visitors expect, so it reads plain "Español"; only the less common variant is qualified ("Español (España)" for es-ES).

The switcher picks the name at render time, so no saved setting has to change. A saved label that is still a stock English one (today's, or one an older release saved, such as plain "Spanish") shows the native name. A label set by hand on the install wins, and a custom language shows its own label. AiTranslator::languageLabel() goes the other way: a predefined code always uses the catalogue's English label, even when the install saved a different one, so a switcher-style "Español" can't drop the dialect from the prompt.

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 hourly 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. Running hourly rather than daily does not multiply that: a field is paid for once its edit settles, and a failing one is retried once a day. 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 hourly check to Only report it.