Handler router kita sejauh ini menerima string dan mengembalikan string — kontrak yang jujur tetapi cepat mentok begitu handler butuh membaca query string, header, atau body request, dan butuh mengatur kode status serta header respons. Di titik ini framework membungkus komunikasi HTTP menjadi dua objek: Request yang mewakili masukan dan Response yang mewakili keluaran. Pada chapter ini kita bangun keduanya sebagai class immutable yang bisa dikonstruksi dari data tiruan — artinya teruji penuh di terminal tanpa web server — lalu kita lihat mengapa pendekatan objek menang telak dari superglobal mentah dan bagaimana standar komunitas PSR-7 merumuskan pola yang sama.

Kenapa Bukan Superglobal Langsung #

Superglobal seperti $_GET, $_POST, dan $_SERVER adalah array global yang bisa ditulis siapa saja dari mana saja. Tiga masalah mengalir dari sifat tersebut. Pertama, pengujian: fungsi yang membaca $_GET tidak bisa diuji tanpa memanipulasi state global, dan dua test yang berjalan bergantian bisa saling mencemari. Kedua, kejelasan: $_SERVER adalah campuran metadata proses dan header request (HTTP_ACCEPT, REQUEST_METHOD) yang perlu didekode manual setiap kali dibaca. Ketiga, mutabilitas: plugin atau kode lama yang menulis $_GET["halaman"] = 1 mengubah data yang dilihat seluruh kode setelahnya, dan bug semacam ini nyaris tidak bisa dilacak.

Objek Request memperbaiki ketiganya sekaligus: dibangun sekali dari superglobal, bersifat immutable setelahnya, dan mengekspos method bertuliskan jelas. Satu-satunya tempat yang menyentuh superglobal adalah front controller — tepi aplikasi — sedangkan seluruh handler di dalam bekerja pada objek yang bisa kita ciptakan dari data tiruan di test.

Objek Request #

Desain kita mengikuti bentuk yang dipakai framework modern: constructor promotion untuk lima properti readonly, factory dariSuperglobal() yang menerjemahkan superglobal, getter bertuliskan jelas, dan method with* yang menghasilkan salinan baru alih-alih memutasi:

php
<?php
final class Request
{
    public function __construct(
        private readonly string $method,
        private readonly string $path,
        private readonly array $query,
        private readonly array $body,
        private readonly array $headers,
    ) {
    }

    public static function dariSuperglobal(array $server, array $get, array $post): self
    {
        $headers = [];
        foreach ($server as $kunci => $nilai) {
            if (str_starts_with((string) $kunci, "HTTP_")) {
                $headers[strtolower(str_replace("_", "-", substr((string) $kunci, 5)))] = (string) $nilai;
            }
        }

        return new self(
            strtoupper($server["REQUEST_METHOD"] ?? "GET"),
            parse_url($server["REQUEST_URI"] ?? "/", PHP_URL_PATH),
            $get,
            $post,
            $headers,
        );
    }

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

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

    public function query(string $kunci, ?string $default = null): ?string
    {
        return isset($this->query[$kunci]) ? (string) $this->query[$kunci] : $default;
    }

    public function input(string $kunci, ?string $default = null): ?string
    {
        return isset($this->body[$kunci]) ? (string) $this->body[$kunci] : $default;
    }

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

    public function withHeader(string $nama, string $nilai): self
    {
        $baru = $this->headers;
        $baru[strtolower($nama)] = $nilai;

        return new self($this->method, $this->path, $this->query, $this->body, $baru);
    }
}

$server = [
    "REQUEST_METHOD" => "GET",
    "REQUEST_URI" => "/api/produk?halaman=2",
    "HTTP_ACCEPT" => "text/html",
];
$request = Request::dariSuperglobal($server, ["halaman" => "2"], ["idProduk" => "p1"]);
$requestJson = $request->withHeader("Accept", "application/json");

echo $request->method() . " " . $request->path() . "\n";
echo "query halaman = " . $request->query("halaman") . "\n";
echo "header asli   = " . $request->header("accept") . "\n";
echo "header baru   = " . $requestJson->header("accept") . "\n";

Output yang Dihasilkan:

text
GET /api/produk
query halaman = 2
header asli   = text/html
header baru   = application/json

Mari kita bedah fungsinya. dariSuperglobal() melakukan tiga penerjemahan: kunci HTTP_* di $server diubah menjadi nama header lowercase (HTTP_ACCEPT menjadi accept), REQUEST_URI dipangkas menjadi path murni lewat parse_url() sehingga query string tidak ikut, dan method dinormalkan ke huruf kapital. Getter query()/input()/header() masing-masing menerima nilai default sehingga pemanggil tidak perlu memeriksa keberadaan kunci manual. Yang paling penting adalah demo dua baris terakhir: withHeader() mengembalikan objek baru — $request asli tetap memegang text/html setelah $requestJson dibuat, dan itulah arti immutable dalam praktik.

Sifat ini yang membuat handler HTTP aman dari efek samping: middleware bisa menghasilkan request berheader tambahan untuk diteruskan ke handler berikutnya tanpa merusak data request asli, dan test yang memakai objek yang sama berjalan deterministik.

Objek Response #

Response menyimpan tiga hal — kode status, daftar header, dan body — dengan pola yang sama: with* mengembalikan salinan, json() menjadi constructor ekspresif, dan satu method kirim() yang menjadi satu-satunya tempat menyentuh fungsi output PHP:

php
<?php
final class Response
{
    private const PESAN_STATUS = [
        200 => "OK",
        201 => "Created",
        404 => "Not Found",
    ];

    public function __construct(
        private readonly int $status = 200,
        private readonly array $headers = [],
        private readonly string $body = "",
    ) {
    }

