Endpoint GET /tasks yang mengembalikan seluruh isi tabel hanya sehat selama datanya delapan baris. Begitu resource bertumbuh menjadi ribuan baris, respons membengkak, klien mobile lambat, dan database bekerja keras untuk data yang tidak dibaca siapa pun. Pada Chapter ini kita membangun endpoint daftar yang layak dipakai produksi: pagination dengan page/limit, filtering lewat query string yang dibatasi allowlist kolom, sorting dengan sintaks -created_at, serta meta respons berisi total dan pages. Semua contoh memakai SQLite :memory: sehingga bisa diuji langsung tanpa server database.

Parameter Halaman: page, limit, dan offset #

Paginasi offset paling umum dipakai karena sederhana: klien menyebut nomor halaman dan ukuran halaman, server menerjemahkannya menjadi LIMIT/OFFSET di SQL. Fungsi paginationParams() membungkus penerjemahan itu sekaligus semua pembersihan nilai:

php
<?php
function paginationParams(array $query, int $defaultLimit = 20, int $maxLimit = 50): array
{
    $page = max(1, (int) ($query["page"] ?? 1));
    $limit = max(1, (int) ($query["limit"] ?? $defaultLimit));

    if ($limit > $maxLimit) {
        $limit = $maxLimit;
    }

    return [
        "page" => $page,
        "limit" => $limit,
        "offset" => ($page - 1) * $limit,
    ];
}

$cases = [
    "tanpa parameter" => [],
    "halaman 3" => ["page" => "3", "limit" => "10"],
    "limit berlebih" => ["limit" => "500"],
    "nilai tak wajar" => ["page" => "abc", "limit" => "-5"],
];

foreach ($cases as $label => $query) {
    $result = paginationParams($query);
    echo $label . ": page=" . $result["page"]
        . ", limit=" . $result["limit"]
        . ", offset=" . $result["offset"] . "\n";
}

Output yang Dihasilkan:

text
tanpa parameter: page=1, limit=20, offset=0
halaman 3: page=3, limit=10, offset=20
limit berlebih: page=1, limit=50, offset=0
nilai tak wajar: page=1, limit=1, offset=0

Mari kita bedah perilakunya:

  1. (int) ($query["page"] ?? 1): semua nilai query string datang sebagai string hasil parsing di Chapter Request Parsing , jadi konversi ke integer wajib. String aneh seperti "abc" menjadi 0, dan "3" menjadi 3.
  2. max(1, ...): penjepit batas bawah — page=0, page=-3, maupun page=abc semuanya jatuh aman ke halaman 1, dan limit tidak pernah di bawah 1.
  3. if ($limit > $maxLimit): penjepit batas atas. Tanpa batas ini, satu request ?limit=1000000 memaksa database dan jaringan bekerja sepenuhnya untuk satu klien — bentuk serangan denial-of-service yang murah.
  4. offset = (page - 1) * limit: halaman 1 dimulai dari baris ke-0, halaman 3 dengan limit 10 dimulai dari baris ke-20.

Filtering Kolom lewat Allowlist #

Filtering memakai query string seperti ?status=todo, tetapi nama kolom database tidak boleh ikut dikendalikan klien. Jembatannya adalah allowlist: peta dari parameter query yang diizinkan ke kolom aslinya, sedangkan nilainya selalu berjalan lewat parameter binding:

php
<?php
function buildWhere(array $query, array $allowedFilters): array
{
    $conditions = [];
    $params = [];

    foreach ($allowedFilters as $param => $column) {
        if (isset($query[$param]) && is_string($query[$param]) && $query[$param] !== "") {
            $conditions[] = "$column = :$param";
            $params[":$param"] = $query[$param];
        }
    }

    if ($conditions === []) {
        return ["", []];
    }

    return [" WHERE " . implode(" AND ", $conditions), $params];
}

$allowedFilters = [
    "status" => "status",
    "user" => "assignee",
];

$queries = [
    "tanpa filter" => [],
    "?status=todo" => ["status" => "todo", "page" => "1"],
    "[email protected]" => ["user" => "[email protected]"],
    "?priority=urgent" => ["priority" => "urgent"],
];

foreach ($queries as $label => $query) {
    [$where, $params] = buildWhere($query, $allowedFilters);
    echo $label . " => [" . trim($where) . "] " . json_encode($params) . "\n";
}

Output yang Dihasilkan:

