Middleware sudah menyaring request sebelum mencapai handler — sekarang saatnya merapikan handler itu sendiri. Dalam pola MVC, peran itu dipegang oleh controller: kelas yang menerima request, memanggil pihak yang tepat untuk bekerja, lalu mengembalikan response. Persoalannya, controller adalah tempat paling mudah untuk menumpukkan kode — database, rumus diskon, dan format JSON bisa saja berdesakan dalam satu method. Pada chapter ini kita akan melihat gejala fat controller, cara memindahkan logika ke service, dan konvensi penamaan yang membuat controller mudah dipelajari orang lain.
Peran Sebenarnya Controller #
Controller adalah lapisan HTTP in, HTTP out: menerjemahkan permintaan yang datang dari dunia luar menjadi panggilan ke logika aplikasi, lalu menerjemahkan hasilnya kembali menjadi response. Sebuah controller yang sehat hanya berisi empat jenis pekerjaan: membaca input dari request, memanggil service atau repository, memilih bentuk response, dan mengembalikannya. Ia TIDAK boleh tahu cara menghitung pajak, cara koneksi ke database, atau cara format tanggal. Prinsip ini sering disebut thin controller — ramping — dan lawannya, fat controller, adalah bau kode (code smell) paling umum di aplikasi web. Sebagai gambaran posisinya dalam arsitektur besar, Anda bisa membaca kembali pola Controller-Service-Repository .
Controller Gemuk: Masalah yang Menumpuk #
Mari kita lihat controller gemuk dalam bentuk nyata. Method show() di bawah terlihat berhasil: mencari pesanan, menghitung total, menerapkan diskon, lalu memformat JSON:
<?php
declare(strict_types=1);
final class PesananControllerGemuk
{
/** @var array<int, array{id: int, items: list<array{nama: string, harga: int, jumlah: int}>}> */
private array $database;
public function __construct()
{
// "database" tiruan — cukup untuk melihat strukturnya
$this->database = [
7 => [
'id' => 7,
'items' => [
['nama' => 'Kopi Gayo', 'harga' => 50000, 'jumlah' => 2],
['nama' => 'Gula Aren', 'harga' => 15000, 'jumlah' => 1],
],
],
];
}
public function show(int $id): string
{
$pesanan = $this->database[$id] ?? null;
if ($pesanan === null) {
http_response_code(404);
return 'Pesanan tidak ditemukan';
}
$total = 0;
foreach ($pesanan['items'] as $item) {
$total += $item['harga'] * $item['jumlah'];
}
$diskon = ($total > 100000) ? $total * 0.10 : 0.0;
return (string) json_encode([
'id' => $pesanan['id'],
'total' => (int) ($total - $diskon),
'diskon' => (int) $diskon,
]);
}
}
$controller = new PesananControllerGemuk();
$hilang = $controller->show(99);
$ok = $controller->show(7);
echo $ok, "\n";
echo $hilang, "\n";Output yang Dihasilkan:
{"id":7,"total":103500,"diskon":11500}
Pesanan tidak ditemukanKode di atas berjalan, tetapi tiga masalah menumpuk di dalamnya. Pertama, aturan bisnis (diskon 10% di atas Rp100.000) terkubur di antara urusan HTTP — tidak ada tempat lain yang bisa memakainya, misalnya job CLI yang menghitung ulang pesanan lama. Kedua, akses data menyatu dengan controller — mengganti database berarti menyentuh semua controller yang meniru pola ini. Ketiga, menguji aturan diskon berarti membangun controller beserta “database"nya; padahal aturan diskon sama sekali tidak peduli HTTP.
Refactor: Pindahkan Logika ke Service #
Obat untuk controller gemuk adalah memindahkan tiap tanggung jawab ke rumahnya sendiri: akses data ke repository, aturan bisnis ke service. Controller yang tersisa hanya meneruskan pesan. Seluruh kelas pendukung chapter ini (Response, BaseController, PesananRepository, HargaService) tersedia dalam berkas mvc.php di bagian bawah halaman; contoh-contoh memuatnya dengan require __DIR__ . '/mvc.php';. Inilah isi rumah-rumah baru tersebut:
<?php
declare(strict_types=1);
final class PesananRepository
{
/** @var array<int, array{id: int, items: list<array{nama: string, harga: int, jumlah: int}>}> */
private array $storage;
/** @param array<int, array{id: int, items: list<array{nama: string, harga: int, jumlah: int}>}>|null $storage */
public function __construct(?array $storage = null)
{
$this->storage = $storage ?? [
7 => [
'id' => 7,
'items' => [
['nama' => 'Kopi Gayo', 'harga' => 50000, 'jumlah' => 2],
['nama' => 'Gula Aren', 'harga' => 15000, 'jumlah' => 1],
],
],
];
}
/** @return array{id: int, items: list<array{nama: string, harga: int, jumlah: int}>}|null */
public function cari(int $id): ?array
{
return $this->storage[$id] ?? null;
}
}
final class HargaService
{
/** @param list<array{nama: string, harga: int, jumlah: int}> $items */
public function hitungTotal(array $items): int
{
$total = 0;
foreach ($items as $item) {
$total += $item['harga'] * $item['jumlah'];
}
return $total;
}
public function hitungDiskon(int $total, bool $member): float
{
return ($member && $total > 100000) ? $total * 0.10 : 0.0;
}
}PesananRepository menyembunyikan tempat data berada, sementara HargaService memegang aturan harga murni — masukannya array, keluarannya angka, tanpa menyentuh HTTP sedikit pun. Sekarang controller bisa menyusut menjadi penerjemah:
<?php
declare(strict_types=1);
require __DIR__ . '/mvc.php';
final class PesananController
{
public function __construct(
private readonly PesananRepository $repository,
private readonly HargaService $harga,
) {}
public function show(int $id): Response
{
$pesanan = $this->repository->cari($id);
if ($pesanan === null) {
return new Response('Pesanan tidak ditemukan', [], 404);
}
$total = $this->harga->hitungTotal($pesanan['items']);
$diskon = $this->harga->hitungDiskon($total, member: true);
return Response::json([
'id' => $pesanan['id'],
'total' => (int) ($total - $diskon),
'diskon' => (int) $diskon,
]);
}
}
// "Framework" yang membangun controller — bukan pengguna
$controller = new PesananController(new PesananRepository(), new HargaService());
$ok = $controller->show(7);
$hilang = $controller->show(99);
echo "show(7) -> status {$ok->status}: {$ok->body}\n";
echo "show(99) -> status {$hilang->status}: {$hilang->body}\n";
// Aturan bisnis kini bisa diuji tanpa HTTP sama sekali
$service = new HargaService();
echo "diskon 90rb : ", $service->hitungDiskon(90000, member: true), "\n";
echo "diskon 120rb: ", $service->hitungDiskon(120000, member: true), "\n";Output yang Dihasilkan:
show(7) -> status 200: {"id":7,"total":103500,"diskon":11500}
show(99) -> status 404: Pesanan tidak ditemukan
diskon 90rb : 0
diskon 120rb: 12000Bandingkan hasil kedua versi: output show(7) dan show(99) identik dengan versi gemuk — refactor tidak mengubah perilaku, hanya struktur. Bedanya sekarang tampak pada tiga baris terakhir: aturan diskon bisa diperiksa langsung sebagai pemanggilan method biasa, tanpa controller, tanpa request, tanpa menangkap output. Semakin sedikit HTTP yang melingkupi sebuah aturan, semakin murah biaya mengujinya. Inilah inti prinsip no business logic in controller.
Mengembalikan Response, Bukan echo #
Ada satu kebiasaan lama yang harus dilepas saat membangun lapisan controller: menuliskan echo di dalam method. Dua versi berikut memperlihatkan bedanya:
<?php
declare(strict_types=1);
// Gaya lama: echo langsung ke output — hasilnya tidak bisa disusun
final class VersiEcho
{
public function show(int $id): void
{
echo "Halo pengguna $id";
http_response_code(200);
}
}
// Gaya framework: kembalikan object Response — bisa diperiksa,
// dibungkus middleware, dan diuji tanpa menangkap output
final class VersiResponse
{
public function show(int $id): Response
{
return new Response("Halo pengguna $id", [], 200);
}
}Method yang meng-echo mencetak begitu dipanggil — middleware tidak sempat menambah header, dan test harus menangkap output global untuk memverifikasi isinya. Method yang mengembalikan Response menyerahkan keputusan akhir ke pemanggil: framework bisa menambah header, middleware bisa membungkusnya, dan test cukup membandingkan property $body dan $status. Aturan praktisnya satu kalimat: controller menyusun response, framework yang mencetaknya.
Dependency Injection pada Controller #
Perhatikan kembali constructor PesananController: kedua kebutuhannya dinyatakan eksplisit sebagai parameter bertipe. Gaya ini disebut constructor injection — pemanggil (dalam aplikasi nyata: framework) wajib menyediakan PesananRepository dan HargaService saat membangun controller. Manfaatnya langsung terasa saat menguji: kita bisa menyuntikkan repository berisi data tiruan tanpa menyentuh database sama sekali, seperti yang kita lakukan lewat new PesananRepository(null) yang memakai data bawaan demo. Pembahasan mendalam tentang prinsip di baliknya tersedia pada chapter Dependency Injection
, dan pada chapter Dependency Container
kita akan membangun mesin yang menyambungkan dependensi ini secara otomatis.
Base Controller dan Helper Bersama #
Aplikasi nyata punya puluhan controller yang sama-sama butuh helper: kirim JSON, redirect, render view. Kode seperti itu tidak perlu ditulis ulang — kumpulkan dalam satu kelas induk abstrak:
<?php
declare(strict_types=1);
abstract class BaseController
{
protected function json(array $data, int $status = 200): Response
{
return Response::json($data, $status);
}
protected function redirect(string $uri): Response
{
return Response::redirect($uri);
}
protected function view(string $template, array $data = []): Response
{
// render template dibahas pada chapter Template Rendering
return new Response("view: {$template}");
}
}Method-method ini sengaja protected karena hanya untuk anak kelasnya, bukan API publik. Controller anak tinggal memanggilnya seperti method miliknya sendiri:
<?php
declare(strict_types=1);
require __DIR__ . '/mvc.php';
final class HalamanController extends BaseController
{
public function tentang(): Response
{
return $this->json(['judul' => 'Tentang Kami', 'penulis' => 'Budi']);
}
public function masuk(): Response
{
return $this->redirect('/masuk');
}
public function beranda(): Response
{
return $this->view('beranda', ['nama' => 'Budi']);
}
}
$controller = new HalamanController();
$json = $controller->tentang();
echo "tentang -> {$json->status} {$json->headers['Content-Type']}: {$json->body}\n";
$redirect = $controller->masuk();
echo "masuk -> {$redirect->status} Location: {$redirect->headers['Location']}\n";
$view = $controller->beranda();
echo "beranda -> {$view->status} {$view->body}\n";Output yang Dihasilkan:
tentang -> 200 application/json: {"judul":"Tentang Kami","penulis":"Budi"}
masuk -> 302 Location: /masuk
beranda -> 200 view: berandaOutput tersebut memperlihatkan ketiga helper bekerja dari satu tempat: JSON lengkap dengan Content-Type, redirect dengan status 302 dan header Location, serta view yang untuk sementara mengembalikan penanda teks. Perhatikan bahwa tiap method tetap satu baris return — base controller berhasil menjaga controller anak tetap ramping.
Konvensi Penamaan dan Resource #
Framework besar sepakat pada konvensi penamaan yang membuat controller bisa ditebak isinya hanya dari namanya: satu controller per resource (satu jenis entitas), nama kelas NamaResourceController, dan nama method mengikuti aksi standar:
| Method | HTTP | Rute | Tugas |
|---|---|---|---|
index() | GET | /users | daftar seluruh user |
show($id) | GET | /users/{id} | tampilkan satu user |
store() | POST | /users | simpan user baru |
edit($id) | PUT/PATCH | /users/{id} | ubah user |
destroy($id) | DELETE | /users/{id} | hapus user |
Dengan konvensi ini, definisi rute dari chapter Membuat Router
tinggal memetakan pola ke pasangan [class, method]:
<?php
declare(strict_types=1);
// Peta rute: method HTTP + path => aksi controller
$routes = [
'GET /users/{id}' => [UserController::class, 'show'],
'GET /pesanan/{id}' => [PesananController::class, 'show'],
'POST /pesanan' => [PesananController::class, 'store'],
];Siapa pun yang membaca UserController::show langsung tahu: kelas mengelola resource user, method menampilkan satu entitas berdasarkan $id. Konvensi bukan sekadar gaya — konvensi mengurangi keputusan yang harus diambil, sehingga tim bisa memperdebatkan logika bisnis, bukan nama method.
Ringkasan #
Controller yang sehat adalah penerjemah dua arah: request masuk, logika dipanggil, response keluar. Controller gemuk diatasi dengan memindahkan akses data ke repository dan aturan bisnis ke service, sehingga aturan-aturan itu bisa diuji tanpa HTTP. Controller mengembalikan Response alih-alih meng-echo, menerima dependensi lewat constructor, dan mewarisi helper umum dari base controller. Nama kelas dan method mengikuti konvensi resource agar struktur aplikasi bisa ditebak. Satu janji dari helper view() masih belum ditepati — cara sebuah template PHP berubah menjadi HTML yang aman adalah topik chapter berikutnya, Template Rendering
.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
mvc.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Kerangka inti chapter Controller Layer.
*
* Berisi Response, BaseController, serta kelas service dan repository
* yang dipakai contoh-contoh chapter; setiap contoh memuat berkas ini
* dengan require.
*/
final class Response
{
public function __construct(
public readonly string $body,
public readonly array $headers = [],
public readonly int $status = 200,
) {}
/** Membuat response JSON dengan header Content-Type yang tepat. */
public static function json(array $data, int $status = 200): self
{
return new self(
(string) json_encode($data),
['Content-Type' => 'application/json'],
$status,
);
}
/** Membuat response pengalihan (redirect) 302. */
public static function redirect(string $uri): self
{
return new self('', ['Location' => $uri], 302);
}
}
abstract class BaseController
{
protected function json(array $data, int $status = 200): Response
{
return Response::json($data, $status);
}
protected function redirect(string $uri): Response
{
return Response::redirect($uri);
}
protected function view(string $template, array $data = []): Response
{
// render template dibahas pada chapter Template Rendering
return new Response("view: {$template}");
}
}
final class PesananRepository
{
/** @var array<int, array{id: int, items: list<array{nama: string, harga: int, jumlah: int}>}> */
private array $storage;
/** @param array<int, array{id: int, items: list<array{nama: string, harga: int, jumlah: int}>}>|null $storage */
public function __construct(?array $storage = null)
{
$this->storage = $storage ?? [
7 => [
'id' => 7,
'items' => [
['nama' => 'Kopi Gayo', 'harga' => 50000, 'jumlah' => 2],
['nama' => 'Gula Aren', 'harga' => 15000, 'jumlah' => 1],
],
],
];
}
/** @return array{id: int, items: list<array{nama: string, harga: int, jumlah: int}>}|null */
public function cari(int $id): ?array
{
return $this->storage[$id] ?? null;
}
}
final class HargaService
{
/** @param list<array{nama: string, harga: int, jumlah: int}> $items */
public function hitungTotal(array $items): int
{
$total = 0;
foreach ($items as $item) {
$total += $item['harga'] * $item['jumlah'];
}
return $total;
}
public function hitungDiskon(int $total, bool $member): float
{
return ($member && $total > 100000) ? $total * 0.10 : 0.0;
}
}