Dispatch satu if pada Bagian 1 tidak akan bertahan lama: begitu endpoint bertambah menjadi GET /api/tugas, POST /api/tugas, dan GET /api/tugas/{id}, percabangan manual berubah menjadi spaghetti. Pada Bagian 2 kita menggantinya dengan tiga kelas inti: Request yang membungkus data masuk, Response yang membungkus data keluar, dan Router yang mencocokkan keduanya. Ketiganya dirancang agar bisa diuji dari CLI murni — kita membangun request tiruan lewat Request::fromArray(), mengirimkannya ke router, lalu memeriksa status dan body response yang kembali, tanpa satu pun socket terbuka.

Objek Request yang Immutable #

Handler yang membaca $_GET atau $_SERVER secara langsung sulit diuji, karena superglobal adalah keadaan global yang dibagikan semua kode. Kelas Request memutus ketergantungan itu: seluruh data mentah (method, path, query string, body JSON hasil decode, header) disalin sekali saat konstruksi, lalu objeknya tidak pernah berubah lagi — semua property-nya readonly.

php
<?php

require __DIR__ . '/src/Request.php';

use App\Request;

$request = Request::fromArray([
    'method'  => 'GET',
    'path'    => '/api/tugas',
    'query'   => ['status' => 'proses', 'halaman' => '2'],
    'headers' => ['Accept' => 'application/json'],
]);

echo $request->method(), ' ', $request->path(), "\n";
echo 'status  = ', var_export($request->query('status'), true), "\n";
echo 'halaman = ', var_export($request->query('halaman'), true), "\n";
echo 'accept  = ', var_export($request->header('ACCEPT'), true), "\n";
echo 'batas   = ', var_export($request->query('batas', 10), true), "\n";

Output yang Dihasilkan:

text
GET /api/tugas
status  = 'proses'
halaman = '2'
accept  = 'application/json'
batas   = 10

Mari kita bedah pemakaian di atas:

  1. Request::fromArray() adalah factory untuk pengujian: seluruh isi request dibangun dari array literal, jadi tidak ada ketergantungan pada keadaan superglobal. Method dan path otomatis dinormalkan — huruf kapital pada method diubah ke uppercase, dan garis miring berlebih pada path dibuang.
  2. query() dan body() memakai pola yang sama: tanpa argumen, kembalikan seluruh data; dengan argumen kunci, kembalikan satu nilai atau $default bila kunci tidak ada. Default inilah yang menghilangkan warning “Undefined array key” dari kode handler.
  3. header() menormalkan nama header ke lowercase sebelum mencari, karena nama header HTTP memang tidak case-sensitive — 'Accept', 'accept', dan 'ACCEPT' merujuk header yang sama.

Dua factory lain melengkapi kelas ini: Request::fromGlobals() membaca superglobal $_SERVER, $_GET, dan stream php://input untuk request sungguhan (body JSON di-decode hanya bila lolos json_validate()), sedangkan fromArray() dipakai pengujian. Kelas ini terinspirasi antarmuka standar PSR-7 — versi kita jauh lebih kecil, tetapi pola dasarnya sama: request adalah objek nilai yang tidak berubah.

Objek Response dan Static Factory #

Di sisi keluaran, handler tidak menyentuh header() maupun echo langsung. Handler hanya membangun objek Response — biasanya lewat static factory Response::json() — lalu mengembalikannya. Front controller-lah yang memanggil send() tepat satu kali di baris terakhir. Dengan begitu status, header, dan body response bisa diperiksa sebagai nilai biasa dalam pengujian.

php
<?php

require __DIR__ . '/src/Response.php';

use App\Response;

$json      = Response::json(['status' => 'ok']);
$kosong    = Response::noContent();
$pindahkan = Response::redirect('/api/tugas');

printf("json      : status=%d tipe=%s\n", $json->status(), $json->header('Content-Type'));
printf("          body=%s\n", $json->body());
printf("kosong    : status=%d body=%s\n", $kosong->status(), var_export($kosong->body(), true));
printf("pindahkan : status=%d lokasi=%s\n", $pindahkan->status(), $pindahkan->header('Location'));

Output yang Dihasilkan:

text
json      : status=200 tipe=application/json; charset=utf-8
          body={"status":"ok"}
kosong    : status=204 body=''
pindahkan : status=302 lokasi=/api/tugas

