Chapter Character Encoding sudah membedah mesin di balik teks: bagaimana byte diterjemahkan menjadi karakter, dan bagaimana kesalahan penerjemahan menghasilkan “café” yang memalukan itu. Sekarang kita naik satu level, dari mekanika byte ke strategi aplikasi. Aplikasi yang bebas masalah encoding bukan yang kebetulan cocok, melainkan yang mengambil satu keputusan tegas — UTF-8 di semua tempat — lalu menjaga keputusan itu di setiap batas yang dilalui data: saat masuk, saat keluar, dan saat disimpan. Operasi string multi-byte sehari-hari seperti mb_substr() sudah kita bahas tuntas di chapter Unicode dan UTF-8 pada Section String ; fokus chapter ini adalah kebijakan lintas lapisan yang membuat operasi tersebut selalu menerima input yang sehat.

UTF-8 sebagai Kebijakan Default #

Pertanyaan pertama pada setiap aplikasi adalah “encoding apa yang kita pakai?”, dan jawaban yang benar hampir selalu UTF-8. UTF-8 kompatibel penuh dengan ASCII, menjangkau seluruh karakter Unicode dari aksen Eropa hingga emoji, didukung semua bahasa pemrograman modern, dan menjadi bawaan JSON, HTML5, serta SQLite. Menerapkannya sebagai kebijakan berarti tiga hal konkret: setiap data yang masuk diverifikasi sebagai UTF-8, setiap data yang keluar dideklarasikan sebagai UTF-8, dan setiap tempat penyimpanan di-set ke UTF-8. Kebijakan ini hanya sekuat batas yang paling lemah — satu koneksi database dengan charset Latin-1 cukup untuk merusak data yang tersimpan, meskipun seluruh aplikasi lainnya benar.

Karena batas-batas itulah tempat encoding biasanya pecah, peta berikut merangkum titik rawannya sekaligus penetapan UTF-8 di masing-masing:

Batas aplikasiYang bisa rusakPenetapan UTF-8
Respons HTTPbrowser menebak encodingheader("Content-Type: text/html; charset=utf-8")
Dokumen HTMLhalaman tampil mojibake<meta charset="utf-8"> di <head>
Koneksi MySQLdata tersimpan sebagai byte Latin-1DSN charset=utf8mb4
SQLite— (sudah UTF-8 secara bawaan)tidak perlu
Ekspor CSV untuk ExcelExcel menebak ANSIsisipkan BOM UTF-8 di awal berkas
API eksternal non-UTF-8data dikirim dengan encoding lainkonversi mb_convert_encoding() di kedua arah

Menyaring Batas Masukan #

Data dari luar — form, berkas upload, respons API pihak ketiga — tidak selalu datang sebagai UTF-8 yang valid, dan byte yang rusak paling murah ditangani di pintu masuk. Fungsi mb_check_encoding() menjawab satu pertanyaan tepat: apakah seluruh byte string ini membentuk UTF-8 yang sah. Dijadikan gerbang, ia mencegah byte rusak menjalar ke penyimpanan dan keluar sebagai teks korup:

php
<?php
/**
 * Pertahankan hanya nilai string yang valid UTF-8.
 */
function saringInputUtf8(array $input): array
{
    $bersih = [];

    foreach ($input as $kunci => $nilai) {
        if (is_string($nilai) && !mb_check_encoding($nilai, "UTF-8")) {
            continue;
        }
        $bersih[$kunci] = $nilai;
    }

    return $bersih;
}

$form = [
    "nama" => "José Pratama",
    "kota" => "Yogyakarta",
    "catatan" => "café \xE9 dari klien lama",
];

$aman = saringInputUtf8($form);

echo "lolos saringan: ", implode(", ", array_keys($aman)), "\n";
echo "panjang nama: ", mb_strlen($aman["nama"]), " karakter\n";

Output yang Dihasilkan:

text
lolos saringan: nama, kota
panjang nama: 12 karakter

