Laravel · Bab 14
Lab 10: Relasi, Pagination & Filter
Membuat API yang nyaman dipakai klien — relasi dan jumlah data di JSON, daftar per halaman lengkap dengan meta dan links, serta pencarian dan pengurutan lewat query string yang aman.
API dari Lab 9 sudah bisa membaca dan menulis, tetapi selalu mengirim semua data sekaligus dan klien tidak bisa mencari atau mengurutkan. Bayangkan katalog berisi sepuluh ribu baris: aplikasi ponsel akan lambat dan boros kuota. Di lab ini API belajar menjawab pertanyaan yang biasa diajukan klien: “berapa banyak?”, “halaman berapa?”, “yang namanya mengandung apa?”, dan “urut berdasarkan apa?”. Contoh kode memakai studi kasus perpustakaan (authors, books); terapkan polanya ke kategori dan produk.
Alur Langkah
- Buat
CategoryResourcedanApi/V1/CategoryControlleryang menampilkan jumlah produk per kategori dan daftar produk satu kategori, lalu daftarkan rutenya. - Ganti
get()denganpaginate(5)diProductController::index()dan bawa query string ke link halaman. - Tambahkan pencarian
?search=dan pengurutan?sort=yang divalidasi dan memakai daftar putih kolom.
Relasi di JSON: Jumlah dan Daftar
Ada dua cara menampilkan relasi hasMany di JSON. Untuk daftar penulis, klien biasanya cukup tahu jumlah bukunya. Untuk detail satu penulis, klien butuh daftar bukunya.
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AuthorResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'books_count' => $this->whenCounted('books'),
'books' => BookResource::collection($this->whenLoaded('books')),
];
}
}
whenCounted('books') hanya muncul bila controller menghitungnya dengan withCount('books'). Laravel menambahkan penghitungan sebagai subkueri COUNT di dalam kueri yang sama yang mengambil daftar penulis, jadi semua penulis beres dalam satu kali jalan, bukan satu kueri tambahan per penulis (masalah N+1). whenLoaded('books') hanya muncul bila relasinya dimuat dengan with() atau load(). Keduanya membuat satu Resource bisa dipakai untuk daftar maupun detail:
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\AuthorResource;
use App\Models\Author;
class AuthorController extends Controller
{
public function index()
{
return AuthorResource::collection(Author::withCount('books')->get());
}
public function show(Author $author)
{
return new AuthorResource($author->load('books'));
}
}
GET /api/v1/authors menjawab {"data": [{"id": 1, "name": "Andrea Hirata", "books_count": 4}, ...]} tanpa kunci books, sedangkan GET /api/v1/authors/1 menjawab satu penulis beserta books tanpa books_count.
Pagination: Data per Halaman
paginate(n) mengambil n baris untuk halaman yang diminta lewat ?page=. Bila hasilnya dibungkus Resource, Laravel menambahkan dua kunci di samping data (contoh JSON di bawah hanya menampilkan sebagian isi meta dan links agar ringkas; aslinya ada kunci tambahan seperti path dan links per halaman):
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\BookResource;
use App\Models\Book;
class BookController extends Controller
{
public function index()
{
return BookResource::collection(Book::with('author')->paginate(5)->withQueryString());
}
}
{
"data": [{ "id": 6, "title": "Bumi Manusia" }],
"links": {
"first": "http://127.0.0.1:8000/api/v1/books?page=1",
"last": "http://127.0.0.1:8000/api/v1/books?page=2",
"prev": "http://127.0.0.1:8000/api/v1/books?page=1",
"next": null
},
"meta": { "current_page": 2, "last_page": 2, "per_page": 5, "from": 6, "to": 6, "total": 6 }
}
meta dipakai klien untuk menulis “Halaman 2 dari 2” atau menonaktifkan tombol berikutnya, dan links berisi alamat yang tinggal diikuti. withQueryString() membuat parameter lain (misalnya ?sort=title) ikut terbawa di links. Tanpanya, klien yang membuka halaman 2 akan kehilangan urutannya.
Mencari dan Mengurutkan lewat Query String
Pencarian dan pengurutan dikirim sebagai query string: /api/v1/books?search=laskar&sort=-year&page=2. Kesepakatan yang umum: awalan - berarti urutan menurun. Karena query string ditulis bebas oleh klien, nilainya harus divalidasi dan nama kolom tidak boleh diteruskan apa adanya ke database:
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\BookResource;
use App\Models\Book;
use Illuminate\Http\Request;
class BookController extends Controller
{
public function index(Request $request)
{
$request->validate([
'search' => 'nullable|string|max:100',
'sort' => 'nullable|string',
]);
$query = Book::with('author');
if ($request->filled('search')) {
$query->where('title', 'like', '%'.$request->input('search').'%');
}
$sort = (string) $request->input('sort', '');
$kolom = ltrim($sort, '-');
if (in_array($kolom, ['title', 'year', 'pages'], true)) {
$query->orderBy($kolom, str_starts_with($sort, '-') ? 'desc' : 'asc');
}
return BookResource::collection($query->paginate(5)->withQueryString());
}
}
Empat hal penting di kode ini:
- Validasi query string.
?search[]=xmembuatsearchberupa array. Tanpa validasi, menggabungkannya dengan string memicu galat 500. Dengan$request->validate(), Laravel menjawab 422 berisi pesan per kolom, asalkan klien mengirimAccept: application/json(tanpa header itu Laravel mengarahkan balik seperti form web). - Daftar putih kolom.
orderBy()menerima nama kolom, bukan nilai, sehingga tidak dilindungi parameter binding.?sort=passwordatau kolom yang tidak ada tidak boleh sampai ke SQL;in_array(..., true)hanya meloloskan kolom yang memang boleh diurutkan, dan nilai lain diabaikan. liketetap aman, tetapi punya wildcard. Kata kunci pencarian masuk sebagai nilai terikat (binding), jadi?search=' OR 1=1hanya dicari sebagai teks dan bukan injeksi SQL. Namun%dan_di dalam kata kunci tetap bekerja sebagai wildcardLIKE(?search=%cocok dengan semua judul); bila perlu mencari karakter itu secara harfiah, escape dulu.- Batasi
per_page. Contoh di atas memakaipaginate(5)tetap. Bila klien boleh memilih jumlah per halaman lewat?per_page=, batasi nilainya (misalnyamin((int) $request->input('per_page', 5), 50)), supaya tidak ada yang meminta sejuta baris sekaligus.
Hasil yang Diharapkan
GET /api/v1/categories menjawab kategori beserta products_count, dan GET /api/v1/categories/2 memuat daftar produknya. GET /api/v1/products menjawab lima produk pertama dengan meta dan links; ?page=2 berisi sisanya. ?search=teh hanya mengembalikan produk yang namanya mengandung “teh”, ?sort=price dan ?sort=-price mengurutkan naik dan turun, kolom di luar daftar putih diabaikan, dan ?search[]=teh dijawab 422 (semua jawaban galat JSON di sini mengandaikan header Accept: application/json; Klien API di lab mengirimnya otomatis, dan perilaku tanpa header dibahas di bab 15).