- Home
- Kelas Belajar
- PHP OOP & Best Practice: Membangun Arsitektur MVC Tanpa Framework
Kelas ini mengajak kamu yang sudah paham dasar PHP OOP untuk naik level ke penerapan class, interface, namespace, dan autoloading Composer secara production-like. Kamu akan belajar kapan dan kenapa suatu pola dipakai, menghindari kesalahan umum developer, hingga akhirnya membangun struktur MVC sederhana dari nol tanpa framework.
Di kelas dasar, kamu mungkin sudah nyaman menulis PHP dengan menjalankannya lewat XAMPP atau langsung buka file di browser. Tapi begitu masuk ke proyek yang menggunakan Composer, namespace, dan struktur MVC, cara kerja seperti itu akan cepat berantakan. Kamu butuh environment yang mendukung workflow modern: PHP CLI untuk menjalankan script tanpa server web, dan Composer sebagai dependency manager sekaligus autoloader.
Modul ini adalah fondasi teknis sebelum kita bicara namespace dan autoloading di modul berikutnya. Tanpa struktur direktori yang benar dan Composer yang terkonfigurasi dengan baik, semua konsep OOP lanjutan yang akan kamu pelajari tidak akan bisa dieksekusi dengan mulus.
PHP CLI (Command Line Interface) berbeda dari PHP yang berjalan di balik Apache/Nginx. Saat kamu menjalankan php nama_file.php dari terminal, PHP dieksekusi langsung tanpa web server. Ini penting karena banyak tools development—termasuk Composer sendiri—berjalan di atas PHP CLI.
Cek instalasi dengan:
php -v
php -m
php -v menampilkan versi PHP yang aktif di terminal-mu. Perhatikan baik-baik: versi PHP CLI bisa berbeda dari versi PHP yang dipakai XAMPP/Laragon jika kamu punya lebih dari satu instalasi. php -m menampilkan daftar extension yang aktif—pastikan minimal mbstring, openssl, dan curl aktif karena Composer bergantung pada extension ini.
Kesalahan umum: developer mengonfigurasi kode dengan PHP versi 8.2 di editor, tapi PHP CLI di terminal masih versi 7.4 bawaan sistem. Akibatnya, fitur seperti readonly property atau enum gagal jalan padahal kode sudah benar. Selalu samakan versi PHP CLI dengan versi target proyek.
Composer bukan sekadar "installer package". Fungsi intinya untuk kelas ini adalah sebagai autoloader generator—ia akan membaca konfigurasi namespace di composer.json dan otomatis memetakan class ke file fisiknya. Ini akan kita bahas detail di modul 3, tapi instalasinya harus rapi dari sekarang.
Setelah instalasi Composer (via installer resmi di getcomposer.org atau package manager OS), verifikasi dengan:
composer -V
Jika muncul versi (misal Composer 2.x), instalasi berhasil. Gunakan Composer versi 2 ke atas—versi 1 sudah deprecated dan beberapa package modern tidak mendukungnya lagi.
Alih-alih membuat composer.json manual, gunakan wizard interaktif:
composer init
Composer akan menanyakan nama package, deskripsi, author, dan tipe project. Untuk kelas ini, jawab tipe project sebagai project (bukan library), karena kita membangun aplikasi, bukan package yang akan didistribusikan. Hasilnya adalah file composer.json seperti ini:
{
"name": "ruangcoding/mvc-tanpa-framework",
"description": "Latihan MVC PHP OOP tanpa framework",
"type": "project",
"require": {},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Bagian autoload.psr-4 ini akan kita bahas mendalam di modul 3. Untuk sekarang, cukup pahami: baris ini memberitahu Composer bahwa setiap class dengan namespace App\ akan dicari di folder src/.
Struktur folder bukan sekadar estetika—ia mencerminkan pemisahan tanggung jawab (separation of concern) yang akan jadi pondasi arsitektur MVC di modul-modul akhir. Berikut struktur yang akan kita pakai sepanjang kelas ini:
proyek-mvc/
├── public/
│ └── index.php
├── src/
│ ├── Controller/
│ ├── Model/
│ ├── View/
│ └── Core/
├── config/
│ └── database.php
├── vendor/
├── composer.json
└── composer.lock
Beberapa poin penting dari struktur ini:
App\. Folder ini dipecah lagi berdasarkan peran: Controller, Model, View, dan Core (untuk class-class dasar seperti Router atau Database connector)..gitignore.Kesalahan yang sering terjadi: pemula meletakkan seluruh file PHP langsung di root folder, termasuk file yang harusnya jadi entry point. Ini membuat setiap file bisa diakses langsung via URL (misal domain.com/Model/User.php), yang berbahaya jika file tersebut berisi koneksi database atau logic sensitif tanpa proteksi.
Sebagai jembatan ke modul-modul berikutnya, mari pastikan struktur ini benar-benar bisa dijalankan. Buat file public/index.php:
<?php
require __DIR__ . '/../vendor/autoload.php';
echo "Environment siap digunakan.";
Jalankan dengan PHP CLI built-in server (tidak perlu Apache/Nginx untuk development):
php -S localhost:8000 -t public
Flag -t public memberitahu PHP bahwa public/ adalah document root—ini mensimulasikan konfigurasi production yang sesungguhnya, di mana web server hanya mengarah ke folder public/.
Bayangkan kamu baru bergabung di sebuah startup sebagai junior developer. Tech lead memberimu task: "Siapkan struktur awal proyek internal tools, nanti kita bangun fitur manajemen inventori pakai pola MVC tanpa framework, biar ringan." Kamu diberi requirement singkat:
composer install.Langkah yang bisa kamu coba sendiri:
composer init dengan namespace App\ mengarah ke src/.public/, src/Controller, src/Model, src/View, src/Core, dan config/ seperti contoh di atas.config/database.php yang mengembalikan array konfigurasi (host, dbname, user, password), lalu tambahkan file ini ke .gitignore agar kredensial tidak ter-commit. Sediakan juga config/database.example.php sebagai template untuk tim lain..gitignore yang mengabaikan vendor/, config/database.php, dan composer.lock jika proyek bersifat aplikasi internal (opsional—untuk library, composer.lock biasanya tetap diabaikan, tapi untuk aplikasi biasanya justru di-commit agar versi dependency konsisten antar developer).php -S localhost:8000 -t public dan pastikan index.php bisa mengakses vendor/autoload.php tanpa error.Jika kelima langkah ini berhasil, environment-mu sudah setara dengan standar proyek production skala kecil-menengah. Di modul berikutnya, kita akan mulai mengisi src/ dengan class-class yang terorganisir lewat namespace—dan kamu akan lihat langsung bagaimana struktur folder yang sudah kamu siapkan ini berperan penting.
Setelah struktur direktori proyek kamu rapi dan Composer terpasang, masalah berikutnya yang akan muncul adalah konflik nama class. Bayangkan kamu punya class User untuk merepresentasikan user aplikasi, tapi kamu juga memakai library pihak ketiga yang kebetulan punya class User sendiri. Tanpa namespace, PHP akan langsung melempar fatal error "Cannot declare class User, because the name is already in use".
Namespace menyelesaikan ini dengan memberi "alamat" pada setiap class, mirip seperti folder di sistem file. Class yang sama namanya bisa hidup berdampingan selama alamatnya berbeda. Ini bukan sekadar solusi konflik nama — namespace juga jadi fondasi wajib sebelum kita masuk ke autoloading PSR-4 di modul berikutnya, karena Composer memetakan namespace ke lokasi file secara otomatis.
Deklarasi namespace harus jadi statement pertama dalam file PHP, sebelum kode lain (kecuali komentar dan declare(strict_types=1)).
<?php
namespace App\Models;
class User
{
public function __construct(
private string $name,
private string $email
) {}
}
Konvensi yang lazim dipakai di proyek production: namespace mengikuti struktur folder, dan nama namespace ditulis PascalCase mengikuti nama foldernya persis. Jadi file di src/Models/User.php punya namespace App\Models, file di src/Services/PaymentService.php punya namespace App\Services. Konsistensi ini penting karena nanti PSR-4 autoloading mengandalkan kecocokan 1:1 antara namespace dan path fisik.
Ada tiga cara memanggil class yang berada di namespace berbeda dari tempat kamu bekerja. Pertama, pakai fully qualified name (FQN) — tulis path lengkap dari root:
$user = new \App\Models\User('Andi', 'andi@mail.com');
Cara ini valid tapi bikin kode berantakan kalau dipakai berulang-ulang. Cara kedua dan yang paling umum di kode production adalah use statement di bagian atas file, setelah deklarasi namespace:
<?php
namespace App\Controllers;
use App\Models\User;
use App\Services\PaymentService;
class UserController
{
public function store(): void
{
$user = new User('Andi', 'andi@mail.com');
$payment = new PaymentService();
}
}
Cara ketiga adalah namespace relatif — memanggil class yang satu "parent" namespace tanpa use. Ini jarang dipakai karena rawan salah baca, sebaiknya dihindari kecuali untuk class yang benar-benar satu grup dekat.
Kadang kamu perlu dua class dengan nama sama dari namespace berbeda dalam satu file — misalnya Model milikmu sendiri dan Model dari library eksternal. Solusinya pakai alias dengan as:
use App\Models\User;
use Vendor\Library\User as LibraryUser;
$localUser = new User('Budi', 'budi@mail.com');
$externalUser = new LibraryUser();
Alias juga berguna untuk mempersingkat nama namespace yang panjang, tapi jangan berlebihan — alias yang terlalu kreatif justru menyulitkan orang lain membaca kode kamu.
Banyak developer PHP tidak sadar bahwa namespace tidak hanya berlaku untuk class, tapi juga function dan constant.
<?php
namespace App\Helpers;
const APP_VERSION = '1.0.0';
function formatCurrency(float $amount): string
{
return 'Rp ' . number_format($amount, 0, ',', '.');
}
Untuk memanggilnya di file lain, kamu bisa pakai use function dan use const:
use function App\Helpers\formatCurrency;
use const App\Helpers\APP_VERSION;
echo formatCurrency(150000);
Kalau kamu tidak melakukan use function, PHP akan fallback mencari function tersebut di namespace global — ini kadang menipu karena kode tetap jalan meski kamu lupa import, padahal sebenarnya kamu memanggil function bawaan PHP, bukan function custom kamu.
strlen() atau array_map(), PHP sebenarnya lebih dulu mencari function itu di namespace kamu sebelum fallback ke global — ini bisa jadi sumber bug halus kalau kamu juga mendefinisikan function dengan nama sama.namespace Models; alih-alih namespace App\Models; — ini meningkatkan risiko bentrok dengan package pihak ketiga yang kamu install lewat Composer nanti.Kamu baru bergabung di sebuah tim dan diminta memperbaiki proyek kecil pengelolaan invoice yang seluruh class-nya masih ditulis tanpa namespace, dengan struktur file seperti ini:
project/
├── User.php
├── Invoice.php
├── InvoicePayment.php
└── index.php
Semua file di-include manual di index.php, dan baru-baru ini tim menambahkan library eksternal untuk generate PDF yang ternyata juga punya class bernama Invoice. Terjadilah fatal error bentrok nama. Tugasmu: refactor struktur ini supaya siap dipakai bersama library eksternal, tanpa mengubah logic bisnis di dalam class.
Langkah yang bisa kamu coba sendiri:
src/ lalu pindahkan User.php, Invoice.php, dan InvoicePayment.php ke dalamnya.namespace App; di baris pertama masing-masing file (setelah tag <?php).index.php, ganti seluruh pemanggilan class lama seperti new Invoice() menjadi new App\Invoice(), atau lebih rapi lagi tambahkan use App\Invoice; di bagian atas file.Invoice dari library eksternal (misalnya Vendor\Pdf\Invoice) diberi alias saat dipanggil, contoh: use Vendor\Pdf\Invoice as PdfInvoice;.php index.php untuk memastikan tidak ada lagi fatal error bentrok nama, sekaligus catat file mana saja yang masih menggunakan require manual — ini akan jadi bahan langsung untuk modul berikutnya, saat kita hilangkan semua require dengan autoloading Composer PSR-4.Kalau kamu berhasil menyelesaikan studi kasus ini tanpa mengubah isi logic class sama sekali, artinya kamu sudah memahami inti dari namespace: ia murni soal organisasi dan resolusi nama, bukan soal mengubah cara kerja kode.
Di modul sebelumnya kamu sudah mengorganisir kode dengan namespace agar tidak ada tabrakan nama class antar modul aplikasi. Tapi ada satu masalah yang belum selesai: PHP tidak tahu di file mana sebuah class dengan namespace tertentu benar-benar berada. Selama ini kamu mungkin masih menuliskan require_once satu per satu di bagian atas file, menyesuaikan path secara manual setiap kali menambah class baru. Cara ini bekerja, tapi tidak scalable — begitu proyek tumbuh ke puluhan atau ratusan class, daftar require akan menjadi mimpi buruk untuk dirawat dan rawan salah path.
Autoloading menyelesaikan masalah ini dengan sebuah mekanisme: PHP akan memanggil fungsi tertentu secara otomatis ketika ia menemukan class yang belum di-load, dan fungsi itu bertugas mencari serta memuat file yang sesuai. Composer menyediakan implementasi autoloading ini secara siap pakai, dan yang paling umum dipakai di industri adalah standar PSR-4.
PSR-4 adalah spesifikasi yang mengatur pemetaan antara namespace dan struktur direktori. Intinya sederhana: sebuah prefix namespace dipetakan ke sebuah folder dasar, lalu setiap segmen namespace setelah prefix itu dipetakan menjadi subfolder, dan nama class terakhir menjadi nama file dengan ekstensi .php.
Contoh, jika kamu punya konfigurasi pemetaan:
App\ => src/
maka class App\Models\User harus berada di file src/Models/User.php. Tidak ada toleransi penamaan — huruf besar-kecil, urutan folder, dan nama file harus persis sama dengan namespace-nya. Kesalahan kecil seperti menulis folder models (huruf kecil) padahal namespace-nya Models bisa membuat autoload gagal di beberapa sistem operasi yang case-sensitive seperti Linux, meski di Windows/macOS kadang masih "kebetulan" jalan — ini jebakan umum yang membuat kode lolos di laptop developer tapi gagal di server produksi.
Pemetaan PSR-4 didefinisikan di file composer.json pada bagian autoload:
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Perhatikan backslash ganda (\\) — ini karena backslash adalah karakter escape di JSON, jadi satu backslash namespace harus ditulis dua kali. Setelah konfigurasi ini ditambahkan atau diubah, kamu wajib menjalankan:
composer dump-autoload
Perintah ini meregenerasi file-file pemetaan di dalam folder vendor/composer/, terutama autoload_psr4.php, yang berisi array asosiatif prefix namespace ke path folder. Composer tidak men-scan seluruh filesystem setiap request — ia membaca pemetaan statis ini, sehingga proses pencarian class jadi sangat cepat walau proyek sudah besar.
Semua ini hanya berguna kalau di titik masuk aplikasi kamu meng-include satu file saja:
<?php
require __DIR__ . '/vendor/autoload.php';
use App\Models\User;
$user = new User();
Baris require itu satu-satunya include manual yang kamu butuhkan selamanya, tidak peduli berapa banyak class yang kamu tambahkan setelahnya. Ini poin krusial yang membedakan pendekatan production-like dari kebiasaan lama: kamu memindahkan tanggung jawab "mencari file" dari developer ke sistem, dan cukup fokus menjaga konsistensi struktur folder terhadap namespace.
Ketika PHP menemukan instansiasi class yang belum pernah di-load, ia memanggil fungsi-fungsi yang terdaftar lewat spl_autoload_register(). Composer mendaftarkan fungsi autoloader-nya sendiri di sana saat vendor/autoload.php dijalankan. Fungsi itu menerima nama class lengkap (termasuk namespace), mencocokkannya dengan pemetaan PSR-4 di composer.json, mengonversi namespace menjadi path file, lalu meng-include file tersebut jika ditemukan.
Kalau file tidak ditemukan sama sekali di path yang diharapkan, PHP akan melempar Error: Class "App\Models\User" not found — bukan warning yang bisa diabaikan, tapi fatal error yang menghentikan eksekusi. Ini kebalikan dari require manual yang error-nya biasanya lebih jelas terlihat ("failed to open stream"), sehingga saat pertama kali migrasi ke autoloading, kamu perlu terbiasa membaca error class-not-found dan langsung mengecek dua hal: (1) apakah nama file dan folder cocok persis dengan namespace, (2) apakah composer dump-autoload sudah dijalankan ulang setelah menambah class baru.
src/Services/Payment/StripeGateway.php tapi menuliskan namespace App\Payment; (skip segmen Services). Ini akan membuat autoloader mencari file di path yang salah.Selain psr-4, composer.json juga mendukung classmap (untuk proyek lama yang strukturnya tidak mengikuti PSR-4) dan files (untuk memuat file fungsi global yang tidak berbentuk class, misalnya kumpulan helper function). Contoh:
{
"autoload": {
"psr-4": {
"App\\": "src/"
},
"files": [
"src/Helpers/functions.php"
]
}
}
Kamu tidak wajib memakai ini di proyek MVC yang akan kita bangun, tapi penting untuk tahu bahwa autoloading Composer bukan cuma soal class — ia adalah mekanisme umum untuk memuat kode secara otomatis dan konsisten.
Bayangkan kamu baru bergabung di sebuah tim, dan mewarisi proyek kecil dengan struktur seperti ini:
project/
├── index.php
├── classes/
│ ├── Database.php (class Database, tanpa namespace)
│ ├── UserModel.php (class UserModel, tanpa namespace)
│ └── UserController.php (class UserController, tanpa namespace)
Di index.php, semua class di-include manual seperti ini:
require 'classes/Database.php';
require 'classes/UserModel.php';
require 'classes/UserController.php';
$controller = new UserController();
$controller->index();
Tugasmu: migrasikan proyek ini agar memakai autoloading PSR-4, tanpa mengubah fungsionalitasnya. Berikut langkah yang bisa kamu praktikkan sendiri:
classes/ ke src/, kelompokkan berdasarkan tanggung jawab: src/Core/Database.php, src/Models/UserModel.php, src/Controllers/UserController.php.namespace App\Core;, namespace App\Models;, namespace App\Controllers;. Jangan lupa tambahkan use statement di file yang saling bergantung, misalnya UserController yang memakai UserModel harus menuliskan use App\Models\UserModel;."App\\": "src/", lalu jalankan composer dump-autoload.use statement:<?php
require __DIR__ . '/vendor/autoload.php';
use App\Controllers\UserController;
$controller = new UserController();
$controller->index();
Models jadi model) untuk melihat error class-not-found secara langsung, lalu kembalikan lagi. Latihan ini melatih kamu mengenali gejala error autoloading sebelum menghadapinya di kode produksi sungguhan.Setelah migrasi ini selesai, proyek kamu sudah punya fondasi struktur yang konsisten dengan namespace dan bebas dari require manual bertumpuk — persis fondasi yang akan kita pakai untuk membangun class abstract, interface, dan pada akhirnya arsitektur MVC penuh di modul-modul berikutnya.
Setelah di modul sebelumnya kita beres-beres soal namespace dan autoloading, sekarang kode kita sudah rapi secara struktur file. Saatnya naik ke lapisan berikutnya: bagaimana merancang class yang benar-benar mencerminkan relasi antar objek di aplikasi nyata. Tiga konsep yang akan kita bahas — abstract class, static property, dan late static binding — adalah fondasi yang nanti dipakai lagi saat kita membangun Model di arsitektur MVC pada modul-modul akhir.
Abstract class berguna ketika beberapa class punya perilaku yang sama persis, tapi juga punya bagian yang wajib diimplementasikan berbeda-beda oleh masing-masing turunannya. Bedanya dengan class biasa yang di-extend: abstract class tidak bisa di-instantiate langsung, dan bisa mendeklarasikan method tanpa isi (abstract method) yang memaksa child class mengisi sendiri.
abstract class Model
{
protected string $table;
public function __construct(protected PDO $db) {}
abstract public function findAll(): array;
public function count(): int
{
$stmt = $this->db->query("SELECT COUNT(*) FROM {$this->table}");
return (int) $stmt->fetchColumn();
}
}
class UserModel extends Model
{
protected string $table = 'users';
public function findAll(): array
{
return $this->db->query("SELECT * FROM {$this->table}")->fetchAll();
}
}
Perhatikan pola di atas: count() adalah logika yang sama untuk semua Model, jadi ditulis sekali di abstract class. Tapi findAll() wajib berbeda tiap tabel, jadi dideklarasikan abstract. Ini mencegah dua masalah umum: duplikasi kode (karena method bersama ditulis sekali) dan lupa implementasi (karena PHP akan melempar fatal error kalau child class tidak mengisi method abstract).
Kesalahan yang sering terjadi: developer memilih abstract class hanya karena "sudah biasa pakai extends", padahal yang dibutuhkan sebenarnya kontrak murni tanpa logika bersama — itu kasus untuk interface, yang akan kita bahas di modul berikutnya. Aturan praktis: pakai abstract class kalau ada logika yang benar-benar di-share, pakai interface kalau yang dibutuhkan cuma kesepakatan nama method.
Static property melekat pada class, bukan pada instance. Artinya semua objek dari class yang sama berbagi satu nilai yang sama. Ini sering dipakai untuk hal seperti counter, cache sementara, atau koneksi yang ingin dibagi:
class QueryLogger
{
private static array $logs = [];
public static function log(string $sql): void
{
self::$logs[] = $sql;
}
public static function getLogs(): array
{
return self::$logs;
}
}
// Dipanggil dari mana saja tanpa instance:
QueryLogger::log("SELECT * FROM users");
Hati-hati: static property gampang disalahgunakan jadi "global variable berkedok OOP". Jika dua request HTTP berbeda (dalam konteks long-running process seperti worker atau Swoole) berbagi satu proses PHP, static property bisa membawa state dari request sebelumnya tanpa disadari. Dalam konteks request-response biasa (PHP-FPM klasik), ini tidak masalah karena tiap request dapat proses baru, tapi tetap jadi sumber bug tersembunyi kalau dipakai sembarangan untuk menyimpan data yang seharusnya per-instance.
Patokan: gunakan static property hanya untuk data yang memang bersifat "milik class", bukan "milik objek". Counter jumlah instance yang pernah dibuat — iya. Data user yang sedang login — tidak, itu harus jadi property instance biasa.
selfIni konsep yang sering bikin bingung karena gejalanya baru muncul saat sudah ada inheritance bertingkat. Perhatikan dulu masalahnya:
class Model
{
public static function create(): static
{
return new self(); // masalah di sini
}
}
class UserModel extends Model {}
$user = UserModel::create();
var_dump($user); // hasilnya object(Model), padahal yang diharapkan object(UserModel)
self di dalam method static selalu merujuk ke class tempat method itu didefinisikan, bukan class yang memanggilnya. Ini jadi masalah nyata pada pola seperti factory method atau active record, di mana child class seharusnya menghasilkan instance dirinya sendiri, bukan instance parent-nya.
Solusinya adalah late static binding dengan keyword static:
class Model
{
public static function create(): static
{
return new static(); // resolved saat runtime
}
}
class UserModel extends Model {}
$user = UserModel::create();
var_dump($user); // sekarang benar: object(UserModel)
static di sini "mengingat" class mana yang sebenarnya memanggil method tersebut saat runtime, bukan saat method itu ditulis. Konsep ini disebut late static binding karena resolusi class dilakukan "lambat" (saat eksekusi), berbeda dengan self yang sudah "mengikat" sejak method ditulis (early binding).
Hal yang sama berlaku untuk pemanggilan static method lain di dalam class yang sama — gunakan static::namaMethod() jika ingin method tersebut bisa di-override oleh child class dan tetap terpanggil versi child-nya, bukan self::namaMethod() yang akan selalu memanggil versi yang didefinisikan di parent.
Bayangkan kamu sedang membangun backend internal tools untuk tim operasional. Ada tiga entitas: Product, Order, dan Customer. Ketiganya butuh method find($id) yang mengembalikan instance dari class masing-masing, dan sebuah static property untuk menghitung berapa kali query dijalankan selama satu request (untuk keperluan debugging performa).
Cobalah selesaikan sendiri dengan langkah berikut sebelum melihat potongan kode di bawah:
BaseModel dengan property protected $table dan static property $queryCount.mapRow(array $row): static yang akan diimplementasikan tiap child class untuk mengubah baris database jadi objek.find(int $id): ?static di BaseModel yang menjalankan query, menambah $queryCount, lalu memanggil static::mapRow() dan mengembalikan new static() yang sesuai — pastikan pakai late static binding, bukan self.ProductModel, OrderModel, CustomerModel yang extend BaseModel dan mengisi $table serta mapRow() masing-masing.abstract class BaseModel
{
protected static int $queryCount = 0;
protected string $table;
abstract public static function mapRow(array $row): static;
public static function find(int $id): ?static
{
self::$queryCount++; // sengaja self, karena ini memang milik BaseModel bersama
// simulasi query, pada proyek nyata gunakan PDO
$row = Database::fetch("SELECT * FROM " . (new static())->table . " WHERE id = ?", [$id]);
return $row ? static::mapRow($row) : null;
}
public static function getQueryCount(): int
{
return self::$queryCount;
}
}
class ProductModel extends BaseModel
{
protected string $table = 'products';
public string $name = '';
public static function mapRow(array $row): static
{
$model = new static();
$model->name = $row['name'];
return $model;
}
}
Dengan pola ini, ProductModel::find(5) akan selalu mengembalikan instance ProductModel, bukan BaseModel, berkat late static binding di new static() dan static::mapRow(). Sementara $queryCount tetap satu nilai yang dibagi oleh semua Model karena didefinisikan di BaseModel dan diakses lewat self — artinya memanggil ProductModel::find() lalu OrderModel::find() akan membuat getQueryCount() bernilai 2, bukan masing-masing 1.
Pola BaseModel seperti ini adalah cikal bakal dari apa yang nanti kita bangun secara utuh pada modul Konsep dan Struktur Pola MVC — jadi pastikan konsep abstract method, static property yang di-share, dan late static binding ini benar-benar melekat sebelum lanjut ke interface di modul berikutnya.
Di modul sebelumnya kamu sudah membangun abstract class dengan static property dan late static binding. Abstract class cocok ketika beberapa class punya kesamaan identitas dan sebagian implementasi yang bisa diwariskan. Tapi di dunia kerja, kamu akan sering menemukan kasus di mana beberapa class tidak punya hubungan "is-a" sama sekali, namun harus bisa diperlakukan dengan cara yang seragam oleh kode lain. Di sinilah interface mengambil peran yang tidak bisa digantikan abstract class.
Interface bukan soal mewariskan kode, tapi soal menyepakati kontrak. Kontrak ini menyatakan: "siapa pun yang mengimplementasikan interface ini wajib punya method-method berikut, dengan signature tertentu." Tidak ada logika di dalamnya, murni deklarasi.
interface Exportable
{
public function export(): string;
}
class InvoicePdf implements Exportable
{
public function export(): string
{
return "Invoice diekspor sebagai PDF";
}
}
class ReportCsv implements Exportable
{
public function export(): string
{
return "Report diekspor sebagai CSV";
}
}
InvoicePdf dan ReportCsv tidak punya hubungan hierarki apa pun — satu soal invoice, satu soal report. Tapi karena keduanya mengimplementasikan Exportable, kode lain bisa memperlakukan keduanya secara seragam tanpa peduli detail internalnya.
implements banyak interface sekaligus, dipisah koma — ini beda dengan extends yang hanya satu parent.interface A extends B, C).public, kamu tidak perlu dan tidak bisa menulis visibility lain.interface Cacheable
{
const DEFAULT_TTL = 3600;
public function getCacheKey(): string;
}
interface Exportable
{
public function export(): string;
}
class ProductReport implements Cacheable, Exportable
{
public function getCacheKey(): string
{
return "product_report_" . date('Ymd');
}
public function export(): string
{
return "Laporan produk diekspor";
}
}
Pertanyaan ini sering dijawab dengan slogan generik seperti "pakai interface kalau tidak ada implementasi bersama". Itu benar tapi kurang praktis. Berikut pertimbangan yang lebih konkret untuk kerja sehari-hari:
PaymentGatewayInterface yang diimplementasikan oleh MidtransGateway, XenditGateway, dan ManualTransferGateway. Ketiganya tidak punya kode bersama sama sekali, hanya kesepakatan method charge() dan refund().Report punya method generateHeader() yang identik, tapi generateBody() berbeda-beda.interface PaymentGatewayInterface
{
public function charge(int $amountInCents): bool;
public function refund(string $transactionId): bool;
}
abstract class BaseGateway implements PaymentGatewayInterface
{
protected function logTransaction(string $message): void
{
echo "[LOG] " . $message . PHP_EOL;
}
}
class MidtransGateway extends BaseGateway
{
public function charge(int $amountInCents): bool
{
$this->logTransaction("Charging via Midtrans");
return true;
}
public function refund(string $transactionId): bool
{
$this->logTransaction("Refunding via Midtrans");
return true;
}
}
Perhatikan di contoh ini, BaseGateway abstract class memberi method bersama logTransaction(), sementara PaymentGatewayInterface memastikan setiap gateway — tidak peduli vendornya siapa — bisa dipanggil lewat method yang sama. Fungsi yang menerima parameter bertipe PaymentGatewayInterface tidak perlu tahu apakah objeknya extends BaseGateway atau tidak.
Kesalahan yang sering dilakukan developer menengah adalah membuat satu interface besar yang memuat banyak method tak terkait, memaksa semua class implementasinya menulis method yang sebenarnya tidak relevan. Ini melanggar Interface Segregation Principle. Solusinya: pecah interface besar menjadi beberapa interface kecil dan spesifik, lalu gabungkan lewat implements ganda jika memang dibutuhkan.
// Hindari ini
interface UserRepositoryInterface
{
public function find(int $id): array;
public function save(array $data): bool;
public function sendWelcomeEmail(int $id): void;
public function generateInvoice(int $id): string;
}
// Lebih baik dipecah
interface Findable { public function find(int $id): array; }
interface Saveable { public function save(array $data): bool; }
interface WelcomeNotifiable { public function sendWelcomeEmail(int $id): void; }
Bayangkan kamu diminta membangun fitur notifikasi untuk aplikasi e-commerce. Saat ini hanya ada notifikasi email, tapi tim produk sudah menyebut rencana menambah SMS dan push notification dalam beberapa bulan ke depan. Kamu tidak boleh menulis kode yang memaksa refactor besar-besaran setiap kali channel baru ditambahkan.
Langkah penyelesaian yang bisa kamu coba sendiri:
NotificationChannel dengan satu method wajib: send(string $recipient, string $message): bool.EmailChannel, SmsChannel, dan PushChannel, masing-masing implements NotificationChannel dengan logika pengiriman berbeda (boleh sekadar echo untuk simulasi).NotificationService yang menerima array berisi objek-objek NotificationChannel lewat constructor, lalu punya method broadcast(string $recipient, string $message) yang melakukan looping dan memanggil send() ke semua channel.WhatsappChannel, tanpa mengubah satu baris pun kode di dalam NotificationService.interface NotificationChannel
{
public function send(string $recipient, string $message): bool;
}
class EmailChannel implements NotificationChannel
{
public function send(string $recipient, string $message): bool
{
echo "Email ke {$recipient}: {$message}" . PHP_EOL;
return true;
}
}
class NotificationService
{
private array $channels;
public function __construct(array $channels)
{
$this->channels = $channels;
}
public function broadcast(string $recipient, string $message): void
{
foreach ($this->channels as $channel) {
$channel->send($recipient, $message);
}
}
}
$service = new NotificationService([
new EmailChannel(),
]);
$service->broadcast('user@example.com', 'Pesanan kamu sudah dikirim');
Jika langkah di atas berhasil kamu jalankan dan kamu bisa menambah WhatsappChannel hanya dengan membuat class baru tanpa menyentuh NotificationService, berarti kamu sudah menerapkan prinsip Open/Closed lewat interface — kode terbuka untuk ekstensi, tertutup untuk modifikasi. Konsep inilah yang akan jadi fondasi saat kita masuk ke Dependency Injection di modul berikutnya, di mana interface menjadi "jembatan" yang memungkinkan class saling bergantung tanpa saling mengenal detail implementasi satu sama lain.
Di modul sebelumnya kita sudah sepakat bahwa interface membuat kode lebih fleksibel karena class bergantung pada kontrak, bukan implementasi konkret. Tapi punya interface saja tidak otomatis membuat kode fleksibel. Kalau class tetap membuat instance dependensinya sendiri di dalam constructor dengan new, interface itu cuma jadi dekorasi. Ketergantungan tetap keras (hard-coded), hanya dibungkus abstraksi yang tidak pernah benar-benar dimanfaatkan.
class OrderService
{
private MySQLOrderRepository $repository;
public function __construct()
{
$this->repository = new MySQLOrderRepository();
}
}
Walaupun MySQLOrderRepository mengimplementasikan OrderRepositoryInterface, OrderService tetap terkunci pada satu implementasi. Mau ganti ke PostgresOrderRepository saat testing atau saat migrasi database? Kamu harus mengubah isi OrderService. Di sinilah Dependency Injection (DI) masuk — bukan konsep baru yang terpisah dari interface, tapi cara menyuntikkan dependensi dari luar supaya interface yang sudah kamu buat benar-benar berguna.
DI bukan satu teknik tunggal. Ada tiga pola umum, dan memilih salah satunya adalah keputusan desain, bukan sekadar selera.
Dependensi diberikan lewat constructor saat object dibuat. Ini pola paling umum dan paling disarankan karena dependensi menjadi eksplisit — siapa pun yang membaca signature constructor langsung tahu apa saja yang dibutuhkan class tersebut.
class OrderService
{
public function __construct(
private OrderRepositoryInterface $repository
) {}
public function placeOrder(array $data): void
{
$this->repository->save($data);
}
}
// Pemakaian
$service = new OrderService(new MySQLOrderRepository());
Perhatikan: OrderService sekarang tidak tahu dan tidak peduli apakah repository-nya MySQL, Postgres, atau mock untuk testing. Yang penting kontraknya terpenuhi.
Dependensi disuntikkan lewat method setter setelah object dibuat. Berguna untuk dependensi opsional yang tidak wajib ada sejak awal, misalnya logger.
class OrderService
{
private ?LoggerInterface $logger = null;
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
}
Risikonya, object bisa berada dalam keadaan "setengah siap" kalau setter lupa dipanggil. Karena itu setter injection sebaiknya dipakai hanya untuk dependensi yang benar-benar opsional, bukan untuk dependensi inti.
Pola ini mewajibkan class mengimplementasikan interface tertentu yang memiliki method untuk menerima dependensi. Jarang dipakai di PHP karena constructor injection sudah cukup ekspresif, tapi penting untuk kamu kenali istilahnya karena sering muncul di literatur DI secara umum.
Prinsip di balik DI disebut Dependency Inversion — huruf D terakhir dalam SOLID yang mungkin sudah pernah kamu dengar. Intinya: class level tinggi (seperti OrderService) seharusnya tidak bergantung pada class level rendah (seperti MySQLOrderRepository), keduanya sama-sama bergantung pada abstraksi (OrderRepositoryInterface).
Konsekuensi praktisnya kelihatan jelas saat menulis unit test. Tanpa DI, menguji OrderService berarti harus punya koneksi database sungguhan. Dengan DI, kamu tinggal buat implementasi palsu:
class FakeOrderRepository implements OrderRepositoryInterface
{
public array $saved = [];
public function save(array $data): void
{
$this->saved[] = $data;
}
}
$service = new OrderService(new FakeOrderRepository());
$service->placeOrder(['item' => 'Buku']);
// assert bahwa $service berfungsi tanpa menyentuh database sama sekali
Ini alasan paling konkret kenapa DI penting di dunia kerja: tanpa DI, test suite proyek jadi lambat, rapuh, dan tergantung infrastruktur eksternal.
Masalah baru muncul ketika dependensi bertingkat. Misalnya OrderService butuh OrderRepositoryInterface, yang implementasinya butuh PDO, yang butuh konfigurasi koneksi. Kalau semua instance dibuat manual satu per satu di banyak tempat, kamu kembali ke masalah duplikasi yang sudah kita hindari dengan autoloading.
Solusinya adalah Dependency Injection Container (DIC) — bukan framework besar, cukup class sederhana yang bertanggung jawab merakit object beserta dependensinya di satu tempat.
class Container
{
private array $bindings = [];
public function bind(string $interface, callable $resolver): void
{
$this->bindings[$interface] = $resolver;
}
public function make(string $interface): mixed
{
if (!isset($this->bindings[$interface])) {
throw new RuntimeException("Tidak ada binding untuk {$interface}");
}
return ($this->bindings[$interface])($this);
}
}
Pemakaiannya seperti ini:
$container = new Container();
$container->bind(PDO::class, function () {
return new PDO('mysql:host=localhost;dbname=toko', 'root', '');
});
$container->bind(OrderRepositoryInterface::class, function (Container $c) {
return new MySQLOrderRepository($c->make(PDO::class));
});
$container->bind(OrderService::class, function (Container $c) {
return new OrderService($c->make(OrderRepositoryInterface::class));
});
$service = $container->make(OrderService::class);
Semua keputusan "siapa dipasangkan dengan siapa" sekarang terpusat di satu file konfigurasi, biasanya dinamai bootstrap.php atau container.php. Ini adalah cikal bakal apa yang dilakukan container bawaan Laravel atau Symfony, hanya saja versi kita jauh lebih sederhana dan tanpa auto-resolving reflection.
$container->make(...) di dalam dirinya sendiri, itu bukan DI, itu Service Locator — class tetap tahu tentang container, yang justru menciptakan ketergantungan tersembunyi. Dependensi harus masuk lewat constructor, container hanya dipakai di titik perakitan (biasanya di index.php atau bootstrap).Bayangkan kamu bekerja di tim yang mengelola sistem pemrosesan order. Saat ini, setiap order yang masuk dicatat ke file log biasa lewat class FileLogger. Tim lead meminta: bulan depan, log harus bisa dikirim ke layanan logging eksternal (anggap saja CloudLogger) tanpa mengganggu kode OrderService yang sudah berjalan di production.
Coba selesaikan sendiri dengan langkah berikut:
LoggerInterface dengan satu method, misalnya log(string $message): void.FileLogger yang menulis ke file lokal, dan CloudLogger yang (untuk latihan ini) cukup menyimpan pesan ke array atau menampilkan lewat echo sebagai simulasi pengiriman ke layanan luar.OrderService supaya menerima LoggerInterface lewat constructor, bukan membuat logger sendiri.bootstrap.php berisi Container sederhana seperti contoh di atas, lalu daftarkan binding untuk LoggerInterface ke FileLogger.placeOrder() dan pastikan log tercatat lewat FileLogger.bootstrap.php dari FileLogger menjadi CloudLogger, jalankan ulang tanpa menyentuh OrderService sama sekali, dan verifikasi bahwa perilaku logging berubah.Kalau langkah terakhir berhasil tanpa membuka file OrderService.php sedikit pun, berarti kamu sudah berhasil menerapkan Dependency Injection dengan benar: kode bisnis tetap stabil, hanya konfigurasi perakitan yang berubah. Inilah fondasi yang nanti dipakai lagi saat kita membangun struktur MVC — controller akan menerima service lewat constructor, bukan membuat sendiri, persis seperti pola yang baru saja kamu latih di sini.
Modul baru ditambahkan secara berkala hingga kelas ini lengkap. Simpan halaman ini dan kembali lagi untuk lanjut belajar.