Tiga factory di atas menutup hampir semua kebutuhan REST API: json() untuk payload data (status bebas diatur, default 200), noContent() untuk DELETE yang berhasil (204 tanpa body), dan redirect() untuk Location (302 secara default). Untuk kasus khusus seperti header Allow pada error 405, tersedia wither withHeader() yang mengembalikan salinan response dengan satu header tambahan — objek aslinya tetap tidak tersentuh.

Router dengan Parameter dan Error Otomatis #

Router menyimpan route sebagai daftar pattern yang dikompilasi menjadi regular expression. Penulisan /api/tugas/{id} diterjemahkan menjadi #^/api/tugas/([^/]+)$# sehingga satu segmen path ditangkap sebagai parameter id. Handler dipanggil dengan dua argumen: objek Request dan array parameter hasil ekstraksi.

php
<?php

require __DIR__ . '/src/Request.php';
require __DIR__ . '/src/Response.php';
require __DIR__ . '/src/Router.php';

use App\Request;
use App\Response;
use App\Router;

$router = new Router();
$router->add('GET', '/api/tugas', static fn (Request $r): Response => Response::json(['total' => 0]));
$router->add('GET', '/api/tugas/{id}', static fn (Request $r, array $params): Response => Response::json(['id' => (int) $params['id']]));
$router->add('DELETE', '/api/tugas/{id}', static fn (Request $r, array $params): Response => Response::noContent());

function tampilkan(Router $router, array $data): void
{
    $response = $router->dispatch(Request::fromArray($data));
    printf('%-7s %-22s -> %d', $data['method'], $data['path'], $response->status());

    if ($response->header('Allow') !== null) {
        printf(' (Allow: %s)', $response->header('Allow'));
    }
    if ($response->body() !== '') {
        printf(' %s', $response->body());
    }
    echo "\n";
}

tampilkan($router, ['method' => 'GET', 'path' => '/api/tugas']);
tampilkan($router, ['method' => 'GET', 'path' => '/api/tugas/7']);
tampilkan($router, ['method' => 'GET', 'path' => '/api/tugas/7/catatan']);
tampilkan($router, ['method' => 'PUT', 'path' => '/api/tugas/7']);
tampilkan($router, ['method' => 'DELETE', 'path' => '/api/tugas/7']);

Output yang Dihasilkan:

text
GET     /api/tugas             -> 200 {"total":0}
GET     /api/tugas/7           -> 200 {"id":7}
GET     /api/tugas/7/catatan   -> 404 {"error":{"code":"NOT_FOUND","message":"Endpoint GET /api/tugas/7/catatan tidak ditemukan."}}
PUT     /api/tugas/7           -> 405 (Allow: GET, DELETE) {"error":{"code":"METHOD_NOT_ALLOWED","message":"Method PUT tidak diizinkan untuk endpoint ini."}}
DELETE  /api/tugas/7           -> 204

Tabel dispatch di atas memperlihatkan seluruh keputusan routing dalam satu pandangan:

  1. GET /api/tugas cocok dengan route pertama — polos, tanpa parameter.
  2. GET /api/tugas/7 cocok dengan route kedua; segmen 7 ditangkap sebagai $params['id'], lalu handler mengembalikannya sebagai angka berkat cast (int).
  3. GET /api/tugas/7/catatan tidak cocok dengan route mana pun — parameter {id} hanya boleh satu segmen ([^/]+), sehingga router menjawab 404 dengan bentuk error JSON yang sama seperti Bagian 1.
  4. PUT /api/tugas/7 menabrak kasus berbeda: path-nya ada, tapi method-nya salah. Router mengumpulkan semua method yang terdaftar untuk path tersebut, menjawab 405, dan melampirkan header Allow: GET, DELETE — persis seperti yang disarankan spesifikasi HTTP.
  5. DELETE /api/tugas/7 berakhir 204 tanpa body, sesuai semantics REST untuk penghapusan yang berhasil.

Front Controller Baru #

Dengan tiga kelas siap, public/index.php menyusut drastis. Seluruh logika autoloading tetap sama; yang berubah adalah bagian dispatch — kini cukup daftarkan route lalu serahkan ke $router->dispatch():

php
<?php
declare(strict_types=1);

$projectRoot = dirname(__DIR__);

if (is_file($projectRoot . '/vendor/autoload.php')) {
    require $projectRoot . '/vendor/autoload.php';
} else {
    spl_autoload_register(static function (string $class): void {
        $prefix = 'App\\';

        if (!str_starts_with($class, $prefix)) {
            return;
        }

        $relative = str_replace('\\', '/', substr($class, strlen($prefix)));
        $file = dirname(__DIR__) . '/src/' . $relative . '.php';

        if (is_file($file)) {
            require $file;
        }
    });
}

