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.
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:
- Explicit
?locale=<slug>query (allow-listed). - The authenticated user's
users.localecolumn. pb_localecookie (set by the geo-banner switch path, persists across sessions for unauth visitors).- The browser's
Accept-Languageheader (highest q-value wins). - The application default from
config/app.php.
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.
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).
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.