Menerjemahkan kata adalah pekerjaan yang sulit; merancang aplikasi yang hidup dalam beberapa bahasa sekaligus adalah pekerjaan yang sama sekali lain. Bahasa menyentuh hampir setiap lapisan: URL mana yang dijual ke mesin pencari, bagaimana preferensi pengguna bertahan antar kunjungan, di mana terjemahan konten dinamis disimpan, hingga arah pembacaan teks dari kanan ke kiri. Chapter penutup section ini memetakan keputusan arsitektur tersebut secara utuh: deteksi bahasa berlapis, strategi URL, penyimpanan pilihan, dua pola terjemahan konten database, dukungan RTL, dan rantai fallback. Seluruh contoh memakai PHP murni dan SQLite sehingga bisa langsung Anda jalankan.

Prioritas Deteksi Bahasa #

Aplikasi multi-bahasa perlu menjawab satu pertanyaan di awal setiap request: bahasa apa yang dipakai? Praktik umum menyusun sumber jawaban berlapis dengan urutan prioritas: pertama, segmen URL saat ini — pilihan yang eksplisit dan bisa dibagikan; kedua, cookie preferensi pengguna yang kembali; ketiga, header Accept-Language yang dikirim browser; keempat, bahasa default aplikasi. Header Accept-Language berisi daftar tag bahasa dengan bobot kualitas (q), misalnya id-ID,id;q=0.9,en;q=0.8, dan menegosiasikannya dengan daftar bahasa yang didukung adalah pekerjaan parsing yang layak dibuat fungsi:

php
<?php
function negosiasikanBahasa(string $acceptLanguage, array $didukung): string
{
    $kandidat = [];
    foreach (explode(",", $acceptLanguage) as $bagian) {
        $bagian = trim($bagian);
        if ($bagian === "") {
            continue;
        }
        [$tag, $q] = array_pad(explode(";q=", $bagian, 2), 2, null);
        $kandidat[] = [$tag, $q === null ? 1.0 : (float) $q];
    }
    usort($kandidat, fn (array $a, array $b): int => $b[1] <=> $a[1]);

    foreach ($kandidat as [$tag]) {
        $bahasa = strtolower(explode("-", $tag)[0]);
        if (in_array($bahasa, $didukung, true)) {
            return $bahasa;
        }
    }

    return $didukung[0];
}

$didukung = ["id", "en"];
echo negosiasikanBahasa("id-ID,id;q=0.9,en;q=0.8", $didukung), "\n";
echo negosiasikanBahasa("fr-FR,fr;q=0.9,en-US;q=0.8", $didukung), "\n";
echo negosiasikanBahasa("zh-CN,zh;q=0.9", $didukung), "\n";

Output yang Dihasilkan:

text
id
en
id

Mari kita bedah mekanismenya. Setiap elemen dipisah koma lalu diurai menjadi pasangan tag dan bobot q, dengan bobot default 1.0 bila tidak ditulis. Fungsi usort() mengurutkan kandidat dari bobot tertinggi dengan perbandingan <=>, lalu perulangan kedua memangkas region dari setiap tag — en-US menjadi en — dan mengembalikan bahasa pertama yang ada di daftar $didukung. Header pertama dimenangkan id secara langsung; header kedua tidak didukung untuk Prancis sehingga jatuh ke en; header ketiga sama sekali tidak cocok sehingga fungsi mengembalikan bahasa default $didukung[0], yaitu id.

Strategi URL untuk Banyak Bahasa #

Bahasa yang dipilih pengguna harus tercermin di URL agar halaman bisa dibagikan, di-cache, dan diindeks mesin pencari per bahasa. Tiga strategi menamainya:

StrategiContohKelebihanKekurangan
Subdirektoriexample.com/id/produkSEO per bahasa, mudah di-cache, mudah dibagikanrouting harus sadar prefix
Subdomainid.example.comisolasi penuh tiap bahasaDNS dan cookie per subdomain, SEO terpecah
Query parameterexample.com/produk?lang=idpaling cepat diimplementasikanburuk untuk SEO dan cache, URL tidak stabil

Subdirektori adalah pilihan paling umum karena menyeimbangkan ketiganya. Router cukup memeriksa segmen pertama path sebelum menyerahkan sisanya ke aturan normal:

php
<?php
function parseRute(string $path, array $localeDidukung): array
{
    $segmen = explode("/", trim($path, "/"));
    $kandidat = strtolower(array_shift($segmen));

    if (in_array($kandidat, $localeDidukung, true)) {
        return ["locale" => $kandidat, "rute" => implode("/", $segmen)];
    }

    return ["locale" => null, "rute" => implode("/", array_merge([$kandidat], $segmen))];
}

