DEV Community

Cover image for Laravel Multilang with Inertia + React: A Real-World Guide
Abd. Asis
Abd. Asis

Posted on

Laravel Multilang with Inertia + React: A Real-World Guide

So you're building a Laravel + Inertia + React app and you need it to speak more than one language. Maybe your client wants both English and Indonesian. Maybe your boss just told you "make it multilingual" and walked away. Either way — welcome to this guide.

We're going to walk through a real implementation from an actual accounting app that serves both English and Indonesian users. No toy examples. No "hello world" translations. Actual production patterns that cover edge cases you'll definitely hit.

Let's go.


Table of Contents


Why Multilang is Trickier with Inertia

In a classic server-rendered Laravel app, translations are simple — you call __('key') in your Blade views and Laravel handles everything. PHP knows the locale, renders the strings, done.

But with Inertia + React, your views render in the browser. PHP is long gone by the time the user sees the page. So you can't just call __() in your JSX — that function doesn't exist on the client.

You need a system that:

  1. Ships translation strings to the browser
  2. Lets React components call them
  3. Keeps the locale in sync between PHP (for backend messages) and JavaScript (for UI strings)
  4. Persists the user's language preference across sessions

That's exactly what we'll build.


The Stack

  • Laravel 12 with PHP 8.4
  • Inertia v2 + React 19
  • laravel-react-i18n — the bridge between Laravel's lang/ files and your React components

Step 1: Install laravel-react-i18n

composer require laravel-lang/common
npm install laravel-react-i18n
Enter fullscreen mode Exit fullscreen mode

Then publish the config:

php artisan vendor:publish --tag=laravel-react-i18n-config
Enter fullscreen mode Exit fullscreen mode

The package works by compiling your lang/ PHP files into JSON during build time and shipping them to the browser. Smart, right?


Step 2: Wire Up the Provider in app.tsx

This is where everything starts. Your resources/js/app.tsx needs to wrap the entire app in LaravelReactI18nProvider:

// resources/js/app.tsx

import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'
import { LaravelReactI18nProvider } from 'laravel-react-i18n'

createInertiaApp({
    resolve: name => {
        const pages = import.meta.glob<{ default: any }>('./pages/**/*.{jsx,tsx}', {
            eager: true,
        })
        // ... page resolution logic
    },
    setup({ el, App, props }) {
        const root = createRoot(el)

        // Read locale from Inertia's shared props
        const initialLocale = (props.initialPage.props as any).locale || 'id'

        root.render(
            <LaravelReactI18nProvider
                locale={initialLocale}
                fallbackLocale="id"
                files={import.meta.glob('/lang/*.json', { eager: true })}
            >
                <App {...props} />
            </LaravelReactI18nProvider>
        )
    },
})
Enter fullscreen mode Exit fullscreen mode

A few things worth noting here:

  • files={import.meta.glob('/lang/*.json', { eager: true })} — this tells the provider where to find the compiled translation JSON files. Vite will bundle them at build time.
  • initialLocale is read from props.initialPage.props — that's Inertia's initial shared data, which we'll set up in the next step.
  • fallbackLocale="id" — if a key is missing in the active locale, fall back to Indonesian. Adjust this to whatever your primary language is.

Step 3: Pass Locale from Laravel to React

The locale needs to travel from PHP to JavaScript on every page load. The cleanest place to do this is in HandleInertiaRequests middleware:

// app/Http/Middleware/HandleInertiaRequests.php

use Illuminate\Support\Facades\App;

public function share(Request $request): array
{
    // Read locale from user preferences (stored in DB)
    $locale = 'id'; // default
    if (auth()->check()) {
        $preferences = auth()->user()->getPreference('general');
        $locale = $preferences['locale'] ?? 'id';
    }

    // Tell Laravel's backend to use the same locale
    // (important for __() calls on the server)
    App::setLocale($locale);

    return [
        ...parent::share($request),
        'auth' => [
            'user' => $request->user(),
        ],
        // Share locale with every Inertia page
        'locale' => $locale,
        // ... other shared data
    ];
}
Enter fullscreen mode Exit fullscreen mode

This runs on every request. So every time a page loads:

  1. Laravel reads the user's saved locale preference
  2. Sets App::setLocale() so PHP translations work
  3. Shares the locale via 'locale' => $locale so React can pick it up

That 'locale' key in the shared data is exactly what app.tsx reads as initialLocale above.


Step 4: Structure Your Translation Files

Here's a pattern that scales really well — one file per feature/model, with deeply nested keys:

