Run your workspace
Languages & i18n
Pitchbar 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).
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 Pitchbar 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.
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.
Country → locale map covers Spanish-speaking Latin America + Spain (es), French Europe + Quebec (fr), Turkey (tr). 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. The
widget pass adds one extra step at the top: the agent's
language_default — admins can pin a vertical-specific
language even if the visitor's browser disagrees. The picker on the
agent form currently offers English, Dutch, Spanish, French,
German, Portuguese, Japanese, Arabic, and Chinese; the LLM answers
in the pinned language and the widget chrome loads the matching
lang/{locale}.json copy.
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.lang/_glossary.md— terminology lock so future strings translate consistently with prior runs.
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.
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().