Media Belajar Coding untuk Developer Indonesia

Ruang Coding

Media belajar online bareng untuk mengembangkan keahlianmu di bidang pemrograman web dan aplikasi smartphone.

139+Member Aktif
30+Artikel & Tutorial
16+Project Siap Pakai
Free Menengah

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.

6Modul
67Menit baca
PHPKategori
1

Konfigurasi Environment Menengah: PHP CLI, Composer, dan Struktur Direktori Proyek

10 menit baca

Kenapa Setup Environment Ini Penting?

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.

Memastikan PHP CLI Terpasang dengan Benar

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.

Instalasi dan Verifikasi Composer

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.

Menginisialisasi Proyek dengan composer init

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 Direktori Proyek yang Direkomendasikan

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:

  • public/ adalah satu-satunya folder yang boleh diakses langsung dari web server. Ini praktik keamanan standar—file konfigurasi, class, dan logic bisnis tidak pernah boleh terekspos langsung ke browser.
  • src/ berisi seluruh kode aplikasi dengan namespace App\. Folder ini dipecah lagi berdasarkan peran: Controller, Model, View, dan Core (untuk class-class dasar seperti Router atau Database connector).
  • config/ menyimpan file konfigurasi terpisah dari logic, sehingga kredensial database atau setting environment tidak tercampur dengan kode class.
  • vendor/ adalah folder yang di-generate otomatis oleh Composer—jangan pernah edit manual isinya, dan selalu masukkan ke .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.

Membuat Entry Point Sederhana

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

Studi Kasus: Menyiapkan Proyek untuk Tim

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:

  • Proyek harus bisa di-clone tim lain dan langsung jalan setelah composer install.
  • File sensitif (kredensial DB) tidak boleh ikut ter-commit ke Git.
  • Struktur folder harus scalable untuk fitur Controller/Model/View yang akan ditambahkan bertahap.

Langkah yang bisa kamu coba sendiri:

  1. Buat folder proyek baru, jalankan composer init dengan namespace App\ mengarah ke src/.
  2. Buat struktur folder public/, src/Controller, src/Model, src/View, src/Core, dan config/ seperti contoh di atas.
  3. Buat file 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.
  4. Buat .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).
  5. Tes dengan menjalankan 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.

Lanjut ke modul berikutnya
2

Namespace: Mengorganisir Kode Skala Besar dan Menghindari Konflik Nama

10 menit baca

Kenapa Namespace Dibutuhkan?

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.

Sintaks Dasar dan Aturan Penulisan

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.

Memanggil Class dari Namespace Lain

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.

Alias untuk Menghindari Bentrok

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.

Namespace untuk Function dan Constant

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.

Kesalahan Umum yang Sering Terjadi

  • Lupa backslash di depan function bawaan PHP. Saat kamu berada dalam namespace custom dan memanggil 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.
  • Mencampur banyak class tanpa namespace jelas dalam satu file besar. Ini menghilangkan seluruh manfaat namespace dan bikin proyek sulit di-scale.
  • Namespace tidak sinkron dengan struktur folder. Ini akan langsung menyebabkan error saat masuk ke tahap autoloading PSR-4, karena Composer tidak bisa menemukan file yang sesuai.
  • Menggunakan namespace root langsung tanpa vendor prefix seperti namespace Models; alih-alih namespace App\Models; — ini meningkatkan risiko bentrok dengan package pihak ketiga yang kamu install lewat Composer nanti.

Studi Kasus: Merapikan Proyek Legacy yang Berantakan

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:

  • Buat folder src/ lalu pindahkan User.php, Invoice.php, dan InvoicePayment.php ke dalamnya.
  • Tambahkan namespace App; di baris pertama masing-masing file (setelah tag <?php).
  • Di 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.
  • Pastikan class Invoice dari library eksternal (misalnya Vendor\Pdf\Invoice) diberi alias saat dipanggil, contoh: use Vendor\Pdf\Invoice as PdfInvoice;.
  • Jalankan kembali aplikasi lewat 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.

Lanjut ke modul berikutnya
3

Autoloading dengan Composer PSR-4: Menghapus require/include Manual

11 menit baca

