Sebaik apa pun kode kita, kesalahan runtime tetap akan terjadi: koneksi database terputus, berkas konfigurasi hilang, atau data yang tidak terduga masuk dari pengguna. Tanpa lapisan penanganan khusus, exception yang tidak tertangkap akan berhenti di PHP itu sendiri dan pengguna hanya melihat pesan error mentah — atau lebih buruk, stack trace yang membocorkan struktur aplikasi. Pada chapter ini kita membangun exception handling layer untuk framework mini: kumpulan exception HTTP, handler global sebagai garis pertahanan terakhir, dan strategi rendering yang berbeda antara lingkungan development dan production.

Bahaya Exception yang Bocor ke Pengguna #

Mari mulai dari masalahnya. Fungsi pencarian produk di bawah ini melempar RuntimeException ketika produk tidak ditemukan di katalog:

php
<?php

function cariProduk(int $id): array
{
    $daftarProduk = [
        1 => ["nama" => "Kopi Gayo"],
        2 => ["nama" => "Teh Melati"],
    ];

    if (!isset($daftarProduk[$id])) {
        throw new RuntimeException("Produk #$id tidak ditemukan di katalog");
    }

    return $daftarProduk[$id];
}

Di dalam aplikasi nyata, fungsi semacam ini dipanggil dari controller, yang dipanggil dari router, yang dipanggil dari kernel. Melempar exception itu sendiri bukanlah masalah — masalahnya adalah ketika tidak ada satu pun lapisan yang menangkapnya. Exception yang lolos sampai ke puncak akan membuat PHP menghentikan eksekusi dan mencetak pesan error mentah ke layar pengguna. Pesan semacam itu tidak hanya merusak pengalaman pengguna, tetapi juga membocorkan nama berkas, jalur direktori, dan detail internal aplikasi kepada siapa pun yang membukanya.

Exception HTTP dan Pemetaan Status Code #

Web bekerja dengan status code: 404 untuk halaman yang tidak ada, 403 untuk akses yang ditolak, 500 untuk kesalahan server. Framework matang menyediakan kelas exception khusus yang membawa makna HTTP ini, misalnya NotFoundHttpException di Symfony. Kita bisa meniru polanya dengan kelas exception bertanda plus sebuah mapper yang menerjemahkan kelas exception menjadi status code:

php
<?php

abstract class HttpException extends RuntimeException
{
}

final class NotFoundException extends HttpException
{
}

final class ForbiddenException extends HttpException
{
}

final class ValidationException extends RuntimeException
{
}

final class HttpExceptionMapper
{
    /** @var array<class-string<Throwable>, int> */
    private array $map = [
        NotFoundException::class => 404,
        ForbiddenException::class => 403,
        ValidationException::class => 422,
    ];

    public function statusCodeFor(Throwable $e): int
    {
        foreach ($this->map as $class => $status) {
            if ($e instanceof $class) {
                return $status;
            }
        }

        return 500;
    }
}

$mapper = new HttpExceptionMapper();

echo $mapper->statusCodeFor(new NotFoundException("Produk #999 tidak ada")), "\n";
echo $mapper->statusCodeFor(new ForbiddenException("Akses ditolak")), "\n";
echo $mapper->statusCodeFor(new ValidationException("Email tidak valid")), "\n";
echo $mapper->statusCodeFor(new RuntimeException("Koneksi database gagal")), "\n";

Output yang Dihasilkan:

text
404
403
422
500

Mari kita bedah bagian-bagian pentingnya:

  1. abstract class HttpException: kelas penanda (marker) yang mewarisi RuntimeException, sehingga semua exception HTTP kita bisa ditangkap dengan satu tipe catch (HttpException $e).
  2. $map: tabel pemetaan dari nama kelas ke status code. Dengan tabel ini, menambah exception baru cukup menambah satu baris array, tanpa mengubah logika mapper.
  3. $e instanceof $class: instance dari kelas turunan juga cocok dengan kelas induknya, jadi pemetaan tetap bekerja ketika kita membuat ProductNotFoundException extends NotFoundException.
  4. return 500: cabang default untuk semua exception yang tidak terdaftar — kesalahan tak terduga memang seharusnya dianggap sebagai kesalahan server.