    public static function json(array $data, int $status = 200): self
    {
        return new self(
            $status,
            ["Content-Type" => "application/json"],
            json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
        );
    }

    public function withHeader(string $nama, string $nilai): self
    {
        return new self($this->status, [...$this->headers, $nama => $nilai], $this->body);
    }

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

    public function headers(): array
    {
        return $this->headers;
    }

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

    public function barisStatus(): string
    {
        return "HTTP/1.1 " . $this->status . " " . (self::PESAN_STATUS[$this->status] ?? "Unknown");
    }

    public function kirim(): void
    {
        http_response_code($this->status);

        foreach ($this->headers as $nama => $nilai) {
            header("{$nama}: {$nilai}");
        }

        echo $this->body;
    }
}

$dibuat = Response::json(["id" => 99, "jumlah" => 3], 201)
    ->withHeader("Location", "/keranjang/99");

echo $dibuat->barisStatus() . "\n";
foreach ($dibuat->headers() as $nama => $nilai) {
    echo "{$nama}: {$nilai}\n";
}
echo "\n" . $dibuat->body() . "\n";

Output yang Dihasilkan:

text
HTTP/1.1 201 Created
Content-Type: application/json
Location: /keranjang/99

{"id":99,"jumlah":3}

Perhatikan alur pembangunan respons pada blok demo: Response::json() membuat respons 201 dengan header Content-Type, lalu withHeader() menambahkan Location dengan tetap mengembalikan objek baru — ketiga propserti terisi tanpa satu pun mutasi. Method kirim() adalah satu-satunya yang menyentuh http_response_code(), header(), dan echo; memisahkannya berarti seluruh sisa aplikasi hanya membangun data, dan pengiriman HTTP sesungguhnya menjadi urusan satu titik di tepi sistem. Output di atas kita cetak lewat getter justru untuk menunjukkan hal tersebut: objek respons hanyalah data sampai kirim() dipanggil.

Dua objek ini lengkap dipakai di front controller sungguhan:

php
<?php
// public/index.php - satu-satunya berkas yang menyentuh superglobal
$request = Request::dariSuperglobal($_SERVER, $_GET, $_POST);

if ($request->method() === "POST" && $request->path() === "/keranjang") {
    $response = Response::json(["status" => "ditambahkan"], 201);
} else {
    $response = Response::json(["error" => "tidak ditemukan"], 404);
}

$response->kirim();

Front controller membangun Request sekali, router memilih handler, handler mengembalikan Response, dan kirim() menutup siklus — superglobal tidak pernah muncul lagi setelah baris pertama. Untuk menguji handler tersebut di PHPUnit, kita cukup memanggilnya dengan Request yang dibangun dari array tiruan; tidak ada server, tidak ada state global.

Standar PSR-7 dan Ekosistem #

Pola yang baru kita bangun memiliki nama resmi: PSR-7, standar antarmuka request/response dari PHP-FIG (Framework Interop Group) yang dipakai lintas framework dan library. PSR-7 mendefinisikan antarmuka seperti ServerRequestInterface dan ResponseInterface dengan method with* immutable — keputusan yang persis sama dengan yang kita ambil secara mandiri, karena masalahnya memang sama. Manfaat standar ini nyata saat mencampur ekosistem: middleware dan HTTP client dari vendor berbeda bisa saling bertukar request/response karena keduanya berbicara lewat antarmuka yang sama. Framework mini kita memilih implementasi sendiri agar setiap baris bisa dipelajari; di proyek nyata, mengadopsi implementasi PSR-7 yang sudah teruji adalah pilihan yang sah dan sering lebih bijak.

Satu bagian HTTP yang belum kita sentuh adalah unggahan berkas. Konsepnya sama — data mentah ($_FILES) dibungkus objek dengan method yang menjaga keamanan:

php
<?php
final class UploadedFile
{
    public function __construct(
        private readonly string $namaAsli,
        private readonly int $ukuran,
        private readonly string $pathSementara,
    ) {
    }

    public function amanNama(): string
    {
        // buang komponen path seperti "../../etc/passwd"
        return basename($this->namaAsli);
    }

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

    public function pindahkan(string $direktoriTujuan): string
    {
        $tujuan = rtrim($direktoriTujuan, "/") . "/" . $this->amanNama();
        move_uploaded_file($this->pathSementara, $tujuan);

        return $tujuan;
    }
}

Daripada menyerahkan array mentah $_FILES (yang struktur bersarangnya mudah salah dibaca) ke seluruh aplikasi, setiap berkas dibungkus menjadi objek dengan kontrak jelas: nama yang sudah disucihamakan lewat basename(), ukuran yang bisa diperiksa, dan satu method pindahkan() yang menjadi satu-satunya pintu menulis berkas ke disk. Validasi tipe berkas dan batas ukuran tetap wajib ditambahkan sebelum menyimpan — detail serangannya dibahas pada chapter File Upload Security .

Ringkasan #

Abstraksi request/response memindahkan PHP dari superglobal mentah ke objek immutable yang teruji: Request dibangun sekali di tepi aplikasi dan dibaca lewat getter jelas, Response dibangun bertahap lewat method with* dan dikirim dari satu titik tunggal. Immutability membuat handler dan middleware bebas efek samping, data tiruan membuat test berjalan tanpa web server, dan PSR-7 menunjukkan bahwa pola ini adalah konsensus industri. Kontrak handler kita kini bersih: menerima Request, mengembalikan Response. Kontrak tersebut adalah bahan bakar sempurna untuk lapisan berikutnya yang menyusun handler-handler menjadi pipeline: Middleware .

Referensi #