Dari Namespace ke Autoloading: Menutup Celah Manual

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.

Apa Itu PSR-4, Sebenarnya?

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.

Mengonfigurasi Autoload di composer.json

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.

Mengaktifkan Autoloader di Entry Point

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.

Bagaimana Cara Kerja Autoloader di Balik Layar

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.

Kesalahan Umum yang Sering Terjadi

  • Menambah class tapi lupa dump-autoload. Sebenarnya untuk PSR-4, class baru dalam struktur folder yang sudah terpetakan biasanya langsung dikenali tanpa dump ulang, karena Composer menghitung path secara dinamis dari prefix. Tapi ini tidak berlaku untuk classmap atau file autoload — jadi kalau ragu, dump-autoload adalah langkah aman.
  • Declare namespace tidak sesuai folder. Misalnya file di src/Services/Payment/StripeGateway.php tapi menuliskan namespace App\Payment; (skip segmen Services). Ini akan membuat autoloader mencari file di path yang salah.
  • Mencampur autoload PSR-4 dengan require manual untuk file yang sama. Ini bisa menyebabkan "Cannot redeclare class" karena file di-load dua kali dari jalur berbeda.
  • Menaruh banyak class dalam satu file. PSR-4 mengasumsikan satu file berisi satu class/interface/trait utama. Menaruh beberapa class sekaligus membuat pemetaan otomatis tidak akurat dan menyulitkan autoloader lain membaca kode kamu.

Autoload Tambahan: classmap dan files

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.

Studi Kasus: Migrasi Proyek Legacy dari require Manual ke PSR-4

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:

  • Langkah 1 — Buat struktur baru. Pindahkan semua file dari classes/ ke src/, kelompokkan berdasarkan tanggung jawab: src/Core/Database.php, src/Models/UserModel.php, src/Controllers/UserController.php.
  • Langkah 2 — Tambahkan namespace di setiap file sesuai lokasi barunya: 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;.
  • Langkah 3 — Konfigurasi composer.json dengan pemetaan "App\\": "src/", lalu jalankan composer dump-autoload.
  • Langkah 4 — Bersihkan index.php jadi hanya memuat satu baris autoload dan mengganti pemanggilan class dengan namespace penuh atau use statement:
<?php
require __DIR__ . '/vendor/autoload.php';

use App\Controllers\UserController;

