# 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.

- Published: 2016-09-04
- Updated: 2026-09-07
- Category: Web Development
- Tags: API
- Reading time: 4 min read
- Source: https://www.muhammetsafak.com.tr/en/blog/pagination-filtering-and-sorting-in-rest-apis/
- Language: en-US
- Author: Muhammet Şafak

---
When you write an [API endpoint](/en/blog/fundamentals-of-restful-api-design/) 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](/en/blog/writing-json-api-endpoints-with-laravel/) `paginate()` method already handles this out of the box — all I need to pass is the `per_page` value:

```php
<?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 — [the documentation notes that returning a paginator instance straight from a route converts it to JSON](https://laravel.com/docs/5.2/pagination#converting-results-to-json) — 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:

```php
public function index(Request $request)
{
    $sorgu = Urun::query();

    if ($request->has('kategori')) {
        $sorgu->where('kategori', $request->input('kategori'));
    }

    if ($request->has('min_fiyat')) {
        $sorgu->where('fiyat', '>=', (float) $request->input('min_fiyat'));
    }

    if ($request->has('max_fiyat')) {
        $sorgu->where('fiyat', '<=', (float) $request->input('max_fiyat'));
    }

    if ($request->has('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 `has()` 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.

A note from today: this behaviour changed. [In the words of the Laravel 5.5 upgrade guide](https://laravel.com/docs/5.5/upgrade#upgrade-5.5.0), `$request->has` "will now return `true` even if the input value is an empty string or `null`", and a new `$request->filled` method was added that provides the previous behaviour of `has`. So if you are writing the code above on 5.5 or later, use `filled()` instead of `has()` — otherwise an empty parameter like `?category=` triggers the filter and no records come back.

## 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:

```php
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:

```json
{
    "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." 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.

---

### Sources

- [Laravel 5.2: Pagination — Converting Results To JSON](https://laravel.com/docs/5.2/pagination#converting-results-to-json)
- [Laravel 5.5: Upgrade Guide — The `has` Method](https://laravel.com/docs/5.5/upgrade#upgrade-5.5.0)