use App\Request;
use App\Response;
use App\Router;

if (PHP_SAPI === 'cli' && !isset($_SERVER['REQUEST_METHOD'])) {
    echo "REST API Manajemen Tugas\n";
    echo "Jalankan: php -S 127.0.0.1:8090 -t public\n";
    exit(0);
}

$router = new Router();

$router->add('GET', '/api/kesehatan', static fn (Request $r): Response => Response::json(['status' => 'ok']));

$router->add('GET', '/api/halo/{nama}', static fn (Request $r, array $params): Response => Response::json(['pesan' => 'Halo, ' . $params['nama'] . '!']));

$router->add('POST', '/api/gema', static fn (Request $r): Response => Response::json(['diterima' => $r->body()]));

$router->dispatch(Request::fromGlobals())->send();

Baris terakhir adalah inti perubahan: Request::fromGlobals() membaca request sungguhan, $router->dispatch() mencocokkan dan menjalankan handler, lalu send() mengirim response. Endpoint /api/gema sengaja meng-echo balik body JSON yang diterima — alat sempurna untuk membuktikan bahwa parsing body php://input bekerja.

Menguji Routing dengan php -S #

Pengujian sungguhan memakai web server bawaan, seperti pada Bagian 1. Endpoint /api/halo/{nama} membuktikan parameter path bekerja melalui HTTP:

bash
# terminal 1 — dari direktori root proyek
$ php -S 127.0.0.1:8094 -t public

# terminal 2
$ curl -sS http://127.0.0.1:8094/api/kesehatan
text
{"status":"ok"}
bash
$ curl -sS http://127.0.0.1:8094/api/halo/Budi
text
{"pesan":"Halo, Budi!"}

Body JSON yang dikirim curl -d masuk lewat php://input, di-decode Request::fromGlobals(), lalu digema balik:

bash
$ curl -sS -X POST -H 'Content-Type: application/json' \
    -d '{"judul":"Belajar Router"}' http://127.0.0.1:8094/api/gema
text
{"diterima":{"judul":"Belajar Router"}}

Terakhir, method yang salah pada path yang benar memicu 405 lengkap dengan header Allow:

bash
$ curl -sS -i -X PUT http://127.0.0.1:8094/api/gema
text
HTTP/1.1 405 Method Not Allowed
Host: 127.0.0.1:8094
Date: Tue, 13 Oct 2026 21:11:00 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8
Allow: POST

{"error":{"code":"METHOD_NOT_ALLOWED","message":"Method PUT tidak diizinkan untuk endpoint ini."}}

Apa yang Berubah #

Tiga berkas baru dan satu berkas berubah bentuk pada tahap ini:

  • src/Request.php — objek request immutable dengan factory fromArray() untuk pengujian dan fromGlobals() untuk produksi; aksesor method(), path(), query(), body(), dan header() selalu aman terhadap kunci yang tidak ada.
  • src/Response.php — objek response immutable dengan factory json(), noContent(), redirect(), wither withHeader(), serta send() yang hanya boleh dipanggil front controller.
  • src/Router.php — add(method, pattern, handler) dengan dukungan {param}, dispatch(Request): Response yang otomatis menghasilkan 404 dan 405 + header Allow.
  • public/index.php — dispatch if digantikan tabel route; handler kini murni membangun Response.

Handler masih mengembalikan data statis karena belum ada penyimpanan. Bagian 3 memperkenalkan Database dan TaskRepository di atas SQLite sehingga tugas benar-benar tersimpan.

Cara Menjalankan #

Dari direktori proyek, tiga lapisan pemeriksaan tersedia:

bash
# 1. Tabel dispatch dari CLI — salin blok "Router dengan Parameter dan Error
#    Otomatis" ke berkas contoh-router.php, lalu jalankan tanpa web server
php contoh-router.php

# 2. Web server bawaan + curl
php -S 127.0.0.1:8094 -t public
curl -sS http://127.0.0.1:8094/api/halo/Budi

# 3. Pastikan semua kelas lolos pemeriksaan sintaks
php -l src/Request.php && php -l src/Response.php && php -l src/Router.php

Tiga berkas pendukung halaman ini — src/Request.php, src/Response.php, dan src/Router.php — tersedia di bagian bawah halaman. Untuk pendalaman, baca dokumentasi resmi tentang superglobal $_SERVER , json_decode() beserta json_validate() , serta header() dan http_response_code() . Berbekal routing yang rapi, kita siap menyambungkan API ini ke database sungguhan pada Bagian 3.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

