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.

Off by default — enable with one env var
The whole language layer is gated behind MULTILINGUAL_ENABLED (default false). While it's off the site is English-only: the locale picker is hidden, ?locale / saved locale / Accept-Language are ignored (every request resolves to the app locale), the geo-suggestion banner never shows, and the Translation manager is hidden (404). Set MULTILINGUAL_ENABLED=true to turn everything below on. The toggle is environment-only by design — flip it per deployment, no UI switch.

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 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. Explicit ?locale=<slug> query (allow-listed).
  2. The authenticated user's users.locale column.
  3. pb_locale cookie (set by the geo-banner switch path, persists across sessions for unauth visitors).
  4. The browser's Accept-Language header (highest q-value wins).
  5. 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:

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.

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 all present in the Dutch dictionary; 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

Where to add translations

Whenever you add a user-facing string in code: