Dalam PHP, object tidak pernah disalin saat dimasukkan ke variabel atau dikirim ke function — yang berpindah hanyalah referensi ke object yang sama. Konsekuensinya, siapa pun yang memegang referensi bisa mengubah isi object, dan perubahan itu langsung terlihat dari semua tempat lain. Untuk data yang seharusnya beku — harga, alamat, konfigurasi — sifat ini adalah sumber bug klasik yang sulit dilacak. PHP 8.1 menjawab dengan readonly pada property, lalu PHP 8.2 menaikkannya ke level class. Chapter ini menelusuri evolusinya dari bug mutability, pembekuan property satu per satu, sampai pola with() yang mengubah data dengan membuat object baru. Aturan lengkap initialization, cloning, dan keterbatasan readonly dibedah tuntas di chapter readonly properties di Section Advanced Object Model
.
Bug Klasik: Object Berubah Diam-diam #
Mari kita lihat wujud masalahnya. Class HargaLama di bawah punya property public biasa, lalu sebuah function menghitung diskon dengan memodifikasi object yang dikirim kepadanya:
<?php
class HargaLama
{
public int $nominal;
public string $mataUang;
public function __construct(int $nominal, string $mataUang)
{
$this->nominal = $nominal;
$this->mataUang = $mataUang;
}
}
function formatHarga(HargaLama $harga): string
{
return $harga->mataUang . ' ' . number_format($harga->nominal, 0, ',', '.');
}
function hitungDiskon(HargaLama $harga): HargaLama
{
// Niatnya menghitung harga diskon, tapi object asli ikut berubah
$harga->nominal = (int) round($harga->nominal * 0.9);
return $harga;
}
$total = new HargaLama(150000, 'IDR');
echo 'Sebelum: ', formatHarga($total), "\n";
$diskon = hitungDiskon($total);
echo 'Diskon: ', formatHarga($diskon), "\n";
echo 'Total: ', formatHarga($total), "\n";Output yang Dihasilkan:
Sebelum: IDR 150.000
Diskon: IDR 135.000
Total: IDR 135.000Fokus pada dua baris terakhir: $total seharusnya tetap IDR 150.000, tetapi tercetak IDR 135.000. Function hitungDiskon() mengembalikan object baru secara semantik, namun implementasinya memodifikasi $harga yang merupakan object yang sama dengan $total — variabel $diskon dan $total hanyalah dua nama untuk satu object. Konvensi penamaan seperti hitungDiskon() tidak mengikat siapa pun; yang kita butuhkan adalah jaminan dari bahasa itu sendiri.
Property readonly Sejak PHP 8.1 #
PHP 8.1 memperkenalkan modifier readonly: property yang ditandai demikian hanya boleh ditulis sekali, yaitu saat initialization dari dalam class pemiliknya. Setelah itu, seluruh upaya penulisan ditolak interpreter:
<?php
class Harga
{
public function __construct(
public readonly int $nominal,
public readonly string $mataUang = 'IDR',
) {}
}
$total = new Harga(150000);
echo $total->mataUang, ' ', $total->nominal, "\n";Output yang Dihasilkan:
IDR 150000Membaca property readonly sama saja dengan property biasa — $total->nominal berjalan normal seperti terlihat pada output. Pembatasan hanya menyasar penulisan, dan penulisan ilegal langsung menyala sebagai error fatal:
<?php
class Harga
{
public function __construct(
public readonly int $nominal,
public readonly string $mataUang = 'IDR',
) {}
}
$total = new Harga(150000);
$total->nominal = 999;Output yang Dihasilkan:
Fatal error: Uncaught Error: Cannot modify readonly property Harga::$nominal in /home/z/my-project/php-course/content/php/modern-php/readonly-properties-dan-classes/.verify_tmp.php:11
Stack trace:
#0 {main}
thrown in /home/z/my-project/php-course/content/php/modern-php/readonly-properties-dan-classes/.verify_tmp.php on line 11Eksekusi berhenti di $total->nominal = 999; dengan pesan Cannot modify readonly property Harga::$nominal. Jika hitungDiskon() dari contoh pertama mencoba trik yang sama terhadap class Harga, kegagalan muncul pada baris yang mencoba memodifikasi — tepat di lokasi kesalahan, bukan tiga lapis fungsi kemudian. Kombinasi public readonly dengan constructor promotion membuat deklarasi value object ringkas: satu baris per property, tanpa getter sama sekali, karena pembacaan tetap langsung.
Pola with(): Mengubah dengan Membuat Baru #
Membekukan property menimbulkan pertanyaan wajar: bagaimana cara “mengubah” harga jika penulisan dilarang? Jawabannya adalah pola with() — alih-alih memodifikasi, kita buat salinan dengan satu nilai berbeda. Sebelum era readonly, pola ini ditulis dengan clone:
<?php
class HargaLama
{
public function __construct(
public int $nominal,
public string $mataUang = 'IDR',
) {}
public function withNominal(int $nominal): static
{
$salinan = clone $this;
$salinan->nominal = $nominal;
return $salinan;
}
}
$awal = new HargaLama(150000);
$baru = $awal->withNominal(200000);
echo 'Awal: ', $awal->nominal, "\n";
echo 'Baru: ', $baru->nominal, "\n";Output yang Dihasilkan:
Awal: 150000
Baru: 200000Method withNominal() menyalin object lewat clone, menimpa satu property pada salinan, lalu mengembalikannya. Object $awal tidak tersentuh — output menunjukkan keduanya berbeda. Pola ini benar, tetapi rapuh: tidak ada yang mencegah programmer lain menulis $awal->nominal = 200000; langsung, melewati method with* yang kita susun dengan hati-hati. Immutability hanyalah kesepakatan, bukan jaminan.
Readonly Class di PHP 8.2 #
Menulis readonly di depan setiap property menjadi repetitif untuk class yang seluruhnya memang beku. PHP 8.2 memindahkan modifier ke depan nama class — semua property yang dideklarasikan otomatis readonly:
<?php
readonly class Harga
{
public function __construct(
public int $nominal,
public string $mataUang = 'IDR',
) {}
public function withNominal(int $nominal): static
{
return new self($nominal, $this->mataUang);
}
}
$awal = new Harga(150000);
$baru = $awal->withNominal(200000);
echo 'Awal: ', $awal->nominal, "\n";
echo 'Baru: ', $baru->nominal, "\n";
echo 'Identik? ', var_export($awal === $baru, true), "\n";Output yang Dihasilkan:
Awal: 150000
Baru: 200000
Identik? falsePerhatikan bagaimana pola with() ikut menyusut. Pada class readonly, menulis $salinan->nominal setelah clone justru dilarang — salinan hasil clone tidak bisa diinisialisasi ulang. Justru karena penulisan lama ditutup, bentuk yang tersisa adalah yang paling jujur: withNominal() cukup membangun new self() dengan nilai baru. Output Identik? false menegaskan object asli dan hasil with() adalah dua object terpisah.
Sebagai peta ringkas, berikut perbandingan tiga tahap evolusi ini:
| Aspek | Property public biasa | public readonly (8.1) | readonly class (8.2) |
|---|---|---|---|
| Penulisan dari luar | Diizinkan kapan saja | Ditolak error fatal | Ditolak error fatal |
| Penulisan dari dalam class | Diizinkan kapan saja | Hanya sekali saat initialization | Hanya sekali saat initialization |
| Cakupan | Per property | Per property | Semua property sekaligus |
| Perlindungan bug aliasing | Tidak ada | Ada | Ada |
Aturan penting yang belum terlihat dari contoh: property readonly harus bertipe (typed) dan tidak boleh static, serta initialization hanya sah dari dalam scope class — detailnya, termasuk interaksi dengan clone dan nilai default, dibahas pada chapter readonly classes di Section Advanced Object Model
.
Ringkasan #
Evolusi readonly menutup celah yang sudah tua: object yang dikirim ke function bisa berubah diam-diam karena semua referensi menunjuk object yang sama. Property readonly di PHP 8.1 memastikan setiap property hanya ditulis sekali saat initialization, class readonly di PHP 8.2 menyebarkan jaminan itu ke seluruh class tanpa pengulangan, dan pola with() menjelaskan cara berpikir “mengubah dengan membuat baru” yang menggantikan mutasi langsung. Data kini bisa dibekukan — berikutnya kita beralih ke fitur PHP 8.1 yang membekukan alur kontrol: fiber, kemampuan menghentikan dan melanjutkan eksekusi function di tengah jalan.