Aplikasi yang tumbuh sering menemui pengguna baru yang tidak berbahasa Indonesia. Internationalization (i18n) adalah praktik merancang aplikasi agar mudah diadaptasi ke berbagai bahasa dan wilayah, sedangkan localization (l10n) adalah proses adaptasinya untuk satu locale tertentu. Keduanya menuntut persiapan sejak awal: teks tidak lagi ditulis langsung di kode, dan angka maupun tanggal tidak lagi diformat mengikuti kebiasaan satu negara saja. Chapter ini merangkai konsep i18n di PHP: kode pengenal locale, katalog pesan, deteksi bahasa pengguna, format angka dan mata uang, serta alat produksi seperti ext-intl dan gettext.

Bahasa, Locale, dan Kode Pengenal #

Dua istilah yang sering tertukar: kode bahasa mengidentifikasi bahasa, sedangkan locale menambahkan wilayah pada bahasa tersebut. Kode bahasa mengikuti standar ISO 639-1 yang hanya dua huruf, sementara locale mengikuti format BCP 47 yang menggabungkan kode bahasa dan kode wilayah dengan tanda hubung.

KodeJenisMakna
idKode bahasaBahasa Indonesia
enKode bahasaBahasa Inggris
id-IDLocaleBahasa Indonesia di wilayah Indonesia
en-USLocaleBahasa Inggris di Amerika Serikat
en-GBLocaleBahasa Inggris di Britania Raya

Pemisahan ini penting karena format internet memakai tanda hubung (id-ID) sedangkan fungsi tingkat sistem operasi seperti setlocale() memakai garis bawah (id_ID). Konversi di antara keduanya sering dibutuhkan di lapisan integrasi:

php
<?php
$kodeBcp47 = "id-ID";                              // format internet, dipakai ext-intl
$kodeSetlocale = str_replace("-", "_", $kodeBcp47); // format sistem operasi

echo $kodeBcp47 . " -> " . $kodeSetlocale . "\n";

Output yang Dihasilkan:

text
id-ID -> id_ID

Variabel $kodeBcp47 menyimpan bentuk BCP 47 yang dipakai antar layanan web, lalu str_replace() menukar tanda hubung menjadi garis bawah sesuai kebiasaan sistem operasi. Dalam praktiknya, simpan satu bentuk saja di database (biasanya BCP 47) dan lakukan konversi hanya di batas sistem yang membutuhkan bentuk lain.

Anatomi Aplikasi Multi-Bahasa #

Aplikasi yang siap i18n memisahkan tiga hal: kunci pesan yang tertanam di kode, katalog terjemahan per locale, dan aturan format untuk tanggal, angka, serta mata uang. Alur pemrosesan sebuah request dapat digambarkan sebagai berikut:

Diagram di atas membaca dari kiri ke kanan: preferensi bahaya dibaca lebih dulu (dari sesi pengguna atau header HTTP), katalog yang sesuai dimuat, kunci-kunci pesan diterjemahkan dengan penggantian placeholder, lalu lapisan format menyesuaikan tanggal dan angka sebelum halaman dirender. Setiap tahap digantikan implementasinya — misalnya katalog berupa array PHP, berkas .mo gettext, atau paket composer — tanpa mengubah alur besarnya.

Katalog Pesan dengan Array PHP #

Inti katalog pesan sesungguhnya sederhana: satu struktur data yang memetakan kunci stabil ke teks per locale. Kode hanya menyebut kunci; teks tinggal diganti menurut bahasa yang aktif. Array PHP sudah cukup untuk membangun mekanisme ini:

php
<?php
$katalog = [
    "id" => [
        "welcome" => "Selamat datang, :name!",
        "cart.items" => "Keranjang Anda berisi :count barang.",
    ],
    "en" => [
        "welcome" => "Welcome, :name!",
        "cart.items" => "Your cart contains :count items.",
    ],
];

function terjemahkan(string $kunci, string $locale, array $katalog, array $parameter = []): string
{
    $teks = $katalog[$locale][$kunci] ?? $katalog["en"][$kunci] ?? $kunci;
    foreach ($parameter as $nama => $nilai) {
        $teks = str_replace($nama, $nilai, $teks);
    }
    return $teks;
}

echo terjemahkan("welcome", "id", $katalog, [":name" => "Budi"]) . "\n";
echo terjemahkan("cart.items", "en", $katalog, [":count" => "3"]) . "\n";
echo terjemahkan("welcome", "ms", $katalog, [":name" => "Siti"]) . "\n";
echo terjemahkan("checkout.title", "id", $katalog) . "\n";

