Chapter Typed Properties memberi property bentuk data yang pasti: $harga selalu integer, $kode selalu string. Namun bentuk yang pasti belum menjamin isi yang stabil — kode luar tetap bebas menimpa nilai property kapan saja, dan sebuah object bisa termodifikasi setengah jalan di tengah proses yang panjang. PHP 8.1 memperkenalkan modifier readonly: property menerima nilai tepat sekali, saat inisialisasi pertama, lalu terkunci seumur hidup object. Chapter ini membahas aturan mainnya, pesan error saat aturan dilanggar, larangan nilai default, pola with() untuk membangun object baru dari object lama, serta pemanfaatan readonly pada value object.

Inisialisasi Tepat Satu Kali #

Kata kunci readonly ditulis setelah visibility dan sebelum tipe: public readonly string $kode. Nilai pertama yang masuk — dari constructor, dari method, maupun dari scope global — mengunci property tersebut; setelah itu tidak ada penulisan kedua yang diterima. Bentuk paling umumnya menempatkan inisialisasi di constructor:

php
<?php

class Tiket
{
    public readonly string $kode;

    public function __construct(string $kode)
    {
        $this->kode = $kode;
    }
}

$tiket = new Tiket("JK-2026-0115");
echo "Kode tiket: {$tiket->kode}\n";

Output yang Dihasilkan:

text
Kode tiket: JK-2026-0115

Baris 5 mendeklarasikan property readonly; baris 9 menuliskan nilai pertamanya dari dalam constructor — satu-satunya penulisan yang akan pernah diterima property tersebut. Membaca property ($tiket->kode) tetap bebas seperti property biasa; readonly hanya mengunci penulisan, bukan pembacaan. Kombinasi dengan constructor property promotion membuat deklarasinya semakin ringkas:

php
<?php

class Pembayaran
{
    public function __construct(
        public readonly string $idTransaksi,
        public readonly int $jumlah,
    ) {}
}

$bayar = new Pembayaran("TRX-2026-0115", 150000);
echo "{$bayar->idTransaksi}: Rp{$bayar->jumlah}\n";

Output yang Dihasilkan:

text
TRX-2026-0115: Rp150000

Promotion pada baris 6-7 sekaligus mendeklarasikan dan menginisialisasi kedua property. Pembahasan tuntas promotion menanti chapter-nya sendiri; untuk sekarang cukup diketahui bahwa public readonly pada parameter constructor berarti “jadikan property readonly, lalu isi dari argumen ini”.

Satu sifat readonly yang perlu dipahami sejak PHP 8.4: inisialisasi hanya sah dari dalam scope class yang mendeklarasikannya. Property readonly yang belum terisi berperilaku seolah-olah memiliki protected(set) — penulisan dari scope global ditolak meskipun itu penulisan pertama:

php
<?php

class Konfigurasi
{
    public readonly ?string $tokenApi;

    public function __construct(?string $tokenApi = null)
    {
        $this->tokenApi = $tokenApi;
    }
}

$konfigurasi = new Konfigurasi("YOUR_API_KEY");

echo "Token: {$konfigurasi->tokenApi}\n";

Output yang Dihasilkan:

text
Token: YOUR_API_KEY

Class Konfigurasi kini menginisialisasi $tokenApi di dalam constructor — satu-satunya tempat penulisan sah di luar clone. Karena itu pola yang lazim adalah kombinasi readonly dengan constructor (atau promotion seperti contoh Transaksi di atas), bukan property telanjang yang ditulis dari luar.

Penulisan Kedua Berakhir Error #

Sekarang kita uji sisi penguncianya. Program berikut mencoba menimpa nilai property yang sudah terisi:

php
<?php

class Konfigurasi
{
    public readonly ?string $tokenApi;
}

$konfigurasi = new Konfigurasi();
$konfigurasi->tokenApi = "YOUR_API_KEY";
echo "Token terpasang: {$konfigurasi->tokenApi}\n";

$konfigurasi->tokenApi = "YOUR_API_KEY_BARU";

Output yang Dihasilkan:

text

Fatal error: Uncaught Error: Cannot modify protected(set) readonly property Konfigurasi::$tokenApi from global scope in /home/z/my-project/php-course/content/php/advanced-object-model/readonly-properties/.verify_tmp.php:9
Stack trace:
#0 {main}
  thrown in /home/z/my-project/php-course/content/php/advanced-object-model/readonly-properties/.verify_tmp.php on line 9

Baris echo sempat mencetak token yang terpasang; baris terakhir kemudian melempar Fatal error: Uncaught Error: Cannot modify readonly property Konfigurasi::$tokenApi dan eksekusi berhenti dengan exit code 255. Penolakan yang sama berlaku untuk penulisan dari dalam method class — sebuah method resetToken() yang menulis $this->tokenApi akan mengalami error serupa setelah property terisi. Operasi unset() juga ikut dilarang dengan pesan Cannot unset readonly property, karena meng-unset pada dasarnya adalah bentuk penulisan yang mengosongkan nilai.

readonly Tidak Boleh Memiliki Nilai Default #

Kombinasi readonly dengan nilai default langsung ditolak PHP bahkan sebelum eksekusi dimulai:

php
<?php

class Konfigurasi
{
    public readonly string $versi = "1.0";
}

Output yang Dihasilkan:

text

Fatal error: Readonly property Konfigurasi::$versi cannot have default value in /home/z/my-project/php-course/content/php/advanced-object-model/readonly-properties/.verify_tmp.php on line 5

