Laravel AI
Translation System
A complete multilingual framework built on top of Laravel's default translation system. Uses standard __() everywhere — no custom directives, no proprietary syntax. AI-powered translations, automatic routing, SEO optimization, and RTL support out of the box.
AI-Powered
OpenAI GPT-4 translations with high-quality results and rate limiting.
Database-Backed
Eloquent models, indexed tables, full CRUD for URLs with API endpoint auto-fetching.
Smart Routing
Auto language-prefixed URLs, SEO hreflang tags, canonical URLs, and middleware.
Inline Editor
Edit translations directly in the dashboard with per-string AI translation.
Zero Custom Syntax
Uses standard Laravel __() — no proprietary directives, zero learning curve.
Missing Keys Detection
Passive collection via handleMissingKeysUsing — catches untranslated strings from live traffic automatically.
Requirements
- PHP 8.2+
- Laravel 11+ / 12+
- OpenAI API Key
- Queue worker (Redis recommended, Database queue supported)
- Livewire 3.x / 4.x
Installation
1. Publish Laravel's Default Localization
php artisan lang:publish2. Install the package via Composer
composer require warmar/laravel-ai-translate3. Install the package assets
php artisan ai-translate:install4. Register the Service Provider
Add to bootstrap/providers.php:
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\Translate\TranslationServiceProvider::class,
];TranslationServiceProvider hooks into Laravel's handleMissingKeysUsing for automatic string collection and missing key detection. You do not need to modify your AppServiceProvider.5. Register Language Middleware
Add to bootstrap/app.php:
use App\Http\Middleware\Translate\LanguageMiddleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'language' => LanguageMiddleware::class,
]);
})6. Configure Environment
OPENAI_API_KEY=your-api-key-here
OPENAI_MODEL=gpt-4o-minihandleMissingKeysUsing hook — no manual toggling needed. All other settings are in config/translation.php.7. Register Global Helpers
Add to composer.json autoload:
"files": ["app/Helpers/Translate/TranslationHelper.php"]composer dump-autoloadGlobal Helper Functions
| Function | Returns | Description |
|---|---|---|
langCode() | string | Current locale code |
isRtl() | bool | Whether current locale is RTL |
langUrl($route, $params) | string | Language-prefixed URL |
isRoute($name) | bool | Whether current route matches |
8. Queue Workers
# Development
composer run dev
# Production
php artisan queue:work9. Run Migrations
php artisan migrate10. Clear Cache
php artisan view:clear
php artisan config:clear
php artisan config:cacheDatabase Schema
The system uses three database tables managed via Eloquent models.
translation_urls
| Column | Type | Description |
|---|---|---|
id | BIGINT UNSIGNED | Auto-increment primary key |
url | TEXT | The URL to scan |
active | TINYINT(1) | Active for extraction (default: 1) |
is_api | TINYINT | 0 = regular URL, 1 = API endpoint |
created_at | TIMESTAMP | Created |
updated_at | TIMESTAMP | Updated |
is_api = 0 URLs are scanned for strings. is_api = 1 endpoints are fetched for URL discovery — never scanned directly.translation_progress
| Column | Type | Description |
|---|---|---|
type | ENUM | 'string_extraction' or 'translation' |
locale | VARCHAR(10) | Target locale (NULL for extraction) |
total | INT UNSIGNED | Total items |
completed | INT UNSIGNED | Completed |
failed | INT UNSIGNED | Failed |
missing_translations
Tracks missing translation keys detected automatically from live traffic via Laravel's handleMissingKeysUsing hook.
| Column | Type | Description |
|---|---|---|
id | BIGINT UNSIGNED | Auto-increment primary key |
key | VARCHAR(500) | The untranslated string key |
locale | VARCHAR(10) | Locale where translation was missing |
occurrences | BIGINT UNSIGNED | Times requested (auto-increments) |
first_seen | DATETIME | When first detected |
last_seen | DATETIME | Most recently requested (auto-updates) |
Raw SQL
-- translation_urls
CREATE TABLE `translation_urls` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`url` TEXT NOT NULL,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`is_api` TINYINT NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NULL,
`updated_at` TIMESTAMP NULL,
PRIMARY KEY (`id`),
INDEX (`active`),
INDEX (`is_api`),
INDEX (`active`, `is_api`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- translation_progress
CREATE TABLE `translation_progress` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`type` ENUM('string_extraction', 'translation') NOT NULL,
`locale` VARCHAR(10) NULL,
`total` INT UNSIGNED NOT NULL DEFAULT 0,
`completed` INT UNSIGNED NOT NULL DEFAULT 0,
`failed` INT UNSIGNED NOT NULL DEFAULT 0,
`started_at` TIMESTAMP NULL,
`updated_at` TIMESTAMP NULL,
`completed_at` TIMESTAMP NULL,
PRIMARY KEY (`id`),
UNIQUE KEY (`type`, `locale`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- missing_translations
CREATE TABLE `missing_translations` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`key` VARCHAR(500) NOT NULL,
`locale` VARCHAR(10) NOT NULL DEFAULT 'en',
`occurrences` BIGINT UNSIGNED NOT NULL DEFAULT 1,
`first_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY (`key`, `locale`),
INDEX (`locale`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;File Structure
your-laravel-app/
├── app/
│ ├── Helpers/Translate/TranslationHelper.php
│ ├── Http/Middleware/Translate/LanguageMiddleware.php
│ ├── Jobs/Translate/
│ │ ├── ScanUrlForStringsJob.php
│ │ └── TranslateStringBatchJob.php
│ ├── Livewire/Translate/TranslateMenu.php
│ ├── Models/Translate/
│ │ ├── TranslationUrl.php
│ │ ├── TranslationProgress.php
│ │ └── MissingTranslation.php
│ ├── Providers/Translate/TranslationServiceProvider.php
│ └── Services/Translate/
│ ├── AITranslator.php
│ ├── StringExtractor.php
│ └── URLCollector.php
├── config/translation.php
├── database/migrations/
│ ├── create_translation_urls_table.php
│ ├── create_translation_progress_table.php
│ └── create_missing_translations_table.php
├── lang/ (en.json, ar.json, ...)
└── resources/views/livewire/translate/
└── translate-menu.blade.phpConfiguration
config/translation.php controls all settings:
return [
'log_process' => false,
'source_locale' => 'en',
'languages' => ['en' => 'English', 'ar' => 'Arabic'],
'target_locales' => ['ar'],
'rtl_languages' => ['ar', 'he'],
'urls' => [
'delay_between_requests' => 1,
'batch_size' => 50,
'timeout' => 20,
'api_scan_internal' => true, // Avoids artisan serve deadlock
],
'extraction' => [
'scan_internal' => true,
'clear_cache' => false,
],
'translation' => [
'model' => env('OPENAI_MODEL', 'gpt-4o-mini'),
'api_key' => env('OPENAI_API_KEY'),
'batch_size' => 20,
'rate_limit_per_minute' => 300,
],
];php artisan serve deadlock.Adding New Languages
'languages' => [
'en' => 'English',
'ar' => 'Arabic',
'es' => 'Spanish', // New
],
'target_locales' => ['ar', 'es'],touch lang/es.jsonRouting System
langRoute() auto-creates routes for all configured languages:
// Instead of repeating:
Route::get('/about', About::class);
Route::get('/ar/about', About::class);
// Just write:
langRoute('get', '/about', About::class, 'about');Examples
langRoute('get', '/', Home::class, 'home');
langRoute('get', '/products/{slug}', ProductShow::class, 'products.show');
// With middleware (6th parameter!)
langRoute('get', '/dashboard', Dashboard::class, 'dashboard', [], ['auth']);->middleware() on langRoute(). Pass middleware as the 6th parameter so it applies to ALL language routes.Language Middleware
LanguageMiddleware detects language from URL prefix, sets locale, stores in session, redirects /en/... to /... (301).
- Detects 2-letter language prefix
- Validates against configured languages
- Redirects
/en/...→/... - Sets
app()->setLocale() - Stores in session
View Helpers
{{-- Current language --}}
{{ langCode() }}
{{-- RTL detection --}}
@if(isRtl())
<div dir="rtl">...</div>
@endif
{{-- Language-aware URLs --}}
<a href="{{ langUrl('about') }}">About</a>
{{-- Standard Laravel __() for translations --}}
<h1>{{ __('Welcome') }}</h1>
<p>{{ __('Hello, :name!', ['name' => $user->name]) }}</p>
{{-- Check current route --}}
@if(isRoute('about'))
<li class="active">About</li>
@endif{{ __('...') }} everywhere — no custom directives. The handleMissingKeysUsing hook automatically detects and collects any untranslated strings at runtime.Usage
Step 1: Use Standard Laravel __() Syntax
{{-- Standard Laravel __() — collected automatically --}}
<h1>{{ __('Welcome to Our Website') }}</h1>
<p>{{ __('We provide the best service') }}</p>
<button>{{ __('Contact Us') }}</button>handleMissingKeysUsing — every __() call that can't find a key is automatically detected and collected. Your templates use 100% standard Laravel syntax.Step 2: Open the Dashboard
http://your-app.com/translation-dashboardStep 3: Add URLs
Paste regular URLs and click "Add URLs". For dynamic content, add API endpoints and click "Fetch & Import URLs".
Step 4: Collect Strings
Extract Strings tab → "Collect Strings". Jobs visit each URL internally — every __() call triggers the missing key handler which saves new strings to en.json.
Step 5: Translate
Translate tab → "Translate All Keys". Real-time per-language progress.
Step 6: Review & Edit
Translation Status tab → click a language → inline editor. Edit manually or click ✨ for AI translation.
Step 7: Monitor Missing Keys
Missing Keys tab → view untranslated strings from live traffic, grouped by locale with occurrence counts. Translate individually (✨) or batch per locale. See Missing Keys Dashboard for details.
How It Works
Standard Laravel __() Integration
The system works with Laravel's standard __() translation function — no custom Blade directives or proprietary syntax. This means:
- Your templates stay clean and portable — standard
{{ __('...') }}everywhere - No learning curve — if you know Laravel, you already know how to use this
- Zero vendor lock-in — remove the package and your templates still work perfectly
- IDE support works out of the box —
__()is recognized by all PHP IDEs
Passive Collection via handleMissingKeysUsing
Laravel's Translator class fires a callback whenever __() can't find a key. The TranslationServiceProvider hooks into this:
Lang::handleMissingKeysUsing(function ($key, $replace, $locale, $fallbackUsed) {
$locale = $locale ?? app()->getLocale();
$sourceLocale = config('translation.source_locale', 'en');
// Filter empty keys and vendor package keys (package::group.key)
if (trim($key) === '' || str_contains($key, '::')) {
return $key;
}
if ($locale === $sourceLocale) {
// Source language → add to en.json automatically
$translations[$key] = $key;
file_put_contents($path, ...);
} else {
// Target language → log to missing_translations table
MissingTranslation::record($key, $locale);
}
return $key;
});This means:
- Source locale (en): Any
__('new string')not inen.jsongets added automatically - Target locales (ar, es): Missing translations logged to
missing_translationstable with occurrence counts - Zero overhead for existing translations: Callback only fires when a key is actually missing
- Minimal filtering needed: Laravel resolves built-in keys (
auth.*,validation.*,pagination.*) via its own lang files before the callback fires — only empty keys and vendor package keys are filtered
Active Scanning via URL Visits (on-demand)
When you click "Collect Strings" in the dashboard:
- Queue jobs visit each URL internally via
app()->handle() - The page renders — every
__()call fires the translator - The missing key handler catches new strings and adds them to
en.json - No HTML markers, no regex, no custom directives — just Laravel's own translator
Useful for: initial setup, after major template changes, or catching strings in unvisited pages.
URL Collection from API Endpoints
- User adds API endpoint URLs
URLCollectorsaves withis_api = 1- Endpoint fetched (internal for local URLs, HTTP for external)
- JSON array parsed → each URL saved as
is_api = 0 - Duplicates skipped; endpoints re-fetchable anytime
Eloquent Models
TranslationUrl
use App\Models\Translate\TranslationUrl;
TranslationUrl::active(); // active = true
TranslationUrl::regularUrls(); // is_api = 0
TranslationUrl::apiEndpoints(); // is_api = 1
TranslationUrl::extractable(); // active AND is_api = 0TranslationProgress
use App\Models\Translate\TranslationProgress;
$ext = TranslationProgress::stringExtraction()->first();
echo $ext->percentage; // 75.5
echo $ext->status; // 'running' | 'completed' | 'idle'
$p = TranslationProgress::translation()->forLocale('ar')->first();MissingTranslation
use App\Models\Translate\MissingTranslation;
// Record a missing key (upsert — increments occurrences)
MissingTranslation::record('Hello World', 'ar');
// Get flat array of keys for a locale
$keys = MissingTranslation::keysForLocale('ar');
// Query with scopes
MissingTranslation::targetLocales()->recentFirst()->get();
MissingTranslation::forLocale('ar')->mostFrequent()->get();
MissingTranslation::since(now()->subDay())->get();
// Clear operations
MissingTranslation::clearLocale('ar');
MissingTranslation::clearAll();Scopes: forLocale($locale), sourceLocale(), targetLocales(), recentFirst(), mostFrequent(), since($datetime)
API Endpoints for Dynamic URLs
// routes/api.php
Route::get('/sitemaps/products', function () {
return Product::all()->map(
fn($p) => url('/products/' . $p->slug)
)->values();
});Add in the dashboard → "Fetch & Import URLs". Use "Re-fetch All" to discover new content.
api_scan_internal is enabled (default), local endpoints are fetched via internal Laravel requests to avoid php artisan serve deadlock.Inline Translation Editor
- Search strings by source or translation
- Edit manually — saves on Enter or blur
- AI-translate per string — click the ✨ icon
- Green checkmark = translated, grey dash = missing
Missing Keys Dashboard
The Missing Keys tab provides real-time visibility into untranslated strings detected from live traffic:
- Grouped by locale — see which languages have the most gaps at a glance
- Occurrence tracking — know which strings are hit most frequently to prioritize
- First/last seen timestamps — understand when strings appeared and how recent they are
- Individual AI translate — click ✨ on any row, job dispatches immediately, row disappears
- Bulk translate per locale — click "Translate All" on a locale group, progress bar appears
- Processing banner — top-of-tab indicator when translations are in progress
- Clear Resolved — removes entries that have been translated since they were logged
- Clear All — wipes the entire
missing_translationstable
/ar/about and the Arabic translation for "About Us" doesn't exist, the handleMissingKeysUsing hook logs it to missing_translations with locale ar. The occurrence counter increments on each hit.Rate Limiting
'rate_limit_per_minute' => 300,- Free tier: 60–100 requests/minute
- Paid tier: 300–3500 requests/minute
Troubleshooting
Strings Not Collected
# Verify the hook is active
php artisan tinker
>>> __('test string that does not exist')
# Check if it appeared in lang/en.json
# Enable logging
# 'log_process' => true in config/translation.php
# Check: storage/logs/laravel.logTranslations Not Working
php artisan tinker
>>> config('translation.translation.api_key')
php artisan queue:work --verbose
php artisan queue:failedAPI Endpoints Not Importing
# Must return flat JSON array
curl https://your-app.com/api/sitemaps/blog
# ["https://...", "https://..."]
php artisan tinker
>>> \App\Models\Translate\TranslationUrl::apiEndpoints()->pluck('url')Local API Endpoints Timing Out
If local API endpoints timeout with php artisan serve, ensure api_scan_internal is enabled:
// config/translation.php
'urls' => [
'api_scan_internal' => true,
],Missing Keys Not Appearing
- Verify
TranslationServiceProvideris registered inbootstrap/providers.php - Missing keys only log for target locales (e.g.,
/ar/about). Source locale keys go directly toen.json. - Check the table:
php artisan tinker >>> \App\Models\Translate\MissingTranslation::count()
API Reference
URLCollector Service
use App\Services\Translate\URLCollector;
$c = new URLCollector();
$c->addUrl('https://...'); // Single (null if dup)
$c->addBulk([...]); // Bulk, returns count
$c->addApiEndpoint('https://...'); // Save endpoint (is_api=1)
$c->collectFromApiEndpoint('...'); // Fetch + import
$c->collectFromApiEndpoints([...]); // Multiple
$c->refreshAllApiEndpoints(); // Re-fetch all saved
$c->getExtractableUrls(); // Active non-API URLs
$c->removeById(42);
$c->toggleActive(42);
$c->clearRegularUrls();
$c->clearApiEndpoints();
$c->clearAll();StringExtractor Service
$ext = new StringExtractor();
// Visit URL — triggers handleMissingKeysUsing for all __() calls
$ext->extractFromUrl('https://...');
// Returns void — the hook handles saving to en.json
$ext->getAllKeys('en');MissingTranslation Model
use App\Models\Translate\MissingTranslation;
MissingTranslation::record('About Us', 'ar'); // Upsert with occurrence++
MissingTranslation::keysForLocale('ar'); // Flat array of keys
MissingTranslation::forLocale('ar'); // Scope
MissingTranslation::targetLocales(); // locale != source
MissingTranslation::recentFirst(); // ORDER BY last_seen DESC
MissingTranslation::mostFrequent(); // ORDER BY occurrences DESC
MissingTranslation::clearLocale('ar');
MissingTranslation::clearAll();AITranslator Service
$t = new AITranslator();
$t->translate('Hello', 'ar');
$t->translateBatch([...], 'ar');
$t->getTargetLocales();
$t->isConfigured();