src/Request.php

Unduh berkas
php
<?php
declare(strict_types=1);

namespace App;

/**
 * Representasi immutable dari satu HTTP request.
 *
 * Objek ini menyalin semua data mentah (method, path, query string, body
 * JSON, header) pada saat konstruksi, lalu tidak pernah berubah lagi.
 * Handler menerima objek Request ini sebagai input tunggalnya sehingga
 * mudah diuji: kita bisa membangun request tiruan tanpa web server.
 */
final class Request
{
    /**
     * @param array<string, mixed>  $query   parameter query string, mis. ?halaman=2.
     * @param array<string, mixed>  $body    body JSON yang sudah di-decode menjadi array.
     * @param array<string, string> $headers header HTTP dengan nama lowercase.
     */
    private function __construct(
        private readonly string $method,
        private readonly string $path,
        private readonly array $query,
        private readonly array $body,
        private readonly array $headers,
    ) {
    }

    /**
     * Factory untuk pengujian: bangun Request dari array biasa.
     *
     * Kunci yang dikenali: method, path, query, body, headers.
     *
     * @param array<string, mixed> $data
     */
    public static function fromArray(array $data): self
    {
        return new self(
            strtoupper((string) ($data['method'] ?? 'GET')),
            self::normalisasiPath((string) ($data['path'] ?? '/')),
            (array) ($data['query'] ?? []),
            (array) ($data['body'] ?? []),
            self::normalisasiHeader((array) ($data['headers'] ?? [])),
        );
    }

    /**
     * Factory untuk produksi: baca request dari superglobal PHP.
     */
    public static function fromGlobals(): self
    {
        $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
        $path = self::normalisasiPath(
            (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/'),
        );

        $body = [];
        $mentah = file_get_contents('php://input');
        if (is_string($mentah) && $mentah !== '' && json_validate($mentah)) {
            $terjemah = json_decode($mentah, true);
            if (is_array($terjemah)) {
                $body = $terjemah;
            }
        }

        $headers = [];
        foreach ($_SERVER as $nama => $nilai) {
            if (str_starts_with((string) $nama, 'HTTP_')) {
                $headers[strtolower(str_replace('_', '-', substr((string) $nama, 5)))] = (string) $nilai;
            }
        }
        if (isset($_SERVER['CONTENT_TYPE'])) {
            $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
        }

        return new self($method, $path, $_GET, $body, $headers);
    }

    public function method(): string
    {
        return $this->method;
    }

    public function path(): string
    {
        return $this->path;
    }

    /**
     * Baca parameter query string. Tanpa argumen, kembalikan semua parameter.
     */
    public function query(?string $kunci = null, mixed $default = null): mixed
    {
        if ($kunci === null) {
            return $this->query;
        }

        return $this->query[$kunci] ?? $default;
    }

    /**
     * Baca field body JSON. Tanpa argumen, kembalikan seluruh body.
     */
    public function body(?string $kunci = null, mixed $default = null): mixed
    {
        if ($kunci === null) {
            return $this->body;
        }

        return $this->body[$kunci] ?? $default;
    }

    /**
     * Baca header; nama header tidak case-sensitive.
     */
    public function header(string $nama, ?string $default = null): ?string
    {
        return $this->headers[strtolower($nama)] ?? $default;
    }

    /**
     * @return array<string, string>
     */
    public function headers(): array
    {
        return $this->headers;
    }

    private static function normalisasiPath(string $path): string
    {
        // '/a/b/' dan 'a/b' keduanya dinormalisasi menjadi '/a/b',
        // sedangkan '/' dan '' menjadi '/'.
        return '/' . trim($path, '/');
    }

    /**
     * @param array<string, mixed> $headers
     *
     * @return array<string, string>
     */
    private static function normalisasiHeader(array $headers): array
    {
        $hasil = [];
        foreach ($headers as $nama => $nilai) {
            $hasil[strtolower((string) $nama)] = (string) $nilai;
        }

        return $hasil;
    }
}

src/Response.php

Unduh berkas
php
<?php
declare(strict_types=1);

namespace App;

/**
 * Representasi immutable dari satu HTTP response.
 *
 * Handler tidak menyentuh fungsi header()/echo langsung; handler hanya
 * membangun objek Response (biasanya lewat static factory json()) dan
 * mengembalikannya. Front controller-lah yang memanggil send() tepat satu
 * kali di akhir. Pola ini membuat response bisa diperiksa dalam pengujian
 * tanpa benar-benar mengirim byte ke client.
 */
final class Response
{
    /**
     * @param array<string, string> $headers
     */
    private function __construct(
        private readonly int $status,
        private readonly array $headers,
        private readonly string $body,
    ) {
    }

