ozner-omali / laravel-to-i18next
Parse Laravel language files into i18next-compatible JSON
v0.1.0
2025-08-03 22:34 UTC
Requires
- php: ^8.4.0
- ext-fileinfo: *
- illuminate/console: ^12.0
- illuminate/filesystem: ^12.0
- illuminate/support: ^12.0
Requires (Dev)
- larastan/larastan: ^3.6
- laravel/pint: ^1.22.1
- orchestra/testbench: ^10.4
- peckphp/peck: ^0.1.3
- pestphp/pest: ^4.0.0
- pestphp/pest-plugin-type-coverage: ^4.0.0
- phpstan/phpstan: ^2.1.16
- rector/rector: ^2.0.16
- symfony/var-dumper: ^7.2.6
README
This package parses your Laravel translation files into i18next-compatible JSON, converting plural forms and placeholders into a format ready for dynamic translation usage in the frontend.
🎬 Features
- Reads every `lang/{locale}/*.php` file
- Parses Laravel’s `trans_choice` plural syntax (`{0}|{1}|[2,*]`)
- Converts `:placeholder`, `:Placeholder`, `:PLACEHOLDER` into i18next interpolations:
- `:foo` → `{{foo}}`
- `:Foo` → `{{foo, capitalize}}`
- `:FOO` → `{{foo, uppercase}}`
- Strips attributes from HTML tags
- Writes to `public/locales/{locale}/*.json`
- Generates a `public/locales/versions.json` file with:
- A unique hash for each locale’s translation files
- A `last_updated` timestamp
- Useful for cache invalidation and detecting translation updates
- Artisan command: `lang:to-i18next {locale?}`
📦 Installation
composer require ozner-omali/laravel-to-i18next
🔧 Usage
1. Export all locales
php artisan lang:to-i18next
2. Export a specific locale
php artisan lang:to-i18next es
This will generate:
• Translation files under: /public/locales/{locale}/*.json
• A version tracking file under: /public/locales/versions.json
Example versions.json file
{ "en": { "hash": "a1b2c3d4e5f6g7h8i9j0", "last_updated": "2025-06-01T12:00:00Z" }, "es": { "hash": "0j9i8h7g6f5e4d3c2b1a", "last_updated": "2025-06-01T12:30:00Z" } }
Example Laravel Translations:
// resources/lang/en/messages.php
return [ 'success' => [ 'created' => '{0} No :resource created.|{1} :Resource created successfully.|[2,*] Many :resource created successfully.' ], ];
// resources/lang/en/models.php
return [ 'user' => 'user|users', 'address' => 'address|addresses', ];
Generated i18next JSON:
// resources/locales/en/messages.json
{ "success": { "created_zero": "No {{resource}} created.", "created_one": "{{resource, capitalize}} created successfully.", "created_other": "Many {{resource}} created successfully." } }
// resources/lang/en/models.json
{ "user_one": "user", "user_other": "users", "address_one": "address", "address_other": "addresses" }
⚙️ Front-end Integration
1. Installation
npm install i18next react-i18next i18next-http-backend react-native-localize
2. useLangStore.ts (Zustand store)
Define a store with:
- hydrated: boolean — whether storage has finished hydration
- lang: string — current language
- versions: Record<string, { hash: string; last_updated: string }> — the translation version hashes
- getVersion(lang) to fetch hash or fallback to "latest"
import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; type VersionsMap = Record<string, { hash: string; last_updated: string }>; type LangStore = { hydrated: boolean; lang: string; setLang: (lang: string) => void; versions: VersionsMap; getVersion: (lang: string) => string; setVersions: (versions: VersionsMap) => void; }; export const useLangStore = create<LangStore>()( persist( immer((set, get) => ({ // Hydration hydrated: false, // Language lang: 'en', setLang: lang => set(state => { state.lang = lang; }), // Versions versions: {}, getVersion: lang => { return get().versions[lang]?.hash ?? 'latest'; }, setVersions: versions => set(state => { state.versions = { ...versions }; }), })), { name: 'lang-storage', storage: createJSONStorage(() => AsyncStorage), onRehydrateStorage: () => { return (_state, error) => { if (!error) { useLangStore.setState({ hydrated: true }); } }; }, partialize: (state): Pick<LangStore, 'lang' | 'versions'> => ({ lang: state.lang, versions: state.versions, }), }, ), );
3. useFetchVersions.ts (Hook to sync version.json)
import { useEffect } from 'react'; import { BACKEND_URL } from '@env'; import { useLangStore } from '@/stores/useLangStore'; import i18next from '@/config/i18next'; export const useFetchVersions = (hydrated: Boolean) => { const setVersions = useLangStore.getState().setVersions; const getVersions = useLangStore.getState().versions; useEffect(() => { if (!hydrated) return; const fetchVersions = async () => { try { // Append timestamp to bust all caches const timestamp = Date.now(); const url = `${BACKEND_URL}/locales/versions.json?ts=${timestamp}`; const res = await fetch(url); if (!res.ok) throw new Error(`Failed to fetch versions (${res.status})`); const data = await res.json(); const previous = getVersions; const changed = JSON.stringify(previous) !== JSON.stringify(data); if (changed) { console.log( '[i18n - useFetchVersions] VERSIONS UPDATED, reloading translations...', ); setVersions(data); const currentLng = i18next.language || 'en'; const namespaces = i18next.options.ns as string[]; await i18next.reloadResources([currentLng], namespaces); } else { console.log( '[i18n - useFetchVersions] VERSIONS UNCHANGED, using cached translations.', ); } } catch (e) { console.warn( '[i18n - useFetchVersions] Failed to fetch versions.json:', e, ); } }; fetchVersions(); }, [hydrated]); };
4. config/i18next.ts (Initialization)
import i18next from 'i18next'; import { BACKEND_URL } from '@env'; import { initReactI18next } from 'react-i18next'; import { useLangStore } from '@/stores/useLangStore'; import HttpApi, { HttpBackendOptions } from 'i18next-http-backend'; const fallbackLng = 'en'; const namespaces = [ 'addresses', 'auth', 'cache', 'common', 'http', 'models', 'pagination', 'passwords', 'resource', 'validation', ]; export const initI18n = async () => { const store = useLangStore.getState(); const initialLang = store.lang; return i18next .use(HttpApi) .use(initReactI18next) .init<HttpBackendOptions>({ lng: initialLang, fallbackLng, ns: namespaces, defaultNS: 'common', backend: { loadPath: (lngs: string[], namespaces: string[]): string => { const lng = lngs[0]; const ns = namespaces[0]; const version = useLangStore.getState().getVersion(lng); console.log( `[i18n] Loading translations for ${lng}/${ns} (version: ${version})`, ); return `${BACKEND_URL}/locales/${lng}/${ns}.json?v=${version}`; }, }, interpolation: { escapeValue: false, format: (value, format) => { if (typeof value !== 'string') return value; if (format === 'capitalize') { return value.charAt(0).toUpperCase() + value.slice(1); } if (format === 'uppercase') { return value.toUpperCase(); } return value; }, }, react: { useSuspense: false, }, }); }; export default i18next;
5. App.tsx (Entry point)
import i18next from './config/i18next'; import { initI18n } from './config/i18next'; import { useEffect, useState } from 'react'; import { I18nextProvider } from 'react-i18next'; import { useLangStore } from './stores/useLangStore'; import { ThemeProvider } from './theme/ThemeProvider'; import { View, ActivityIndicator } from 'react-native'; import AppNavigation from './navigations/AppNavigation'; import { useFetchVersions } from './hooks/useFetchVersions'; import { SafeAreaProvider } from 'react-native-safe-area-context'; export default function App() { const langStoreHydrated = useLangStore(state => state.hydrated); const [i18nReady, setI18nReady] = useState(false); // Fetch versions only after hydration useFetchVersions(langStoreHydrated); useEffect(() => { if (langStoreHydrated) { initI18n().then(() => setI18nReady(true)); } }, [langStoreHydrated]); if (!langStoreHydrated || !i18nReady) { return ( <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> <ActivityIndicator size="large" /> </View> ); } return ( <I18nextProvider i18n={i18next}> <ThemeProvider> <SafeAreaProvider> <AppNavigation /> </SafeAreaProvider> </ThemeProvider> </I18nextProvider> ); }
6. Basic Example Usage
// passing 2 when count is 0 or less on the model // so that the pluralization in this case is correct // Note: could have also set up the pluralization for the resource // on the laravel end with {0}, {1}, [2,*] // then you could use the same count in the model translation let count = null; let resource = null; // 0 items count = 0; resource = i18next.t('models.user', {count: (count <= 0) ? 2 : count}); i18next.t('success.created', { count: count, resource }); // → "No users created." // 1 item count = 1 resource = i18next.t('models.user', {count: (count <= 0) ? 2 : count}); i18next.t('success.created', { count, resource }); // → "User created successfully." // Multiple items count = 5; resource = i18next.t('models.user', {count: (count <= 0) ? 2 : count}); i18next.t('success.created', { count, resource }); // → "Many users created successfully."
7. Trans Component Example
A. Laravel translation: resources/lang/en/{file}.php
'apples' => '{0} Hello <b style="background-color: #0ea5e9">:name</b>, <br/>there are none |{1} Hello <b class="highlight">:name</b>, there is one apple |[2,*] Hello <b id="special-name">:name</b>, there are :count apples',
B. Generated i18next JSON: public/locales/en/{file}.json
{ "apples_zero": "Hello <b>{{name}}</b>, <br/>there are none", "apples_one": "Hello <b>{{name}}</b>, there is one apple", "apples_other": "Hello <b>{{name}}</b>, there are {{count}} apples" }
C. /components/i18Next/TransComponents.tsx
import React from 'react'; import { Text } from 'react-native'; export const transComponents = { // Bold text b: <Text style={{ fontWeight: 'bold' }} />, bold: <Text style={{ fontWeight: 'bold' }} />, // Italic text i: <Text style={{ fontStyle: 'italic' }} />, italic: <Text style={{ fontStyle: 'italic' }} />, // Line breaks br: <Text>{'\n'}</Text>, // Span (generic inline container) span: <Text />, };
D. Usage in component
<Text> <Trans i18nKey="models:apples" values={{ name: 'Ozner', count: 0 }} components={transComponents} /> </Text>
E. Output
Hello Ozner,
there are none
✅ Notes
The parser:
• Converts Laravel’s pipe plural syntax ('user|users') into _one and _other keys.
• Supports Laravel’s pluralization brackets ({0}, {1}, [2,*]) and converts them to *_zero, *_one, *_other.
• Placeholders are automatically transformed:
• :key → {{key}}
• :Key → {{key, capitalize}}
• :KEY → {{key, uppercase}}
• Strips HTML attributes from tags, e.g., <b class="highlight"> → <b>.
• Adds versions.json for change detection and frontend cache management.
📖 Changelog
See CHANGELOG.md for release notes and breaking changes.
🤝 Contributing
Contributions, issues, and feature requests are welcome! 🔗 Report Issues 🔗 Submit Pull Requests
🔑 License
🔑 MIT License © Lorenzo Wynberg / Ozner Omali