lang/
  en/
    expense.php
    invoice.php
    over_receipt.php
    general.php
    sidebar-menu.php
  id/
    expense.php
    invoice.php
    over_receipt.php
    general.php
    sidebar-menu.php
Enter fullscreen mode Exit fullscreen mode

Each file covers all the strings for that feature — page titles, form labels, table headers, toast messages, error messages, everything. Here's a real example from the expense module:

// lang/en/expense.php

return [
    // Page titles
    'title' => 'Expense Data',
    'create_title' => 'Create Expense',
    'edit_title' => 'Edit Expense',

    // Table Columns
    'columns' => [
        'transaction_date' => 'Transaction Date',
        'description' => 'Description',
        'recipient' => 'Recipient',
        'status' => 'Status',
        'total' => 'Total',
    ],

    // Form
    'form' => [
        'title' => 'Create New Expense',
        'edit_title' => 'Edit Expense :number',    // <- parameter placeholder
        'pay_from' => 'Pay From',
        'select_bank_cash' => 'Select Bank/Cash',
        'recipient' => 'Recipient',
        'memo' => 'Memo',
        'grand_total' => 'Total',
        // Toast messages
        'toast_saving' => 'Saving data...',
        'toast_save_success' => 'Expense saved successfully!',
        'toast_error' => 'Error saving data!',
    ],

    // Actions
    'actions' => [
        'create' => 'Create Expense',
        'edit' => 'Edit',
        'delete' => 'Delete',
        'print' => 'Print',
        'back' => 'Back',
    ],
];
Enter fullscreen mode Exit fullscreen mode

And the matching Indonesian file:

// lang/id/expense.php

return [
    'title' => 'Data Pengeluaran',
    'create_title' => 'Buat Pengeluaran',
    'edit_title' => 'Edit Pengeluaran',

    'columns' => [
        'transaction_date' => 'Tanggal Transaksi',
        'description' => 'Deskripsi',
        'recipient' => 'Penerima',
        'status' => 'Status',
        'total' => 'Total',
    ],

    'form' => [
        'title' => 'Buat Pengeluaran Baru',
        'edit_title' => 'Edit Pengeluaran :number',
        'pay_from' => 'Bayar Dari',
        'select_bank_cash' => 'Pilih Bank/Kas',
        'recipient' => 'Penerima',
        'memo' => 'Memo',
        'grand_total' => 'Total',
        'toast_saving' => 'Sedang menyimpan data',
        'toast_save_success' => 'Pengeluaran Berhasil Disimpan!',
        'toast_error' => 'Kesalahan saat menyimpan data!',
    ],

    'actions' => [
        'create' => 'Buat Pengeluaran',
        'edit' => 'Ubah',
        'delete' => 'Hapus',
        'print' => 'Print',
        'back' => 'Kembali',
    ],
];
Enter fullscreen mode Exit fullscreen mode

The key insight: both files must be maintained in parallel. Add a key to one, add it to the other. Some teams enforce this with tests; in this project it's a discipline thing.


Step 5: Use Translations in React Components

Once the provider is set up, every React component has access to the t() function via a hook:

// resources/js/pages/expense/index.tsx

import { useLaravelReactI18n } from 'laravel-react-i18n'
import { ColumnDef } from '@tanstack/react-table'