Nilai catatan sengaja berisi byte \xE9 tunggal — byte pembuka UTF-8 yang yatim, sama seperti yang kita bedah di chapter Character Encoding — sehingga gagal pemeriksaan dan digugurkan, sementara nama dan kota lolos. Perhatikan juga bahwa mb_strlen() pada nilai yang lolos menghitung 12 karakter untuk “José Pratama”: fungsi mb_* hanya bisa dipercaya setelah input dipastikan valid, dan itulah urutan kerjanya — saring dulu, olah kemudian.

JSON Selalu Berbicara UTF-8 #

Spesifikasi JSON menetapkan UTF-8 sebagai satu-satunya encoding yang sah, sehingga JSON menjadi batas yang ramah — selama kita paham dua perilaku json_encode(). Pertama, secara baku seluruh karakter non-ASCII lolos sebagai sekuens \uXXXX; kedua, input yang mengandung byte UTF-8 tidak valid membuat pengodean gagal:

php
<?php
$produk = ["nama" => "Kopi Toraja", "catatan" => "crème brûlée & kopi susu"];

echo json_encode($produk), "\n";
echo json_encode($produk, JSON_UNESCAPED_UNICODE), "\n";

$bytesRusak = "catatan caf\xE9";

$hasil = json_encode(["catatan" => $bytesRusak]);
var_dump($hasil);
echo json_last_error_msg(), "\n";
echo json_encode(["catatan" => $bytesRusak], JSON_INVALID_UTF8_SUBSTITUTE), "\n";

Output yang Dihasilkan:

text
{"nama":"Kopi Toraja","catatan":"cr\u00e8me br\u00fbl\u00e9e & kopi susu"}
{"nama":"Kopi Toraja","catatan":"crème brûlée & kopi susu"}
bool(false)
Malformed UTF-8 characters, possibly incorrectly encoded
{"catatan":"catatan caf\ufffd"}

Baris pertama dan kedua memperlihatkan perbedaan flag: tanpa flag, crème tersandi sebagai cr\u00e8me — sah dan aman, tetapi menyulitkan dibaca manusia; dengan JSON_UNESCAPED_UNICODE, karakter beraksen ditulis apa adanya. Empat baris terakhir menunjukkan nasib byte rusak: json_encode() mengembalikan false, json_last_error_msg() menjelaskan alasannya, dan flag JSON_INVALID_UTF8_SUBSTITUTE mengganti byte bermasalah dengan karakter pengganti U+FFFD (\ufffd) alih-alih menggagalkan seluruh payload. Untuk API publik, menolak payload dengan respons 422 umumnya lebih jujur daripada menyimpan teks yang sudah disubsitusi diam-diam.

Escaping Output dengan Charset yang Benar #

Mengirim teks ke browser menuntut dua lapis perlindungan sekaligus: deklarasi charset agar browser tahu cara mendekode, dan escaping HTML agar tag tidak disusupi. Kedua lapis itu bertemu pada argumen ketiga htmlspecialchars():

php
<?php
$komentar = "<b>Promosi</b> kopi murah & gratis ongkir";
$namaToko = "Café “Sinar Pagi”";

echo htmlspecialchars($komentar, ENT_QUOTES, "UTF-8"), "\n";
echo htmlspecialchars($namaToko, ENT_QUOTES, "UTF-8"), "\n";

$byteRusak = "<b>café \xE9</b>";
var_dump(htmlspecialchars($byteRusak, ENT_QUOTES, "UTF-8"));

Output yang Dihasilkan:

text
&lt;b&gt;Promosi&lt;/b&gt; kopi murah &amp; gratis ongkir
Café “Sinar Pagi”
string(0) ""

Dua baris pertama menunjukkan kerja normal: tag <b> dan & berubah menjadi entitas HTML sehingga aman dirender, sementara tanda kutip lengkung dan huruf beraksen lewat utuh karena memang bukan karakter berbahaya. Baris terakhir adalah gotcha yang jarang disadari: input dengan byte UTF-8 tidak valid membuat htmlspecialchars() mengembalikan string kosong — bukan error, bukan teks parsial. Satu byte rusak dari pengguna cukup membuat seluruh komentarnya menghilang dari halaman; satu alasan lagi mengapa gerbang mb_check_encoding() di bagian sebelumnya harus berdiri sebelum data sampai ke lapisan output.

