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
<?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:

text
#^/produk/(?P<id>[^/]+)$#
#^/blog/(?P<tahun>[^/]+)/(?P<slug>[^/]+)$#

Mari kita bedah fungsi kecil ini:

  1. preg_replace_callback('#\{(\w+)\}#', ...): mencari setiap kemunculan {nama} dan menggantinya dengan hasil callback. Ekspresi \w+ mencocokkan huruf, angka, dan garis bawah sebagai nama parameter.
  2. (?P<id>[^/]+): named capture group bernama id. Bagian [^/]+ berarti “satu karakter atau lebih, selama bukan garis miring” — sehingga parameter tidak pernah melampaui satu segmen path.
  3. '#^...$#': anchor awal dan akhir memastikan /produk/42/tambahan tidak 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
<?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:

text
{"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
<?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:

text
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
<?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:

text
{"lang":"id","id":"42"}
{"lang":"en","id":"42"}
null

Tiga keputusan desain bekerja bersama di sini:

  1. (?:/(?P<lang>[^/]+))?: seluruh segmen beserta garis miringnya dibungkus grup non-capturing yang boleh absen. Jika hanya {lang} yang dibuat opsional, path /produk/42 akan cocok pola //produk/42 — dua garis miring — dan gagal.
  2. Pengosongan string kosong: keanehan preg_match() yang penting diketahui — grup bernama yang tidak ikut serta tetap muncul di $matches dengan nilai string kosong, bukan hilang. Filter is_string($key) && $value !== '' membuangnya; aman karena parameter wajib memakai + yang tidak mungkin cocok dengan string kosong.
  3. array_merge($defaults, $params): nilai default diletakkan lebih dulu sehingga parameter hasil tangkapan menimpanya. Path tanpa awalan mendapat lang default id; path dengan awalan menimpanya dengan en.

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
<?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:

text
Menampilkan produk #42 dalam bahasa en
Menampilkan produk #17 dalam bahasa id

Cara 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
<?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:

text
id/2026/belajar-php

Perhatikan 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
<?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:

text
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
<?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:

text
/produk/42
/blog/2026/belajar%20php

Fungsi 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:

AspekPath parameterQuery string
Contoh/produk/42/produk/42?sort=murah
PeranIdentitas resourceFilter, urutan, pagination
Ditangani olehRouter + constraintSuperglobal $_GET
SifatBagian dari identitas routeSelalu opsional

Karena keduanya sama-sama “parameter” bagi action, pipeline kita tinggal menggabungkannya:

php
<?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:

text
id: 42
page: 2
sort: murah

array_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
<?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.

Referensi #