foreach (["/id/produk/123", "/en/about", "/produk/123"] as $path) {
    $hasil = parseRute($path, ["id", "en"]);
    echo $path, " -> locale=", ($hasil["locale"] ?? "(default)"), ", rute=", $hasil["rute"], "\n";
}

Output yang Dihasilkan:

text
/id/produk/123 -> locale=id, rute=produk/123
/en/about -> locale=en, rute=about
/produk/123 -> locale=(default), rute=produk/123

Fungsi parseRute() membuang garis miring di kedua ujung, memecah path menjadi segmen, lalu mengambil segmen pertama sebagai kandidat locale. Bila kandidat ada di daftar yang didukung, segmen itu dikonsumsi sebagai locale dan sisanya menjadi rute murni; bila tidak, seluruh segmen dirakit kembali dan locale bernilai null — sinyal bagi aplikasi untuk memakai bahasa default atau membaca cookie. Dengan begitu /produk/123 tanpa prefix tetap berfungsi, sehingga migrasi dari aplikasi satu bahasa tidak memutus tautan lama.

Menyimpan Pilihan Pengguna #

Saat pengguna secara eksplisit memilih bahasa — lewat menu bahasa, misalnya — pilihan itu perlu bertahan melewati request berikutnya. Cookie adalah wadah yang tepat karena bertahan antar kunjungan, tidak seperti session yang hangus ketika pengguna menutup browser:

php
<?php
// Simpan pilihan bahasa pengguna selama 30 hari
setcookie("preferensi_bahasa", "id", [
    "expires"  => time() + 30 * 24 * 3600,
    "path"     => "/",
    "httponly" => true,
    "samesite" => "Lax",
]);

Opsi httponly mencegah JavaScript membaca cookie ini, dan samesite bernilai Lax sudah memadai untuk data preferensi yang bukan kredensial. Detail lengkap opsi cookie dibahas pada Chapter Cookies . Rantai deteksi lengkapnya kemudian berbunyi: baca segmen URL, jika kosong baca cookie preferensi_bahasa, jika tidak ada negosiasikan Accept-Language, dan terakhir jatuh ke bahasa default.

Menerjemahkan Konten Dinamis #

String antarmuka hidup di katalog terjemahan berkas — strukturnya dibahas pada Chapter Translation Files . Tetapi konten yang dibuat pengguna atau editor — nama produk, artikel, kategori — hidup di database, dan di sanalah dua pola bersaing. Pola pertama menambahkan kolom per bahasa langsung pada tabel utama:

sql
CREATE TABLE produk (
    id       INTEGER PRIMARY KEY,
    nama_id  TEXT NOT NULL,
    nama_en  TEXT NOT NULL,
    harga    INTEGER NOT NULL
);