Berkas di atas gagal dikompilasi dengan Fatal error: Readonly property Konfigurasi::$versi cannot have default value. Alasannya mendasar: nilai default terpasang otomatis pada saat object diciptakan, sehingga property akan langsung terkunci dengan nilai yang sama untuk seluruh instance — tidak ada jalan bagi tiap object untuk membawa nilainya sendiri. Penulisan readonly tanpa tipe (public readonly $x;) juga ditolak dengan pesan must have type; readonly memang dirancang berpasangan dengan typed properties.

Pola with untuk Perubahan Terarah #

Data yang tidak boleh berubah bukan berarti aplikasi tidak berubah — saldo tetap perlu ditambah, jadwal tetap perlu digeser. Pola penyelesaiannya bernama wither: sebuah method with...() menyiapkan object baru yang membawa nilai baru, sementara object lama dibiarkan utuh. Bentuk paling lugas membangun kembali lewat constructor:

php
<?php

class Uang
{
    public function __construct(
        public readonly string $mataUang,
        public readonly int $jumlah,
    ) {}

    public function withJumlah(int $jumlahBaru): static
    {
        return new self($this->mataUang, $jumlahBaru);
    }
}

$tabungan = new Uang("IDR", 500000);
$setelahTransfer = $tabungan->withJumlah(350000);

echo "Tabungan lama: Rp{$tabungan->jumlah}\n";
echo "Setelah transfer: Rp{$setelahTransfer->jumlah}\n";

Output yang Dihasilkan:

text
Tabungan lama: Rp500000
Setelah transfer: Rp350000

Method withJumlah() pada baris 10-13 tidak menyentuh $this sama sekali: method tersebut membaca nilai lama, membangun object baru lewat new self(), lalu mengembalikannya. Keluarannya membuktikan kedua object hidup berdampingan — tabungan lama tetap Rp500000, object hasil with bernilai Rp350000. Pemanggil yang masih memegang object lama tidak pernah terkejut oleh perubahan di bawah kakinya sendiri.

Mengapa tidak cukup clone lalu menimpa property salinannya? Karena clone ikut menyalin status “sudah terinisialisasi” — penulisan property pada object hasil clone tetap melempar Cannot modify readonly property, baik dari method class maupun dari scope global. PHP 8.3 memberi satu pintu keluar yang terkontrol: di dalam method magic __clone(), property readonly boleh diinisialisasi ulang sekali.

Reinisialisasi dalam __clone #

Pintu keluar tersebut dirancang untuk deep clone: memastikan object hasil salinan tidak berbagi object anak dengan induknya. Kedua property RentangTanggal berikut bertipe DateTimeImmutable — object yang sebaiknya juga tidak dibagikan antar-pemilik:

php
<?php

class RentangTanggal
{
    public function __construct(
        public readonly DateTimeImmutable $mulai,
        public readonly DateTimeImmutable $akhir,
    ) {}

    public function __clone()
    {
        $this->mulai = clone $this->mulai;
        $this->akhir = clone $this->akhir;
    }
}

$rentang = new RentangTanggal(
    new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta")),
    new DateTimeImmutable("2026-01-20 08:00:00", new DateTimeZone("Asia/Jakarta")),
);
$salinan = clone $rentang;

var_dump($rentang->mulai === $salinan->mulai);

Output yang Dihasilkan:

text
bool(false)

Tanpa __clone(), property $mulai milik kedua rentang akan menunjuk object DateTimeImmutable yang sama sehingga perbandingan === mengembalikan true. Baris 12-13 mengganti property milik salinan dengan clone milik salinan itu sendiri — penulisan yang diizinkan karena terjadi di dalam __clone() — dan hasilnya bool(false): kedua rentang kini sepenuhnya terisolasi satu sama lain. Di luar method __clone(), aturan lama tetap berlaku: sekali terisi, tidak ada penulisan kedua.

Value Object dengan readonly #

Value object adalah pola di mana identitas object ditentukan oleh nilainya, bukan oleh lokasi memorinya — Rp50.000 IDR hasil perhitungan mana pun dianggap sama. readonly adalah bahan bakunya, karena kesetaraan nilai hanya bermakna selama nilainya memang tidak bisa berubah setelah diciptakan:

php
<?php

class Nominal
{
    public function __construct(
        public readonly string $mataUang,
        public readonly int $jumlah,
    ) {}
}

$a = new Nominal("IDR", 50000);
$b = new Nominal("IDR", 50000);

var_dump($a == $b);
var_dump($a === $b);

Output yang Dihasilkan:

text
bool(true)
bool(false)

Operator == membandingkan object berdasarkan nilai property demi property (rincian perilakunya dibahas pada Chapter Object Comparison ), sehingga hasilnya bool(true) — kedua Nominal membawa mata uang dan jumlah yang sama. Operator === membandingkan identitas object dan mengembalikan bool(false). Tanpa readonly, kesetaraan == bisa “busuk” diam-diam: dua object yang tadinya setara bisa dibedakan nilainya lima baris kemudian oleh satu penugasan di tempat lain.

Ringkasan #

readonly menutup celah yang tersisa setelah typed properties: nilai yang sudah masuk tidak bisa diganti, di-unset, ataupun diberi default. Perubahan nilai mengalir lewat pola with() yang selalu menghasilkan object baru, __clone() menjadi satu-satunya pintu reinisialisasi untuk deep clone, dan value object menikmati kesetaraan nilai yang tidak lapuk oleh waktu. Dua chapter pertama section ini sama-sama menempatkan constructor sebagai titik inisialisasi — chapter berikutnya membedah constructor property promotion, fitur yang meleburkan deklarasi property, parameter, dan penugasan constructor menjadi satu baris.

Referensi #