text
tanpa filter => [] []
?status=todo => [WHERE status = :status] {":status":"todo"}
[email protected] => [WHERE assignee = :user] {":user":"[email protected]"}
?priority=urgent => [] []

Ada dua lapis pertahanan yang bekerja di sini. Pertama, peta $allowedFilters menjelaskan bahwa parameter user dipetakan ke kolom assignee — nama parameter dan nama kolom sengaja tidak harus sama, sehingga skema internal tidak bocor ke URL. Kedua, parameter priority sama sekali tidak ada di peta, jadi request ?priority=urgent diabaikan tanpa menghasilkan kondisi apa pun. Nilai filter sendiri tidak pernah ditempel ke SQL — nilai masuk array $params yang belakangan dikirim ke execute(), persis pola prepared statement pada Chapter SQL Injection Prevention .

Sorting dengan Awalan Minus #

Untuk sorting kita pakai konvensi yang lazim: ?sort=created_at berarti menaik, ?sort=-created_at berarti menurun. Fungsi buildOrderBy() menerjemahkan format itu menjadi klausa ORDER BY yang aman:

php
<?php
function buildOrderBy(?string $sort, array $sortableColumns): string
{
    $column = $sort ?? "id";
    $direction = "ASC";

    if (str_starts_with($column, "-")) {
        $direction = "DESC";
        $column = substr($column, 1);
    }

    if (!in_array($column, $sortableColumns, true)) {
        return "ORDER BY id ASC";
    }

    return "ORDER BY $column $direction";
}

$sortableColumns = ["id", "title", "created_at"];

$cases = [
    null,
    "created_at",
    "-created_at",
    "title",
    "id; DROP TABLE tasks",
];

foreach ($cases as $case) {
    echo var_export($case, true) . " => " . buildOrderBy($case, $sortableColumns) . "\n";
}

Output yang Dihasilkan:

text
NULL => ORDER BY id ASC
'created_at' => ORDER BY created_at ASC
'-created_at' => ORDER BY created_at DESC
'title' => ORDER BY title ASC
'id; DROP TABLE tasks' => ORDER BY id ASC

Baris str_starts_with($column, "-") mendeteksi awalan minus lalu membalik arah menjadi DESC; substr() mengupas minusnya sehingga tersisa nama kolom. Pemeriksaan in_array($column, $sortableColumns, true) adalah inti pertahanannya: hanya kolom dalam daftar yang boleh masuk klausa ORDER BY, sisanya — termasuk input jahat pada baris terakhir — diterjemahkan menjadi ORDER BY id ASC. Kolom yang bisa disortir juga sengaja dipilih; kolom seperti password_hash atau email pribadi tidak ada dalam daftar meskipun namanya benar.

Kolom Sort sebagai Pintu SQL Injection #

Mengapa allowlist kolom begitu penting? Karena identifier — nama kolom dan nama tabel — tidak bisa memakai parameter binding: ORDER BY :sort akan mengurutkan berdasarkan string konstan, bukan kolom. Satu-satunya jalan adalah menyisipkan nama kolom langsung ke string SQL, dan di situlah pintu masuknya:

php
<?php
// CONTOH RENTAN — hanya untuk memahami ancaman, jangan pernah dipakai:
$sort = "id; DROP TABLE tasks";
$sql = "SELECT * FROM tasks ORDER BY " . $sort;

echo $sql . "\n";

Output yang Dihasilkan:

text
SELECT * FROM tasks ORDER BY id; DROP TABLE tasks

Penyisipan mentah seperti ini menghasilkan dua pernyataan SQL: SELECT yang sah disusul DROP TABLE tasks. Serangan nyata bisa lebih halus — id UNION SELECT email, password_hash FROM users — untuk membaca tabel lain tanpa merusak apa pun. buildOrderBy() pada contoh sebelumnya adalah jawabannya: input apa pun yang tidak persis cocok dengan allowlist dijatuhkan ke pengurutan baku, sehingga string injeksi tidak pernah sampai ke database.

Handler Lengkap dengan Meta total dan pages #

Sekarang kita rakit seluruh bagian menjadi satu handler GET /tasks yang mengembalikan data beserta meta paginasi. Handler dibuat sebagai closure factory makeListTasksHandler(PDO $pdo) — pola ini menjaga bentuk bakunya (array $request menjadi [status, headers, body]) sambil tetap membawa koneksi database ke dalam:

php
<?php
require "tasks-api.php";

$handler = makeListTasksHandler(connectTasksDb());