$controller = new UserController();
$controller->index();
  • Langkah 5 — Verifikasi. Hapus sengaja satu huruf di nama folder (misal 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.

    Lanjut ke modul berikutnya
    4

    Class Lanjutan: Abstract Class, Static Property, dan Late Static Binding

    12 menit baca

    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: Kontrak Setengah Jadi

    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: Shared State yang Perlu Hati-hati

    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.

    Late Static Binding: Masalah yang Tidak Bisa Diselesaikan self

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

    Studi Kasus: Sistem Model Dasar untuk Beberapa Entitas

    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:

    • Buat abstract class BaseModel dengan property protected $table dan static property $queryCount.
    • Buat abstract method mapRow(array $row): static yang akan diimplementasikan tiap child class untuk mengubah baris database jadi objek.
    • Buat method 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.
    • Implementasikan 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.

    Lanjut ke modul berikutnya
    5

    Interface dan Kontrak Kode: Kapan Interface Lebih Tepat daripada Abstract Class

    11 menit baca

    Dari Abstract Class ke Interface: Pergeseran Cara Berpikir

    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.

    Aturan Teknis yang Sering Terlewat

    • Satu class boleh implements banyak interface sekaligus, dipisah koma — ini beda dengan extends yang hanya satu parent.
    • Interface boleh extends interface lain, bahkan beberapa sekaligus (interface A extends B, C).
    • Semua method di interface otomatis public, kamu tidak perlu dan tidak bisa menulis visibility lain.
    • PHP 8 mengizinkan constant di dalam interface, tapi tidak mengizinkan property biasa — ini pembeda penting dari abstract class.
    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";
        }
    }
    

    Kapan Pilih Interface, Kapan Pilih Abstract Class

    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:

    • Gunakan interface ketika kamu ingin beberapa class yang secara konsep berbeda tetap bisa dipakai bergantian lewat satu tipe yang sama — misalnya PaymentGatewayInterface yang diimplementasikan oleh MidtransGateway, XenditGateway, dan ManualTransferGateway. Ketiganya tidak punya kode bersama sama sekali, hanya kesepakatan method charge() dan refund().
    • Gunakan abstract class ketika class-class turunan memang satu keluarga dan punya logika umum yang ingin dihindari duplikasinya — misalnya semua Report punya method generateHeader() yang identik, tapi generateBody() berbeda-beda.
    • Kombinasikan keduanya adalah pola paling umum di proyek production: abstract class menangani logika bersama, interface menangani kontrak yang dipakai lintas class tidak berkerabat.
    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 Umum: Interface "Gemuk"

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

    Studi Kasus: Sistem Notifikasi Multi-Channel

    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:

    • Buat interface NotificationChannel dengan satu method wajib: send(string $recipient, string $message): bool.
    • Buat tiga class: EmailChannel, SmsChannel, dan PushChannel, masing-masing implements NotificationChannel dengan logika pengiriman berbeda (boleh sekadar echo untuk simulasi).
    • Buat class 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.
    • Uji dengan menambahkan channel baru, misalnya 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.

    Lanjut ke modul berikutnya
    6

    Dependency Injection: Mengurai Ketergantungan Antar Class secara Fleksibel

    13 menit baca

    Dari Interface ke Masalah Baru: Ketergantungan yang Mengeras

    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.

    Tiga Cara Menyuntikkan Dependensi

    DI bukan satu teknik tunggal. Ada tiga pola umum, dan memilih salah satunya adalah keputusan desain, bukan sekadar selera.

    1. Constructor Injection

    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.

    2. Setter Injection

    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.

    3. Interface Injection

    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.

    Mengapa Harus Bergantung pada Interface, Bukan Class Konkret

    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.

    Container Sederhana: Mengatur Siapa Bergantung pada Siapa

    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.

    Kesalahan Umum yang Perlu Dihindari

    • Service Locator yang menyamar jadi DI. Kalau class memanggil $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).
    • Terlalu banyak dependensi di satu constructor. Kalau satu class butuh lebih dari 4-5 dependensi, itu sinyal class tersebut melanggar Single Responsibility — pecah jadi beberapa class yang lebih kecil.
    • Binding yang dibuat ulang di banyak tempat. Pastikan proses binding container hanya terjadi sekali di satu file bootstrap, bukan tersebar di berbagai controller.

    Studi Kasus: Mengganti Penyimpanan Log Tanpa Mengubah Kode Bisnis

    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:

    • Buat interface LoggerInterface dengan satu method, misalnya log(string $message): void.
    • Buat dua implementasi: 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.
    • Ubah OrderService supaya menerima LoggerInterface lewat constructor, bukan membuat logger sendiri.
    • Buat file bootstrap.php berisi Container sederhana seperti contoh di atas, lalu daftarkan binding untuk LoggerInterface ke FileLogger.
    • Jalankan placeOrder() dan pastikan log tercatat lewat FileLogger.
    • Terakhir, ubah satu baris binding di 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.

    Ini modul terakhir yang sudah tersedia — modul berikutnya segera menyusul.
    7

    Modul berikutnya

    Segera hadir — 6 modul lagi menyusul
    Progresmu
    0 dari 6 modul selesai
    • Konfigurasi Environment Menengah: PHP CLI, Composer, dan Struktur Direktori Proyek
    • Namespace: Mengorganisir Kode Skala Besar dan Menghindari Konflik Nama
    • Autoloading dengan Composer PSR-4: Menghapus require/include Manual
    • Class Lanjutan: Abstract Class, Static Property, dan Late Static Binding
    • Interface dan Kontrak Kode: Kapan Interface Lebih Tepat daripada Abstract Class
    • Dependency Injection: Mengurai Ketergantungan Antar Class secara Fleksibel
    • Modul berikutnya (segera)
    Tentang Kelas Ini

    Modul baru ditambahkan secara berkala hingga kelas ini lengkap. Simpan halaman ini dan kembali lagi untuk lanjut belajar.

    Copyright © 2026 — Made with by Ruang Coding