Output yang Dihasilkan:

text
Selamat datang, Budi!
Your cart contains 3 items.
Welcome, Siti!
checkout.title

Mari kita bedah komponen utamanya:

  1. Array $katalog bertingkat dua: kode bahasa menjadi kunci pertama, kunci pesan menjadi kunci kedua, dan nilainya teks final.
  2. Baris $teks = ... memakai rantai null coalescing: ambil teks dari locale yang diminta, jatuh ke "en" bila locale tidak dikenal, lalu jatuh ke nama kunci itu sendiri sebagai upaya terakhir.
  3. Perulangan foreach mengganti placeholder seperti :name dengan nilai parameter melalui str_replace().
  4. Empat pemanggilan terakhir menguji keempat jalur: locale id, locale en, locale ms yang tidak ada di katalog (jatuh ke Inggris), dan kunci checkout.title yang memang belum diterjemahkan.

Kunci yang belum diterjemahkan tampil apa adanya (checkout.title) sehingga celah terjemahan mudah terlihat saat pengujian, bukan tersamar menjadi error. Pola placeholder :nama juga aman dari masalah urutan kata: penerjemah bebas mengatur ulang posisi placeholder di dalam kalimat.

Mendeteksi Bahasa Pengguna #

Browser modern mengirim header Accept-Language pada setiap request, berisi daftar locale berurutan menurut prioritas. Contoh nilainya "id-ID,id;q=0.9,en-US;q=0.8": pengguna lebih memilih Indonesia, lalu bahasa Indonesia generik, lalu Inggris Amerika. Fungsi kecil berikut memetakan header tersebut ke daftar bahasa yang didukung aplikasi:

php
<?php
function localeDariHeader(string $header, array $didukung): ?string
{
    foreach (explode(",", $header) as $bagian) {
        $locale = trim(explode(";", $bagian)[0]);
        if (in_array($locale, $didukung, true)) {
            return $locale;
        }
        $bahasa = strtolower(substr($locale, 0, 2));
        foreach ($didukung as $kandidat) {
            if (strtolower(substr($kandidat, 0, 2)) === $bahasa) {
                return $kandidat;
            }
        }
    }
    return null;
}

var_dump(localeDariHeader("id-ID,id;q=0.9,en-US;q=0.8", ["id", "en"]));
var_dump(localeDariHeader("ja-JP,en;q=0.5", ["id", "en"]));
var_dump(localeDariHeader("fr-FR,de;q=0.5", ["id", "en"]));

Output yang Dihasilkan:

text
string(2) "id"
string(2) "en"
NULL

Fungsi ini memecah header berdasarkan koma, membuang bagian prioritas setelah titik koma, lalu mencocokkan entri dengan daftar yang didukung. Pencocokan dilakukan dua tahap: locale penuh lebih dulu (id-ID), lalu kode bahasa dua huruf (id) sebagai padanan longgar. Header "fr-FR,de;q=0.5" tidak memuat satu pun bahasa yang didukung sehingga fungsi mengembalikan null, dan aplikasi boleh memilih fallback defaultnya.

Keterbatasan setlocale di Lingkungan Nyata #

Jalan klasik untuk locale di PHP adalah pasangan setlocale() dan strftime(). Kenyataannya jalan ini rapuh: strftime() telah deprecated (kita bahas pada chapter Locale-Aware Formatting ), hasilnya bergantung pada data locale yang terinstal di sistem operasi, dan status setlocale() bersifat global untuk seluruh proses. Build materi ini memperlihatkan perilaku yang menegaskan hal tersebut:

php
<?php
var_dump(setlocale(LC_ALL, "0"));
var_dump(setlocale(LC_ALL, "id_ID"));
var_dump(setlocale(LC_ALL, "xx_XX"));

Output yang Dihasilkan:

text
string(17) "C.UTF-8;C;C;C;C;C"
string(5) "id_ID"
string(5) "xx_XX"

Baris pertama menampilkan locale aktif per kategori sebelum diubah, yaitu C.UTF-8 untuk kategori utama. Dua baris berikutnya memperlihatkan jebakannya: setlocale() melaporkan keberhasilan untuk id_ID, tetapi juga “berhasil” untuk xx_XX yang bukan locale sungguhan. Penerimaan nama sembarangan ini membuktikan bahwa nilai balik setlocale() tidak menjamin data locale benar-benar tersedia. Kita buktikan dengan melihat hasil formatnya:

php
<?php
setlocale(LC_ALL, "id_ID");