    /**
     * Factory response JSON — tipe yang dominan pada REST API ini.
     *
     * @param mixed $data data apa pun yang bisa di-json_encode.
     */
    public static function json(mixed $data, int $status = 200): self
    {
        return new self(
            $status,
            ['Content-Type' => 'application/json; charset=utf-8'],
            (string) json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
        );
    }

    /**
     * Factory response 204 tanpa body, cocok untuk DELETE yang berhasil.
     */
    public static function noContent(): self
    {
        return new self(204, [], '');
    }

    /**
     * Factory response redirect dengan header Location.
     */
    public static function redirect(string $lokasi, int $status = 302): self
    {
        return new self($status, ['Location' => $lokasi], '');
    }

    public function status(): int
    {
        return $this->status;
    }

    public function body(): string
    {
        return $this->body;
    }

    public function header(string $nama, ?string $default = null): ?string
    {
        return $this->headers[$nama] ?? $default;
    }

    /**
     * @return array<string, string>
     */
    public function headers(): array
    {
        return $this->headers;
    }

    /**
     * Salinan response dengan satu header tambahan (immutable wither).
     */
    public function withHeader(string $nama, string $nilai): self
    {
        $headers = $this->headers;
        $headers[$nama] = $nilai;

        return new self($this->status, $headers, $this->body);
    }

    /**
     * Kirim response ke client. Hanya boleh dipanggil front controller.
     */
    public function send(): void
    {
        http_response_code($this->status);
        foreach ($this->headers as $nama => $nilai) {
            header("$nama: $nilai");
        }
        echo $this->body;
    }
}

src/Router.php

Unduh berkas
php
<?php
declare(strict_types=1);

namespace App;

/**
 * Router minimalis dengan dukungan parameter {nama}.
 *
 * Cara pakai:
 *
 *     $router = new Router();
 *     $router->add('GET', '/api/tugas/{id}', $handler);
 *     $response = $router->dispatch($request);
 *
 * Handler dipanggil dengan dua argumen: objek Request dan array parameter
 * yang diekstrak dari pattern, mis. ['id' => '7']. Handler wajib
 * mengembalikan objek Response. Bila tidak ada route yang cocok, Router
 * membangun response 404; bila path cocok tapi method salah, Router
 * membangun response 405 lengkap dengan header Allow.
 */
final class Router
{
    /**
     * @var list<array{method: string, regex: string, params: list<string>, handler: callable}>
     */
    private array $routes = [];

    /**
     * Daftarkan satu route. Pattern boleh memuat parameter {nama} yang
     * mencocokan satu segmen path (karakter selain garis miring).
     */
    public function add(string $method, string $pattern, callable $handler): self
    {
        $params = [];
        $regex = (string) preg_replace_callback(
            '/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
            static function (array $cocok) use (&$params): string {
                $params[] = $cocok[1];

                return '([^/]+)';
            },
            $pattern,
        );

        $this->routes[] = [
            'method' => strtoupper($method),
            'regex' => '#^' . $regex . '$#',
            'params' => $params,
            'handler' => $handler,
        ];

        return $this;
    }

    /**
     * Cocokkan request dengan daftar route lalu panggil handler-nya.
     */
    public function dispatch(Request $request): Response
    {
        $methodDiizinkan = [];

        foreach ($this->routes as $route) {
            if (preg_match($route['regex'], $request->path(), $cocok) !== 1) {
                continue;
            }

            if ($route['method'] !== $request->method()) {
                $methodDiizinkan[] = $route['method'];
                continue;
            }

            $params = array_combine($route['params'], array_slice($cocok, 1));

            return ($route['handler'])($request, $params);
        }

        if ($methodDiizinkan !== []) {
            return Response::json(
                [
                    'error' => [
                        'code' => 'METHOD_NOT_ALLOWED',
                        'message' => sprintf(
                            'Method %s tidak diizinkan untuk endpoint ini.',
                            $request->method(),
                        ),
                    ],
                ],
                405,
            )->withHeader('Allow', implode(', ', array_values(array_unique($methodDiizinkan))));
        }

        return Response::json(
            [
                'error' => [
                    'code' => 'NOT_FOUND',
                    'message' => sprintf('Endpoint %s %s tidak ditemukan.', $request->method(), $request->path()),
                ],
            ],
            404,
        );
    }
}