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.phpfor the admin SPA,resources/views/marketing/_layout.blade.phpfor the marketing site,resources/views/emails/leads/captured.blade.phpfor the lead-captured email) emitdir="rtl"on<html>when the active locale's catalog entry hasrtl => true. - Tailwind v4 logical properties carry the layout: every
ms-/me-/ps-/pe-/start-/end-/text-start/text-endutility class flips automatically based on the document direction. The codebase uses logical properties exclusively; physicalml-/mr-/pl-/pr-/left-/right-classes were swept out by an earlier codemod. - A Radix
<DirectionProvider>wraps the React app atresources/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 inresources/js/lib/direction.tsread 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" />fromresources/js/components/dir-icon.tsxso the icon itself flips. Icons whose direction is decorative (paper-plane send, undo) use the.flip-rtlCSS utility instead of swapping glyphs. - The visitor widget reads
init.agent.localeon boot and setsdir="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 itssideprop 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:
- 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. - Explicit
?locale=<slug>query (allow-listed). - The authenticated user's
users.localecolumn. pb_localecookie (set by a manual switch or the first geo/host resolution; persists across sessions, per-domain).- Geo — Cloudflare's
CF-IPCountrymapped throughCOUNTRY_TO_LOCALE. Physical location outranks the browser's language list. - The browser's
Accept-Languageheader (highest q-value wins). - 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. Setsusers.localewhen signed in and thepb_localecookie always (1-year lifetime), then reloads in the new language. - ✕ — POSTs
/locale/dismiss-suggestion, setspb_locale_dismiss=1cookie (180-day lifetime). The banner never appears again on that device.
Suppression rules (server-side, in
LocaleResolver::suggestionFor):
- Visitor already dismissed → null.
- Current locale already matches the suggestion → null.
- No
CF-IPCountryheader (local dev, non-CF deploys) or value isXX/T1→ null. - 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 foren,esandnl; a locale without them shows these messages in English. The Dutchvalidation.phpalso 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.
SsrDictionaryGatewayhands the strings to the SSR process together with the page;ssr.tsxrenders 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) readsdictionaryStrings(locale)fromresources/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();, thent('English source'). - Widget: import
tfromcore/i18n.tsand callt('English source'). Add the key toWidgetCopy::KEYSso the server materialises it into the/initpayload.
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
- Drop a
lang/<slug>.jsonfile. 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. - (Optional, but recommended) Add an entry in
app/Services/I18n/LocaleCatalog::ENTRIESwith the locale'snativename,englishname,flagemoji, andrtlflag. Without an entry, the picker still works — it just shows the locale code as the label and a 🌐 globe. - (Optional) Copy
lang/en/{auth,validation,passwords,pagination}.phpintolang/<slug>/if you want Fortify / validation messages translated. Without these files, Laravel falls through to English. - Run
php artisan test --filter=I18nTestand--filter=LocaleResolverTestto confirm the new locale doesn't introduce phantom keys. - (Optional) Update
lang/_glossary.mdwith 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.copyat/inittime, 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().