Client API tidak punya layar untuk “menampilkan error” — yang ada hanyalah status code dan body JSON yang harus diprogram. Error 404 dari route model binding, 422 dari validasi, 429 dari rate limit, sampai 500 dari kegagalan tak terduga: semuanya adalah bagian kontrak yang sama pentingnya dengan response sukses. Masalahnya, bentuk bawaan Laravel berbeda-beda per jenis error, dan sebagian isi justru membocorkan struktur internal aplikasi ke luar. Chapter ini menstandarkan semuanya lewat withExceptions() di bootstrap/app.php: envelope error konsisten, domain exception yang bermakna, logging ber-request-id, dan disiplin mematikan APP_DEBUG di produksi.

Bentuk Bawaan: 404 dan 422 #

Kita mulai dengan mengamati apa yang dikirim Laravel tanpa kustomisasi, pada request GET /api/v1/posts/999 dan filter tidak valid:

Output yang Dihasilkan:

text
### GET /api/v1/posts/999 → HTTP 404 (bawaan)
{
    "message": "No query results for model [App\Models\Post] 999"
}

### GET /api/v1/posts?status=archived → HTTP 422 (bawaan)
{
    "message": "The selected status is invalid.",
    "errors": {
        "status": [
            "The selected status is invalid."
        ]
    }
}

Format 422 sudah bagus dan kita bahas tuntas di chapter API Validation . Format 404 bermasalah: pesan "No query results for model [App\Models\Post] 999" membocorkan nama class lengkap beserta namespace aplikasi kita ke client publik — informasi yang tidak ada gunanya bagi client dan berharga bagi penjelajah struktur internal.

Envelope Error Konsisten #

Kita definisikan bentuk standar untuk semua error milik kita: objek error berisi code yang mesin bisa switch dan message yang manusia bisa baca. Kustomisasi terjadi di withExceptions() di bootstrap/app.php lewat renderable() — closure yang menerima exception dan request, lalu mengembalikan response:

php
<?php

// bootstrap/app.php (cuplikan withExceptions)

use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

$exceptions->renderable(function (NotFoundHttpException $e, Request $request) {
    if (! $request->is('api/*') || ! $e->getPrevious() instanceof ModelNotFoundException) {
        return null;
    }

    return response()->json([
        'error' => [
            'code' => 'not_found',
            'message' => 'Resource tidak ditemukan.',
        ],
    ], 404);
});

Dua syarat di dalam closure adalah gerbang yang penting. $request->is('api/*') memastikan renderable hanya aktif untuk request API — halaman web tetap menikmati halaman 404 HTML bawaan. Pemeriksaan $e->getPrevious() instanceof ModelNotFoundException memastikan kita hanya membungkus 404 akibat model tidak ditemukan — 404 karena route salah tetap memakai pesan bawaan. Mengembalikan null dari closure memberi tahu Laravel untuk melanjutkan ke penanganan bawaan. Hasilnya:

Output yang Dihasilkan:

text
### GET /api/v1/posts/999 → HTTP 404 (setelah renderable)
{
    "error": {
        "code": "not_found",
        "message": "Resource tidak ditemukan."
    }
}

Nama class model kini tidak pernah meninggalkan aplikasi, dan client mendapat code yang stabil untuk di-switch di UI mereka.

Domain Exception: 409 untuk Konflik Bisnis #

Chapter API Authorization menolak penerbitan ulang post yang sudah published dengan 403 — padahal masalahnya bukan larangan akses, melainkan konflik state. Perbaikan yang jujur: buat exception domain sendiri dan jawab 409 Conflict:

php
<?php

// app/Exceptions/PostAlreadyPublishedException.php

namespace App\Exceptions;

use App\Models\Post;
use RuntimeException;

class PostAlreadyPublishedException extends RuntimeException
{
    public function __construct(public readonly Post $post)
    {
        parent::__construct("Post [{$post->id}] sudah dipublikasikan sebelumnya.");
    }
}

Route publish melemparkannya saat status sudah terbit — pemeriksaan kepemilikan tetap di policy, sedangkan pemeriksaan state yang semula tinggal di policy (chapter API Authorization ) pindah ke route:

php
<?php

// routes/api_v1.php

use App\Exceptions\PostAlreadyPublishedException;

Route::post('/posts/{post}/publish', function (Post $post) {
    Gate::authorize('publish', $post);

    if ($post->status === 'published') {
        throw new PostAlreadyPublishedException($post);
    }

    $post->update(['status' => 'published', 'published_at' => now()]);

    return response()->json(['id' => $post->id, 'status' => $post->status]);
})->middleware(['auth:sanctum', 'ability:posts:publish']);

Renderable-nya membungkus exception ke envelope kita, sekalian mencatat ke log dengan request id dari header X-Request-Id yang dikirim client:

php
<?php

// bootstrap/app.php (cuplikan withExceptions)

use App\Exceptions\PostAlreadyPublishedException;
use Illuminate\Support\Facades\Log;

$exceptions->renderable(function (PostAlreadyPublishedException $e, Request $request) {
    $requestId = $request->header('X-Request-Id');

    Log::warning('post_already_published', [
        'request_id' => $requestId,
        'post_id' => $e->post->id,
    ]);

    return response()->json([
        'error' => [
            'code' => 'post_already_published',
            'message' => $e->getMessage(),
            'request_id' => $requestId,
        ],
    ], 409);
});

Budi yang mencoba menerbitkan ulang postnya menerima jawaban yang bermakna sekaligus jejak yang bisa ditelusuri:

Output yang Dihasilkan:

text
### POST /api/v1/posts/1/publish untuk post yang sudah published → HTTP 409
{
    "error": {
        "code": "post_already_published",
        "message": "Post [1] sudah dipublikasikan sebelumnya.",
        "request_id": "req-demo-123"
    }
}

baris terakhir storage/logs/laravel.log:
[2026-10-13 20:02:57] testing.WARNING: post_already_published {"request_id":"req-demo-123","post_id":1}

Ketika client menghubungi support dengan menyebut request_id, kita tinggal mencari nilai itu di log untuk menemukan baris persisnya — timestamp pada log berjalan mengikuti waktu eksekusi, sisanya identik. Di produksi, request id biasanya dibuat otomatis oleh middleware atau load balancer dan dimasukkan ke setiap baris log lewat context, topik yang kita dalami di section Error Handling dan Logging .

500 dan Disiplin APP_DEBUG #

Kesalahan tak terduga adalah ujian sesungguhnya. Route demo berikut sengaja melempar RuntimeException yang pesannya menyimpan rahasia tiruan:

php
<?php

// routes/api_v1.php

Route::get('/bug', fn (): never => throw new RuntimeException(
    'Gagal koneksi database: host=db.internal, password=BUDI_RAHASIA_123',
));

Perbedaan jawaban antara APP_DEBUG=true dan APP_DEBUG=false sangat dramatis. Saat debug mati — kondisi produksi yang benar — client hanya menerima:

Output yang Dihasilkan:

text
### GET /api/v1/bug dengan APP_DEBUG=false → HTTP 500
{
    "message": "Server Error"
}

Sedangkan pada environment pengembangan dengan debug nyala, body memuat lima kunci — message (lengkap dengan rahasia tiruan itu), exception (nama class), file (path file di server), line, dan trace berisi 52 frame stack trace. Informasi luar biasa untuk debugging kita, dan luar biasa berbahaya bila bocor — path server, nama class internal, sampai isi variabel di stack trace semuanya terbuka.

Perhatikan pula bahwa exception yang ditangani renderable kita menembus aturan ini dengan sengaja — envelope 409 dan 404 di atas tetap informatif di produksi karena isinya memang dirancang untuk publik: code, message, dan request id, tanpa path file atau stack trace.

Error kini berbahasa kontrak: bentuk bawaan dipetakan ke envelope, konflik bisnis mendapat exception sendiri, dan rahasia terkunci di balik APP_DEBUG=false. Satu pekerjaan terakhir menutup section ini: bagaimana client tahu semua kontrak tersebut — endpoint, parameter, bentuk response, dan error — tanpa membaca kode kita? Itu pekerjaan chapter API Documentation , dan rujukan resminya ada di dokumentasi errors .