const ExpenseIndex = ({ expenses, contacts }: ExpenseIndexProps) => {
    const { t } = useLaravelReactI18n()

    const columns: ColumnDef<Expense>[] = useMemo(
        () => [
            {
                header: t('expense.columns.transaction_date'),
                // ...
            },
            {
                header: t('expense.columns.description'),
                // ...
            },
            {
                header: t('expense.columns.total'),
                // ...
            },
        ],
        [t]  // <-- important: t is a dependency
    )

    return (
        <div>
            <h1>{t('expense.title')}</h1>
            {/* ... */}
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

The t() function signature: t('file.nested.key') — where file maps to a lang/en/file.php translation file, and dots separate nested array keys.


Step 6: Dynamic Values in Translations

Sometimes your translation string needs to include a dynamic value — like an amount, a name, or a number. Laravel's :placeholder syntax works here too.

Define the key with a :placeholder:

// lang/en/over_receipt.php

'refund_form' => [
    // The :max will be replaced at runtime
    'amount_exceeds_balance' => 'Amount cannot exceed remaining credit (:max)',
],
Enter fullscreen mode Exit fullscreen mode
// lang/id/over_receipt.php

'refund_form' => [
    'amount_exceeds_balance' => 'Jumlah tidak boleh lebih dari sisa kredit (:max)',
],
Enter fullscreen mode Exit fullscreen mode

Then in your React component, pass the values as a second argument to t():

// resources/js/components/refund-over-receipt/refund-over-receipt-form.tsx

import { useLaravelReactI18n } from 'laravel-react-i18n'
import { toast } from 'sonner'
import { currency } from '@/utilities/Currency.js'

const handleAmountChange = (value: string) => {
    const numericValue = Number(value || 0)

    // Validate: amount cannot exceed remaining credit
    if (numericValue > available_balance) {
        toast.error(
            t('over_receipt.refund_form.amount_exceeds_balance', {
                max: currency(available_balance),   // <- dynamic value
            })
        )
        return
    }

    // ... rest of handler
}
Enter fullscreen mode Exit fullscreen mode

The { max: currency(available_balance) } object maps placeholder names to their values. When the locale is en, the user sees "Amount cannot exceed remaining credit (Rp 500,000)". When it's id, they see "Jumlah tidak boleh lebih dari sisa kredit (Rp 500.000)".

Another real example — the POS session modal shows warehouse-specific messages:

// resources/js/components/pos/close-session-modal.tsx

{t('pos.close_session.no_deposit_config_description', {
    warehouse: session.warehouse?.name ?? '',
})}
Enter fullscreen mode Exit fullscreen mode

Which maps to:

// lang/en/pos.php (simplified)
'no_deposit_config_description' => 'No deposit account configured for warehouse :warehouse.',

// lang/id/pos.php
'no_deposit_config_description' => 'Tidak ada akun deposit untuk gudang :warehouse.',
Enter fullscreen mode Exit fullscreen mode

Step 7: Build a Language Switcher

Users need a way to actually switch languages. Here's the real component from the app:

// resources/js/components/language-switcher.tsx

import { Button } from '@/components/ui/button'
import {
    DropdownMenu,
    DropdownMenuContent,
    DropdownMenuItem,
    DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useLaravelReactI18n } from 'laravel-react-i18n'
import { LanguagesIcon } from 'lucide-react'

interface Language {
    code: string
    name: string
    flag: string
}

const languages: Language[] = [
    { code: 'id', name: 'Indonesia', flag: '🇮🇩' },
    { code: 'en', name: 'English', flag: '🇺🇸' },
]

export const LanguageSwitcher = () => {
    const { currentLocale, setLocale, loading } = useLaravelReactI18n()

    const handleChangeLanguage = (code: string) => {
        // Update the React i18n context immediately (instant UI feedback)
        setLocale(code)

        // Update the HTML lang attribute for accessibility
        document.documentElement.lang = code

        // Persist to backend (see next section)
        // router.post('/user/preferences', { locale: code })
    }

    const currentLanguage =
        languages.find(lang => lang.code === currentLocale()) || languages[0]

    return (
        <DropdownMenu>
            <DropdownMenuTrigger asChild>
                <Button
                    variant="ghost"
                    size="sm"
                    className="gap-2"
                    disabled={loading}    // shows while translations are loading
                >
                    <span className="text-lg">{currentLanguage.flag}</span>
                    <span className="hidden sm:inline-block text-sm">
                        {currentLanguage.name}
                    </span>
                    <LanguagesIcon className="h-4 w-4 opacity-50" />
                </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end" className="w-40">
                {languages.map(lang => (
                    <DropdownMenuItem
                        key={lang.code}
                        onClick={() => handleChangeLanguage(lang.code)}
                        className={`cursor-pointer ${
                            currentLocale() === lang.code ? 'bg-accent' : ''
                        }`}
                    >
                        <span className="text-lg mr-2">{lang.flag}</span>
                        <span>{lang.name}</span>
                    </DropdownMenuItem>
                ))}
            </DropdownMenuContent>
        </DropdownMenu>
    )
}

export default LanguageSwitcher
Enter fullscreen mode Exit fullscreen mode

Three things from useLaravelReactI18n() being used here:

  • currentLocale() — returns the active locale code (e.g., 'en' or 'id')
  • setLocale(code) — switches the locale in React's context, causing all t() calls to re-render
  • loading — true while new locale files are being loaded; use it to disable the button

Step 8: Persist Locale Across Sessions

setLocale() only updates the current browser session. If the user refreshes the page, the locale reverts to whatever the server sends. To make it stick, you need to save it on the backend.

The backend controller:

// app/Http/Controllers/UserPreferenceController.php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;

class UserPreferenceController extends Controller
{
    public function generalStore(Request $request)
    {
        $locale = $request->locale ?? 'id';

        // Whitelist only supported locales
        if (! in_array($locale, ['id', 'en'])) {
            $locale = 'id';
        }

        // Save to user's preferences (stored as JSON in DB)
        auth()->user()->setPreference('general', [
            'per_page' => $request->perPage,
            'locale' => $locale,
        ]);

        // Update the server-side locale for this request
        App::setLocale($locale);
        session(['locale' => $locale]);

        return redirect()->back();
    }
}
Enter fullscreen mode Exit fullscreen mode

The settings page where users pick their language:

// resources/js/pages/setting/general.tsx

import { router } from '@inertiajs/react'
import { useLaravelReactI18n } from 'laravel-react-i18n'

const handleLocaleChange = (locale: string) => {
    // Update React UI immediately
    setLocale(locale)

    // Persist to backend (Inertia form submit)
    router.post('/user/preferences/general', {
        locale: locale,
        perPage: currentPerPage,
    })
}
Enter fullscreen mode Exit fullscreen mode

The flow on the next page load:

  1. Inertia request comes in
  2. HandleInertiaRequests reads auth()->user()->getPreference('general')['locale']
  3. That value is set on App::setLocale() AND shared as 'locale' in Inertia props
  4. app.tsx reads it as initialLocale and passes it to LaravelReactI18nProvider
  5. Every component using t() renders in the correct language

This means locale persists across devices — it's tied to the user account, not the browser.


Step 9: Translations in the Backend

Don't forget — your backend also generates strings. Validation error messages, journal titles, observer messages, and more. These all need to be translated too, using PHP's __() function.

Journal titles in event listeners:

// app/Listeners/CreateJournalDisposalListener.php

$journal_title = __('asset.journal.disposal_title');
Enter fullscreen mode Exit fullscreen mode
// app/Listeners/PostJournalRefundLoanListener.php

$journal_title = __('refund_loan.journal.journal_title');
Enter fullscreen mode Exit fullscreen mode

Validation messages in DTOs:

// app/Data/PosProductData.php (using spatie/laravel-data)

public static function rules(): array
{
    return [
        'name' => ['required', 'string', 'max:255'],
        'product_category_id' => ['required', 'exists:product_categories,id'],
        'selling_price' => ['required', 'numeric', 'min:0'],
    ];
}

public static function messages(): array
{
    return [
        'name.required' => __('product.pos.validation.name_required'),
        'name.max' => __('product.pos.validation.name_max'),
        'product_category_id.required' => __('product.pos.validation.category_required'),
        'selling_price.required' => __('product.pos.validation.selling_price_required'),
        'selling_price.numeric' => __('product.pos.validation.selling_price_numeric'),
    ];
}
Enter fullscreen mode Exit fullscreen mode

Enum labels — a neat pattern for making enum values show translated labels:

// app/Enums/FiscalPeriodStatus.php

enum FiscalPeriodStatus: string
{
    case Open = 'open';
    case Closing = 'closing';
    case Closed = 'closed';

    public function label(): string
    {
        return match($this) {
            self::Open    => __('period_closing.status.open'),
            self::Closing => __('period_closing.status.closing'),
            self::Closed  => __('period_closing.status.closed'),
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
// app/Enums/AdjustingEntryType.php

public function label(): string
{
    return match($this) {
        self::Depreciation       => __('period_closing.adjusting_types.depreciation'),
        self::Amortization       => __('period_closing.adjusting_types.amortization'),
        self::Accrual            => __('period_closing.adjusting_types.accrual'),
        self::CurrencyRevaluation => __('period_closing.adjusting_types.currency_revaluation'),
        self::InventoryAdjustment => __('period_closing.adjusting_types.inventory_adjustment'),
        self::Other              => __('period_closing.adjusting_types.other'),
    };
}
Enter fullscreen mode Exit fullscreen mode

Since App::setLocale() is called in the middleware on every request, these __() calls automatically use the user's preferred locale.


File Organization Patterns

Here's the full picture of how this app organizes translations:

lang/
  en/
    expense.php          # all expense feature strings
    invoice.php          # invoice PDF strings
    over_receipt.php     # over receipt feature strings
    general.php          # shared status labels (paid, pending, etc.)
    sidebar-menu.php     # navigation labels
    dashboard.php        # dashboard widgets
    # ... one file per feature
  id/
    expense.php          # same structure, Indonesian
    invoice.php
    over_receipt.php
    general.php
    sidebar-menu.php
    # ...
  php_en.json            # compiled output (generated by laravel-react-i18n)
  php_id.json            # compiled output
Enter fullscreen mode Exit fullscreen mode

The JSON files are auto-generated from the PHP files when you run the build. You never edit them manually.

Key nesting conventions used in this codebase:

Namespace Usage
expense.columns.* Table column headers
expense.form.* Form field labels, placeholders, button text
expense.actions.* Action button labels
expense.stats.* Statistics card labels
expense.info.* Detail page info section
expense.journal.* Journal-related strings

This makes it obvious where to find (or add) any string. When you're looking for the "Delete" button label on the expense page, you instinctively check expense.actions.delete.


Gotchas and Things That Will Bite You

1. The JSON files need to be regenerated after you add PHP keys

When you add a new key to lang/en/expense.php, the browser won't see it until the JSON is regenerated:

php artisan lang:export   # or just run your build
npx vite build
Enter fullscreen mode Exit fullscreen mode

In development with vite dev, this usually happens automatically via the plugin. But if something seems missing, this is the first thing to check.

2. t() in useMemo needs t as a dependency

When you use t() inside useMemo or useCallback, always include t in the dependency array:

// Wrong - translation won't update when locale changes
const columns = useMemo(() => [
    { header: t('expense.columns.total') }
], []) // missing t!

// Correct
const columns = useMemo(() => [
    { header: t('expense.columns.total') }
], [t]) // t is stable but this is correct practice
Enter fullscreen mode Exit fullscreen mode

3. Backend strings must stay in sync with the locale

When the middleware sets App::setLocale($locale), all subsequent __() calls in that request use that locale. This means if you generate a journal title or validation message, it'll be in the user's language — which is usually what you want, but be intentional about it.

4. Don't use t() outside of React components

The useLaravelReactI18n hook only works inside React components. If you need translations in a utility function or class, pass the t function as a parameter, or restructure to call it from a component.

5. Keys that don't exist return the key itself

If you call t('expense.form.nonexistent_key'), the library returns 'expense.form.nonexistent_key' as a string. This is fine for catching missing keys (you'll see the raw key in the UI), but don't rely on it in production — keep your key files complete.


Cheat Sheet

// Import in any React component
import { useLaravelReactI18n } from 'laravel-react-i18n'

const MyComponent = () => {
    const { t, currentLocale, setLocale, loading } = useLaravelReactI18n()

    // Basic translation
    t('expense.title')                          // -> 'Expense Data' (en) or 'Data Pengeluaran' (id)

    // Nested keys
    t('expense.form.pay_from')                  // -> 'Pay From' or 'Bayar Dari'

    // With dynamic values
    t('over_receipt.refund_form.amount_exceeds_balance', {
        max: 'Rp 500,000'
    })                                          // -> 'Amount cannot exceed remaining credit (Rp 500,000)'

    // Get current locale
    currentLocale()                             // -> 'en' or 'id'

    // Switch locale (current session only)
    setLocale('en')

    // Check if translations are loading
    loading                                     // -> boolean
}
Enter fullscreen mode Exit fullscreen mode
// Laravel backend

// Basic translation (uses current App locale)
__('expense.title')                            // -> 'Expense Data' or 'Data Pengeluaran'

// With parameters
__('over_receipt.refund_form.amount_exceeds_balance', ['max' => 'Rp 500.000'])

// Set locale for a request
App::setLocale('en');

// Read locale
App::getLocale();                              // -> 'en'
Enter fullscreen mode Exit fullscreen mode
// lang/en/example.php

return [
    'simple' => 'A simple string',
    'with_param' => 'Hello, :name!',
    'nested' => [
        'deeply' => [
            'key' => 'Deeply nested value',
        ],
    ],
];
Enter fullscreen mode Exit fullscreen mode
// Using the above in React:
t('example.simple')                            // -> 'A simple string'
t('example.with_param', { name: 'World' })     // -> 'Hello, World!'
t('example.nested.deeply.key')                 // -> 'Deeply nested value'
Enter fullscreen mode Exit fullscreen mode

That's the full picture. Once you internalize the pattern — PHP files define the strings, JSON compilation bridges them to the browser, LaravelReactI18nProvider makes them available in React, and the middleware keeps server and client in sync — the day-to-day work is just maintaining the key files in parallel.

The rule in this codebase: never hardcode a user-facing string. Every label, every toast message, every table header, every error — goes in lang/. Your future self (and your users) will thank you.

Top comments (0)