Ketika orang lain mulai memakai kode kita — pengguna library, tim sebelah, atau kita sendiri enam bulan lalu — setiap perubahan kita berhenti menjadi urusan pribadi. Menghapus satu parameter yang tampak sepele bagi kita bisa meruntuhkan ratusan pemanggil di tempat lain dalam semalam. Backward compatibility (BC) adalah seni mengembangkan kode tanpa memutus orang-orang yang bergantung padanya, dan section ini kita tutup dengannya karena ia menjadi ujian akhir dari seluruh standar yang kita bangun: kode yang rapi, teruji, dan terkelola baru benar-benar matang ketika bisa berubah secara bertanggung jawab.
Apa Arti Backward Compatibility #
Backward compatibility berarti versi baru kode dapat dipasang sebagai pengganti versi lama tanpa memutus pemanggil yang sudah ada. “Pemanggil” di sini mencakup semua konsumen API kita: pengguna library publik, modul lain dalam aplikasi yang sama, template yang memanggil helper, hingga skrip deploy yang membaca format konfigurasi kita. Cakupan yang dijaga disebut kontrak publik: nama dan signature fungsi, jenis kembalian, exception yang dilempar, perilaku yang dijanjikan, sampai struktur berkas konfigurasi.
Penting dipahami bahwa BC adalah janji untuk kontrak publik saja. Kode internal — helper privat, nama method di class yang tidak pernah dipublikasikan — bebas berubah kapan pun. Membuat seluruh kode bebas perubahan justru mustahil; yang kita jaga adalah bagian yang orang lain pegang. Batas antara keduanya harus eksplisit, dan section berikutnya membahas cara menandainya.
SemVer: Mayor.Minor.Patch #
Standar penomoran versi semantik (SemVer) menjadikan janji BC sebagai tiga angka: MAJOR.MINOR.PATCH. Setiap segmen naik untuk jenis perubahan tertentu, dan setiap kenaikan membawa janji berbeda bagi pengguna:
| Segmen | Naik ketika | Janji bagi pengguna |
|---|---|---|
PATCH (z pada 2.3.1) | perbaikan bug — perilaku salah dikoreksi | aman diperbarui tanpa pemeriksaan |
MINOR (y pada 2.4.0) | fitur baru yang kompatibel ke belakang | aman diperbarui; fitur baru opsional |
MAYOR (x pada 3.0.0) | perubahan yang merusak kontrak lama | baca panduan migrasi, sesuaikan kode |
Kode berikut menerapkan keputusan tersebut sebagai fungsi — diberi versi saat ini dan jenis perubahan yang hendak dirilis, fungsi mengembalikan versi baru yang benar:
<?php
function naikkanVersi(string $versiSaatIni, string $jenisPerubahan): string
{
[$mayor, $minor, $patch] = array_map("intval", explode(".", $versiSaatIni));
return match ($jenisPerubahan) {
"perbaikan-bug" => sprintf("%d.%d.%d", $mayor, $minor, $patch + 1),
"fitur-baru" => sprintf("%d.%d.0", $mayor, $minor + 1),
"perubahan-merusak" => sprintf("%d.0.0", $mayor + 1),
};
}
echo naikkanVersi("1.4.2", "perbaikan-bug"), "\n";
echo naikkanVersi("1.4.2", "fitur-baru"), "\n";
echo naikkanVersi("1.4.2", "perubahan-merusak"), "\n";Output yang Dihasilkan:
1.4.3
1.5.0
2.0.0Perhatikan pola reset di balik tiga baris output tersebut. Perbaikan bug menaikkan PATCH saja dan MINOR berhenti di angka yang sama. Fitur baru menaikkan MINOR dan mengembalikan PATCH ke nol — ada fitur baru, tidak ada perubahan merusak. Perubahan merusak menaikkan MAYOR dan mereset sisanya: angka 2.0.0 adalah pengumuman keras kepada pengguna bahwa kontrak lama boleh berbeda. Pengguna yang menulis constraint ^1.4 akan otomatis terlindung dari versi 2.0.0 — topik yang kita bahas pada chapter Version Constraints
.
Katalog Perubahan Merusak #
Perubahan merusak tidak selalu berwujud hapus-fungsi. Katalog berikut memetakan bentuk-bentuk yang paling sering luput dari perhatian:
| Jenis perubahan | Contoh | Mengapa merusak |
|---|---|---|
| Signature | menambah parameter wajib pada hitung($a, $b) | pemanggil lama kekurangan argumen |
| Return type | mengubah kembalian int menjadi ?int | kode pemanggil yang menjumlahkan hasilnya kini bisa menerima null |
| Exception | melempar ValidationException dari fungsi yang tadinya tak pernah melempar | blok catch pemanggil tidak menyiapkannya |
| Perilaku | mengubah pembulatan intdiv($x, 10) menjadi pembulatan ke atas | output berubah diam-diam tanpa satu pun error |
| Konfigurasi | mengganti kunci kurs_locale menjadi mata_uang | berkas konfigurasi lama berhenti terbaca |
| Dependensi | menaikkan requirement PHP dari 8.1 ke 8.3 | server pengguna yang belum siap terblokir |
Menandai API Usang dengan Deprecation #
Menhapus API publik tidak boleh dilakukan mendadak. Pola yang beradab adalah masa transisi: tandai API lama sebagai usang (deprecated) selama beberapa rilis MINOR, beri tahu pengguna penggantinya, baru hapus pada rilis MAYOR berikutnya. PHP menyediakan mekanisme bawaan untuk ini lewat trigger_error() dengan level E_USER_DEPRECATED:
<?php
set_error_handler(function (int $level, string $pesan): bool {
if (($level & E_USER_DEPRECATED) !== 0) {
echo "[usang] ", $pesan, "\n";
return true;
}
return false;
});
function formatRupiah(int $angka, string $mataUang = "Rp"): string
{
return $mataUang . number_format($angka, 0, ",", ".");
}
function formatRupiahLama(int $angka): string
{
trigger_error(
"formatRupiahLama() usang sejak 2.3.0, gunakan formatRupiah(); akan dihapus di 3.0.0",
E_USER_DEPRECATED
);
return formatRupiah($angka);
}
echo formatRupiahLama(150000), "\n";
echo formatRupiah(2500000), "\n";
echo formatRupiah(2500000, "MYR"), "\n";Output yang Dihasilkan:
[usang] formatRupiahLama() usang sejak 2.3.0, gunakan formatRupiah(); akan dihapus di 3.0.0
Rp150.000
Rp2.500.000
MYR2.500.000Mari kita bedah yang terjadi di balik layar:
trigger_error(..., E_USER_DEPRECATED): fungsi lama mengumumkan status usangnya setiap kali dipanggil. Tanpa penangan khusus, PHP mencetak barisDeprecated:lengkap dengan nama berkas dan nomor baris — informasi berharga saat menelusuri pemanggil di proyek besar.set_error_handler(...): contoh memasang penangan error sendiri yang menyaring khusus levelE_USER_DEPRECATEDdan mencetaknya dalam format ringkas. Returningtruememberi tahu PHP bahwa error sudah ditangani sehingga perilaku bawaan tidak ikut berjalan. Mekanisme serupa dipakai PHPUnit untuk mengumpulkan deprecation saat menjalankan tes.return formatRupiah($angka): tubuh fungsi lama kini hanya mendelegasikan ke implementasi baru — satu sumber kebenaran, tanpa duplikasi logika pemformatan.- Tiga baris output terakhir: pemanggilan lama tetap menghasilkan nilai yang benar (
Rp150.000) sambil membawa pengumuman usangnya; pemanggilan baru bekerja dengan parameter tambahan yang menjadi alasan migrasinya.
@internal versus API Publik #
Ketika library tumbuh, tidak semua kode yang terlihat dari luar memang dimaksudkan sebagai kontrak. PHPDoc menyediakan penanda @internal untuk menyatakan bahwa sebuah elemen bebas berubah tanpa pemberitahuan — janji BC tidak berlaku untuknya. Penanda ini bisa diperiksa secara program lewat Reflection:
<?php
/**
* @internal Hanya untuk pemakaian antar modul di dalam paket ini,
* bukan bagian dari kontrak publik.
*/
function normalisasiInternal(string $teks): string
{
return trim(mb_strtolower($teks));
}
function apakahInternal(string $namaFungsi): bool
{
$refleksi = new ReflectionFunction($namaFungsi);
$dokumen = $refleksi->getDocComment();
return $dokumen !== false && str_contains($dokumen, "@internal");
}
echo apakahInternal("normalisasiInternal") ? "internal" : "publik", "\n";
echo apakahInternal("trim") ? "internal" : "publik", "\n";Output yang Dihasilkan:
internal
publikReflectionFunction membaca blok dokumentasi fungsi, dan penanda @internal cukup diperiksa dengan str_contains() — sederhana namun menjadi dasar bagi alat dokumentasi untuk memisahkan halaman API publik dari internal. Disiplinnya dua arah: berani menandai @internal untuk kode yang memang bukan kontrak (agar tidak terbebani janji BC berlebihan), dan berani tidak menandainya untuk kode yang memang dipakai orang (agar tidak berubah sembarangan di rilis MINOR).
Lapisan Kompatibilitas dengan Shim #
Untuk perubahan besar — misalnya mengganti nama class — masa transisi dapat dijembatani dengan shim: lapisan tipis yang menjaga API lama tetap hidup di atas implementasi baru. PHP memiliki alat bawaan class_alias() untuk kasus penggantian nama:
<?php
final class PelaporanPenjualan
{
public function __construct(
private array $baris,
) {
}
public function total(): int
{
$total = 0;
foreach ($this->baris as $barisPenjualan) {
$total += $barisPenjualan["harga"] * $barisPenjualan["jumlah"];
}
return $total;
}
}
class_alias("PelaporanPenjualan", "SalesReport");
$laporanLama = new SalesReport([["harga" => 95000, "jumlah" => 2]]);
$laporanBaru = new PelaporanPenjualan([["harga" => 95000, "jumlah" => 2]]);
echo "SalesReport adalah instance dari ", get_class($laporanLama), "\n";
echo "SalesReport: Rp", $laporanLama->total(), "\n";
echo "PelaporanPenjualan: Rp", $laporanBaru->total(), "\n";Output yang Dihasilkan:
SalesReport adalah instance dari PelaporanPenjualan
SalesReport: Rp190000
PelaporanPenjualan: Rp190000Setelah class_alias() berjalan, SalesReport menjadi nama lain untuk class yang sama persis — bukan class turunan, bukan salinan. Baris output pertama membuktikannya: instance yang dibuat lewat nama lama melaporkan dirinya sebagai PelaporanPenjualan. Kode pengguna lama tetap berjalan tanpa perubahan, sementara dokumentasi mendorong semua kode baru memakai nama yang modern. Shim seperti ini sebaiknya diberi umur: tandai usang di rilis berikutnya, lalu hapus bersama nama lamanya pada rilis MAYOR — shim yang hidup selamanya hanya memindahkan kekacauan, bukan menyelesaikannya.
Panduan Migrasi Rilis Mayor #
Rilis MAYOR yang menghapus kontrak lama wajib disertai panduan migrasi: dokumen yang memandu pengguna berpindah dari versi lama ke baru. Ringkasan panduan migrasi umumnya berbentuk daftar perubahan yang dikelompokkan berdasarkan dampaknya:
UPGRADE-3.0.md (ringkasan)
RUSAK formatRupiahLama() dihapus. Gunakan formatRupiah($angka, $mataUang).
RUSAK SalesReport dihapus. Gunakan PelaporanPenjualan (alias sudah tidak didaftarkan).
PERBAIKI formatRupiah() kini memakai pembulatan ke bawah yang konsisten.
BARU formatRupiah() menerima parameter kedua $mataUang (default "Rp").
PINDAH Kunci konfigurasi "kurs_locale" diganti "mata_uang_default".Panduan yang baik menjawab tiga kebutuhan sekaligus: apa yang berubah (daftar di atas), bagaimana memperbaruinya (contoh kode sebelum-sesudah untuk setiap perubahan merusak), dan bagaimana menemukan semua titik yang terdampak (grep yang bisa disalin, atau skrip migrasi otomatis). PHP sendiri menjadi contoh terbaik praktik ini — setiap rilis mayor punya halaman migrasi resmi yang rinci, seperti panduan migrasi PHP 8.4 .
Constraint Composer sebagai Janji #
SemVer hanya bermakna jika diterjemahkan menjadi mekanisme nyata, dan di ekosistem PHP mekanisme itu adalah constraint Composer. Ketika pengguna library kita menulis ^2.3, mereka sedang mempercayai janji kita: rilis 2.x hingga 3.0.0 eksklusif tidak akan memutus kode mereka. Sebaliknya, janji yang kita tulis di composer.json library juga mengikat kita sendiri:
{
"require": {
"php": "^8.1",
"psr/log": "^3.0"
}
}Dua baris constraint di atas berarti library kita menjanjikan kompatibilitas dengan PHP 8.1 hingga sebelum 10.0.0 secara semantik — menaikkannya menjadi ^8.3 adalah perubahan yang merusak bagi pengguna yang masih memakai PHP 8.1, meskipun tidak ada satu baris kode library yang berubah. Aturan main lengkapnya — beda ^ dan ~, titik koma versus pipe — sudah kita bahas pada chapter Version Constraints
.
Dukungan Jangka Panjang #
Konsep terakhir yang melengkapi gambaran BC adalah LTS (Long-Term Support): versi yang berjanji menerima perbaikan lebih lama daripada siklus normal, biasanya untuk memberi waktu bermigrasi bagi pengguna yang tidak bisa bergerak cepat. PHP menjalankan pola ini pada setiap rilis mayor: sekitar dua tahun pertama versi menerima perbaikan bug aktif, lalu dua tahun berikutnya hanya perbaikan keamanan — halaman versi yang didukung memperlihatkan posisi setiap versi dalam siklus itu. Framework besar seperti Symfony dan Laravel juga merilis versi LTS dengan jadwal dukungan bertahun-tahun. Bagi tim pemakai, LTS berarti ruang napas; bagi tim pembuat, LTS berani menjanjikan kecocokan perbaikan pada kontrak yang sudah tua — beban BC yang paling berat, hanya layak diambil bila benar-benar ada pengguna yang membutuhkannya.
Ringkasan #
Backward compatibility adalah penghormatan kepada semua orang yang memegang kode kita: SemVer menerjemahkan perubahan menjadi angka yang bisa dipercaya, masa deprecation memberi waktu bermigrasi, penanda @internal menjaga batas kontrak tetap jujur, dan shim menjembatani jarak antara nama lama dengan implementasi baru. Rilis MAYOR yang merusak tetap sah — perangkat lunak yang sehat kadang memang harus memutus masa lalunya — asalkan disertai panduan migrasi yang menghormati waktu pengguna. Dengan ini rangkaian coding standards dan maintainability kita genap: dari gaya penulisan, penamaan, dan clean code, hingga refactoring, review, utang teknis, dependensi, dan kompatibilitas — satu kesatuan kebiasaan kecil yang menentukan apakah kode kita bertahan layak dihuni bertahun-tahun ke depan.
Referensi #
- Semantic Versioning
— spesifikasi resmi penomoran
MAJOR.MINOR.PATCH. - Versi dan Constraint Composer — bagaimana SemVer diterjemahkan menjadi constraint paket.
- Versi PHP yang Didukung — siklus dukungan aktif dan keamanan tiap rilis PHP.
- trigger_error — php.net
— mekanisme
E_USER_DEPRECATEDuntuk menandai API usang.