B Blengi docs

Run your workspace

Languages & i18n

Blengi ships translations for 130+ languages out of the box, with English, Spanish, French, and Turkish covered end-to-end and the long tail (German, Hindi, Bengali, Arabic, Hebrew, Chinese, Japanese, Korean, Vietnamese, every popular European/Asian/African language, plus RTL scripts) covered for UI chrome — buttons, navigation, forms, status pills. Every key not yet translated for a given locale falls back to the English source automatically, so the UI never breaks while individual translators contribute the long tail.

What localises on the public marketing site

The marketing surface a visitor sees first is fully translatable: the top nav menu (Home, Pricing, How it works, Integrations, Documentation), the home page, the pricing page (including the operator-editable plan cards), and the integrations page. The server runs every one of these payloads through MarketingTranslator at render time, looking each string up in the visitor's locale dictionary (shipped lang/{locale}.json plus admin overrides from the Translation Manager). A string with no entry falls back to English, so a partially-translated locale never breaks — it just shows English for the untranslated bits until someone fills them in (by hand or via the DeepL bulk-translate button).

The overrides are read once per request (#753): TranslationOverrides is bound as scoped, so one instance serves the whole request (or queued job) and remembers each locale's map and the overrides version after the first read. Before, every looked-up string read the whole map from the cache again — the database cache on blengi — and a Dutch homepage with a few hundred strings took 2–4.5 seconds to build. A save in the Translation Manager clears the memory in the same request and bumps the shared version, so every worker serves the new text from its next request.

Because these strings are content, not hard-coded t() keys, they are surfaced into the Translation Manager via MarketingCopy so they can be edited per language alongside the regular UI keys.

Adding a new language is just dropping a file

The LocaleResolver::supported() service auto-discovers locales by scanning lang/*.json at request time. To add a new language, drop a lang/<code>.json file (e.g. lang/sv.json for Swedish) — no code change, no migration, no service restart. The next page load picks it up, the picker modal lists it, the SetLocale middleware accepts ?locale=sv, and it shows up in every API response that enumerates supported locales.

The picker pulls metadata (native name, English name, flag emoji, RTL flag) from App\Services\I18n\LocaleCatalog::ENTRIES — a curated list of 130+ popular languages. If you ship a JSON file for a code that isn't in the catalog, it still works — the picker falls back to the code itself, a 🌐 globe emoji, and LTR direction. Contributing the catalog metadata just upgrades the visual.

Right-to-left languages

The catalog flags Arabic, Hebrew, Persian, Urdu, Pashto, Sindhi, Dhivehi, Yiddish, and Uyghur as RTL. The rendering pipeline mirrors accordingly:

  • The Blade root templates (resources/views/app.blade.php for the admin SPA, resources/views/marketing/_layout.blade.php for the marketing site, resources/views/emails/leads/captured.blade.php for the lead-captured email) emit dir="rtl" on <html> when the active locale's catalog entry has rtl => true.
  • Tailwind v4 logical properties carry the layout: every ms-/me-/ps-/pe-/start-/end-/text-start/text-end utility class flips automatically based on the document direction. The codebase uses logical properties exclusively; physical ml-/mr-/pl-/pr-/left-/right- classes were swept out by an earlier codemod.
  • A Radix <DirectionProvider> wraps the React app at resources/js/app.tsx, so every Radix primitive (DropdownMenu, Popover, Tooltip, Select, Sheet, ContextMenu) gets correct alignment + animation direction without per-component code.
  • The useIsRtl() / useDirection() hooks in resources/js/lib/direction.ts read the current locale's RTL flag from the shared i18n catalog. Use them when a component needs explicit JS direction logic.
  • Directional icons (back, forward, chevrons) wrap with <DirArrow direction="forward|back" /> from resources/js/components/dir-icon.tsx so the icon itself flips. Icons whose direction is decorative (paper-plane send, undo) use the .flip-rtl CSS utility instead of swapping glyphs.
  • The visitor widget reads init.agent.locale on boot and sets dir="rtl" on its shadow root if the locale is RTL — every Tailwind logical-property class inside the widget then flips like the admin SPA.
  • The <Sidebar> component defaults its side prop to the visual start edge based on direction (side="left" in LTR, side="right" in RTL), so a vanilla <Sidebar /> always pins to the visual start.

The tests/Feature/I18n/RtlDirTest.php regression asserts that every RTL locale produces dir="rtl" on the admin Inertia root, the marketing layout, and the lead-captured email; LTR locales conversely produce dir="ltr".

Locale picker modal

Both the admin shell and the marketing site open a searchable Dialog when you click the language pill. The list shows native name + English name + flag for every locale, filterable by code or name. Same component on both surfaces. With 130+ entries, the old dropdown was unscrollable — the modal scales to thousands of languages.

Resolution order

The SetLocale middleware runs after the session middleware on every web request and walks this priority list:

  1. A locale path prefix (/nl/pricing) — the most explicit signal, resolved in the global middleware so it's active before Inertia shares props / the root view renders.
  2. Explicit ?locale=<slug> query (allow-listed).
  3. The authenticated user's users.locale column.
  4. pb_locale cookie (set by a manual switch or the first geo/host resolution; persists across sessions, per-domain).
  5. Geo — Cloudflare's CF-IPCountry mapped through COUNTRY_TO_LOCALE. Physical location outranks the browser's language list.
  6. The browser's Accept-Language header (highest q-value wins).
  7. The application default from config/app.php.

Geo auto-switch

On a visitor's first request the country (CF-IPCountry) auto-selects the language — NL/BE → nl, DE/AT/CH → de, the Spanish/French/Turkish families as mapped in COUNTRY_TO_LOCALE. The first resolution is written to pb_locale so it sticks and resolves once; a manual switch overwrites the same cookie, so an explicit choice always wins afterwards. For a mapped country the legacy suggestion banner self-suppresses (active locale already matches) — auto-apply replaces suggest.

Country domains (blengi.nl → blengi.com/nl)

An operator can point per-language country domains at this app and each is 301-redirected to the primary domain's locale path: blengi.nl/pricing → blengi.com/nl/pricing, blengi.de → blengi.com/de, blengi.be → blengi.com/nl (Belgium → Dutch). The destination already renders translated via the /nl path-prefix routing below.

RedirectCountryDomainToLocalePath (global web middleware) does this: when the request host differs from APP_URL's host and the host's ccTLD maps to a locale (LocaleResolver::localeFromHost — the ccTLD equals the ISO country code, resolved through COUNTRY_TO_LOCALE), it 301s to {APP_URL}/{locale}{path}?{query}. Path and query survive; api / widget / billing/webhook / up prefixes are skipped so cross-origin embeds and webhooks on a country host pass through. Loop-safe — the target is always the primary host, which is never rewritten. Generic: no operator domains are hardcoded, and a gTLD (.com/.io) maps to nothing, so the primary domain just serves.

Deployment (aapanel / nginx). Bind every country domain to the same app, then remove any web-server redirect on them — the app owns the redirect now (a panel rule sending the domain to ?app_locale=… is not a Blengi feature and conflicts). Keep APP_URL on the primary domain (it's the redirect target + email / widget origin), issue SSL for every domain, and leave SESSION_DOMAIN unset.

Locale path prefix (/nl, /de)

The public marketing surface is served both bare (/pricing) and under an optional locale prefix (/nl/pricing) — every marketing route is registered twice, the localised copy carrying a .l name suffix so the bare routes stay the canonical route() targets. Both return 200, so deleting the /nl keeps working and the session language stays put. SetLocaleFromPath persists the segment and pins URL::defaults(['locale']) so links stay prefixed inside a localised URL. The admin app is intentionally NOT prefixed (Wayfinder/SSR churn, zero indexing benefit) — it still picks the language from geo via the session.

Switching language (PATCH /locale/switch, used by every picker) saves the choice and then opens the same page in the new language: /nl/pricing → /pricing or /de/pricing, query string kept (LocaleResolver::twinUrl, #760). Before, it went "back" to /nl/pricing, where the prefix won and the page stayed Dutch. Pages without a localised twin (the app) still go back.

SEO: marketing pages emit a locale-correct <link rel="canonical"> plus hreflang alternates across the marketed locales (the default + every COUNTRY_TO_LOCALE target, narrowed to the admin’s enabled set via supported() so we never advertise a language we don’t serve) and an x-default, built by LocaleResolver::hreflangFor. The same alternates appear as xhtml:link entries per URL in /sitemap.xml. Admin pages emit neither.

Geo-suggested locale banner

When Cloudflare's CF-IPCountry header maps to a locale we ship and the visitor's current locale is something else, a slim banner appears asking "Switch to Español?" The banner never auto-switches — surprise = bad UX. Two actions:

  • Switch to <language> — PATCHes /locale/switch. Sets users.locale when signed in and the pb_locale cookie always (1-year lifetime), then reloads in the new language.
  • ✕ — POSTs /locale/dismiss-suggestion, sets pb_locale_dismiss=1 cookie (180-day lifetime). The banner never appears again on that device.

Suppression rules (server-side, in LocaleResolver::suggestionFor):

  1. Visitor already dismissed → null.
  2. Current locale already matches the suggestion → null.
  3. No CF-IPCountry header (local dev, non-CF deploys) or value is XX/T1 → null.
  4. Country isn't in COUNTRY_TO_LOCALE → null.

The country → locale map (COUNTRY_TO_LOCALE) covers Spain and Spanish-speaking Latin America plus Puerto Rico (es), France, Luxembourg and Monaco (fr), the Netherlands and Belgium (nl), Germany, Austria and Switzerland (de), and Turkey (tr). Canada is not in it. Other countries get no suggestion.

Banner mounts on the admin SPA (app-sidebar-layout), on every Inertia marketing page (marketing-shell), and via Blade {{ __('Switch to :language?') }} in resources/views/marketing/_layout.blade.php for any future Blade-rendered marketing pages.

The same LocaleResolver service powers the widget, with its own order (forWidget): the host page's language (the embed's data-locale, else the page's <html lang>), then the agent's language_default, then the browser's Accept-Language, then the app default. The widget chrome loads the matching lang/{locale}.json copy. The picker on the AI employee (Personality › Language) offers English, Dutch, Spanish, French, German, Portuguese, Japanese, Arabic and Chinese. The reply language is decided separately, on every message (card #768): the AI replies in the language the visitor's message is written in (MessageLanguage, which reads Dutch, English, German and French function words). A message too short to tell, such as "ja" or "44", follows the visitor's last few messages. When those are silent too, the conversation's language decides, then language_default. That reply language is stated twice in the AI's instructions: near the top, and again as one of the last lines (card #820) — instructions, notes and sources written in Dutch made the AI answer English questions in Dutch while the rule sat only at the top. A fixed fallback sentence (strict mode) is translated for a visitor who writes another language, and the small "No," → "Nee," clean-up at the start of a reply only runs when the reply is Dutch.

A conversation's language (conversations.lang) is set when it starts: the host page's language (data-locale or <html lang>) when it is a known language, else the browser's Accept-Language (any language in the LocaleCatalog; Dutch was missing until #768), else language_default. Most visitors on Dutch shops run English browsers, which is why the page wins. The same value picks the language of handoff lines that cannot be read from the message, and filters buttons and fixed answers that are set to one language.

Owner-written widget strings follow the widget locale through the same lookup (TranslationLoader: Translation manager overrides first, then lang/{locale}.json): the launcher label, placeholder, opening message, greeting, inline-block copy and — since card #774 — the persona's name and role. A role of "AI employee" shows as "AI-medewerker" on a Dutch page and the default name "Assistant" as "Assistent"; a real name such as "Blengi" has no entry and stays as written. The pricing card's "/ month" and "/ year" come from the widget dictionary too.

Texts without a translation yet (card #839). When the page's language differs from the AI employee's language, init collects the owner texts that have no entry for the page's language — launcher label, placeholder, opening message, greeting, starter questions, role, inline-block copy and the invitation button — and queues one TranslateOwnerCopyJob. It sends them to DeepL (the source language is detected per text, and the form of address follows the AI employee's u/je setting — or, when that is unset, the form the widget's own copy uses in that language: informal Dutch, formal German and French) and stores the results as unreviewed machine translations, the same way the Translation manager's auto-translate does. The visitor who triggered it sees the texts as written; every visitor after that sees the page's language, and an owner can correct a line in the Translation manager. At most 20 texts of up to 300 characters per run, and the same set is queued at most once every 6 hours. Page questions are left out: they are written in the page's language already. Without a DeepL key nothing is translated. A page without <html lang> falls back to a language prefix in its path (/nl/…, /de-de/…).

Marketing site copy

Marketing pages are operator-editable content (the JSON in Settings → System → Marketing content), not i18n keys — so they're localised at render time by MarketingTranslator: every string in the resolved payload is looked up in lang/{locale}.json and rendered translated when an entry exists. The shipped English defaults are present in every supported locale's dictionary (all 131 non-English languages carry the full marketing copy set; a regression test enforces Dutch parity for all of them), so the marketing site renders natively in whichever language the visitor picks. Any copy you customise in the editor simply has no dictionary entry and renders verbatim in every locale. To localise custom copy, add it as a key/translation pair to the locale's JSON file. The admin content editor always shows the canonical English source.

Where the strings live

  • lang/{locale}.json — the JSON dictionary used by the admin React SPA, the widget, the marketing Blade pages, and the mail templates. English source strings act as the keys.
  • lang/{locale}/auth.php, validation.php, passwords.php, pagination.php — Laravel's namespaced PHP files for built-in framework messages: a failed login, a missing field, a password-reset link. They ship for en, es and nl; a locale without them shows these messages in English. The Dutch validation.php also names the auth fields (e-mailadres, wachtwoord, …), so a Dutch error reads "Wachtwoord is verplicht."
  • lang/_glossary.md — terminology lock so future strings translate consistently with prior runs.

How the strings reach the browser

The admin app and the marketing pages read their strings from one dictionary per language: lang/{locale}.json with the Translation Manager's overrides on top. Pages do not carry it. Each page's shared i18n prop holds only its address, i18n.dictionary.url = /i18n/{locale}.{version}.js, where the version is a fingerprint of the strings themselves. The root view loads that file with a deferred script just before the app bundle, so the strings are in place before the page becomes interactive, and the browser keeps the file for a year. Before card #754 the whole dictionary travelled inside every page: on blengi.com/nl that was 565 KB of the 633 KB HTML, placed ahead of the visible content and downloaded again on every click.

  • An edit changes the address. A save in the Translation Manager (or a deploy that changes a language file) gives the dictionary a new fingerprint, so nobody keeps stale text. An outdated address still answers, with today's strings and no caching.
  • Server-side rendering still writes translated HTML. SsrDictionaryGateway hands the strings to the SSR process together with the page; ssr.tsx renders with them and removes them before Inertia writes the page data into the HTML.
  • Switching language shows no English in between. When a page response names a dictionary the browser does not have yet (another language, or a newer version), the app loads it before it shows the page.
  • The file behaves like a static asset: no session, cookies or CSRF check, and Cache-Control: public, max-age=31536000, immutable. Unknown languages return 404.
  • In code, keep using useT(). Code outside the Inertia tree (the flash toasts) reads dictionaryStrings(locale) from resources/js/lib/i18n-dictionary.ts.

The chat widget is not affected: it receives its own strings in the /init payload (see WidgetCopy::KEYS below).

Where to add translations

Whenever you add a user-facing string in code:

  • Backend Blade: wrap with English source.
  • Admin React: import the hook and call const { t } = useT();, then t('English source').
  • Widget: import t from core/i18n.ts and call t('English source'). Add the key to WidgetCopy::KEYS so the server materialises it into the /init payload.

Translated keys are added to lang/<locale>.json. Missing keys silently fall back to the English source — the UI never breaks. The tests/Feature/I18nTest::every supported locale ships a parseable JSON dictionary regression asserts every shipped locale file is valid JSON with non-empty string values; a sister test prevents typo-introduced keys (any key in a locale file that is absent from en.json fails CI).

Filling a language fast with DeepL

You don't have to translate by hand. The Translation manager (/admin/translations) has an Auto-translate button that machine-translates a language with DeepL, then lets you review the results in place. Add a DeepL API key under Settings → System → DeepL translation (or the DEEPL_API_KEY env var) and the button appears for every DeepL-supported language. By default it only fills the strings still showing English, placeholders are protected, and machine results land badged “unreviewed” until you approve them — see the Translation manager page for the full workflow.

Proofreading a language in a spreadsheet

Machine translation gets short interface labels wrong more often than sentences: without context DeepL turned Primary into "primary school" and Port into "harbour". Reviewing thousands of strings in the browser is slow, so export them instead:

php artisan translations:export nl

This writes a CSV to storage/app/private/ (or to --path=) with one row per English source string: the translation that is actually shown, where it came from (file for the shipped lang/nl.json, deepl or human for a Translation manager override, missing when English shows) and whether it was approved. Unreviewed machine translations come first, and an empty correction column is there for the proofreader. Corrections go back in through the Translation manager.

Adding a new locale

  1. Drop a lang/<slug>.json file. That alone makes the locale appear in the picker and accept ?locale=<slug> via the SetLocale middleware. LocaleResolver::supported() auto-discovers the file on the next request.
  2. (Optional, but recommended) Add an entry in app/Services/I18n/LocaleCatalog::ENTRIES with the locale's native name, english name, flag emoji, and rtl flag. Without an entry, the picker still works — it just shows the locale code as the label and a 🌐 globe.
  3. (Optional) Copy lang/en/{auth,validation,passwords,pagination}.php into lang/<slug>/ if you want Fortify / validation messages translated. Without these files, Laravel falls through to English.
  4. Run php artisan test --filter=I18nTest and --filter=LocaleResolverTest to confirm the new locale doesn't introduce phantom keys.
  5. (Optional) Update lang/_glossary.md with a column so future translations stay coherent.

There is no code change required to enable a locale. The previous LocaleResolver::SUPPORTED constant has been removed; the picker, validation rules, and middleware all read from LocaleResolver::supported() which returns the auto-discovered set.

Per-user vs per-visitor locale

Admins and operators set their preferred language at /settings/locale. The choice is persisted to users.locale and applies on every subsequent request, including emails sent on their behalf.

Visitors see the agent's language_default by default. If the agent has no language pinned, the widget falls back to the visitor's browser locale, then to English. There is no in-widget locale switcher — that's a deliberate decision so the visitor experience matches what the admin configured.

Coverage

Every customer-facing surface — visitor widget, marketing site (home, pricing, how-it-works, integrations, changelog, privacy, terms), the admin SPA (every customer-admin and platform-admin page including agent customize, sources, curated answers, knowledge, playground, behavior, CTAs, leads, conversations, experiments, billing, integrations, analytics, workflows, every settings tab), the auth + onboarding flows, transactional emails, and validation messages — is wired through useT() or __(). The English source (lang/en.json) is the source of truth and currently holds about 2,300 keys.

Per-locale coverage varies. en, es, fr, and tr are fully translated end-to-end (every key in en.json has a localised value). The other 130-ish auto-discovered locale files start with the most-common UI chrome translated (~130 keys: buttons, navigation, forms, status pills) and expand from there as translators contribute. Keys not yet translated for a given locale fall back to the English source via Laravel's standard JSON-key behaviour, so the UI never breaks; users see partial translation while the long tail fills in.

To check coverage for a locale at any time:

node -e 'const fs=require("fs"); const en=JSON.parse(fs.readFileSync("lang/en.json")); const m=JSON.parse(fs.readFileSync("lang/<code>.json")); const total=Object.keys(en).length; const translated=Object.keys(en).filter(k=>k in m && m[k]!==en[k]).length; console.log(`${translated}/${total} = ${Math.round(translated/total*100)}% covered`);'

The documentation pages under resources/views/documentation/pages/ stay in English by policy — translating dense technical writeups is a copywriting project, not engineering. Track follow-up on the Kanban board if a specific deal needs translated docs.

Browser SEO + accessibility

  • The <html lang> attribute on the marketing layout, the admin Inertia root, and transactional emails reflects the resolved locale on every render.
  • Validation messages, password-reset emails, and Fortify auth messages all flow through Laravel's translator — they pick up the user's locale automatically.
  • The widget's first paint already speaks the right language — the server materialises agent.copy at /init time, so there is never a moment of English flash before the translated copy hydrates.

Choosing which languages are available

130+ languages is a lot to show every visitor. A super_admin can curate the offered set under Settings → System → Languages: a searchable list of every installed language with a toggle each, plus Enable all / Disable all. Disabled languages disappear everywhere the picker is read — the settings language switcher, the geo-suggested-locale banner, locale validation, and the widget's language resolution.

  • English is always on — it's the fallback contract and can't be disabled (its toggle is locked).
  • Enable all stores no restriction, so any language you drop in later is automatically available too.
  • Restricting the set stores the explicit list; a user whose saved locale is later disabled simply falls back to English on their next request.

Mechanically: the choice is saved on app_settings.enabled_locales (null = unrestricted) and read into config('app.enabled_locales') at boot. LocaleResolver::allInstalled() is the raw on-disk scan; LocaleResolver::supported() is that set narrowed to the enabled locales — and everything else reads through supported().