Endpoint GET /posts kita selama ini mengembalikan semua baris sekaligus — tidak apa-apa untuk dua belas post, tetapi bencana untuk dua belas ribu: response membengkak, client menunggu lama, dan memori server tertekan. Solusinya adalah membagi koleksi menjadi halaman dan membiarkan client memilih potongannya lewat query parameter. Chapter ini membangun endpoint daftar yang bertanggung jawab: paginate() dengan bentuk JSON lengkap, simplePaginate() untuk performa, filter status dan author, sorting dengan notasi minus, serta penolakan parameter asing demi keamanan.
paginate() dan Bentuk JSON-nya #
Perubahan di index() kecil tapi berdampak: kembalikan paginator — koleksi yang sudah dipotong per halaman — melalui PostResource::collection(). Sumber data kita adalah dua belas post yang dibuat dengan created_at eksplisit (1–12 Januari) milik Budi dan Siti:
<?php
// app/Http/Controllers/Api/PostController.php (index tahap pertama)
public function index()
{
return PostResource::collection(
Post::query()->with('author')->paginate(5),
);
}Panggilan halaman kedua dengan ?per_page=5&page=2 (setelah paginate() kita evolusikan menerima per_page) menghasilkan bentuk JSON tiga bagian:
Output yang Dihasilkan:
### GET /api/v1/posts?per_page=5&page=2 → HTTP 200
{
"data": [
{
"id": 7,
"judul": "Post ke-7",
"status": "published",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": "2026-01-07T08:00:00.000000Z"
},
{
"id": 6,
"judul": "Post ke-6",
"status": "draft",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": null
},
{
"id": 5,
"judul": "Post ke-5",
"status": "published",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": "2026-01-05T08:00:00.000000Z"
},
{
"id": 4,
"judul": "Post ke-4",
"status": "draft",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": null
},
{
"id": 3,
"judul": "Post ke-3",
"status": "published",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": "2026-01-03T08:00:00.000000Z"
}
],
"links": {
"first": "http://localhost/api/v1/posts?per_page=5&page=1",
"last": "http://localhost/api/v1/posts?per_page=5&page=3",
"prev": "http://localhost/api/v1/posts?per_page=5&page=1",
"next": "http://localhost/api/v1/posts?per_page=5&page=3"
},
"meta": {
"current_page": 2,
"from": 6,
"last_page": 3,
"path": "http://localhost/api/v1/posts",
"per_page": 5,
"to": 10,
"total": 12
}
}Array meta.links berisi tautan per nomor halaman, persis navigasi pagination halaman web — dipangkas dari output di atas demi keringkasan. Blok meta inilah peta yang dibaca client:
| Kunci | Arti |
| | |
| current_page | Halaman yang sedang dibaca |
| from / to | Nomor urut item pertama dan terakhir pada halaman ini |
| last_page | Halaman terakhir yang tersedia |
| total | Jumlah seluruh item di seluruh halaman |
| per_page | Jumlah item per halaman |
| path | Basis URL endpoint untuk menyusun navigasi |
Filtering lewat Query Parameter #
Koleksi yang bisa dipaginasi juga ingin bisa disaring: /posts?status=published&author=budi seharusnya hanya mengembalikan post terbit milik Budi. Pola implementasinya adalah when() pada query builder — setiap filter hanya aktif bila parameternya dikirim. Ini index() final kita:
<?php
// app/Http/Controllers/Api/PostController.php (final)
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
public function index(Request $request)
{
$data = $request->validate([
'status' => 'sometimes|in:draft,published',
'author' => 'sometimes|string|max:100',
'sort' => 'sometimes|in:created_at,-created_at,title,-title',
'per_page' => 'sometimes|integer|min:1|max:50',
'page' => 'sometimes|integer|min:1',
]);
$unknown = array_diff(
array_keys($request->query()),
['status', 'author', 'sort', 'per_page', 'page'],
);
if ($unknown !== []) {
return response()->json([
'message' => 'Parameter tidak dikenal: '.implode(', ', array_values($unknown))
.'. Parameter yang diizinkan: status, author, sort, per_page, page.',
], 422);
}
$sort = $data['sort'] ?? '-created_at';
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
$column = ltrim($sort, '-');
$posts = Post::query()
->with('author')
->when($data['status'] ?? null, fn ($query, $status) => $query->where('status', $status))
->when($data['author'] ?? null, fn ($query, $author) => $query->whereHas(
'author',
fn ($authorQuery) => $authorQuery->where('name', $author),
))
->orderBy($column, $direction);
return PostResource::collection(
$posts->paginate($data['per_page'] ?? 15)->withQueryString(),
);
}Mari kita bedah bagian-bagiannya. Validasi di awal menjalankan dua tugas: memastikan status hanya draft/published, dan membatasi per_page maksimal 50 — client tidak bisa memaksa server mengirim 10.000 baris. Pemeriksaan array_diff mencari parameter asing di luar daftar izin; kehadirannya ditolak 422 dengan pesan yang menyebut nama pelanggarnya. Filter author memakai whereHas() karena nama penulis hidup di tabel users, bukan posts. Terakhir, withQueryString() pada paginator memastikan filter dan sorting ikut terbawa di links.next — tanpanya, tombol “halaman berikutnya” akan diam-diam melepas semua filter.
Output yang Dihasilkan:
### GET /api/v1/posts?status=published&author=Budi → HTTP 200
{
"data": [
{
"id": 7,
"judul": "Post ke-7",
"status": "published",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": "2026-01-07T08:00:00.000000Z"
},
{
"id": 5,
"judul": "Post ke-5",
"status": "published",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": "2026-01-05T08:00:00.000000Z"
},
{
"id": 3,
"judul": "Post ke-3",
"status": "published",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": "2026-01-03T08:00:00.000000Z"
},
{
"id": 1,
"judul": "Post ke-1",
"status": "published",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": "2026-01-01T08:00:00.000000Z"
}
],
"links": {
"first": "http://localhost/api/v1/posts?status=published&author=Budi&page=1",
"last": "http://localhost/api/v1/posts?status=published&author=Budi&page=1",
"prev": null,
"next": null
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 1,
"path": "http://localhost/api/v1/posts",
"per_page": 15,
"to": 4,
"total": 4
}
}Empat post Budi yang terbit — filter bekerja, dan meta.total jujur menghitung hasil filter, bukan seluruh tabel.
Sorting dengan Notasi Minus #
Parameter sort mengikuti konvensi yang populer di API publik: ?sort=created_at berarti menaik, ?sort=-created_at berarti menurun. Tanda minus dipotong menjadi arah desc, kolomnya diperiksa whitelist in:created_at,-created_at,title,-title — sehingga ?sort=password atau upaya menyuntikkan nama kolom sembarangan tidak pernah sampai ke orderBy(). Pada dua belas post kita, ?sort=created_at menghasilkan urutan id [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] — kebalikan dari bawaan -created_at.
Menolak Parameter Tak Dikenal #
Penolakan parameter asing bukan sekadar kerapian. Endpoint yang menerima parameter apa pun tanpa protes cenderung menumbuhkan “parameter hantu”: filter lama yang sudah tidak berlaku tetap dikirim, salah ketik satus=published lolos tanpa peringatan, dan kontrak berubah diam-diam. Bentuk jawabannya:
Output yang Dihasilkan:
### GET /api/v1/posts?category=teknologi → HTTP 422
{
"message": "Parameter tidak dikenal: category. Parameter yang diizinkan: status, author, sort, per_page, page."
}Menggabungkan Semua dan simplePaginate() #
Semua parameter dirancang saling bekerja. Panggilan ?status=published&sort=-created_at&per_page=2&page=2 memotong daftar post terbit terbaru ke halaman keduanya, dan links otomatis mempertahankan seluruh parameter untuk navigasi berikutnya:
Output yang Dihasilkan:
### GET /api/v1/posts?status=published&sort=-created_at&per_page=2&page=2 → HTTP 200
{
"data": [
{
"id": 7,
"judul": "Post ke-7",
"status": "published",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": "2026-01-07T08:00:00.000000Z"
},
{
"id": 5,
"judul": "Post ke-5",
"status": "published",
"penulis": {"nama": "Budi", "email": "[email protected]"},
"published_at": "2026-01-05T08:00:00.000000Z"
}
],
"links": {
"first": "http://localhost/api/v1/posts?status=published&sort=-created_at&per_page=2&page=1",
"last": "http://localhost/api/v1/posts?status=published&sort=-created_at&per_page=2&page=3",
"prev": "http://localhost/api/v1/posts?status=published&sort=-created_at&per_page=2&page=1",
"next": "http://localhost/api/v1/posts?status=published&sort=-created_at&per_page=2&page=3"
},
"meta": {
"current_page": 2,
"from": 3,
"last_page": 3,
"per_page": 2,
"to": 4,
"total": 6
}
}Enam post terbit dari dua belas total, dipotong dua per halaman, terurut dari yang termuda — dan meta.total tetap jujur menghitung hasil filter. (Kunci meta.path dan array meta.links dipangkas dari output di atas demi keringkasan, sama seperti pada output paginate pertama.) Untuk koleksi yang benar-benar besar, simplePaginate() menawarkan kompromi: query COUNT(*) untuk menghitung total dan last_page dilewati, sehingga database bekerja lebih ringan — konsekuensinya meta tidak lagi memuat total maupun last_page, dan links.last menjadi null. Perbandingannya:
| Aspek | paginate() | simplePaginate() |
| | | |
| Query tambahan | COUNT(*) tiap request | Tidak ada |
| meta.total | Tersedia | Tidak ada |
| meta.last_page | Tersedia | Tidak ada |
| Cocok untuk | Dashboard, tabel berjumlah | Feed tak berujung, infinite scroll |
Endpoint GET /api/v1/posts-simpel memakai simplePaginate() dan halaman keduanya menunjukkan bentuk yang lebih ramping — current_page_url menggantikan hitungan total:
Output yang Dihasilkan:
### GET /api/v1/posts-simpel?per_page=5&page=2 → HTTP 200 (hanya links dan meta)
"links": {
"first": "http://localhost/api/v1/posts-simpel?per_page=5&page=1",
"last": null,
"prev": "http://localhost/api/v1/posts-simpel?per_page=5&page=1",
"next": "http://localhost/api/v1/posts-simpel?per_page=5&page=3"
},
"meta": {
"current_page": 2,
"current_page_url": "http://localhost/api/v1/posts-simpel?per_page=5&page=2",
"from": 6,
"path": "http://localhost/api/v1/posts-simpel",
"per_page": 5,
"to": 10
}Koleksi kini tersajikan dalam potongan yang sehat, terfilter, dan terurut — detail lengkapnya ada di dokumentasi pagination . Namun ini menambah tanggung jawab baru: ketika bentuk response terus berkembang, client lama bisa patah tanpa peringatan. Jalan keluarnya adalah mengelola perubahan lewat versi dan deprecation yang jujur, topik chapter API Versioning .