Controller sekarang cukup melempar new NotFoundException(...) dan lapisan atas yang akan menerjemahkannya menjadi respons 404 — tanpa perlu menulis http_response_code() di mana-mana.

Handler Global sebagai Garis Pertahanan Terakhir #

Di mana exception sebaiknya ditangkap? Jawabannya: di satu titik saja, yaitu titik masuk aplikasi. Kernel yang kita rakit pada chapter middleware dan dependency container dibungkus satu blok try/catch di index.php:

php
<?php

// index.php — titik masuk tunggal framework mini
$request = Request::fromGlobals();

try {
    $response = $kernel->handle($request);
} catch (Throwable $e) {
    $response = $errorHandler->toResponse($e);
}

$response->send();

Pola ini disebut handler global: satu garis pertahanan terakhir yang menangkap Throwable — tipe induk untuk semua exception dan error fatal yang bisa ditangkap di PHP 7 ke atas. Middleware, controller, dan fungsi bantu tidak perlu lagi mencoba-coba try/catch untuk segalanya; mereka cukup melempar exception bermakna dan biarkan lapisan teratas yang memutuskan bagaimana menampilkannya. Konsekuensinya, seluruh kebijakan penanganan kesalahan terkumpul di satu kelas yang bisa diuji secara terpisah.

Renderer per Lingkungan dan Pencatatan Log #

Handler global punya dua tugas: menghasilkan respons yang pantas untuk pengguna, dan memastikan kesalahan tidak hilang begitu saja. Detail yang ditampilkan harus berbeda menurut lingkungan — developer membutuhkan informasi sebanyak mungkin, sedangkan pengguna produksi justru tidak boleh melihat detail apa pun:

php
<?php

final class ArrayLogger
{
    /** @var list<string> */
    public array $lines = [];

    public function error(string $message): void
    {
        $this->lines[] = "[ERROR] " . $message;
    }
}

final class FrameworkHandler
{
    public function __construct(
        private string $environment = "production",
        private ArrayLogger $logger = new ArrayLogger(),
    ) {
    }

    /** @return array{status: int, body: string} */
    public function toResponse(Throwable $e): array
    {
        $status = match (true) {
            $e instanceof NotFoundHttpException => 404,
            $e instanceof AccessDeniedHttpException => 403,
            default => 500,
        };

        if ($this->environment === "development") {
            $body = sprintf("[%s] %s", get_class($e), $e->getMessage());
        } else {
            $this->logger->error(get_class($e) . ": " . $e->getMessage());
            $body = "Terjadi kesalahan pada server. Silakan coba beberapa saat lagi.";
        }

        return ["status" => $status, "body" => $body];
    }

    /** @return list<string> */
    public function loggedErrors(): array
    {
        return $this->logger->lines;
    }
}

final class NotFoundHttpException extends RuntimeException
{
}

final class AccessDeniedHttpException extends RuntimeException
{
}

$dev  = new FrameworkHandler("development");
$prod = new FrameworkHandler("production");

var_export($dev->toResponse(new NotFoundHttpException("Produk #999 tidak ada")));
echo "\n";
var_export($prod->toResponse(new RuntimeException("Koneksi database gagal")));
echo "\n";
echo "Log: " . implode(" | ", $prod->loggedErrors()) . "\n";

Output yang Dihasilkan:

text
array (
  'status' => 404,
  'body' => '[NotFoundHttpException] Produk #999 tidak ada',
)
array (
  'status' => 500,
  'body' => 'Terjadi kesalahan pada server. Silakan coba beberapa saat lagi.',
)
Log: [ERROR] RuntimeException: Koneksi database gagal