foreach ([1, 2] as $page) {
    $request = [
        "method" => "GET",
        "path" => "/tasks",
        "query" => [
            "status" => "todo",
            "user" => "[email protected]",
            "sort" => "-created_at",
            "page" => (string) $page,
            "limit" => "2",
        ],
        "headers" => [],
        "body" => null,
    ];

    [$status, $headers, $body] = $handler($request);
    echo "halaman $page => HTTP " . $status . " " . $body . "\n";
}

Output yang Dihasilkan:

text
halaman 1 => HTTP 200 {"data":[{"id":7,"title":"Migrasi ke PHP 8.4","status":"todo","assignee":"[email protected]","created_at":"2026-01-11 13:10:00"},{"id":5,"title":"Tulis dokumentasi API","status":"todo","assignee":"[email protected]","created_at":"2026-01-09 11:20:00"}],"meta":{"page":1,"limit":2,"total":3,"pages":2}}
halaman 2 => HTTP 200 {"data":[{"id":3,"title":"Review pull request","status":"todo","assignee":"[email protected]","created_at":"2026-01-07 14:15:00"}],"meta":{"page":2,"limit":2,"total":3,"pages":2}}

Query ?status=todo&[email protected]&sort=-created_at menyaring tiga task milik Budi, lalu page dan limit membaginya menjadi dua halaman. Perhatikan bagaimana meta membaca situasinya dengan jujur: total: 3 adalah jumlah baris yang lolos filter (bukan seluruh tabel), dan pages dihitung dengan ceil($total / $limit). Dengan meta semacam ini klien bisa membangun navigasi halaman tanpa menebak-nebak. Kunci meta sengaja dipisah dari data supaya konsumen respons tidak mencampur baris data dengan informasi halaman.

Berkas tasks-api.php pada bagian bawah halaman memuat seluruh fungsi chapter ini — connectTasksDb(), paginationParams(), buildWhere(), buildOrderBy(), dan makeListTasksHandler() — dalam satu library yang bisa Anda unduh dan jalankan langsung. Contoh di atas memuatnya dengan require "tasks-api.php" dari direktori yang sama.

Konsep Cursor Pagination #

Offset pagination punya dua kelemahan yang muncul pada data yang berubah cepat. Pertama, halaman bergeser: saat satu baris baru ditambahkan di depan, halaman 1 “mencuri” baris terakhir halaman 2, dan klien melihat data yang sama dua kali. Kedua, OFFSET 100000 memaksa database melewati 100.000 baris lebih dulu — makin dalam halamannya, makin lambat querynya. Cursor pagination mengganti nomor halaman dengan penanda posisi:

php
<?php
require "tasks-api.php";

$pdo = connectTasksDb();

$cursor = 5;
$limit = 3;

$stmt = $pdo->prepare(
    "SELECT id, title FROM tasks WHERE id > :cursor ORDER BY id ASC LIMIT :limit"
);
$stmt->execute([":cursor" => $cursor, ":limit" => $limit]);
$rows = $stmt->fetchAll();

foreach ($rows as $row) {
    echo $row["id"] . " — " . $row["title"] . "\n";
}

$nextCursor = $rows === [] ? null : (int) end($rows)["id"];
echo "cursor berikutnya: " . ($nextCursor ?? "tidak ada, data habis") . "\n";

Output yang Dihasilkan:

text
6 — Setup CI pipeline
7 — Migrasi ke PHP 8.4
8 — Audit keamanan
cursor berikutnya: 8

Alih-alih “halaman 2”, klien menyimpan id task terakhir yang dilihat — itulah cursor — lalu meminta baris setelahnya dengan WHERE id > :cursor. Baris yang disisipkan di tengah tidak menggeser posisi cursor, dan database melompat langsung lewat indeks id tanpa memindai baris yang dilewati. Variabel $nextCursor mengambil id baris terakhir sebagai cursor untuk request berikutnya, dan null menandakan data sudah habis. Perbandingannya ringkas:

AspekOffset (page/limit)Cursor
Lompat ke halaman acakBisaTidak bisa, hanya maju
Konsistensi saat data berubahBisa bergeser/duplikatStabil
Biaya halaman yang dalamMahal (memindai offset)Murah (lewat indeks)
Kompleksitas klienSederhanaPerlu menyimpan cursor

Ringkasan #

