Server-side i18n (Laravel/Node) — i18n

Server-side i18n (Laravel/Node) i18n di backend penting untuk email, push notification, PDF generation, error messages API, dan konten yang di-render…

Server-side i18n artinya server yang memilih bahasa, lalu mengirim HTML, email, dan pesan API yang sudah dalam bahasa itu. Bayangin resepsionis Homestay Kabut yang menyapa tamu di pintu, sebelum tamu membuka brosur apa pun. Dia bisa melihat kartu pesanan tamu (pilihan yang tersimpan), atau mendengar daftar bahasa yang tamu kuasai, dari yang paling lancar. Dari situ dia memilih bahasa yang dia bisa, dan menyapa dengan bahasa itu sejak kata pertama. Daftar bahasa itu dikirim browser di header Accept-Language.

🧪 Tebakan cepat: Firefox tamu dari Osaka mengirim Accept-Language: ja,en-US;q=0.7,en;q=0.3. Laravel memanggil $request->getPreferredLanguage(['id-ID', 'en-US', 'ja-JP', 'ar-EG']). Nilai apa yang kembali: 'ja-JP', 'ja_JP', atau 'ja'? Jawabannya ada di bagian "Accept-Language: daftar bahasa dengan bobot q".

Accept-Language: daftar bahasa dengan bobot q

Tiap bahasa di header boleh membawa bobot q dari 0 sampai 1. Tanpa q, bobotnya 1. Makin besar, makin disukai, dan q=0 artinya "jangan bahasa ini". Kami membaca header yang benar-benar dikirim Firefox 146: dengan daftar bahasa Jepang, Inggris (AS), Inggris, hasilnya ja,en-US;q=0.7,en;q=0.3. Rina yang memasang Indonesia, Inggris (AS), Inggris, Jepang mengirim id,en-US;q=0.8,en;q=0.5,ja;q=0.3.

Negosiasinya: urutkan menurut q, lalu ambil bahasa pertama yang didukung. Pertama cari yang sama persis, lalu yang bahasanya sama (ja cocok dengan ja-JP).

function negotiate(header, supported, fallback) {
  const wanted = header
    .split(',')
    .map((part, index) => {
      const [tag, ...params] = part.trim().split(';');
      const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
      return { tag: tag.trim().toLowerCase(), q: q ? Number(q.slice(2)) : 1, index };
    })
    .filter((w) => w.tag && w.q > 0)
    .sort((a, b) => b.q - a.q || a.index - b.index);

  for (const w of wanted) {
    const exact = supported.find((s) => s.toLowerCase() === w.tag);
    if (exact) return exact;
    const sameLanguage = supported.find((s) => s.split('-')[0].toLowerCase() === w.tag.split('-')[0]);
    if (sameLanguage) return sameLanguage;
  }
  return fallback;
}

const SUPPORTED = ['id-ID', 'en-US', 'ja-JP', 'ar-EG'];
negotiate('ja,en-US;q=0.7,en;q=0.3', SUPPORTED, 'id-ID'); // 'ja-JP'
negotiate('en;q=0.5,ja', SUPPORTED, 'id-ID');             // 'ja-JP': q 1 menang
negotiate('fr-FR,fr;q=0.9', SUPPORTED, 'id-ID');          // 'id-ID': gak ada yang cocok

Kalau ja-JP belum siap, tamu Osaka dapat en-US, bahasa keduanya (q 0,7). Kode lama sering menulis header.split(',')[0]: untuk en;q=0.5,ja hasilnya en, padahal ja punya q 1, dan bahasa yang gak didukung pun ikut terpilih.

Laravel memakai Request::getPreferredLanguage() dari Symfony. Kami menjalankannya di Laravel 13.35.0 (symfony/http-foundation 8.1.8): dengan daftar ['id-ID', 'en-US', 'ja-JP', 'ar-EG'], header Osaka memberi 'ja_JP', dengan garis bawah. Symfony mengubah semua locale ke gaya bahasa_WILAYAH. Itu jawaban tebakannya. Header Paris (fr-FR,fr;q=0.9) memberi item pertama daftar, 'id_ID'. Dengan daftar ['id', 'en', 'ja', 'ar'], hasilnya 'ja', 'ar', 'en', atau 'id', cocok dengan nama folder lang/.

Urutan sumber: URL, user, cookie, lalu header

Accept-Language cuma tebakan dari setelan browser. Pilihan tamu sendiri menang:

// app/Http/Middleware/SetLocale.php
class SetLocale
{
    private const SUPPORTED = ['id', 'en', 'ja', 'ar'];

    public function handle(Request $request, Closure $next): Response
    {
        $candidates = [
            $request->route('locale'),                        // 1. awalan URL: /ja/kamar
            $request->user()?->locale,                        // 2. user yang login
            $request->cookie('lang'),                         // 3. cookie
            $request->getPreferredLanguage(self::SUPPORTED),  // 4. Accept-Language, lalu 'id'
        ];

        // Yang pertama ada dan didukung yang menang.
        App::setLocale(collect($candidates)->first(fn ($l) => in_array($l, self::SUPPORTED, true)) ?? 'id');

        return $next($request);
    }
}