Beberapa keputusan desain penting ada di dalam kode ini:

  1. match (true): bentuk ringkas dari rantai if ($e instanceof ...). Setiap lengan diperiksa berurutan sampai ada yang bernilai true, dan default menjamin selalu ada status yang dihasilkan.
  2. Cabang development: body berisi nama kelas dan pesan exception. Framework nyata menambahkan berkas, nomor baris, dan stack trace — kita sengaja menahannya agar contoh tetap fokus pada alurnya.
  3. Cabang production: pengguna hanya melihat pesan generik, tetapi detail lengkap dicatat ke logger lebih dulu sehingga tim bisa menyelidiki insiden dari log.
  4. ArrayLogger: logger tiruan yang menyimpan baris di array agar perilaku handler bisa kita verifikasi lewat pengujian. Di produksi, implementasi yang sama diganti dengan logger sungguhan yang menulis ke berkas atau layanan eksternal.

Mengubah Error PHP menjadi Exception #

Ada satu jenis kebocoran yang belum kita tutup: warning dan notice PHP. Membaca variabel yang belum didefinisikan atau mengakses indeks array yang tidak ada hanya menghasilkan Warning, bukan exception — artinya handler global kita tidak akan pernah terpanggil. Solusinya adalah mengubah error menjadi exception menggunakan set_error_handler():

php
<?php

set_error_handler(
    function (int $errno, string $errstr, string $errfile, int $errline): bool {
        throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
    }
);

try {
    $total = $hargaProduk + 1000; // variabel belum pernah didefinisikan
} catch (ErrorException $e) {
    echo "Tertangkap sebagai exception: ", $e->getMessage(), "\n";
}

Output yang Dihasilkan:

text
Tertangkap sebagai exception: Undefined variable $hargaProduk

Callback yang kita daftarkan dipanggil oleh PHP setiap kali sebuah error non-fatal muncul; alih-alih membiarkan PHP mencetak peringatan, callback tersebut melempar ErrorException — kelas bawaan PHP yang membungkus informasi error ke dalam bentuk exception normal. Terdaftarnya handler ini di bootstrap, warning kecil seperti variabel yang lupa diinisialisasi kini mengalir melalui jalur yang sama seperti exception lain: tertangkap oleh handler global, dirender sesuai lingkungan, dan tercatat di log. Konfigurasi error_reporting tetap berguna untuk menyaring level mana yang boleh dikonversi; callback di atas menerima $errno sehingga kita bisa memilih untuk mengabaikan level tertentu.

Whoops untuk Periode Development #

Menulis renderer development yang informatif ternyata pekerjaan tidak kecil: tampilan stack trace yang rapi, penyorotan baris kode, sampai daftar nilai variabel lokal. Untuk kebutuhan itu, ekosistem PHP punya paket whoops yang sudah menjadi standar de facto — Laravel pun memakainya:

php
<?php

// Pasang sekali di bootstrap saat APP_ENV=development:
// composer require filp/whoops
$whoops = new \Whoops\Run();
$whoops->pushHandler(new \Whoops\Handler\PrettyPageHandler());
$whoops->register();

Setelah terpasang, whoops mengambil alih tugas menampilkan exception di lingkungan development: halaman error yang berwarna, dapat dijelajahi per frame stack trace, dan menampilkan potongan kode di sekitarnya. Perhatikan bahwa whoops hanya menggantikan renderer development — konsep handler global, pemetaan status code, dan logging produksi yang kita bangun tetap berlaku persis sama. Inilah keuntungan memisahkan kebijakan (handler) dari tampilan (renderer): keduanya bisa diganti satu per satu.

Ringkasan #

Lapisan penanganan exception yang baik bekerja seperti jaring pengaman di sirkus: tidak ada yang berharap dipakai, tetapi semua orang bergantung padanya. Kita memetakan kelas exception ke status code, menangkap Throwable di satu titik masuk, merender detail hanya untuk development, mencatat semua kesalahan ke log di production, dan mengonversi error PHP menjadi exception agar tidak ada yang lolos. Dengan lapisan ini, framework mini kita sudah punya siklus hidup request yang utuh dan tahan banting. Selanjutnya kita melengkapi router dengan kemampuan yang paling sering dibutuhkan aplikasi nyata: menangkap parameter dinamis dari URL pada chapter routing parameters .

Referensi #