Framework seperti Laravel dan Symfony terasa punya sihir tersendiri: URL yang rapi, kode yang terorganisir, dan fitur yang “sudah ada”. Sihir tersebut sesungguhnya adalah komposisi pola-pola sederhana, dan cara tercepat memahaminya adalah membangun versi kecilnya sendiri. Kita mulai section ini dari pola yang paling terkenal: Model-View-Controller (MVC), kerangka pemisahan tanggung jawab yang menjadi tulang belakang hampir semua framework web PHP. Pada chapter ini kita bedah anatomi MVC, jalankan siklus request-nya dalam PHP murni, lalu lihat bagaimana framework nyata menafsirkan — dan kadang melampaui — pola klasik tersebut.
Anatomi MVC #
MVC membagi aplikasi menjadi tiga jenis komponen dengan aturan yang tegas. Model bertanggung jawab atas data dan aturan bisnis; view bertanggung jawab mengubah data menjadi tampilan; controller bertanggung jawab menerima input dan mengoordinasikan keduanya. Kekuatan pola ini justru terletak pada larangannya, bukan hanya izinnya:
| Komponen | Tanggung jawab utama | Dilarang menyentuh |
|---|---|---|
| Model | Mengambil/menyimpan data, aturan bisnis | HTML, echo, $_GET |
| View | Merender data menjadi tampilan | Query database, logika keputusan |
| Controller | Membaca request, memanggil model, memilih view | SQL langsung, perhitungan bisnis rumit |
Aturan tabel tersebut terasa kaku, tetapi setiap pelanggarannya punya harga nyata. View yang menjalankan query membuat halaman mustahil dirender tanpa database; controller yang memegang aturan bisnis membuat aturan tersebut tidak bisa diuji tanpa HTTP; model yang mencetak HTML tidak bisa dipakai ulang untuk API. Pemisahan yang disiplin adalah syarat agar tiap bagian bisa diganti dan diuji sendiri-sendiri.
Alur Request Front Controller #
Aplikasi MVC modern memakai pola front controller: seluruh request masuk melalui satu berkas (index.php), lalu diteruskan ke handler yang sesuai. Diagram berikut memetakan perjalanan satu request di MVC mini yang akan kita bangun:
Dua hal penting dari alur tersebut. Pertama, model dan view tidak pernah saling bicara — koordinasi sepenuhnya di tangan controller, sehingga aliran data selalu searah dan mudah diikuti. Kedua, semua komponen di sisi kanan router adalah fungsi biasa; “request” di sini hanyalah string method dan path, dan itulah sebabnya seluruh siklus ini bisa kita jalankan dan uji di terminal tanpa web server.
MVC Mini dalam PHP Murni #
Sekarang kita wujudkan diagram tersebut menjadi kode yang benar-benar berjalan. Katalog sengaja memakai array di memori agar fokus tetap pada alur, bukan pada database — di aplikasi nyata, isi cariProduk() diganti query PDO tanpa mengubah satu baris lain:
<?php
// Model: mengambil data - tidak tahu apa pun tentang HTTP
function cariProduk(string $id): ?array
{
$katalog = [
"p1" => ["nama" => "Kopi Gayo", "harga" => 85000],
"p2" => ["nama" => "Teh Melati", "harga" => 42000],
];
return $katalog[$id] ?? null;
}
// View: mengubah data menjadi tampilan - tidak memanggil model
function renderDetailProduk(array $produk): string
{
$nama = htmlspecialchars($produk["nama"]);
$harga = number_format($produk["harga"], 0, ",", ".");
return "<h1>{$nama}</h1><p>Harga: Rp{$harga}</p>";
}
// Controller: menghubungkan model dan view - tetap tipis
function detailProduk(string $id): array
{
$produk = cariProduk($id);
if ($produk === null) {
return ["status" => 404, "body" => "Produk tidak ditemukan"];
}
return ["status" => 200, "body" => renderDetailProduk($produk)];
}
// Front controller: satu-satunya pintu masuk request
function dispatch(string $method, string $path): array
{
if ($method !== "GET") {
return ["status" => 405, "body" => "Method tidak didukung"];
}
if (preg_match("#^/produk/(\w+)$#", $path, $cocok)) {
return detailProduk($cocok[1]);
}
return ["status" => 404, "body" => "Halaman tidak ditemukan"];
}
foreach ([
["GET", "/produk/p1"],
["GET", "/produk/p9"],
["POST", "/produk/p1"],
["GET", "/tentang"],
] as [$method, $path]) {
$respons = dispatch($method, $path);
printf("%-4s %-14s -> HTTP %d %s\n", $method, $path, $respons["status"], $respons["body"]);
}Output yang Dihasilkan:
GET /produk/p1 -> HTTP 200 <h1>Kopi Gayo</h1><p>Harga: Rp85.000</p>
GET /produk/p9 -> HTTP 404 Produk tidak ditemukan
POST /produk/p1 -> HTTP 405 Method tidak didukung
GET /tentang -> HTTP 404 Halaman tidak ditemukanMari kita ikuti satu request melewati kode tersebut. dispatch() berperan sebagai front controller: menerima method dan path, menolak method selain GET dengan 405, lalu mencocokkan path dengan pola regex /produk/{id}. Untuk /produk/p1, tangkapan p1 diteruskan ke controller detailProduk(). Controller bertanya pada model lewat cariProduk(); hasil null (seperti /produk/p9) dijawab 404 tanpa menyentuh view sama sekali, sedangkan hasil berupa array diteruskan ke renderDetailProduk() — view yang memformat harga dan meng-escape nama produk sebelum menjadi HTML. Perhatikan bahwa view mengembalikan string dan controller mengembalikan array status+body; tidak ada satu pun echo di dalam lapisan ini, sehingga seluruh rangkaian bisa dites sebagai fungsi biasa.
Kenapa Pemisahan Ini Berharga #
Manfaat pemisahan menjadi nyata pada saat perubahan datang. Ingin menyediakan JSON untuk aplikasi mobile? Tulis renderDetailProdukJson() dan ganti satu baris di controller — model tidak tersentuh. Ingin pindah dari array ke database? Ganti isi cariProduk() — view dan controller tidak berubah. Ingin menguji aturan “produk tidak ditemukan menghasilkan 404”? Panggil detailProduk("p9") langsung di PHPUnit tanpa booting web server, session, maupun browser.
Pemisahan juga memperlancar kerja tim: desainer mengedit berkas view tanpa memahami query, dan pengembang backend mengubah model tanpa mengganggu tampilan. Konflik merge menyusut karena perubahan jarang menyentuh berkas yang sama. Semua ini terdengar abstrak sampai Anda pernah mencoba memelihara satu berkas raksasa yang bercampur HTML, SQL, dan logika bisnis sekaligus.
MVC di Dunia Framework Nyata #
Framework PHP tidak menerapkan MVC secara tekstual dari buku teks — keduanya menafsirkannya. Pada Laravel dan Symfony, yang bertajuk “controller” di kode Anda tetap tipis, tetapi logika bisnis besar tidak dimasukkan ke model juga; framework modern menambahkan lapisan service di antara keduanya. Berikut wujud controller Laravel yang sehat sebagai gambaran (kode tampilan, bukan untuk dijalankan):
<?php
// app/Http/Controllers/ProdukController.php - gaya Laravel
class ProdukController extends Controller
{
public function __construct(private ProdukService $produk) {}
public function detail(Request $request, string $id): Response
{
$produk = $this->produk->cariUntukTampilan($id);
if ($produk === null) {
return response()->view("produk.tidak-ditemukan", status: 404);
}
return response()->view("produk.detail", ["produk" => $produk]);
}
}Perhatikan kemiripannya dengan MVC mini kita: controller yang sama tipis, keputusan 404 di tempat yang sama, dan kini ada ProdukService yang memegang logika “cara mencari produk untuk ditampilkan”. Konstruktor menerima service tersebut lewat dependency injection, sehingga pengujian cukup menyuntikkan versi palsu. Detail mekanisme tiap framework berbeda, tetapi kerangkanya sama persis.
Di Mana Logika Bisnis Sebaiknya Berada #
Jawaban singkatnya: di fungsi atau class yang tidak tahu apa pun tentang HTTP. Aturan diskon, perhitungan ongkos, dan validasi lintas entitas adalah logika murni — logika tersebut menerima input, mengembalikan output, dan tidak peduli apakah pemanggilnya request web, pekerjaan cron, atau test. Perbandingan berikut memperlihatkan arah refactoring-nya:
<?php
// Smell: controller gemuk - aturan diskon menempel di tempat yang salah
function simpanPesananVersiGemuk(array $keranjang, array $pengguna): array
{
// ... puluhan baris validasi, perhitungan diskon, poin loyalitas,
// pemotongan stok, dan pengiriman notifikasi bercampur di satu fungsi
return [];
}
// Perbaikan: aturan diskon menjadi service murni yang teruji sendiri
function hitungTotalSetelahDiskon(array $items, float $diskonPersen): float
{
$subtotal = 0.0;
foreach ($items as $item) {
$subtotal += $item["harga"] * $item["jumlah"];
}
return round($subtotal * (1 - $diskonPersen / 100), 2);
}Fungsi hitungTotalSetelahDiskon() tidak menerima $_POST, tidak mencetak apa pun, dan tidak membaca cookie — fungsi tersebut menerima data, mengembalikan angka, dan selesai. Controller cukup memanggilnya lalu meneruskan hasilnya ke view; aturan bisnisnya sendiri bisa diuji dengan sepuluh baris PHPUnit tanpa infrastruktur apa pun. Prinsip sederhana ini — HTTP di tepi, logika di tengah — akan menjadi benang merah sepanjang section ini.
Ringkasan #
MVC membagi aplikasi menjadi model (data dan aturan), view (tampilan), dan controller (koordinasi), dengan front controller sebagai pintu tunggal request. Pemisahan yang disiplin membuat tiap bagian teruji dan terganti sendiri-sendiri, dan framework modern menambahkan lapisan service agar controller tetap tipis tanpa menciptakan fat model. MVC mini kita sudah punya satu kelemahan yang disengaja: pencocokan URL masih berupa regex tulisan tangan di dispatch(). Pada chapter berikutnya kita angkat bagian tersebut menjadi komponen sungguhan — router yang layak disebut Membuat Router
.