Pagination, Filtering, and Sorting in REST APIs
How I design pagination, filtering, and sorting parameters in an API to present growing datasets to clients in a manageable way.
When you write an API endpoint and return all records in a single response on the first attempt, that’s fine at a small scale. But once the table grows and the client starts receiving 10,000 records, both the server and the client slow to a crawl. On top of that, the client usually doesn’t need everything — it wants a specific subset: records that match certain conditions, ordered in a particular way.
Pagination, filtering, and sorting are three distinct but closely related concepts that solve this problem. In this post I walk through how I set them up at the API level.
Pagination
The most common pagination approach is to use page and per_page parameters:
GET /api/urunler?page=2&per_page=20
Laravel’s paginate() method already handles this out of the box — all I need to pass is the per_page value:
<?php
namespace App\Http\Controllers\Api;
use App\Urun;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class UrunController extends Controller
{
public function index(Request $request)
{
$adet = $request->input('per_page', 20);
// Enforcing a maximum cap — preventing the client from requesting 1000 records at once
$adet = min($adet, 100);
$urunler = Urun::paginate($adet);
return response()->json($urunler);
}
}
paginate() also appends metadata like total, per_page, current_page, and last_page to the response, so the client knows how many pages exist and which page it is currently on.
Filtering
For filtering I use URL query parameters:
GET /api/urunler?kategori=elektronik&min_fiyat=100&max_fiyat=500
I apply these parameters to the query through conditional blocks:
public function index(Request $request)
{
$sorgu = Urun::query();
if ($request->filled('kategori')) {
$sorgu->where('kategori', $request->input('kategori'));
}
if ($request->filled('min_fiyat')) {
$sorgu->where('fiyat', '>=', (float) $request->input('min_fiyat'));
}
if ($request->filled('max_fiyat')) {
$sorgu->where('fiyat', '<=', (float) $request->input('max_fiyat'));
}
if ($request->filled('arama')) {
$aranan = '%' . $request->input('arama') . '%';
$sorgu->where('ad', 'like', $aranan);
}
$adet = min($request->input('per_page', 20), 100);
$urunler = $sorgu->paginate($adet);
return response()->json($urunler);
}
The filled() method checks both for the presence of a parameter and that it is not empty, so if an empty string is sent, the filter is simply not applied.
Sorting
For sorting I use sort and order parameters:
GET /api/urunler?sort=fiyat&order=asc
GET /api/urunler?sort=olusturulma_tarihi&order=desc
For a safe approach I validate the sort column against an allowlist — I don’t let the client sort by arbitrary fields:
public function index(Request $request)
{
$sorgu = Urun::query();
// Filtering (as above)...
// Allowed sort fields
$izinliAlanlar = ['ad', 'fiyat', 'created_at'];
$siralama = $request->input('sort', 'created_at');
$yon = $request->input('order', 'desc');
if (in_array($siralama, $izinliAlanlar)) {
$sorgu->orderBy($siralama, $yon === 'asc' ? 'asc' : 'desc');
}
$adet = min($request->input('per_page', 20), 100);
$urunler = $sorgu->paginate($adet);
return response()->json($urunler);
}
Without the in_array check, a client could sort by sensitive fields like sort=password, or even attempt a raw SQL injection. The allowlist eliminates that risk entirely.
Response structure
Keeping pagination metadata in a separate section of the response body makes it easy for the client to process. Laravel’s paginate() output looks something like this:
{
"current_page": 2,
"data": [
{ "id": 21, "ad": "Klavye", "fiyat": 249.90 },
{ "id": 22, "ad": "Mouse", "fiyat": 189.50 }
],
"from": 21,
"last_page": 15,
"per_page": 20,
"to": 40,
"total": 287
}
The client can calculate the total number of pages from total and per_page, and knows its current position from current_page.
Combined example
A real-world call looks like this:
GET /api/urunler?kategori=elektronik&min_fiyat=100&sort=fiyat&order=asc&page=1&per_page=15
This call returns: products in the electronics category, priced above 100, sorted by price in ascending order, 15 items on the first page.
A small but important detail: consistent parameter naming
The thing I paid the most attention to when designing these three features was making sure parameter names were consistent from the start. Calling it page on one endpoint, pageNumber on another, and sayfa on yet another needlessly frustrates client developers. Using the same names across the entire API surface reduces documentation burden and prevents bugs.
Even on a small project, building this habit from day one provides automatic consistency as you add more endpoints later.
On cursor-based pagination
Paginating with page and per_page introduces a problem on large tables: the OFFSET query. Jumping to the ten-thousandth page requires the database to read and skip through 200,000 records — and the performance degrades as the table grows.
I don’t have this problem right now because my tables haven’t reached that scale yet. But there is an alternative called cursor pagination: it works on the principle of “give me the ID of the last record you saw, and return everything after it.” Laravel’s cursorPaginate() method implements this approach. When scale isn’t an issue, paginate() is sufficient; switching when the need arises is relatively straightforward.
Getting these three features right from the beginning makes future development easier. The client developer doesn’t have to solve pagination on their own; there’s no need to open separate endpoints for filtering, so the API surface stays small. When a mobile client or a third-party integration comes along, it uses the same endpoint with the same parameters. That consistency is invisible at first, but its value compounds over time — you save real hours when you don’t have to write a separate endpoint for a new client or invent a different pagination scheme.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.