Satu file PHP, tujuh konsep berpadu: trait, interface, abstract class, inheritance, dan polymorphism — dibedah lapis demi lapis seperti membaca buku besar toko.
Trait adalah cara PHP menyalin sekumpulan method ke dalam kelas tanpa mewarisi (inheritance) dari satu induk. Di sistem ini, HasTimestamp dan HasLogger dipakai ulang oleh Produk, Pelanggan, maupun Toko — tiga kelas yang sama sekali tidak berkerabat, tapi sama-sama butuh "kapan dibuat" dan "catat aktivitas".
1trait HasTimestamp { 2 private string $dibuatPada; 3 public function initTimestamp(): void { 4 $this->dibuatPada = date('Y-m-d H:i:s'); 5 } 6} 7 8trait HasLogger { 9 private array $logs = []; 10 public function log(string $msg): void { 11 $this->logs[] = "[INFO] {$msg}"; 12 } 13}
PHP tidak mendukung multiple inheritance. Jika "punya timestamp" dan "punya logger" dijadikan kelas induk, sebuah kelas hanya bisa mewarisi satu di antaranya. Trait memecahkan ini — bisa "pakai" sebanyak yang dibutuhkan.
ProdukAbstrak, Pelanggan, dan Toko ketiganya memakai use HasLogger — masing-masing punya riwayat log sendiri yang terisolasi, walau kodenya disalin dari sumber yang sama.
Interface tidak berisi implementasi — hanya daftar method yang harus ada. Ini adalah "perjanjian": apa pun yang mengaku Dijual, wajib punya method jual() dan isStokTersedia(), apa pun cara kerjanya di dalam.
1interface Dijual { 2 public function jual(Pelanggan $pelanggan, int $jumlah): TransaksiJual; 3 public function isStokTersedia(int $jumlah): bool; 4} 5 6interface Searchable { 7 public function getKeywords(): array; 8}
Pinjamable pada sistem perpustakaan: konsepnya identik — "kontrak meminjam" diganti "kontrak menjual". Interface inilah yang nanti memungkinkan Toko::jual() memanggil $produk->jual() tanpa peduli apakah itu produk fisik atau digital.
ProdukAbstrak tidak boleh dibuat objeknya secara langsung (new ProdukAbstrak() akan error). Ia hanya menjadi cetakan: menyimpan field bersama (kode, nama, hargaBeli, stok) dan memaksa setiap anak kelas mengisi tiga method abstrak — getLabel(), getHargaJual(), dan getPotonganMaksimal().
1abstract class ProdukAbstrak implements Dijual, Searchable 2{ 3 use HasTimestamp, HasLogger; 4 5 protected int $stok; 6 7 public function __construct( 8 public readonly string $kode, 9 public readonly string $nama, 10 public readonly float $hargaBeli, 11 int $stokAwal 12 ) { 13 $this->stok = $stokAwal; 14 $this->initTimestamp(); 15 } 16 17 // Method abstrak: WAJIB diisi anak kelas 18 abstract public function getHargaJual(): float; 19 abstract public function getPotonganMaksimal(): float; 20}
kode, nama, hargaBeli dideklarasikan readonly — sekali diisi di konstruktor, tidak bisa diubah lagi. Mencegah harga beli "diam-diam" berubah di tengah jalan.
Parameter konstruktor langsung dideklarasikan sebagai property (PHP 8+) — tidak perlu menulis $this->kode = $kode; secara manual untuk tiap field.
Dua kelas nyata mewarisi ProdukAbstrak: ProdukFisik (barang bergudang, perlu cek stok ketat) dan ProdukDigital (lisensi software, margin lebih tinggi karena tanpa biaya gudang). Inilah inheritance — keduanya otomatis punya semua field dan method dari induknya, lalu menambahkan rumus harga sendiri.
1class ProdukFisik extends ProdukAbstrak 2{ 3 public function getHargaJual(): float { 4 return $this->hargaBeli * 1.25; // margin 25% 5 } 6 public function getPotonganMaksimal(): float { return 10.0; } 7} 8 9class ProdukDigital extends ProdukAbstrak 10{ 11 public function getHargaJual(): float { 12 return $this->hargaBeli * 1.60; // margin 60% 13 } 14 public function getPotonganMaksimal(): float { return 30.0; } 15}
$produk->getHargaJual() — tanpa tahu (atau peduli) apakah $produk itu fisik atau digital. PHP otomatis memanggil rumus margin yang sesuai dengan jenis objeknya saat itu.
Pelanggan berdiri sendiri (tidak mewarisi apa pun selain trait), tapi tetap menyimpan state: total belanja, riwayat transaksi, dan tipe keanggotaan yang menentukan persentase diskon lewat match expression.
1class Pelanggan 2{ 3 use HasTimestamp, HasLogger; 4 5 private float $totalBelanja = 0; // disembunyikan dari luar 6 private static int $total = 0; // dihitung lintas-instance 7 8 public function getDiskonTipe(): float { 9 return match ($this->tipe) { 10 'VIP' => 15.0, 11 'MEMBER' => 5.0, 12 default => 0.0, 13 }; 14 } 15}
$totalBelanja bersifat private — tidak bisa diubah langsung dari luar kelas ($pelanggan->totalBelanja = 0 akan ditolak PHP). Satu-satunya jalan masuk adalah method tambahBelanja(), sehingga logikanya selalu konsisten.
$total bersifat static: dimiliki kelas, bukan objek. Setiap kali Pelanggan baru dibuat, angka ini bertambah — dipakai untuk Pelanggan::getTotal() tanpa perlu instance apa pun.
Inilah kelas yang "menyaksikan" pertemuan antara Pelanggan dan ProdukAbstrak. Begitu objek TransaksiJual dibuat, ia langsung menghitung subtotal, memilih diskon yang berlaku (diskon tipe pelanggan dibatasi oleh potongan maksimal produk), dan mencatat nomor faktur otomatis.
1class TransaksiJual 2{ 3 private static int $counter = 0; 4 public readonly string $nomorFaktur; 5 6 public function __construct( 7 public readonly Pelanggan $pelanggan, 8 public readonly ProdukAbstrak $produk, 9 public readonly int $jumlah 10 ) { 11 // diskon dipilih: min(diskon pelanggan, maksimal produk) 12 $diskonDiminta = $pelanggan->getDiskonTipe(); 13 $this->diskonPersen = min($diskonDiminta, $produk->getPotonganMaksimal()); 14 15 $this->nomorFaktur = 'INV-' . str_pad(++self::$counter, 4, '0', STR_PAD_LEFT); 16 } 17}
TransaksiJual tidak mewarisi Pelanggan atau ProdukAbstrak, ia hanya memegang referensi ke keduanya. Hubungan "punya" (has-a), berbeda dengan inheritance yang hubungannya "adalah" (is-a).
Toko tidak tahu cara menghitung harga jual atau diskon — ia hanya tahu cara meminta hal itu terjadi. Inilah inti desain berorientasi objek yang baik: setiap kelas hanya bertanggung jawab atas dirinya sendiri (single responsibility), dan Toko sekadar menjadi penghubung antar-entitas.
1class Toko 2{ 3 public function tambahProduk(ProdukAbstrak $produk): static 4 { 5 $this->produk[$produk->kode] = $produk; 6 return $this; // fluent interface → bisa di-chain 7 } 8 9 public function jual(string $idPelanggan, string $kodeProduk, int $jumlah): TransaksiJual 10 { 11 $pelanggan = $this->pelanggan[$idPelanggan] ?? throw new \InvalidArgumentException("Pelanggan tidak ditemukan"); 12 $produk = $this->produk[$kodeProduk] ?? throw new \InvalidArgumentException("Produk tidak ditemukan"); 13 14 return $produk->jual($pelanggan, $jumlah); // POLYMORPHISM! 15 } 16}
Karena tambahProduk() mengembalikan $this, pemanggilannya bisa dirangkai: $toko->tambahProduk(...)->tambahProduk(...)->daftarkanPelanggan(...) — satu kalimat, banyak aksi.
?? throw new ... (PHP 8) menggabungkan pengecekan "tidak ditemukan" dan pelemparan error dalam satu baris, menggantikan blok if/else yang lebih panjang.
Begini urutan objek saling berbicara saat $toko->jual("P001", "D001", 1) dipanggil:
Struktur OOP-nya sengaja dipertahankan identik dengan sistem perpustakaan — hanya nama dan rumus bisnisnya yang berubah. Ini membuktikan bahwa pola desain (design pattern) yang sama bisa dipakai ulang untuk domain berbeda:
| Sistem Perpustakaan | Sistem Penjualan | Peran dalam OOP |
|---|---|---|
| KoleksiAbstrak | ProdukAbstrak | Abstract class — cetakan bersama |
| Buku / BukuDigital | ProdukFisik / ProdukDigital | Concrete class — implementasi nyata |
| Pinjamable | Dijual | Interface — kontrak aksi utama |
| Anggota | Pelanggan | Entitas mandiri dengan state & diskon/denda |
| Peminjaman | TransaksiJual | Composition — mengikat dua entitas |
| Perpustakaan | Toko | Orchestrator — fluent interface |
| getDurasiPinjamHari() | getHargaJual() / getPotonganMaksimal() | Abstract method — wajib diisi anak kelas |
Skenario: tiga jenis pelanggan (VIP, MEMBER, REGULER) membeli dari katalog campuran produk fisik dan digital.
1$toko = new Toko("Toko Sumber Rejeki"); 2 3$toko->tambahProduk(new ProdukFisik("F001", "Kabel HDMI 2m", "Vention", "Aksesoris", 25000, 50, 0.2)) 4 ->tambahProduk(new ProdukDigital("D001", "Lisensi Office 365", "Microsoft", "Software", 300000, 100, "1 Tahun")); 5 6$toko->daftarkanPelanggan(new Pelanggan("P001", "Budi Santoso", "budi@mail.com", "VIP")); 7 8$toko->jual("P001", "D001", 1); 9$toko->tampilkanLaporanTransaksi();
Perhatikan angka 15%: Budi adalah pelanggan VIP (berhak diskon 15%), dan produk digital mengizinkan potongan maksimal 30% — jadi diskon penuh 15% disetujui. Jika Budi membeli produk fisik yang maksimal diskonnya hanya 10%, sistem otomatis akan membatasi ke 10%, bukan 15%. Itulah logika min($diskonDiminta, $produk->getPotonganMaksimal()) bekerja.