Front controller pada chapter sebelumnya mencocokkan URL dengan regex tulisan tangan, dan pendekatan tersebut cepat menjadi tidak terpelihara begitu jumlah halaman bertambah. Router adalah komponen yang mengambil alih pekerjaan tersebut: tempat mendaftarkan rute dengan pola yang mudah dibaca, mencocokkan method dan path, lalu menyerahkan eksekusi ke handler yang sesuai. Laravel dan Symfony punya router yang sangat matang, tetapi router bukan sihir — pada chapter ini kita bangun satu kelas Router sungguhan dari nol, lengkap dengan parameter pola, route group, dan penanganan 404/405, semuanya teruji di terminal.
Dari Pola ke Regex #
Fitur paling khas sebuah router adalah parameter pola: /produk/{id} seharusnya cocok dengan /produk/p-009 dan menyerahkan p-009 ke handler sebagai variabel. Mekanismenya sederhana — pola diterjemahkan menjadi regex sekali pada saat registrasi. Penampung {nama} diganti menjadi named capture group yang menangkap segmen path tanpa garis miring:
<?php
function compile(string $pattern): string
{
$regex = preg_replace("#\{(\w+)\}#", "(?<$1>[^/]+)", $pattern);
return "#^" . $regex . "$#";
}
foreach (["/produk/{id}", "/produk/{id}/ulasan/{nomor}", "/tentang"] as $pola) {
printf("%-32s -> %s\n", $pola, compile($pola));
}Output yang Dihasilkan:
/produk/{id} -> #^/produk/(?<id>[^/]+)$#
/produk/{id}/ulasan/{nomor} -> #^/produk/(?<id>[^/]+)/ulasan/(?<nomor>[^/]+)$#
/tentang -> #^/tentang$#Fungsi compile() mengganti setiap {nama} dengan (?<nama>[^/]+): capture group bernama yang menangkap satu atau lebih karakter selain /. Pola yang tidak memuat penampung lolos tanpa berubah, sehingga /tentang hanya mencocokkan path persis tersebut. Anchor ^ dan $ memastikan path harus cocok penuh — tanpa keduanya, /produk/1/ulasan/2/hapus akan lolos untuk pola /produk/{id}/ulasan/{nomor}.
Kelas Router Lengkap #
Sekarang kita rakit seluruh komponen router dalam satu kelas: registrasi lewat get(), post(), dan any(), pengelompokan prefix lewat group(), pencocokan method+path di dispatch(), serta pembedaan 404 (path tidak dikenal) dari 405 (path dikenal, method salah):
<?php
class Router
{
private string $prefix = "";
private array $routes = [];
public function get(string $pattern, callable $handler): void
{
$this->tambah("GET", $pattern, $handler);
}
public function post(string $pattern, callable $handler): void
{
$this->tambah("POST", $pattern, $handler);
}
public function any(string $pattern, callable $handler): void
{
$this->tambah("GET", $pattern, $handler);
$this->tambah("POST", $pattern, $handler);
}
public function group(string $prefix, callable $callback): void
{
$sebelumnya = $this->prefix;
$this->prefix .= $prefix;
$callback($this);
$this->prefix = $sebelumnya;
}
private function tambah(string $method, string $pattern, callable $handler): void
{
$this->routes[] = [
"method" => $method,
"regex" => $this->compile($this->prefix . $pattern),
"handler" => $handler,
];
}
private function compile(string $pattern): string
{
$regex = preg_replace("#\{(\w+)\}#", "(?<$1>[^/]+)", $pattern);
return "#^" . $regex . "$#";
}
public function dispatch(string $method, string $path): array
{
$pathDikenal = false;
foreach ($this->routes as $route) {
if (!preg_match($route["regex"], $path, $cocok)) {
continue;
}
if ($route["method"] !== $method) {
$pathDikenal = true;
continue;
}
$params = array_filter($cocok, is_string(...), ARRAY_FILTER_USE_KEY);
return ["status" => 200, "body" => ($route["handler"])(...array_values($params))];
}
return $pathDikenal
? ["status" => 405, "body" => "Method Not Allowed"]
: ["status" => 404, "body" => "Not Found"];
}
}
$router = new Router();
$router->get("/", fn() => "Beranda Toko");
$router->get("/produk/{id}", fn(string $id) => "Detail produk {$id}");
$router->post("/keranjang", fn() => "Keranjang diperbarui");
$router->any("/ping", fn() => "pong");
$router->group("/admin", function (Router $r): void {
$r->get("/laporan", fn() => "Laporan penjualan");
});
foreach ([
["GET", "/produk/p-009"],
["POST", "/produk/p-009"],
["GET", "/admin/laporan"],
["PUT", "/ping"],
["GET", "/tidak-ada"],
] as [$method, $path]) {
$hasil = $router->dispatch($method, $path);
printf("%-4s %-18s -> %d %s\n", $method, $path, $hasil["status"], $hasil["body"]);
}Output yang Dihasilkan:
GET /produk/p-009 -> 200 Detail produk p-009
POST /produk/p-009 -> 405 Method Not Allowed
GET /admin/laporan -> 200 Laporan penjualan
PUT /ping -> 405 Method Not Allowed
GET /tidak-ada -> 404 Not FoundMari kita bedah bagian-bagiannya:
- Registrasi (
get()/post()/any()): masing-masing hanya tipis di atastambah(), yang menyimpan method, regex hasilcompile(), dan handler ke array$routes. Handler bertipecallable— closure, nama fungsi string, ataupun[Objek::class, "method"]semuanya diterima. - Route group:
group()menumpuk prefix sebelum memanggil callback, lalu mengembalikan prefix lama. Rute/laporanyang didaftarkan di dalamgroup("/admin", ...)dikompilasi menjadi/admin/laporan; setelah callback selesai, prefix dikembalikan sehingga rute berikutnya tidak ikut terkena. - Dispatch dua tahap: loop menguji regex tiap rute. Path cocok tetapi method berbeda menyetel
$pathDikenaldan lanjut — inilah yang membedakanPOST /produk/p-009(405) dariGET /tidak-ada(404). - Ekstraksi parameter:
preg_match()mengisi$cocokdengan seluruh capture group — bernama maupun numerik.array_filter()denganis_string(...)(sintaks first-class callable dari PHP 8.1) menyaring hanya kunci string, sehingga yang tersisa murni parameter bernama, laluarray_values()merapikan urutannya sebelum di-spread ke handler.
Blok demo memperlihatkan keempat kasus: rute berparameter yang mengalir mulus, method salah pada path yang dikenal, rute di dalam group, dan PUT /ping yang menghasilkan 405 karena any() memang hanya mendaftarkan GET dan POST.
Apa yang Ditambahkan Framework #
Router kami berfungsi, tetapi framework produksi menambahkan lapisan yang membuatnya tahan skala. Symfony mengompilasi seluruh definisi rute menjadi kode PHP/cache saat build sehingga tidak ada regex yang dikompilasi ulang per request; Laravel memungkinkan rute didefinisikan terpusat atau lewat attribute pada controller. Berikut rasa deklarasinya di kedua framework (kode tampilan, bukan untuk dijalankan):
<?php
// Symfony: rute dideklarasikan lewat attribute + dikompilasi ke cache
#[Route('/produk/{id}', name: 'produk_detail', methods: ['GET'])]
public function detail(string $id): Response
{
// ...
}
// Laravel: definisi rute terpusat di routes/web.php
Route::get('/produk/{id}', [ProdukController::class, 'detail']);Perbedaan pentingnya bukan sintaks, melainkan apa yang disuntikkan ke handler: framework meneruskan objek Request yang kaya (query, input, header) dan mengharapkan objek Response kembali — bukan string mentah seperti handler kita. Middleware, constraint parameter (->whereNumber('id')), dan naming rute melengkapi daftarnya. Objek Request dan Response tersebut cukup penting untuk kita bangun sendiri, dan itulah agenda chapter berikutnya: Request-Response Abstractions
.
Ringkasan #
Router adalah komponen yang menerjemahkan method+path menjadi pemanggilan handler: pola {param} dikompilasi menjadi regex dengan named capture group, dispatch membedakan 404 dari 405, dan route group menyusun prefix tanpa pengulangan. Handler kami menerima parameter string dan mengembalikan string — kontrak paling sederhana yang bisa dibayangkan. Begitu aplikasi tumbuh, kontrak tersebut menjadi sempit: handler butuh membaca query string, header, dan body; pemanggil butuh mengatur kode status dan header respons. Kebutuhan itulah yang kita jawab dengan abstraksi request dan response pada chapter berikutnya.