Laravel: folder lang, __(), dan trans_choice

Skeleton Laravel 13 gak punya folder lang; php artisan lang:publish membuatnya. Satu folder per locale, satu file per kelompok teks:

// lang/ar/booking.php
return [
    'room' => 'غرفة التراس',
    'thanks' => 'شكرًا لكَ يا :name!',
    'nights' => ':count ليلة|ليلة واحدة|ليلتان|:count ليالٍ|:count ليلة|:count ليلة',
];
App::setLocale('ar');
__('booking.room');                        // "غرفة التراس"; trans('booking.room') sama
__('booking.thanks', ['name' => 'Alex']); // "شكرًا لكَ يا Alex!"
trans_choice('booking.nights', 3);        // "3 ليالٍ"

trans_choice bukan ICU. Laravel memecah teks di tanda | dan memilih potongan menurut nomor, dari tabel aturan per locale di dalam Laravel. Diukur di Laravel 13.35.0:

n ar en (:count night lalu :count nights) id, ja
1 ليلة واحدة (potongan ke-2) 1 night (ke-1) potongan ke-1
2 ليلتان (ke-3) 2 nights (ke-2) potongan ke-1
3 3 ليالٍ (ke-4) 3 nights potongan ke-1

Render dengan lang dan dir yang benar

Server yang sudah tahu bahasanya langsung menulis <html> yang benar, jadi gak ada kedip. Header Content-Language menyebut bahasanya, dan Vary memberi tahu cache bahwa isi halaman bergantung pada header dan cookie, supaya CDN gak mengirim halaman Jepang ke tamu Indonesia.

{{-- Response-nya membawa header: Content-Language: ja-JP dan Vary: Accept-Language, Cookie --}}
@php($tag = ['id' => 'id-ID', 'en' => 'en-US', 'ja' => 'ja-JP', 'ar' => 'ar-EG'][app()->getLocale()])
<html lang="{{ $tag }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">

Hydration mismatch: server dan browser memformat beda

Di React atau Next.js, HTML dirender dulu di server (Node), lalu React di browser me-render ulang komponen yang sama dan mencocokkan teksnya. Proses ini disebut hydration. Kalau teksnya beda, React membuang HTML server dan merender ulang. Kami mengujinya dengan React 19.2.4:

Kode Server (Node 22.22.1, zona UTC) Browser (Chrome 147, zona WIB)
new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR' }).format(700000) "Rp 700.000" (CLDR 48: 0 desimal) "Rp 700.000,00"
new Intl.DateTimeFormat('id-ID', { timeStyle: 'short' }).format(checkIn) "07.00" "14.00"

React menulis: "Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client.", dan salah satu penyebab yang dia sebut adalah "Date formatting in a user's locale which doesn't match the server." Di build production pesannya "Minified React error #418".

Email dan notifikasi dalam bahasa tamu

Email konfirmasi sering dikirim dari queue, tanpa request, jadi locale-nya bawaan config/app.php. Simpan bahasa tamu di kolom locale, lalu biarkan Laravel memakainya:

use Illuminate\Contracts\Translation\HasLocalePreference;

class User extends Authenticatable implements HasLocalePreference
{
    public function preferredLocale(): string
    {
        return $this->locale; // 'ja'
    }
}

$user->notify(new BookingConfirmed($booking));               // otomatis memakai lang/ja
$user->notify((new BookingConfirmed($booking))->locale('ar')); // atau sebut langsung

Dengan HasLocalePreference, Laravel memakai preferredLocale() untuk notification dan mailable, juga yang masuk queue; untuk mailable ada juga Mail::to($user)->locale('ja'). Di server Node, jangan panggil i18next.changeLanguage per request pada satu instance bersama, karena request yang datang bersamaan saling menimpa bahasanya; pakai i18next.getFixedT(lng) per request.

⚠️ Jebakan yang sering ketemu

TL;DR: Server memilih bahasa dari, berurutan, awalan URL, locale user, cookie, lalu Accept-Language yang diurutkan menurut q (ja,en-US;q=0.7,en;q=0.3), dengan bahasa sumber sebagai cadangan. getPreferredLanguage() Laravel mengembalikan gaya garis bawah (ja_JP), jadi pakai daftar ['id', 'en', 'ja', 'ar'] yang sama dengan folder lang/, lalu App::setLocale() di middleware. __() dan trans() mengambil teks, trans_choice memilih potongan | menurut nomor dari tabel Laravel (bukan ICU), dan locale dengan tanda minus membuatnya selalu memilih potongan pertama. Kirim <html lang dir>, Content-Language, dan Vary. Hydration mismatch muncul kalau server dan browser memformat beda (Node 22 "Rp 700.000", Chrome "Rp 700.000,00", jam UTC lawan WIB): tulis opsinya, atau format sekali di server. Email memakai bahasa tamu lewat HasLocalePreference atau ->locale('ja').

Yang akan kamu pelajari