Penyimpanan Database #

Lapisan penyimpanan adalah tempat korupsi encoding paling sering terjadi karena justru paling jarang diperiksa. Pada MySQL, charset ditetapkan di tiga tempat yang harus cocok: koneksi (DSN charset=utf8mb4), tabel, dan kolom. SQLite berada di ujung yang berlawanan — formatnya UTF-8 murni sejak lahir sehingga tidak butuh deklarasi apa pun:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->exec("CREATE TABLE pelanggan (nama TEXT, kota TEXT)");

$stmt = $pdo->prepare("INSERT INTO pelanggan (nama, kota) VALUES (?, ?)");
$stmt->execute(["José Fernández", "Yogyakarta"]);

$baris = $pdo->query("SELECT nama, kota, LENGTH(nama) AS panjang FROM pelanggan")
    ->fetch(PDO::FETCH_ASSOC);

echo $baris["nama"], " dari ", $baris["kota"], "\n";
echo "panjang menurut SQLite: ", $baris["panjang"], " karakter\n";

Output yang Dihasilkan:

text
José Fernández dari Yogyakarta
panjang menurut SQLite: 14 karakter

Nama dengan dua huruf beraksen tersimpan dan terbaca kembali utuh, dan LENGTH() SQLite menghitung karakter (14), bukan byte (16) — SQLite menafsirkan kolom TEXT sebagai UTF-8 sehingga seluruh rantai round-trip ini aman tanpa satu pun konfigurasi. Untuk MySQL, pola koneksi yang setara adalah new PDO("mysql:host=...;dbname=...;charset=utf8mb4", ...) seperti yang dicontohkan di chapter Character Encoding.

Menguji Multibyte di CI #

Kebijakan encoding hanya bisa dipercaya jika diuji, dan pengujiannya harus masuk ke pipeline CI agar regresi tertangkap sebelum produksi. Prinsipnya satu: fixture pengujian wajib berisi teks multi-byte nyata — aksen, kutip lengkung, emoji — karena data ASCII murni tidak akan pernah menemukan bug encoding. Contoh berikut memeriksa panjang tiga string campuran dengan mb_strlen(), pola yang persis sama bila ditulis sebagai metode pengujian PHPUnit:

php
<?php
$kasusUji = [
    ["Belajar PHP bersama Budi", 24],
    ["Menikmati kopi di café", 22],
    ["Espresso ☕ dan croissant", 24],
];

foreach ($kasusUji as [$teks, $panjangHarapan]) {
    $status = mb_strlen($teks) === $panjangHarapan ? "OK" : "GAGAL";
    printf("%-5s %s (%d karakter)\n", $status, $teks, mb_strlen($teks));
}

Output yang Dihasilkan:

text
OK    Belajar PHP bersama Budi (24 karakter)
OK    Menikmati kopi di café (22 karakter)
OK    Espresso ☕ dan croissant (24 karakter)

Ketiga baris mencetak OK karena mb_strlen() menghitung karakter, bukan byte — café menyumbang empat karakter dan emoji ☕ satu. Bila suatu saat ada kode yang tanpa sengaja memakai strlen(), baris emoji akan mencatat GAGAL dengan selisih tiga, dan CI memblokir merge sebelum bug itu menyentuh pengguna.

Ringkasan #

UTF-8 yang sehat adalah keputusan arsitektur, bukan kebetulan: gerbang mb_check_encoding() di batas masuk, flag JSON_UNESCAPED_UNICODE dan substitusi yang sadar di batas JSON, htmlspecialchars() dengan charset eksplisit di batas output, utf8mb4 di batas MySQL, dan fixture multibyte di CI yang menjaga semuanya. Data yang kini aman tersimpan dan tersajikan utuh masih menyisakan satu dimensi: gaya penyajiannya berbeda antar wilayah — “1.234.567,89” versus “1,234,567.89”, “Kamis” versus “Thursday”. Chapter Locale-Aware Formatting berikutnya mengambil alih dimensi itu.

Referensi #