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);
}
}
- Daftarkan di
bootstrap/app.php:$middleware->web(append: [SetLocale::class]);. Awalan URL datang dariRoute::prefix('{locale}')->whereIn('locale', ['id', 'en', 'ja', 'ar'])->group(...). App::setLocale()mengganti locale untuk request ini saja.App::currentLocale()membacanya,App::isLocale('ja')mengeceknya.- Laravel mengenkripsi semua cookie. Cookie
lang=jayang ditulis JavaScript gak bisa didekripsi, jadi$request->cookie('lang')memberinull. Kecualikan namanya:$middleware->encryptCookies(except: ['lang']);.
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 |
- Urutan potongan untuk
armengikuti kategori CLDR: zero, one, two, few, many, other. Di ICU kategorinya ditulis dengan nama (two {ليلتان}), jadi urutannya gak penting; di Laravel salah urutan berarti salah kalimat.:countditulis apa adanya ("3"), sedangkan#di ICU diformat locale ("٣"). - Bisa juga rentang eksplisit:
'{0} Belum ada malam|[1,*] :count malam'. - Nama locale Laravel pakai garis bawah (
ar,ar_EG). DenganApp::setLocale('ar-EG'),trans_choiceselalu mengambil potongan pertama: kami mendapat "1 ليلة" dan "2 ليلة".
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".
- Selalu tulis opsinya:
maximumFractionDigits: 0dantimeZone: 'Asia/Jakarta', dengan locale yang dikirim server, bukannavigator.language. - Opsi yang sama pun bisa beda per mesin: Safari 26.4 menulis "Rp700.000" tanpa spasi, Node "Rp 700.000". Paling aman: format sekali di server dan kirim string-nya sebagai props.
- PHP juga punya ICU sendiri:
Number::currency(700000, in: 'IDR', locale: 'id')memberi "Rp 700.000,00" di PHP 8.4.6 (ICU 77.1). Tambahprecision: 0untuk "Rp 700.000".
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
split(',')[0]. Mengabaikan q dan daftar bahasa yang didukung.- Locale dengan tanda minus di Laravel.
ar-EGmembuattrans_choiceselalu memilih potongan pertama. Nama folderlang/arataulang/ar_EG; ubahja_JPjadija-JPsebelum ditulis di<html lang>. Cookielangdari JavaScript juga perlu dikecualikan diencryptCookies. - Format tanpa opsi di halaman yang di-hydrate. Rupiah 0 desimal di Node dan 2 di browser, jam UTC di server: hydration mismatch.
- Email dari queue dalam bahasa bawaan. Pakai
HasLocalePreferenceatau->locale().
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').