// Tanda @ menyembunyikan peringatan Deprecated dari strftime()
echo @strftime("%d %B %Y", 1768438800) . "\n";

Output yang Dihasilkan:

text
15 January 2026

Meski setlocale() melaporkan id_ID aktif, strftime() tetap mencetak 15 January 2026 dalam bahasa Inggris. Data nama bulan Indonesia tidak pernah benar-benar termuat pada proses ini. Perilaku seperti inilah yang membuat kode bergantung setlocale() tidak portabel antar server.

Format Angka dan Mata Uang #

Locale tidak hanya mengatur bahasa, tetapi juga notasi angka. Konvensi Indonesia memakai titik sebagai pemisah ribuan dan koma sebagai desimal — 1.234.567,89 — kebalikan dari kebiasaan Inggris. Fungsi number_format() menerima kedua pemisah tersebut sebagai argumen:

php
<?php
function formatAngkaIndonesia(float $angka, int $jumlahDesimal = 2): string
{
    return number_format($angka, $jumlahDesimal, ",", ".");
}

function formatRupiah(float $nominal): string
{
    return "Rp" . number_format($nominal, 0, ",", ".");
}

echo formatAngkaIndonesia(1234567.891) . "\n";
echo formatAngkaIndonesia(0.5) . "\n";
echo formatRupiah(250000) . "\n";
echo formatRupiah(1234500) . "\n";

Output yang Dihasilkan:

text
1.234.567,89
0,50
Rp250.000
Rp1.234.500

Fungsi formatAngkaIndonesia() meneruskan koma sebagai desimal dan titik sebagai ribuan ke number_format(), menghasilkan 1.234.567,89 dari nilai 1234567.891. Fungsi formatRupiah() memakai nol desimal karena rupiah jarang memakai pecahan, lalu membubuhkan awalan Rp tanpa spasi sesuai kaidah penulisan resmi. Untuk mata uang asing dan format lainnya, ext-intl menyediakan kelas NumberFormatter:

php
<?php
$pemformatDesimal = new NumberFormatter("id_ID", NumberFormatter::DECIMAL);
echo $pemformatDesimal->format(1234567.891) . "\n";

$pemformatMataUang = new NumberFormatter("id_ID", NumberFormatter::CURRENCY);
echo $pemformatMataUang->formatCurrency(250000, "IDR") . "\n";

Blok di atas membutuhkan ext-intl yang tidak aktif pada build ini. Pada server yang memilikinya, dua baris tersebut menghasilkan 1.234.567,891 dan Rp250.000,00 — pemisah, simbol, dan pembulatan diurus penuh oleh pustaka ICU tanpa satu pun if tambahan dari kita.

gettext dan Solusi Framework #

Untuk katalog pesan berukuran besar, PHP memiliki mekanisme klasik bernama gettext. Alih-alih array di dalam kode, terjemahan disimpan pada berkas .po yang dikompilasi menjadi .mo oleh perkakas msgfmt, lalu dimuat berdasarkan domain:

php
<?php
// Membutuhkan ekstensi gettext yang aktif; tidak tersedia pada build ini
setlocale(LC_ALL, "id_ID.UTF-8");
bindtextdomain("pesan", __DIR__ . "/locale");
textdomain("pesan");

echo gettext("welcome");

Konstruktornya tiga baris: bindtextdomain() menghubungkan domain "pesan" dengan direktori katalog, textdomain() menetapkan domain bawaan, dan gettext() mengambil terjemahan untuk sebuah kunci. Keunggulan gettext adalah alur kerjanya — penerjemah bekerja pada berkas .po terpisah tanpa menyentuh kode. Ekstensi ini pun tidak aktif pada build materi kita, dan banyak tim modern memilih lapisan berbasis PHP murni (misalnya komponen translation dari framework) yang bekerja di atas array atau berkas tanpa dependensi sistem.

Ringkasan #

Internationalization dimulai dari disiplin kecil: pisahkan teks dari kode lewat katalog pesan berbasis kunci, kenali perbedaan kode bahasa dan locale, deteksi preferensi pengguna tanpa menjadikannya satu-satunya sumber kebenaran, dan format angka serta tanggal menurut kebiasaan pembaca. Alat produksi seperti ext-intl dan gettext menyingkat banyak pekerjaan, sementara pendekatan murni-PHP menjamin portabilitas. Tampilan yang ramah pembaca hanyalah separuh cerita — fondasi yang benar ada pada data waktu yang disimpan dan dibandingkan secara aman, topik penutup section ini pada chapter berikutnya.

Referensi #