Catatan Teknis · PHP 8 · OOP Lanjutan

Sistem Penjualan Toko
dalam Tujuh Lapis OOP

Satu file PHP, tujuh konsep berpadu: trait, interface, abstract class, inheritance, dan polymorphism — dibedah lapis demi lapis seperti membaca buku besar toko.

Bahasa: PHP 8.1+ Pola: Domain Model Entitas inti: 4 kelas, 2 trait, 2 interface Studi banding: Sistem Perpustakaan
LAYER 0

Trait — Perilaku yang Dipakai Bersama

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".

trait
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}

Mengapa trait, bukan parent class?

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.

Dipakai di mana saja?

ProdukAbstrak, Pelanggan, dan Toko ketiganya memakai use HasLogger — masing-masing punya riwayat log sendiri yang terisolasi, walau kodenya disalin dari sumber yang sama.

LAYER 1

Interface — Kontrak yang Wajib Dipenuhi

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.

interface
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}
Bandingkan dengan 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.
LAYER 2

Abstract Class — Cetakan Umum Produk

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().

abstract class
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}

readonly property

kode, nama, hargaBeli dideklarasikan readonly — sekali diisi di konstruktor, tidak bisa diubah lagi. Mencegah harga beli "diam-diam" berubah di tengah jalan.

Constructor property promotion

Parameter konstruktor langsung dideklarasikan sebagai property (PHP 8+) — tidak perlu menulis $this->kode = $kode; secara manual untuk tiap field.

LAYER 3

Concrete Class — Produk yang Sesungguhnya

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.

inheritance
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}
Polymorphism muncul tepat di sini: kode pemanggil cukup menulis $produk->getHargaJual() — tanpa tahu (atau peduli) apakah $produk itu fisik atau digital. PHP otomatis memanggil rumus margin yang sesuai dengan jenis objeknya saat itu.
LAYER 4

Pelanggan — Entitas Mandiri dengan State

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.

encapsulation
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}

Encapsulation

$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.

Static property

$total bersifat static: dimiliki kelas, bukan objek. Setiap kali Pelanggan baru dibuat, angka ini bertambah — dipakai untuk Pelanggan::getTotal() tanpa perlu instance apa pun.

LAYER 5

TransaksiJual — Mengikat Semua Pihak

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.

composition
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}
Ini adalah compositionTransaksiJual tidak mewarisi Pelanggan atau ProdukAbstrak, ia hanya memegang referensi ke keduanya. Hubungan "punya" (has-a), berbeda dengan inheritance yang hubungannya "adalah" (is-a).
LAYER 6

Toko — Sang Orkestrator

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.

orchestration + fluent interface
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}

Fluent interface

Karena tambahProduk() mengembalikan $this, pemanggilannya bisa dirangkai: $toko->tambahProduk(...)->tambahProduk(...)->daftarkanPelanggan(...) — satu kalimat, banyak aksi.

Null coalescing + throw

?? throw new ... (PHP 8) menggabungkan pengecekan "tidak ditemukan" dan pelemparan error dalam satu baris, menggantikan blok if/else yang lebih panjang.

VISUAL

Diagram Alur: Satu Transaksi Penjualan

Begini urutan objek saling berbicara saat $toko->jual("P001", "D001", 1) dipanggil:

Toko ->jual(P001,D001,1) Pelanggan getDiskonTipe() ProdukDigital ->jual($pelanggan,1) TransaksiJual hitung subtotal+diskon INV-0001 faktur jadi 1 2 3 4 1. Toko mencari produk & pelanggan, lalu memanggil polymorphic method jual() 2. ProdukDigital meminta diskon yang berhak diterima pelanggan ini 3. Konstruktor TransaksiJual menghitung subtotal, membatasi diskon, dan membuat nomor faktur 4. Faktur dikembalikan ke Toko, dicatat ke log transaksi, dan total belanja pelanggan diperbarui
PEMBANDING

Pemetaan Konsep: Perpustakaan → Toko

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 PerpustakaanSistem PenjualanPeran dalam OOP
KoleksiAbstrakProdukAbstrakAbstract class — cetakan bersama
Buku / BukuDigitalProdukFisik / ProdukDigitalConcrete class — implementasi nyata
PinjamableDijualInterface — kontrak aksi utama
AnggotaPelangganEntitas mandiri dengan state & diskon/denda
PeminjamanTransaksiJualComposition — mengikat dua entitas
PerpustakaanTokoOrchestrator — fluent interface
getDurasiPinjamHari()getHargaJual() / getPotonganMaksimal()Abstract method — wajib diisi anak kelas
Inilah keuntungan nyata OOP: begitu kerangka abstract class + interface sudah matang, menambah domain bisnis baru (toko, gudang, klinik, apa pun) hanya butuh mengisi ulang rumus di lapisan concrete class — bukan menulis ulang seluruh sistem.
SIMULASI

Contoh Pemakaian & Output

Skenario: tiga jenis pelanggan (VIP, MEMBER, REGULER) membeli dari katalog campuran produk fisik dan digital.

demo
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();
=== KATALOG Toko Sumber Rejeki === 📦 FISIK [F001] Kabel HDMI 2m - Rp31.250 (stok: 50) 💾 DIGITAL [D001] Lisensi Office 365 - Rp480.000 (stok: 100) ... === LAPORAN TRANSAKSI Toko Sumber Rejeki === INV-0001: Budi Santoso membeli 1x Lisensi Office 365 = Rp408.000 (diskon 15%) ---------------------------------------- TOTAL OMZET: Rp408.000

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.