Complete, copy-paste ready code for implementing frontend localization in your project.
<?php
declare(strict_types=1);
namespace App\Enums;
enum Locale: string
{
case EN = 'en';
case NL = 'nl';
/**
* @return array<string>
*/
public static function values(): array
{
return array_map(static fn (self $case) => $case->value, self::cases());
}
/**
* Get the locale from the cookie value.
*
* @param array<mixed>|string|null $cookieValue
*/
public static function fromCookie(array|null|string $cookieValue, Locale $default = self::EN): self
{
if (is_array($cookieValue)) {
$cookieValue = $cookieValue[0];
}
if (! $cookieValue || ! in_array($cookieValue, self::values(), true)) {
return $default;
}
return self::from($cookieValue);
}
}Customization: Add/remove locale cases as needed.
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Enums\Locale;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
final class HandleLocale
{
/**
* Handle an incoming request.
*
* @param Closure(Request):Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$userLocale = $request->cookie('locale', App::getLocale());
// Validate locale
$isValid = in_array(
$userLocale,
Locale::values(),
true
);
if (! $isValid) {
$userLocale = App::getLocale();
}
View::share('locale', $userLocale);
App::setLocale($userLocale);
return $next($request);
}
}Registration: Add to your bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
HandleLocale::class,
// ... other middleware
]);
})import i18n from 'i18next';
import HttpBackend from 'i18next-http-backend';
import { initReactI18next } from 'react-i18next';
export const initI18n = async (lng: string) => {
await i18n
.use(HttpBackend)
.use(initReactI18next)
.init({
lng: lng || import.meta.env.APP_LOCALE,
fallbackLng: import.meta.env.APP_FALLBACK_LOCALE,
debug: import.meta.env.MODE !== 'production',
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/lang/processed_{{lng}}.json',
},
});
};
export default i18n;Dependencies to install:
npm install i18next i18next-http-backend react-i18nextimport i18n, { CallbackError, ResourceKey } from 'i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import { initReactI18next } from 'react-i18next';
const files = import.meta.glob('/lang/processed_*.json', { eager: true });
const loadResources = (
language: string,
namespace: string,
callback: (
err: CallbackError,
resources: ResourceKey | boolean | null | undefined,
) => void,
) => {
const key = `/lang/processed_${language}.json`;
if (files[key]) {
callback(
null,
(files[key] as { default: Record<string, unknown> }).default,
);
} else {
callback(null, {});
}
};
export const initI18n = async (lng?: string) => {
await i18n
.use(resourcesToBackend(loadResources))
.use(initReactI18next)
.init({
lng: lng || import.meta.env.APP_LOCALE,
fallbackLng: import.meta.env.APP_FALLBACK_LOCALE,
debug: import.meta.env.MODE !== 'production',
interpolation: {
escapeValue: false,
},
});
};
export default i18n;Dependencies to install:
npm install i18next-resources-to-backendimport { PageProps } from '@/types/global';
import { usePage } from '@inertiajs/react';
import i18n from 'i18next';
import React from 'react';
import Locale = App.Enums.Locale;
export function useLocale() {
const page = usePage<PageProps>();
const initialLocale = React.useRef(page.props.locale);
const [locale, setLocale] = React.useState<Locale>(initialLocale.current);
const availableLocales: Locale[] = ['en', 'nl'];
const setLocaleState = React.useCallback((newLocale: Locale) => {
setLocale(newLocale);
i18n.changeLanguage(newLocale);
localStorage.setItem('locale', newLocale);
if (typeof document !== 'undefined') {
document.documentElement.lang = newLocale;
}
}, []);
const updateLocale = React.useCallback(
async (newLocale: Locale) => {
setLocaleState(newLocale);
document.cookie = `locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}`;
},
[setLocaleState],
);
React.useEffect(() => {
const savedLocale = localStorage.getItem('locale') as Locale | null;
setLocaleState(savedLocale || initialLocale.current);
}, [setLocaleState]);
return { locale, updateLocale, availableLocales } as const;
}Notes:
- Update
availableLocalesto match your locales - Requires Inertia.js props type with
localefield - Requires TypeScript global
App.Enums.Localetype
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { ChevronDownIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Locale = App.Enums.Locale;
export function LocaleToggle() {
const { locale, updateLocale, availableLocales } = useLocale();
const { t } = useTranslation();
const getNextLocale = (current: Locale): Locale => {
const index = availableLocales.findIndex((loc) => loc === current);
return availableLocales[(index + 1) % availableLocales.length];
};
const getLocaleIconName = (loc: Locale): string => {
return loc === 'en' ? 'gb' : loc;
};
const handleCycle = () => {
const next = getNextLocale(locale);
updateLocale(next);
};
return (
<ButtonGroup>
<Button
variant="outline"
onClick={handleCycle}
size="icon"
className={'w-10.5'}
>
<span className={cn('fi', `fi-${getLocaleIconName(locale)}`)} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className={'w-6'}>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{availableLocales.map((option) => (
<DropdownMenuItem
key={option}
onClick={() => updateLocale(option)}
>
<span
className={cn(
'fi w-4',
`fi-${getLocaleIconName(option)}`,
)}
/>
{t(`locale.${option}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
);
}Dependencies:
@headlessui/reactor custom dropdownlucide-reactfor iconsflag-iconsfor language flags (optional)
Customize:
- Replace
Button,ButtonGroup,DropdownMenucomponents with your UI library - Update
getLocaleIconName()to match your icon/flag system - Change
w-10.5,w-6class names to your sizing system
import fs from 'fs';
import path from 'path';
import { fromString } from 'php-array-reader';
import { Plugin } from 'vite';
function processTranslations(
data: Record<string, unknown>,
): Record<string, unknown> {
const newData: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
const processedKey = key.replace(/:(\w+)/g, '{{$1}}');
const processedValue =
typeof value === 'string'
? value.replace(/:(\w+)/g, '{{$1}}')
: value;
if (
typeof processedValue === 'string' &&
processedValue.includes('|')
) {
const parts = processedValue.split('|');
// Assume first is singular (_one), last is plural (_other)
const baseKey = processedKey.replace(/\berrors\b/g, 'error');
newData[`${baseKey}_one`] = parts[0];
newData[`${baseKey}_other`] = parts[parts.length - 1];
} else {
newData[processedKey] = processedValue;
}
}
return newData;
}
export function laravelI18n(langDir = 'lang'): Plugin {
const generatedFiles: string[] = [];
const clean = () => {
generatedFiles.forEach(
(file) => fs.existsSync(file) && fs.unlinkSync(file),
);
generatedFiles.length = 0;
};
const processTranslationsFromFiles = () => {
if (!fs.existsSync(langDir)) return;
const translations: Record<string, Record<string, unknown>> = {};
// Parse PHP files
const locales = fs
.readdirSync(langDir)
.filter((f) => fs.statSync(path.join(langDir, f)).isDirectory());
locales.forEach((locale) => {
translations[locale] = {};
const getFiles = (dir: string, prefix = '') => {
fs.readdirSync(dir).forEach((file) => {
const fullPath = path.join(dir, file);
if (fs.statSync(fullPath).isDirectory()) {
getFiles(
fullPath,
`${prefix}${file.replace('.php', '')}.`,
);
} else if (file.endsWith('.php')) {
const phpObj = fromString(
fs.readFileSync(fullPath, 'utf-8'),
);
const flatten = (obj: unknown, baseKey = '') => {
Object.entries(
obj as Record<string, unknown>,
).forEach(([k, v]) => {
const newKey = baseKey ? `${baseKey}.${k}` : k;
if (
v &&
typeof v === 'object' &&
!Array.isArray(v)
)
flatten(v, newKey);
else
translations[locale][
`${prefix}${file.replace('.php', '')}.${newKey}`
] = v;
});
};
flatten(phpObj);
}
});
};
getFiles(path.join(langDir, locale));
});
// Merge JSON files
const files = fs
.readdirSync(langDir)
.filter((f) => f.endsWith('.json') && !f.startsWith('processed_'));
files.forEach((file) => {
const locale = file.includes('php_')
? file.split('_')[1].replace('.json', '')
: file.replace('.json', '');
const data = JSON.parse(
fs.readFileSync(path.join(langDir, file), 'utf-8'),
) as Record<string, unknown>;
if (!translations[locale]) translations[locale] = {};
Object.assign(translations[locale], data);
});
// Process and save
Object.entries(translations).forEach(([locale, data]) => {
const processed = processTranslations(data);
const outPath = path.join(langDir, `processed_${locale}.json`);
fs.writeFileSync(outPath, JSON.stringify(processed, null, 2));
if (!generatedFiles.includes(outPath)) generatedFiles.push(outPath);
});
};
return {
name: 'laravel-i18n',
config: () => processTranslationsFromFiles(),
handleHotUpdate: ({ file, server }) => {
if (
file.endsWith('.php') ||
(file.endsWith('.json') &&
file.includes(`/${langDir}/`) &&
!file.includes('processed_'))
) {
processTranslationsFromFiles();
server.ws.send({ type: 'full-reload' });
}
},
configureServer: () => {
process.on('exit', clean);
},
};
}Dependencies to install:
npm install php-array-readerAdd to your existing vite.config.js:
import { laravelI18n } from './resources/js/plugins/laravel-i18n.ts';
export default defineConfig({
plugins: [
// ... other plugins
laravelI18n()
],
});Add i18n initialization to your Inertia setup:
import { I18nextProvider } from 'react-i18next';
import { default as i18n, initI18n } from './lib/i18n.client';
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
resolve: (name) =>
resolvePageComponent(
`./pages/${name}.tsx`,
import.meta.glob('./pages/**/*.tsx'),
),
setup({ el, App, props }) {
const pageProps: PageProps = props.initialPage.props;
initI18n(pageProps.locale).then(() => {
hydrateRoot(
el,
<I18nextProvider i18n={i18n}>
<App {...props} />
</I18nextProvider>,
);
});
},
});import { I18nextProvider } from 'react-i18next';
import { default as i18n, initI18n } from './lib/i18n.server';
createServer(async (page) => {
const pageProps: PageProps = page.props;
await initI18n(pageProps.locale);
return createInertiaApp({
page,
render: ReactDOMServer.renderToString,
setup({ App, props }) {
return (
<I18nextProvider i18n={i18n}>
<App {...props} />
</I18nextProvider>
);
},
});
});import { PageProps as InertiaPageProps } from '@inertiajs/core';
declare global {
namespace App {
interface Enums {
Locale: 'en' | 'nl';
}
}
}
export interface PageProps extends InertiaPageProps {
locale: App.Enums.Locale;
// ... other props
}import { useTranslation } from 'react-i18next';
export function MyComponent() {
const { t } = useTranslation();
return (
<div>
<h1>{t('pages.dashboard.title')}</h1>
<p>{t('pages.dashboard.welcome')}</p>
</div>
);
}const { t } = useTranslation();
const message = t('welcome.user', { name: 'John' });
// Translation: "welcome.user": "Welcome {{name}}"
// Result: "Welcome John"const { t } = useTranslation();
const text = t('item', { count: 5 });
// Translation: "item_one": "1 item", "item_other": "{{count}} items"
// Result: "5 items"import { LocaleToggle } from '@/components/locale-toggle';
export function Header() {
return (
<header>
<LocaleToggle />
</header>
);
}lang/
├── en/
│ ├── messages.php
│ ├── validation.php
│ └── auth.php
├── nl/
│ ├── messages.php
│ ├── validation.php
│ └── auth.php
├── en.json
├── nl.json
├── processed_en.json (generated)
└── processed_nl.json (generated)
<?php
return [
'pages' => [
'dashboard' => [
'title' => 'Dashboard',
'welcome' => 'Welcome back',
],
],
'buttons' => [
'submit' => 'Submit',
'cancel' => 'Cancel',
],
'item' => '1 item|:count items',
];{
"locale.en": "English",
"locale.nl": "Dutch",
"errors.validation.required": "This field is required",
"errors.validation.email": "This must be a valid email"
}APP_LOCALE=en
APP_FALLBACK_LOCALE=en
export default defineConfig({
define: {
'import.meta.env.APP_LOCALE': JSON.stringify(
process.env.APP_LOCALE || 'en'
),
'import.meta.env.APP_FALLBACK_LOCALE': JSON.stringify(
process.env.APP_FALLBACK_LOCALE || 'en'
),
},
});Add to your routes/web.php or similar:
// Serve processed translation files as static assets
Route::get('/lang/processed_{locale}.json', function ($locale) {
$path = resource_path("../lang/processed_{$locale}.json");
if (! file_exists($path)) {
abort(404);
}
return response()->file($path, [
'Content-Type' => 'application/json',
'Cache-Control' => 'public, max-age=31536000, immutable',
]);
})->where('locale', '[a-z]+');Or better, serve as static files from public/lang/:
# Copy processed files to public after build
cp lang/processed_*.json public/lang/- Install dependencies:
npm install i18next i18next-http-backend react-i18next i18next-resources-to-backend php-array-reader - Create
app/Enums/Locale.php - Create
app/Http/Middleware/HandleLocale.php - Register middleware in
bootstrap/app.php - Create
resources/js/lib/i18n.client.ts - Create
resources/js/lib/i18n.server.ts - Create
resources/js/hooks/use-locale.tsx - Create
resources/js/plugins/laravel-i18n.ts - Update
vite.config.jswith plugin - Create
resources/js/components/locale-toggle.tsx - Update
resources/js/app.tsxwith i18n provider - Update
resources/js/ssr.tsxwith i18n provider - Update
resources/js/types/global.tswith locale type - Create
lang/*/directories and translation files - Set up
.envvariables - Set up HTTP endpoint for translation files
- Test language switching
Q: Translations not loading?
- Check that
/lang/processed_*.jsonfiles are being generated - Verify HTTP endpoint returns correct JSON
- Check browser Network tab for 404 errors
Q: Language doesn't switch?
- Ensure
useTranslation()hook is used in components - Check that
updateLocale()is being called - Verify localStorage and cookies are not blocked
Q: SSR hydration mismatch?
- Ensure both client and server initialize with same locale
- Check that
initI18n()is called before render - Verify pageProps.locale is passed correctly
Q: Pluralization not working?
- Check translation key has
_oneand_othersuffixes - Ensure
countparameter is passed tot()function - Verify plugin is converting Laravel syntax correctly
If you have existing hard-coded strings:
// Before
<button>Submit</button>
// After
import { useTranslation } from 'react-i18next';
export function MyButton() {
const { t } = useTranslation();
return <button>{t('buttons.submit')}</button>;
}Add to translation files:
{
"buttons.submit": "Submit"
}That's it! You now have a complete, production-ready localization system.