DEV Community

Cover image for Building a Production-Grade Multilanguage System for Laravel SaaS (with Inertia + Vue)
Dmitry Isaenko
Dmitry Isaenko

Posted on

Building a Production-Grade Multilanguage System for Laravel SaaS (with Inertia + Vue)

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

For Guest Users

Guests follow a similar chain but persist to a cookie instead of the database:

  1. Check locale cookie (set for 10 years after first detection)
  2. Session
  3. Browser language
  4. IP geolocation
  5. 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',
],
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Exposed via a single route:

Route::get('/language_switch/{locale?}', [LanguageController::class, 'languageSwitch'])
    ->name('language_switch');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

PHP array files for framework-specific strings:

lang/
  en/
    auth.php
    validation.php
    pagination.php
    passwords.php
  uk/
    auth.php
    validation.php
    ...
Enter fullscreen mode Exit fullscreen mode

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;
        },
    ];
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Using Translations in Components

<template>
    <h1>{{ t('Dashboard') }}</h1>
    <p>{{ t('Welcome back') }}</p>
    <button>{{ t('Save changes') }}</button>
</template>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Implementations: DeepLTranslator and GoogleTranslator. Swap via environment variable:

'translator_default' => env('TRANSLATION_SERVICE', 'deepl'),
Enter fullscreen mode Exit fullscreen mode

REST endpoints included:

POST /translate        - translate text
GET  /translate/usage  - check API quota
GET  /translate/languages - list supported languages
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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.

laravel #vue #i18n #saas #larafoundry

Top comments (0)