Shipping a Multilingual SaaS: i18n Beyond Greeting the User
"Add German" is the kind of request that sounds small on the call and turns into a month of work in the codebase — if the codebase wasn't built for it. Making a SaaS multilingual is 10% translation and 90% architecture: URLs, plurals, dates, numbers, SEO, and the discipline of never hardcoding a string. Here's how we do it in Laravel + Vue projects.
Start with the route, not the strings
Pick a locale strategy before writing the first translation file. We use locale-prefixed URLs — /en/..., /de/... — because they are the only approach that plays well with SEO. A cookie or Accept-Language header is invisible to search engines and breaks link sharing; a per-locale URL keeps every language crawlable and cacheable.
// routes/web.php
Route::prefix('{locale}')
->whereIn('locale', ['en', 'de', 'es', 'fr', 'pt'])
->middleware(SetLocale::class)
->group(function () {
Route::get('/', HomeController::class);
// ...
});
Set the app locale in a middleware, not per request scattered across controllers:
public function handle(Request $request, Closure $next): Response
{
app()->setLocale($request->route('locale'));
return $next($request);
}
Make the root URL / redirect to the user's preferred locale, and keep config('app.fallback_locale') as a safety net so an untranslated key never means an empty page.
Translations: one dictionary, shared with the frontend
In a Laravel + Inertia app, the frontend and backend must read from the same dictionary, or you end up with two translation systems drifting apart. Use one source of truth — the lang/ directory — and pass the keys the Vue components need into the page:
// Controller
return Inertia::render('Checkout/Index', [
't' => [
'checkout.title' => __('checkout.title'),
'checkout.place_order' => __('checkout.place_order'),
],
]);
Components reference t.checkout.title, never a literal string. If a key is missing, you want it to break loudly in development, not silently show English in production — a CI check that scans views and components for keys missing from every locale file catches this before deploy, not after.
Plurals, dates, and numbers are the real trap
Translating words is easy; translating grammar is not. English has two plural forms, Russian three, and some languages have more. Never concatenate strings to fake plurals:
// ❌ "3 item(s) in cart"
// ✅ use Lang::choice with proper plural rules per locale
$label = Lang::choice('cart.items_in_cart', $count, ['count' => $count]);
{
"items_in_cart": "{1} 1 item in cart|[2,*] :count items in cart"
}
Dates and numbers follow their own rules. Use Carbon::locale() for dates server-side, and the Intl API in the browser; never hardcode separators or month names. 1,234.56 is correct in the US and meaningless in Germany, where it's 1.234,56.
The SEO half that no translation tool covers
Multilingual means multi-URL, and multi-URL means the search engine needs a map. For every page, emit hreflang alternates linking the same content across locales, plus a x-default pointing at the root redirect:
<link rel="alternate" hreflang="en" href="https://example.com/en/pricing">
<link rel="alternate" hreflang="de" href="https://example.com/de/preise">
<link rel="alternate" hreflang="x-default" href="https://example.com/">
Keep slugs translated or identical — pick one and stay consistent — and generate one sitemap that includes every locale's URLs. If you translate slugs, every inbound link becomes locale-dependent, so decide deliberately at the start: we generally keep product slugs the same across locales and translate only content.
What still hurts after launch
Three things every multilingual project discovers late:
- User-generated content stays in one language. Comments, support tickets, and profile text are stored as-is; only UI strings get translated. Decide this early so you don't build a fake "translate everything" assumption into the schema.
- Emails are content, not code. Transactional email templates need the same key discipline as views — and a previewer that shows each locale, because an untranslated invoice email is the fastest way to lose a customer.
- Don't translate the debugging layer. Logs, exception messages, and admin-only text should stay in one language; you'll thank yourself at 2 a.m. during an incident.
The checklist
- Locale-prefixed URLs with a redirect from
/ - One shared dictionary across backend and frontend
- CI job failing on missing translation keys
Lang::choicefor plurals,Intlfor dates and numbershreflang+ per-locale sitemap- Test suite run in at least two locales
Multilingual is a feature users notice only when it's wrong — when the invoice is in English, the plural says "1 item(s)", or the date reads backwards. Baked in from the start, it costs days; bolted on later, it costs weeks. We prefer the first option, and so does your German customer.