cation container.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ResolveLocale
{
protected array $supportedLocales = ['en', 'es', 'fr'];
public function handle(Request $request, Closure $next): Response
{
$locale = $request->session()->get('app_locale', config('app.fallback_locale'));
if ($request->has('locale') && in_array($request->locale, $this->supportedLocales)) {
$locale = $request->locale;
$request->session()->put('app_locale', $locale);
}
app()->setLocale($locale);
return $next($request);
}
}
Why this choice? Middleware runs before controllers and views, guaranteeing app()->getLocale() returns a consistent value. Session fallback ensures returning users retain their preference without URL pollution. The supported locales array acts as a validation boundary, preventing injection attacks or invalid locale states.
Step 2: Database Schema with JSON Translation Columns
Dynamic content requires flexible storage. JSON columns allow storing multiple language variants in a single field while maintaining queryability. Proper casting and indexing are mandatory for performance.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('catalog_items', function (Blueprint $table) {
$table->string('primary_title');
$table->text('primary_body');
$table->json('title_translations')->nullable()->after('primary_title');
$table->json('body_translations')->nullable()->after('primary_body');
});
}
};
Why this choice? Storing the primary language separately avoids JSON parsing overhead for the default locale. Nullable JSON columns prevent null reference errors during initial record creation. Laravel’s JSON casting handles serialization automatically, but explicit nullable definitions ensure database-level consistency.
Step 3: Model Accessors with Fallback Chains
Accessors must resolve translations gracefully. A missing translation should never break the UI; it should fall back to the primary language or a configured fallback locale.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CatalogItem extends Model
{
protected $fillable = [
'primary_title',
'primary_body',
'title_translations',
'body_translations'
];
protected $casts = [
'title_translations' => 'array',
'body_translations' => 'array'
];
public function getLocalizedTitle(): string
{
$currentLocale = app()->getLocale();
$translations = $this->title_translations ?? [];
return $translations[$currentLocale]
?? $translations[config('app.fallback_locale')]
?? $this->primary_title;
}
public function getLocalizedBody(): string
{
$currentLocale = app()->getLocale();
$translations = $this->body_translations ?? [];
return $translations[$currentLocale]
?? $translations[config('app.fallback_locale')]
?? $this->primary_body;
}
}
Why this choice? The three-tier fallback chain (current → fallback → primary) ensures zero broken UI states. Using app()->getLocale() instead of session() aligns with middleware resolution. Explicit null coalescing prevents Undefined array key warnings when translations are incomplete.
Step 4: Decoupled Translation Service with Queueing
Synchronous API calls belong in background jobs. A dedicated service class handles API interaction, while Laravel’s queue system manages execution, retries, and failure logging.
namespace App\Services;
use Stichoza\GoogleTranslate\GoogleTranslate;
use Illuminate\Support\Facades\Log;
class TranslationService
{
protected GoogleTranslate $translator;
public function __construct()
{
$this->translator = new GoogleTranslate();
}
public function translateToLocales(string $text, array $targetLocales): array
{
$results = [];
foreach ($targetLocales as $locale) {
try {
$this->translator->setTarget($locale);
$results[$locale] = $this->translator->translate($text);
} catch (\Exception $e) {
Log::error("Translation failed for locale {$locale}: {$e->getMessage()}");
$results[$locale] = null;
}
}
return $results;
}
}
Why this choice? Encapsulating API logic in a service class enables testing, mocking, and future provider swaps (e.g., DeepL, AWS Translate). Try-catch blocks prevent single-locale failures from halting the entire batch. Logging ensures observability without crashing the job.
Step 5: Controller Integration with Deferred Translation
Controllers should never block on translation. Instead, they persist the primary content and dispatch a job to populate translation columns asynchronously.
namespace App\Http\Controllers;
use App\Models\CatalogItem;
use App\Jobs\TranslateCatalogItem;
use Illuminate\Http\Request;
class CatalogController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'primary_title' => 'required|string|max:255',
'primary_body' => 'required|string',
]);
$item = CatalogItem::create($validated);
TranslateCatalogItem::dispatch($item->id, ['es', 'fr']);
return response()->json(['id' => $item->id], 201);
}
}
Why this choice? Immediate response improves UX and prevents timeout errors. Dispatching a job decouples translation from the HTTP lifecycle. The job receives the model ID and target locales, allowing it to fetch fresh data and update the record without race conditions.
Pitfall Guide
1. Mixing Session and Application Locale Contexts
Explanation: Developers often set session('locale') in routes but call app()->setLocale() in controllers or middleware. This creates divergent locale states where views, models, and validation rules operate on different languages.
Fix: Centralize locale resolution in a single middleware. Never call app()->setLocale() outside the middleware stack. Use app()->getLocale() consistently across models, views, and services.
2. Synchronous Translation in Request Lifecycle
Explanation: Calling translation APIs directly in store() or update() methods blocks PHP workers. At scale, this exhausts FPM pools, increases memory usage, and triggers 504 Gateway Timeouts.
Fix: Always dispatch translation jobs to a queue. Use dispatch() or dispatchSync() only in CLI/seed contexts. Implement exponential backoff for API retries.
3. Missing Fallback Logic in Accessors
Explanation: Accessors that return $translations[$locale] without fallbacks throw Undefined array key warnings when translations are incomplete or delayed.
Fix: Implement a three-tier fallback: current locale → configured fallback → primary language. Use null coalescing operators and validate translation arrays before access.
4. Storing Raw JSON Without Casting or Validation
Explanation: Saving translation data as raw strings or unvalidated arrays leads to malformed JSON, query failures, and security vulnerabilities.
Fix: Always use $casts = ['field' => 'array']. Validate incoming translation payloads with array and nullable rules. Sanitize API responses before persistence.
5. Hardcoding Translation Arrays in Blade Templates
Explanation: Inline arrays like $translations['key'][$locale] in views bypass Laravel’s localization pipeline, prevent automated extraction, and create maintenance nightmares when adding new languages.
Fix: Reserve inline arrays for emergency overrides. Use resources/lang/{locale}.php for static UI text. Keep dynamic content in JSON columns with model accessors.
6. Ignoring API Rate Limits and Quotas
Explanation: Translation APIs enforce strict rate limits. Bursting requests during bulk imports or high-traffic periods triggers 429 Too Many Requests errors and account suspension.
Fix: Implement rate limiting in the translation service. Use Laravel’s throttle middleware for API calls. Batch translations and distribute jobs across multiple queue workers.
7. Querying JSON Columns Without Proper Indexing
Explanation: Laravel’s JSON extraction (->>) bypasses standard B-tree indexes. Filtering or sorting by translated fields causes full table scans on datasets >10k rows.
Fix: Generate virtual columns for frequently queried translations. Use database-specific JSON indexing (PostgreSQL GIN, MySQL GENERATED ALWAYS AS). Avoid WHERE JSON_EXTRACT() in production queries.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static UI strings (buttons, labels) | Native resources/lang/ files | Zero API cost, framework-native, extractable | None |
| Admin-managed dynamic content | JSON columns + queued translation | Decouples API calls, enables fallbacks, scalable | Low (batched API usage) |
| High-volume user-generated content | Async translation pipeline + caching | Prevents request blocking, handles rate limits | Medium (queue infrastructure) |
| Real-time translation requirement | Synchronous API + aggressive caching | Only viable for low-traffic, latency-tolerant apps | High (per-request API fees) |
| Enterprise multi-region deployment | Database-level JSON indexing + CDN | Optimizes query performance, reduces latency | High (infrastructure scaling) |
Configuration Template
// config/app.php
'locales' => ['en', 'es', 'fr'],
'fallback_locale' => 'en',
// app/Http/Kernel.php
protected $middlewareGroups = [
'web' => [
// ...
\App\Http\Middleware\ResolveLocale::class,
],
];
// config/queue.php
'connections' => [
'database' => [
'driver' => 'database',
'table' => 'jobs',
'retry_after' => 90,
'after_commit' => true,
],
],
// app/Jobs/TranslateCatalogItem.php
namespace App\Jobs;
use App\Models\CatalogItem;
use App\Services\TranslationService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class TranslateCatalogItem implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $catalogItemId,
public array $targetLocales
) {}
public function handle(TranslationService $translator): void
{
$item = CatalogItem::findOrFail($this->catalogItemId);
$titleTranslations = $translator->translateToLocales(
$item->primary_title,
$this->targetLocales
);
$bodyTranslations = $translator->translateToLocales(
$item->primary_body,
$this->targetLocales
);
$item->update([
'title_translations' => $titleTranslations,
'body_translations' => $bodyTranslations,
]);
}
}
Quick Start Guide
- Install the translation package:
composer require stichoza/google-translate-php
- Generate middleware:
php artisan make:middleware ResolveLocale and register it in the web middleware group.
- Create migration: Add
primary_title, primary_body, title_translations, and body_translations columns to your target table. Run php artisan migrate.
- Dispatch your first job: In your controller’s
store method, call TranslateCatalogItem::dispatch($model->id, ['es', 'fr']) and start your queue worker with php artisan queue:work.
This architecture transforms translation from a request-blocking chore into a scalable, observable background process. By decoupling locale resolution, queuing API calls, and implementing robust fallback chains, you eliminate latency spikes, reduce API costs, and maintain consistent multilingual experiences across all traffic patterns.