Translation Management System (i18n) Development
A translation management system is essential for multilingual sites where translation text needs to be regularly added and updated across multiple languages without editing code or JSON files manually. Translators work in a convenient interface, developers add new keys through code.
Translation Storage Options
File-based approach (Laravel lang, i18next) — translations in PHP/JSON files. Drawbacks: editing requires file system access, no change history, difficult for non-technical translators.
Database approach — translations in DB. Editors work through UI, change history available, ability to translate without deploy. This is the recommended approach for teams with translators.
Hybrid — translations in files as fallback, override via DB.
Data Model
translation_keys (
id, key, -- 'checkout.button.pay', 'nav.home'
namespace, -- 'frontend', 'emails', 'admin'
description, -- hint for translator
created_at
)
translations (
id, key_id, locale,
value, -- translated text
status: pending | approved | needs_review,
translated_by, approved_by,
created_at, updated_at
)
translation_change_log (
id, translation_id, old_value, new_value, changed_by, changed_at
)
Collection of Untranslated Keys
Code uses keys via __() or t(). Artisan command scans code and adds new keys to DB:
class ScanTranslationKeys extends Command
{
public function handle(): void
{
$files = File::allFiles(resource_path('views'))
->merge(File::allFiles(resource_path('js')));
$keys = [];
foreach ($files as $file) {
$content = File::get($file);
preg_match_all("/__\('([^']+)'\)/", $content, $matches);
preg_match_all("/t\('([^']+)'\)/", $content, $jsMatches);
$keys = array_merge($keys, $matches[1], $jsMatches[1]);
}
$unique = array_unique($keys);
$existing = TranslationKey::pluck('key')->toArray();
$new = array_diff($unique, $existing);
foreach ($new as $key) {
TranslationKey::create(['key' => $key, 'namespace' => $this->guessNamespace($key)]);
}
$this->info("Found new keys: " . count($new));
}
}
Translator Interface
Main screen — table with filters:
| Filters | Columns |
|---|---|
| Namespace | Key |
| Language | Original (ru) |
| Status | Current translation |
| Search by key/text | Action: edit |
Edit form — inline in table or side panel. Next to translation field — original text in base language. "Machine Translation" button makes request to DeepL or Google Translate API for draft.
Machine Translation API
class DeepLTranslationService
{
public function translate(string $text, string $targetLang, string $sourceLang = 'RU'): string
{
$response = Http::withToken(env('DEEPL_API_KEY'))
->post('https://api-free.deepl.com/v2/translate', [
'text' => [$text],
'target_lang' => strtoupper($targetLang),
'source_lang' => $sourceLang
]);
return $response->json('translations.0.text');
}
}
Translation Caching
Translations from DB cached in Redis. On change — invalidate only affected namespace:
class Translation extends Model
{
protected static function booted(): void
{
static::saved(fn($t) => Cache::forget("translations:{$t->locale}:{$t->key->namespace}"));
}
}
Export/Import for Translation Agencies
For working with agencies — export to XLIFF (standard format for CAT tools) or Excel:
// Export untranslated keys to Excel
$untranslated = TranslationKey::whereDoesntHave('translations', fn($q) =>
$q->where('locale', $targetLocale)
)->get();
$export = $untranslated->map(fn($key) => [
'key' => $key->key,
'source' => $key->translations->where('locale', 'ru')->first()?->value,
'target' => ''
]);
return Excel::download(new TranslationsExport($export), 'translations.xlsx');
Adding New Language Process
- Add locale to
config/app.phpand runphp artisan translations:scan - In translator interface select new language — all untranslated keys shown
- Translate manually or via machine translation → review → approve
- Enable language in site's language switcher
Development timeline: 4–6 weeks for full system with translator interface, machine translation, change history, and export/import.







