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.
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.
The catalog flags Arabic, Hebrew, Persian, Urdu, Pashto, Sindhi, Dhivehi, Yiddish, and Uyghur as RTL. The rendering pipeline mirrors accordingly:
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.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.<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.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.<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.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.<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".
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.
The SetLocale middleware runs after the session middleware
on every web request and walks this priority list:
?locale=<slug> query (allow-listed).users.locale column.pb_locale cookie (set by the geo-banner switch
path, persists across sessions for unauth visitors).Accept-Language header (highest q-value
wins).config/app.php.
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:
/locale/switch. Sets users.locale
when signed in and the pb_locale cookie always
(1-year lifetime), then reloads in the new language./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):
CF-IPCountry header (local dev, non-CF
deploys) or value is XX/T1 β null.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 @verbatim{{ __('Switch to :language?') }}@endverbatim
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.
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.Whenever you add a user-facing string in code:
{{ __('English source') }}.const { t } = useT();, then
t('English source').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).
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.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.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.php artisan test --filter=I18nTest and
--filter=LocaleResolverTest to confirm the
new locale doesn't introduce phantom keys.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.
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.
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.
<html lang> attribute on the marketing
layout, the admin Inertia root, and transactional emails reflects
the resolved locale on every render.agent.copy at /init
time, so there is never a moment of English flash before the
translated copy hydrates.