Endpoint daftar yang matang menggabungkan paginationParams() yang menjepit nilai, buildWhere() yang hanya menerima parameter dalam allowlist, dan buildOrderBy() yang menolak kolom asing — tiganya dirangkai handler dengan meta total dan pages. Cursor pagination melengkapi opsi saat konsistensi dan performa halaman dalam lebih penting daripada lompat halaman. Dengan endpoint yang tumbuh canggih ini, pertanyaan berikutnya adalah bagaimana API Anda bisa berubah tanpa memutus klien lama — jawabannya adalah versioning, fokus Chapter berikutnya.

Referensi #

Berkas Sumber

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

tasks-api.php

Unduh berkas
php
<?php
// Library pendamping Chapter "Pagination, Filtering, dan Sorting".
// Salin berkas ini ke direktori yang sama dengan skrip contoh Anda,
// lalu muat dengan: require "tasks-api.php";

function connectTasksDb(): PDO
{
    $pdo = new PDO("sqlite::memory:");
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

    $pdo->exec("CREATE TABLE tasks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        status TEXT NOT NULL,
        assignee TEXT NOT NULL,
        created_at TEXT NOT NULL
    )");

    $pdo->exec("INSERT INTO tasks (title, status, assignee, created_at) VALUES
        ('Desain skema database', 'done', '[email protected]', '2026-01-05 09:00:00'),
        ('Tulis endpoint /tasks', 'done', '[email protected]', '2026-01-06 10:30:00'),
        ('Review pull request', 'todo', '[email protected]', '2026-01-07 14:15:00'),
        ('Riset rate limiting', 'todo', '[email protected]', '2026-01-08 08:45:00'),
        ('Tulis dokumentasi API', 'todo', '[email protected]', '2026-01-09 11:20:00'),
        ('Setup CI pipeline', 'doing', '[email protected]', '2026-01-10 16:00:00'),
        ('Migrasi ke PHP 8.4', 'todo', '[email protected]', '2026-01-11 13:10:00'),
        ('Audit keamanan', 'todo', '[email protected]', '2026-01-12 09:30:00')");

    return $pdo;
}

function paginationParams(array $query, int $defaultLimit = 20, int $maxLimit = 50): array
{
    $page = max(1, (int) ($query["page"] ?? 1));
    $limit = max(1, (int) ($query["limit"] ?? $defaultLimit));

    if ($limit > $maxLimit) {
        $limit = $maxLimit;
    }

    return [
        "page" => $page,
        "limit" => $limit,
        "offset" => ($page - 1) * $limit,
    ];
}

function buildWhere(array $query, array $allowedFilters): array
{
    $conditions = [];
    $params = [];

    foreach ($allowedFilters as $param => $column) {
        if (isset($query[$param]) && is_string($query[$param]) && $query[$param] !== "") {
            $conditions[] = "$column = :$param";
            $params[":$param"] = $query[$param];
        }
    }

    if ($conditions === []) {
        return ["", []];
    }

    return [" WHERE " . implode(" AND ", $conditions), $params];
}

function buildOrderBy(?string $sort, array $sortableColumns): string
{
    $column = $sort ?? "id";
    $direction = "ASC";

    if (str_starts_with($column, "-")) {
        $direction = "DESC";
        $column = substr($column, 1);
    }

    if (!in_array($column, $sortableColumns, true)) {
        return "ORDER BY id ASC";
    }

    return "ORDER BY $column $direction";
}

function makeListTasksHandler(PDO $pdo): Closure
{
    return function (array $request) use ($pdo): array {
        $query = $request["query"] ?? [];
        $pagination = paginationParams($query);
        [$where, $params] = buildWhere($query, [
            "status" => "status",
            "user" => "assignee",
        ]);
        $orderBy = buildOrderBy($query["sort"] ?? null, ["id", "title", "created_at"]);

        $stmt = $pdo->prepare(
            "SELECT id, title, status, assignee, created_at FROM tasks
            $where
            $orderBy
            LIMIT :limit OFFSET :offset"
        );
        $stmt->execute($params + [
            "limit" => $pagination["limit"],
            "offset" => $pagination["offset"],
        ]);
        $tasks = $stmt->fetchAll();

        $countStmt = $pdo->prepare("SELECT COUNT(*) FROM tasks $where");
        $countStmt->execute($params);
        $total = (int) $countStmt->fetchColumn();

        $payload = [
            "data" => $tasks,
            "meta" => [
                "page" => $pagination["page"],
                "limit" => $pagination["limit"],
                "total" => $total,
                "pages" => (int) ceil($total / $pagination["limit"]),
            ],
        ];

        return [
            200,
            ["Content-Type: application/json; charset=utf-8"],
            json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
        ];
    };
}