Router yang kita rakit pada chapter membuat router
sejauh ini hanya mencocokkan path statis seperti /tentang. Aplikasi nyata jarang semudah itu: halaman detail produk harus menerima /produk/42, halaman artikel menerima /blog/2026/belajar-php. Nilai-nilai yang berubah pada setiap permintaan ini disebut routing parameter. Pada chapter ini kita memperluas router mini agar bisa menangkap parameter bernama, memvalidasi bentuknya dengan constraint, mendukung parameter opsional, meng-inject nilainya ke controller, dan bahkan menghasilkan URL kembali dari definisi route.
Parameter Bernama dalam Pola Route #
Ide dasarnya sederhana: pola /produk/{id} mendeklarasikan bahwa segmen kedua adalah parameter bernama id, yang boleh berisi nilai apa pun. Kompiler route kita menerjemahkan kurung kurawal menjadi named capture group regex, lalu menambahkan anchor ^ dan $ agar seluruh path harus cocok:
<?php
function compilePattern(string $pattern): string
{
$regex = preg_replace_callback(
'#\{(\w+)\}#',
fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)',
$pattern
);
return '#^' . $regex . '$#';
}
echo compilePattern('/produk/{id}'), "\n";
echo compilePattern('/blog/{tahun}/{slug}'), "\n";Output yang Dihasilkan:
#^/produk/(?P<id>[^/]+)$#
#^/blog/(?P<tahun>[^/]+)/(?P<slug>[^/]+)$#Mari kita bedah fungsi kecil ini:
preg_replace_callback('#\{(\w+)\}#', ...): mencari setiap kemunculan{nama}dan menggantinya dengan hasil callback. Ekspresi\w+mencocokkan huruf, angka, dan garis bawah sebagai nama parameter.(?P<id>[^/]+): named capture group bernamaid. Bagian[^/]+berarti “satu karakter atau lebih, selama bukan garis miring” — sehingga parameter tidak pernah melampaui satu segmen path.'#^...$#': anchor awal dan akhir memastikan/produk/42/tambahantidak lolos sebagai kecocokan/produk/{id}.
Setelah pola menjadi regex, pencocokan dan ekstraksi parameter tinggal memanfaatkan array $matches dari preg_match() — PHP otomatis mengisi kunci bernama sesuai nama capture group:
<?php
function matchRoute(string $pattern, string $path): ?array
{
$regex = preg_replace_callback(
'#\{(\w+)\}#',
fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)',
$pattern
);
if (preg_match('#^' . $regex . '$#', $path, $matches) !== 1) {
return null;
}
return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
}
echo json_encode(matchRoute('/produk/{id}', '/produk/42')), "\n";
echo json_encode(matchRoute('/produk/{id}', '/login')), "\n";
echo json_encode(matchRoute('/blog/{tahun}/{slug}', '/blog/2026/belajar-php')), "\n";Output yang Dihasilkan:
{"id":"42"}
null
{"tahun":"2026","slug":"belajar-php"}Trik penting ada di baris terakhir fungsi: preg_match() mengisi $matches dengan kunci numerik untuk seluruh kecocokan dan kunci string untuk setiap capture group bernama. array_filter() dengan flag ARRAY_FILTER_USE_KEY hanya menyimpan entri yang kuncinya bertipe string — hasilnya array parameter murni seperti {"id":"42"} tanpa sampah numerik. Perhatikan juga bahwa nilai parameter selalu bertipe string; URL adalah teks, dan konversinya ke tipe lain akan kita tangani di bagian casting.
Constraint: Memvalidasi Bentuk Parameter #
Secara default, {id} menerima teks apa pun yang bukan garis miring — termasuk abc atau tanda baca aneh. Route tertentu butuh jaminan bentuk yang lebih ketat: ID produk harus numerik, slug artikel harus huruf kecil dan tanda hubung. Kita tambahkan tabel constraint yang dipakai saat kompilasi:
<?php
function compileWithConstraints(string $pattern, array $constraints): string
{
$regex = preg_replace_callback(
'#\{(\w+)\}#',
function (array $m) use ($constraints): string {
$rule = $constraints[$m[1]] ?? '[^/]+';
return '(?P<' . $m[1] . '>' . $rule . ')';
},
$pattern
);
return '#^' . $regex . '$#';
}
$patternProduk = compileWithConstraints('/produk/{id}', ['id' => '\d+']);
var_dump((bool) preg_match($patternProduk, '/produk/42'));
var_dump((bool) preg_match($patternProduk, '/produk/belajar'));Output yang Dihasilkan:
bool(true)
bool(false)Callback kompilasi sekarang mengambil aturan dari tabel $constraints berdasarkan nama parameter, dan jatuh kembali ke [^/]+ ketika parameter tidak punya aturan khusus. Dengan constraint \d+, path /produk/42 cocok sedangkan /produk/belajar ditolak — route lain yang cocok tetap bisa menangkap permintaan tersebut. Inilah mekanisme yang dipakai Laravel melalui where('id', '[0-9]+') dan Symfony melalui requirements; keduanya hanya nama lain untuk regex yang sama.
Parameter Opsional dan Nilai Default #
Aplikasi dua bahasa sering memakai awalan bahasa: /id/produk/42 dan /en/produk/42. Masalahnya, kita ingin /produk/42 tanpa awalan juga valid. Notasi {lang}? (tanda tanya setelah kurung tutup) menyatakan bahwa seluruh segmen tersebut opsional — dan ketika absen, nilai default yang dipakai:
<?php
function compileOptional(string $pattern): string
{
// Langkah 1: {param}? => segmen opsional beserta "/" penghubungnya
$regex = preg_replace_callback(
'#/?\{(\w+)\}\?#',
fn (array $m): string => '(?:/(?P<' . $m[1] . '>[^/]+))?',
$pattern
);
// Langkah 2: {param} biasa => tangkapan wajib
$regex = preg_replace_callback(
'#\{(\w+)\}#',
fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)',
$regex
);
return '#^' . $regex . '$#';
}
function matchOptional(string $pattern, string $path, array $defaults): ?array
{
if (preg_match(compileOptional($pattern), $path, $m) !== 1) {
return null;
}
// Grup opsional yang tidak ikut serta tetap muncul sebagai string kosong;
// kita buang supaya nilai default-lah yang berlaku.
$params = array_filter(
$m,
fn (mixed $value, int|string $key): bool => is_string($key) && $value !== '',
ARRAY_FILTER_USE_BOTH
);
return array_merge($defaults, $params);
}
$pattern = '/{lang}?/produk/{id}';
$defaults = ['lang' => 'id'];
echo json_encode(matchOptional($pattern, '/produk/42', $defaults)), "\n";
echo json_encode(matchOptional($pattern, '/en/produk/42', $defaults)), "\n";
echo json_encode(matchOptional($pattern, '/produk/42/ekstra', $defaults)), "\n";Output yang Dihasilkan:
{"lang":"id","id":"42"}
{"lang":"en","id":"42"}
nullTiga keputusan desain bekerja bersama di sini:
(?:/(?P<lang>[^/]+))?: seluruh segmen beserta garis miringnya dibungkus grup non-capturing yang boleh absen. Jika hanya{lang}yang dibuat opsional, path/produk/42akan cocok pola//produk/42— dua garis miring — dan gagal.- Pengosongan string kosong: keanehan
preg_match()yang penting diketahui — grup bernama yang tidak ikut serta tetap muncul di$matchesdengan nilai string kosong, bukan hilang. Filteris_string($key) && $value !== ''membuangnya; aman karena parameter wajib memakai+yang tidak mungkin cocok dengan string kosong. array_merge($defaults, $params): nilai default diletakkan lebih dulu sehingga parameter hasil tangkapan menimpanya. Path tanpa awalan mendapatlangdefaultid; path dengan awalan menimpanya denganen.
Injeksi Parameter ke Controller #
Parameter yang sudah ditangkap harus sampai ke action controller. Bentuk paling sederhana memakai spread operator — array_values() mengubah array asosiatif menjadi daftar posisi, lalu ... menyebarkannya sebagai argumen:
<?php
final class ProdukController
{
public function show(string $id, string $lang = "id"): string
{
return "Menampilkan produk #$id dalam bahasa $lang";
}
}
function invokeAction(ProdukController $controller, array $params): string
{
return $controller->show(...array_values($params));
}
$controller = new ProdukController();
echo invokeAction($controller, ['id' => '42', 'lang' => 'en']), "\n";
echo invokeAction($controller, ['id' => '17']), "\n";Output yang Dihasilkan:
Menampilkan produk #42 dalam bahasa en
Menampilkan produk #17 dalam bahasa idCara ini punya kelemahan: urutan array harus persis sama dengan urutan parameter method. Framework nyata menyelesaikannya dengan Reflection — membaca nama parameter dari signature, lalu mencocokkannya dengan kunci array parameter berdasarkan nama:
<?php
final class ArtikelController
{
public function show(string $tahun, string $slug, string $lang = "id"): string
{
return "$lang/$tahun/$slug";
}
}
function buildArguments(string $class, string $method, array $params): array
{
$reflection = new ReflectionMethod($class, $method);
$arguments = [];
foreach ($reflection->getParameters() as $parameter) {
$name = $parameter->getName();
if (array_key_exists($name, $params)) {
$arguments[] = $params[$name];
} elseif ($parameter->isDefaultValueAvailable()) {
$arguments[] = $parameter->getDefaultValue();
} else {
throw new InvalidArgumentException(
"Parameter \$$name tidak diberikan oleh route"
);
}
}
return $arguments;
}
$args = buildArguments(ArtikelController::class, 'show', [
'slug' => 'belajar-php',
'tahun' => '2026',
]);
echo (new ArtikelController())->show(...$args), "\n";Output yang Dihasilkan:
id/2026/belajar-phpPerhatikan bahwa array input sengaja kita balik urutannya — slug lebih dulu daripada tahun — namun hasilnya tetap benar. ReflectionMethod::getParameters() mengembalikan daftar parameter sesuai urutan deklarasi, dan untuk tiap parameter kita mencari nilainya berdasarkan nama. Parameter lang tidak ada di array, tetapi signature-nya punya nilai default "id" sehingga isDefaultValueAvailable() bernilai true dan default itulah yang dipakai. Jika parameter wajib tidak ditemukan, exception yang jelas dilempar alih-alih biarkan PHP menampilkan error argument count.
Casting Parameter ke Tipe yang Benar #
Apa pun bentuknya di URL, preg_match() selalu menghasilkan string — "42", bukan 42. Signature controller bertipe int $id akan menolak string tersebut saat memakai strict_types. Solusinya: gunakan kembali tabel constraint sebagai sumber kebenaran untuk casting:
<?php
function castParams(array $params, array $constraints): array
{
foreach ($params as $name => $value) {
// constraint '\d+' menandakan parameter murni numerik
if (($constraints[$name] ?? null) === '\d+') {
$params[$name] = (int) $value;
}
}
return $params;
}
$params = castParams(
['id' => '42', 'slug' => 'belajar-php'],
['id' => '\d+', 'slug' => '[a-z-]+'],
);
var_dump($params['id']);
var_dump($params['slug']);Output yang Dihasilkan:
int(42)
string(11) "belajar-php"Parameter id yang constraint-nya \d+ dikonversi menjadi int(42); parameter slug tetap string. Karena constraint sudah menjamin hanya digit yang lolos, cast (int) tidak pernah menghasilkan nilai tak terduga seperti (int) "42abc" — validasi bentuk dan konversi tipe terjadi dalam satu kesatuan aturan. Framework matang menyebut mekanisme ini parameter binding atau param converter.
Reverse Routing: Membuat URL dari Route #
Menulis URL secara manual ("/produk/" . $id) menyebarkan bentuk URL ke seluruh kode — dan ketika polanya berubah, semua tautan harus dicari satu per satu. Reverse routing membalik arah kerja router: dari nama pola plus nilai parameter, hasilkan URL:
<?php
function generateUrl(string $pattern, array $params): string
{
return preg_replace_callback(
'#\{(\w+)\}#',
function (array $m) use ($params): string {
if (!array_key_exists($m[1], $params)) {
throw new InvalidArgumentException(
"Parameter {{$m[1]}} belum diberi nilai"
);
}
return rawurlencode((string) $params[$m[1]]);
},
$pattern
);
}
echo generateUrl('/produk/{id}', ['id' => 42]), "\n";
echo generateUrl('/blog/{tahun}/{slug}', ['tahun' => 2026, 'slug' => 'belajar php']), "\n";Output yang Dihasilkan:
/produk/42
/blog/2026/belajar%20phpFungsi ini memakai preg_replace_callback() dengan arah berkebalikan dari compilePattern(): alih-alih mengganti {nama} dengan capture group, {nama} diganti dengan nilai parameternya. rawurlencode() memastikan nilai yang mengandung spasi atau karakter khusus tetap menghasilkan URL yang sah — spasi menjadi %20. Bila parameter wajib tidak diberi nilai, exception dilempar lebih dulu; gagal keras di awal jauh lebih baik daripada menghasilkan URL rusak yang diam-diam mati di production.
Sumber Parameter: Path dan Query String #
Path parameter bukan satu-satunya cara nilai masuk ke action. Query string — bagian setelah tanda tanya pada /produk/42?sort=murah&page=2 — adalah sumber kedua. Keduanya punya peran yang berbeda:
| Aspek | Path parameter | Query string |
|---|---|---|
| Contoh | /produk/42 | /produk/42?sort=murah |
| Peran | Identitas resource | Filter, urutan, pagination |
| Ditangani oleh | Router + constraint | Superglobal $_GET |
| Sifat | Bagian dari identitas route | Selalu opsional |
Karena keduanya sama-sama “parameter” bagi action, pipeline kita tinggal menggabungkannya:
<?php
// Permintaan: /produk/42?sort=murah&page=2
$pathParams = ['id' => '42'];
$queryParams = ['page' => '2', 'sort' => 'murah'];
$actionParams = array_merge($pathParams, $queryParams);
foreach ($actionParams as $name => $value) {
echo "$name: $value\n";
}Output yang Dihasilkan:
id: 42
page: 2
sort: muraharray_merge() menyatukan keduanya dengan path parameter lebih dulu, sehingga bentuk aksinya seragam. Controller show(string $id, string $sort = 'baru', string $page = '1') bisa menerima semuanya lewat mekanisme buildArguments() di atas tanpa perlu tahu nilai itu datang dari path atau query string. Pemisahan sumbernya tetap penting secara semantik: mengubah id berarti mengubah resource yang diminta, sedangkan mengubah sort hanya mengubah tampilannya.
Merangkai Semuanya di Kernel #
Semua potongan di atas kini bisa dirangkai menjadi satu alur dispatch lengkap:
<?php
// Kernel mini: definisi route kini punya constraints dan defaults
$routes = [
'/{lang}?/produk/{id}' => [
'action' => [ProdukController::class, 'show'],
'constraints' => ['id' => '\d+'],
'defaults' => ['lang' => 'id'],
],
];
foreach ($routes as $pattern => $definition) {
$params = matchOptional($pattern, $path, $definition['defaults']);
if ($params === null) {
continue;
}
$params = castParams($params, $definition['constraints']);
$arguments = buildArguments(
$definition['action'][0],
$definition['action'][1],
$params
);
$response = callAction($definition['action'], $arguments);
break;
}Definisi route tumbuh dari string pola sederhana menjadi array berisi action, constraints, dan defaults — persis struktur yang dipakai definisi route pada framework nyata. Alur kernelnya membaca seperti cerita: cocokkan pola dengan defaults, lanjutkan bila tidak cocok, cast nilai sesuai constraint, susun argumen berdasarkan nama, lalu panggil action.
Ringkasan #
Router mini kita kini menguasai parameter dinamis: capture group bernama untuk menangkap nilai, constraint regex untuk memvalidasi bentuk, notasi {param}? plus defaults untuk segmen opsional, Reflection untuk injeksi argumen berbasis nama, casting terkontrol, dan reverse routing untuk menghasilkan URL dari satu sumber kebenaran. Framework ini makin lengkap — dan makin banyak kode yang pantas diuji. Pada chapter framework testing
berikutnya, kita menguji semua komponen ini secara sistematis dengan assertion, test client, dan golden master.