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
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:
GET /api/tugas
status = 'proses'
halaman = '2'
accept = 'application/json'
batas = 10Mari kita bedah pemakaian di atas:
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.query()danbody()memakai pola yang sama: tanpa argumen, kembalikan seluruh data; dengan argumen kunci, kembalikan satu nilai atau$defaultbila kunci tidak ada. Default inilah yang menghilangkan warning “Undefined array key” dari kode handler.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
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:
json : status=200 tipe=application/json; charset=utf-8
body={"status":"ok"}
kosong : status=204 body=''
pindahkan : status=302 lokasi=/api/tugasTiga 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
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:
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 -> 204Tabel dispatch di atas memperlihatkan seluruh keputusan routing dalam satu pandangan:
GET /api/tugascocok dengan route pertama — polos, tanpa parameter.GET /api/tugas/7cocok dengan route kedua; segmen7ditangkap sebagai$params['id'], lalu handler mengembalikannya sebagai angka berkat cast(int).GET /api/tugas/7/catatantidak cocok dengan route mana pun — parameter{id}hanya boleh satu segmen ([^/]+), sehingga router menjawab404dengan bentuk error JSON yang sama seperti Bagian 1.PUT /api/tugas/7menabrak kasus berbeda: path-nya ada, tapi method-nya salah. Router mengumpulkan semua method yang terdaftar untuk path tersebut, menjawab405, dan melampirkan headerAllow: GET, DELETE— persis seperti yang disarankan spesifikasi HTTP.DELETE /api/tugas/7berakhir204tanpa 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
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:
# 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{"status":"ok"}$ curl -sS http://127.0.0.1:8094/api/halo/Budi{"pesan":"Halo, Budi!"}Body JSON yang dikirim curl -d masuk lewat php://input, di-decode Request::fromGlobals(), lalu digema balik:
$ curl -sS -X POST -H 'Content-Type: application/json' \
-d '{"judul":"Belajar Router"}' http://127.0.0.1:8094/api/gema{"diterima":{"judul":"Belajar Router"}}Terakhir, method yang salah pada path yang benar memicu 405 lengkap dengan header Allow:
$ curl -sS -i -X PUT http://127.0.0.1:8094/api/gemaHTTP/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 factoryfromArray()untuk pengujian danfromGlobals()untuk produksi; aksesormethod(),path(),query(),body(), danheader()selalu aman terhadap kunci yang tidak ada.src/Response.php— objek response immutable dengan factoryjson(),noContent(),redirect(), witherwithHeader(), sertasend()yang hanya boleh dipanggil front controller.src/Router.php—add(method, pattern, handler)dengan dukungan{param},dispatch(Request): Responseyang otomatis menghasilkan 404 dan 405 + headerAllow.public/index.php— dispatchifdigantikan tabel route; handler kini murni membangunResponse.
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:
# 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.phpTiga 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
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
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
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,
);
}
}