Pola kolom sederhana dan cepat ditulis, tetapi setiap bahasa baru menuntut ALTER TABLE pada tabel produksi plus pengisian ulang data lama — migrasi yang mahal untuk konten yang terus bertambah. Pola kedua memisahkan terjemahan ke tabel sendiri:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->exec("
    CREATE TABLE produk (
        id INTEGER PRIMARY KEY,
        slug TEXT NOT NULL,
        harga INTEGER NOT NULL
    );
    CREATE TABLE produk_terjemahan (
        produk_id INTEGER NOT NULL REFERENCES produk(id),
        locale TEXT NOT NULL,
        nama TEXT NOT NULL,
        PRIMARY KEY (produk_id, locale)
    )
");
$pdo->exec("INSERT INTO produk (id, slug, harga) VALUES (1, 'kopi-gayo', 85000)");
$pdo->exec("INSERT INTO produk_terjemahan VALUES (1, 'id', 'Kopi Gayo Arabika')");
$pdo->exec("INSERT INTO produk_terjemahan VALUES (1, 'en', 'Gayo Arabica Coffee')");

$stmt = $pdo->prepare("
    SELECT t.nama, p.harga
    FROM produk p
    JOIN produk_terjemahan t ON t.produk_id = p.id
    WHERE p.slug = :slug AND t.locale = :locale
");
foreach (["id", "en"] as $locale) {
    $stmt->execute(["slug" => "kopi-gayo", "locale" => $locale]);
    $baris = $stmt->fetch(PDO::FETCH_ASSOC);
    echo $locale, ": ", $baris["nama"], " (Rp", number_format($baris["harga"], 0, ",", "."), ")\n";
}

Output yang Dihasilkan:

text
id: Kopi Gayo Arabika (Rp85.000)
en: Gayo Arabica Coffee (Rp85.000)

Tabel produk kini hanya menyimpan data yang tidak diterjemahkan — slug dan harga — sedangkan seluruh teks pindah ke produk_terjemahan dengan kunci utama gabungan (produk_id, locale). Query menggabungkan kedua tabel dan memfilter locale lewat prepared statement, sehingga satu tampilan halaman cukup satu query. Menambah bahasa ketiga berarti menyisipkan baris-baris terjemahan baru tanpa menyentuh skema sama sekali; itulah alasan pola tabel terjemahan menang begitu jumlah bahasa atau jumlah konten tumbuh.

Dukungan Arah Teks RTL #

Bahasa Arab dan Ibrani dibaca dari kanan ke kiri (RTL), dan dokumen harus mendeklarasikannya agar browser membalik tata letak dengan benar:

html
<html lang="ar" dir="rtl">
  <body>
    <p>مرحبا بالعالم</p>
  </body>
</html>

Atribut dir="rtl" membalik seluruh arah tata letak: baris teks, perataan paragraf, dan urutan kolom. Untuk gaya yang tetap benar di kedua arah, gunakan properti logis CSS seperti margin-inline-start dan padding-inline-end menggantikan margin-left dan padding-right — properti logis menyesuaikan diri sesuai arah dokumen. Dua pengecualian layak diingat: angka dan kode tetap dibaca kiri ke kanan meski di halaman RTL, dan ikon yang mengandung makna arah (tombol kembali, panah) perlu dicerminkan secara eksplisit.

Rantai Fallback Terjemahan #

Terjemahan selalu ketinggalan dari fitur — itu realitas setiap tim. Rantai fallback menjawabnya dengan urutan locale yang dicoba berturut-turut sebelum menyerah:

php
<?php
$katalog = [
    "id" => [
        "menu.home"  => "Beranda",
        "menu.about" => "Tentang Kami",
    ],
    "en" => [
        "menu.home"  => "Home",
        "menu.about" => "About Us",
        "menu.jobs"  => "Careers",
    ],
];

function terjemah(string $key, array $katalog, array $rantaiFallback): string
{
    foreach ($rantaiFallback as $locale) {
        if (isset($katalog[$locale][$key])) {
            return $katalog[$locale][$key];
        }
    }

    return $key;
}

foreach (["menu.home", "menu.about", "menu.jobs"] as $key) {
    echo $key, " -> ", terjemah($key, $katalog, ["id", "en"]), "\n";
}

Output yang Dihasilkan:

text
menu.home -> Beranda
menu.about -> Tentang Kami
menu.jobs -> Careers

Untuk pengunjung locale Indonesia, rantainya ["id", "en"]: kunci menu.home dan menu.about ditemukan di katalog Indonesia, tetapi menu.jobs terlewat sehingga fungsi jatuh ke katalog Inggris dan mengembalikan Careers. Pengguna melihat bahasa kedua pada satu menu — jauh lebih baik daripada ruang kosong. Bila kunci tidak ada di mana pun, fungsi mengembalikan kunci itu sendiri; teks seperti menu.jobs di layar adalah penanda masalah yang mudah dikenali, bukan kegagalan senyap.

Bahasa Campuran dan Terjemahan Mesin #

Aplikasi multi-bahasa yang ceroboh berakhir sebagai campuran bahasa: tombol berbahasa Indonesia, pesan error berbahasa Inggris, dan nama produk yang diterjemahkan setengah jalan. Halaman campuran seperti ini terkesan tidak dirawat, dan yang lebih buruk, mengajari pengguna untuk tidak membaca pesan aplikasi Anda sama sekali. Pertahanan pertamanya bukan teknis: kelola glosarium istilah (Keranjang atau Troli? pilih satu) dan pastikan seluruh string melewati katalog terjemahan, bukan ditulis langsung di template.

Terjemahan mesin menempati posisi yang perlu dibatasi dengan sadar. Mesin menerjemahkan kalimat, bukan istilah — string UI pendek seperti Draft atau Checkout bisa didarmakan ke sepuluh arti berbeda tanpa konteks. Terjemahan mesin boleh menjadi draf pertama untuk konten panjang buatan pengguna, dengan peninjauan manusia sebelum dipublikasikan; untuk string antarmuka, terjemahan manusia tetap satu-satunya jalan yang aman.

Ringkasan #

Arsitektur multi-bahasa adalah seperangkat keputusan berlapis: deteksi bahasa dari URL, cookie, lalu Accept-Language dengan default di ujung; URL subdirektori yang sadar prefix; preferensi yang bertahan di cookie; konten dinamis di tabel terjemahan terpisah; deklarasi dir untuk pembaca RTL; dan rantai fallback yang menjamin tidak ada string kosong. Semua keputusan itu berdiri di atas fondasi section ini — encoding, locale, terjemahan, plural, uang, dan waktu. Aplikasi Anda kini siap bertemu dunia; perjalanan berikutnya adalah menjalankannya untuk dunia sungguhan, dimulai dengan Section Deployment dan Infrastructure .

Referensi #