Most Laravel tutorials on i18n stop at "use __() helper and create translation files." That's maybe 10% of what a real SaaS application needs. What about automatic language detection? What about passing 1700+ translation strings to a Vue frontend without extra API calls? What about translating user-generated content?
I built all of this for Kohana.io - a production CRM/ERP system - and I'm now extracting it into LaraFoundry, an open-source SaaS framework for Laravel.
This post covers the complete Multilanguage module: backend, frontend, translation APIs, and testing.
The Problem
Every SaaS boilerplate handles i18n the same way: "We support it. Here's a __() function. Good luck."
But in production you need answers to:
- How do you detect a user's preferred language before they've even signed up?
- Where do you persist that preference across sessions, devices, and auth states?
- How do you efficiently pass translations to a JavaScript frontend?
- What about translating actual user content, not just UI labels?
Architecture Overview
Request
|
v
SetLocale Middleware
|-- Check user.locale (DB)
|-- Check session
|-- Check browser Accept-Language
|-- Check IP geolocation (ip-api.com)
|-- Fall back to default 'en'
|
v
app()->setLocale($locale)
|
v
HandleInertiaRequests
|-- Load lang/{locale}.json
|-- Load lang/{locale}/*.php
|-- Pass as Inertia shared props
|
v
Vue + vue-i18n
|-- Global t() function
|-- Language switcher component
Backend: The Locale Detection Chain
The core of the system is the SetLocale middleware. It runs on every request and uses a 5-step fallback chain.
For Authenticated Users
// app/Http/Middleware/SetLocale.php
public function handle(Request $request, Closure $next)
{
$locale = null;
if (auth()->check()) {
// Step 1: User's saved preference
$locale = auth()->user()->locale;
// Step 2: Session
if (!$locale) {
$locale = session('locale');
}
// Step 3: Browser Accept-Language
if (!$locale) {
$locale = $this->detectFromBrowser($request);
}
// Step 4: IP Geolocation
if (!$locale) {
$locale = $this->detectFromIp($request->ip());
}
// Step 5: Default
$locale = $locale ?: config('app.locale');
// Persist
auth()->user()->update(['locale' => $locale]);
session(['locale' => $locale]);
}
app()->setLocale($locale);
return $next($request);
}
For Guest Users
Guests follow a similar chain but persist to a cookie instead of the database:
- Check
localecookie (set for 10 years after first detection) - Session
- Browser language
- IP geolocation
- Default
The IP geolocation calls ip-api.com to detect the visitor's country, then maps it via config:
// config/app.php
'country_locale_map' => [
'US' => 'en',
'GB' => 'en',
'UA' => 'uk',
'RU' => 'uk',
'PL' => 'pl',
'DE' => 'de',
],
'browser_locale_map' => [
'en' => 'en',
'uk' => 'uk',
'ru' => 'uk',
],
Language Switching
Manual language switching is a simple controller:
// app/Http/Controllers/LanguageController.php
public function languageSwitch($locale = 'en')
{
if (array_key_exists($locale, config('app.available_languages'))) {
app()->setLocale($locale);
Session::put('locale', $locale);
if (auth()->check()) {
auth()->user()->update(['locale' => $locale]);
}
Cookie::queue('locale', $locale, 525600); // 1 year
}
return redirect()->back();
}
Exposed via a single route:
Route::get('/language_switch/{locale?}', [LanguageController::class, 'languageSwitch'])
->name('language_switch');
Translation Files Structure
LaraFoundry uses two formats:
JSON files for UI strings (the bulk of translations):
lang/
en.json -> {} (empty - English keys are the strings)
uk.json -> 1700+ key-value pairs
pl.json -> Polish translations
de.json -> German translations
PHP array files for framework-specific strings:
lang/
en/
auth.php
validation.php
pagination.php
passwords.php
uk/
auth.php
validation.php
...
The empty en.json is intentional. English strings serve as their own keys. t('Dashboard') returns 'Dashboard' for English users with no translation file needed. This means adding a new string to the app requires zero changes to translation files for the default language.
Frontend: Laravel to Vue via Inertia
This is where it gets interesting. No extra API calls, no lazy loading of translations - they travel with every Inertia response.
Sharing Translations via Inertia Props
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return [
// ...
'locale' => fn () => App::getLocale(),
'translations' => function () {
$locale = App::getLocale();
$data = [];
// Load JSON translations
$jsonPath = base_path("lang/{$locale}.json");
if (File::exists($jsonPath)) {
$data = array_merge($data,
json_decode(File::get($jsonPath), true) ?? []
);
}
// Load PHP array translations
$dir = base_path("lang/{$locale}");
if (File::exists($dir)) {
foreach (File::files($dir) as $file) {
$name = pathinfo($file, PATHINFO_FILENAME);
$data[$name] = Lang::get($name);
}
}
return $data;
},
];
}
Vue i18n Setup
In app.js, vue-i18n is initialized with the translations from Inertia props:
import { createI18n } from 'vue-i18n';
const locale = pageProps.locale || 'en';
const translations = pageProps.translations || {};
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: 'en',
messages: {
[locale]: translations,
},
});
const app = createApp({ render: () => h(App, props) })
.use(plugin)
.use(i18n)
.use(ZiggyVue);
// Global t() - no imports needed anywhere
app.config.globalProperties.t = (...args) => i18n.global.t(...args);
globalThis.t = (...args) => i18n.global.t(...args);
Using Translations in Components
<template>
<h1>{{ t('Dashboard') }}</h1>
<p>{{ t('Welcome back') }}</p>
<button>{{ t('Save changes') }}</button>
</template>
No imports. No useI18n(). No composables. Just t() and it works everywhere.
Language Switcher Component
The language switcher gets its data from Inertia layout props:
<template>
<div v-for="lang in availableLanguages" :key="lang.locale">
<img :src="lang.flagUrl" alt="" />
<div v-if="lang.locale === currentLocale" class="active-lang">
{{ lang.title }}
</div>
<a v-else :href="lang.routeUrl">
{{ lang.title }}
</a>
</div>
</template>
The backend provides everything the component needs:
// app/Services/LayoutDataService.php
public function getAvailableLanguages()
{
return collect(config('app.available_languages'))
->map(fn ($title, $locale) => [
'title' => $title,
'shortTitle' => $locale,
'flagUrl' => url("/icons/flag-{$locale}-icon.png"),
'locale' => $locale,
'routeUrl' => route('language_switch', ['locale' => $locale]),
])->toArray();
}
Bonus: Pluggable Translation API
Beyond UI translations, LaraFoundry includes a translation service for user-generated content. Two providers out of the box:
// app/Contracts/Translator.php
interface Translator
{
public function translateText(string $text, string $from, array $to): array;
public function getSupportedLanguages(): array;
public function isLanguageSupported(string $code): bool;
public function getUsageInfo(): ?array;
}
Implementations: DeepLTranslator and GoogleTranslator. Swap via environment variable:
'translator_default' => env('TRANSLATION_SERVICE', 'deepl'),
REST endpoints included:
POST /translate - translate text
GET /translate/usage - check API quota
GET /translate/languages - list supported languages
Testing
The multilanguage module has comprehensive test coverage using Pest:
it('detects locale from user profile', function () {
$user = User::factory()->create(['locale' => 'uk']);
actingAs($user)
->get('/dashboard')
->assertOk();
expect(app()->getLocale())->toBe('uk');
});
it('switches language and persists to database', function () {
$user = User::factory()->create(['locale' => 'en']);
actingAs($user)
->get('/language_switch/uk')
->assertRedirect();
expect($user->fresh()->locale)->toBe('uk');
});
it('ignores unsupported locale', function () {
actingAs($user)
->get('/language_switch/xx')
->assertRedirect();
expect($user->fresh()->locale)->toBe('en');
});
Test coverage includes:
- All 5 detection fallback levels for both auth and guest
- Language switching with DB, session, and cookie persistence
- Translation loading and merging (JSON + PHP)
- Invalid/unsupported locale handling
- Mocked DeepL and Google Translate API responses
- Inertia props validation (locale + translations present)
What's Included
| Feature | Details |
|---|---|
| Languages | EN, UK, PL, DE (2 active, 2 ready) |
| Detection | Browser + IP geolocation + fallback chain |
| Persistence | DB (auth) + Cookie (guest) + Session |
| Frontend | vue-i18n v3 + global t() + Inertia props |
| Translation files | JSON (UI) + PHP arrays (framework) |
| Content translation | DeepL + Google Translate API |
| UI | Language switcher with flags (auth + guest) |
| Testing | Pest tests for all detection paths |
Wrapping Up
Most SaaS i18n implementations are either too simple (just __()) or too complex (dedicated translation management services, CDN-hosted locale files, runtime language switching via WebSockets).
LaraFoundry's approach sits in the middle: comprehensive enough for production, simple enough to understand in 30 minutes. The entire system is ~10 files and zero external dependencies beyond vue-i18n.
If you're building a SaaS with Laravel + Inertia + Vue and need multilanguage support, this module gives you everything you need.
LaraFoundry is an open-source Laravel SaaS framework, being built in public and extracted from a production CRM/ERP.
- GitHub: github.com/dmitryisaenko/larafoundry
- Website: larafoundry.com
Top comments (0)