Simon Kollross

Simon Kollross

Software developer, blogger, elegant code enthusiast.

Implementing cursor-based pagination in Laravel

In this post I will show you how to implement cursor-based pagination for Laravel applications using Eloquent.

In a typical web application that uses a database you might have more records than you can fit on a page or in a single result set from a query. To retrieve more data, there are basically two options: Offset-based and cursor-based pagination.

Offset-based pagination

Offset pagination is the most common form of pagination, e.g. for blog posts. You see a list of records and on the bottom there is a pagination bar you can use to navigate to a specific part of the dataset by clicking on a page number. Offset-based pagination uses a LIMIT and an OFFSET clause to control the records to load.

OFFSET is the number of records to skip before a specified and limited amount of rows is retrieved thereafter. Implementing offset-based pagination for Eloquent results in Laravel is pretty easy, as there is an official integration you can check out in the docs.

Cursor-based pagination

Sometimes, offset pagination leads to problems. One of them is performance. If the OFFSET gets really large, the database has to scan through lots of records just to find the right starting point for retrieving a relatively small amount of rows for the next page.

If you are dealing with data that is likely to change, another problem occurs. It may happen that offset pagination yields partially the same results multiple times and you have to deal with the duplication in application code, which sometimes is not obvious.

This is where cursor pagination comes into play. A cursor is a unique identifier for a specific record, from where further querying has to start to get the next page of results. Cursor pagination on the backend is the perfect counterpart to an infinite scrollable list of results on the frontend and makes its implementation really easy.

The idea of cursor pagination is that there is a clear unique order on the dataset. As a result of this, we can remove records we don't want to have in our results using a WHERE clause. This is very efficient, since our database can use indexes in this case. Additionally, we use LIMIT to restrict the amount of results per page and calculate the next cursor from the last result row, which is passed in on the next query as a starting point to get further results.

Cursor-based pagination with Eloquent

So let's see how to implement cursor based pagination with Laravel's ORM Eloquent. Most of this code will be living in a cursorPaginate($limit, $columns) macro on the Illuminate\Database\Eloquent\Builder at the end of the day, so I just pick out and explain the most interesting parts before I provide the full source code. $this always references to the instance of the Builder in the following code snippets.

First, we get the current cursor from the URL by calling $cursor = CursorPaginator::currentCursor();. A cursor is basically just an array that contains all relevant column values that identify a row. The names of those columns are passed by the user as keys of the $columns array, but later more about that.

Cursor-based pagination using multiple columns

As I've said before, a clear unique order has to be defined on the dataset to prevent fetching the same results twice and to work with a stable cursor. Of course, an obvious choice would be the table's primary key, e.g. the id column. But what happens if you want to order the results based on the created_at column, a user-specified column, or even multiple columns with different sort directions? Or what if you can't just order by id to mimic ordering by created_at (which is an ugly hack anyways), because your primary keys are UUIDs that are not sequential? Stay tuned, I've got you covered.

The created_at column is obviously not guaranteed to be unique, since multiple records could be created at the same time, so we would miss results. Therefore, our cursor has to combine created_at and at least one truly unique column to clearly identify a specific record of our dataset, like id.

As a result of that, we have to use created_at and id for the ORDER BY clause of the query to get a stable order in addition to a WHERE clause, that removes all results before the values specified by the cursor, including the cursor's own values.

Let's have a look at the mathematical background to find out how to create this WHERE clause. We have to use tuple comparison to remove all tuples from the results that are less than or equals the cursor's value.

So let a, b, c, d be columns. (a, b), (c, d) represent rows of a table. (a, b) > (c, d) is basically the same as a > c or (a = c and (b > d)). So let's expand this to use a third column: (a, b, c) > (d, e, f) is basically the same as a > d or (a = d and (b > e or (b = e and (c > f)))). You see the recursive pattern?

A recursive apply function for tuple comparison

We are not using tuple comparison in SQL directly (which would be possible), because basic tuple comparison is too inflexible for our use case. So we construct our WHERE clause using a recursive function. This allows us to specify different sort directions for different columns, e.g. created_at could be sorted descending and id could be sorted ascending at the same time, so we have the most flexibility.

$apply = function ($query, $columns, $cursor) use (&$apply) {
    $query->where(function ($query) use ($columns, $cursor, $apply) {
        $column = key($columns);
        $direction = array_shift($columns);
        $value = array_shift($cursor);

        $query->where($column, $direction === 'asc' ? '>' : '<', $value);

        if (! empty($columns)) {
            $query->orWhere($column, $value);
            $apply($query, $columns, $cursor);
        }
    });
};

$apply($this, $columns, $cursor);

$apply basically takes an Eloquent Query, an array of columns that specify the sort order, e.g. $columns = ['created_at' => 'desc', 'id' => 'asc'], and an array containing the values stored in the cursor, e.g. ['2019-06-22 17:01:14', 42], where the values contain the last created_at and id values of the previous query. Using this data as input, apply constructs the WHERE clause recursively based on the concepts of tuple comparison shown in the previous section.

Get the results in the right order

We have enforce a stable order for getting stable results. Therefore, we simply add an ORDER BY clause that appends the sort directions from the $columns array to the query.

foreach ($columns as $column => $direction) {
    $this->orderBy($column, $direction);
}

Limit the results and create the next cursor

Finally, we append a LIMIT clause that is equals to the amount of records per page the user wants to retrieve.

But why the $limit + 1? That's a little trick to find out if there is another page of results available. We fetch one row more than required, which is later removed from the results. So we know we have to create another cursor, because there is at least one more result available that lives on the next page.

$items = $this->limit($limit + 1)->get();

if ($items->count() <= $limit) {
    return new CursorPaginator($items);
}

$items->pop();

return new CursorPaginator($items, array_map(function ($column) use ($items) {
    return $items->last()->{$column};
}, array_keys($columns)));

If a next page is available, we create the next cursor by specifying the second argument for CursorPaginator, which is an array of the relevant values of the last row of the current results to include in the next cursor.

The CursorPaginator helper class

We are returning an instance of CursorPaginator to the class that calls our cursorPaginate macro, e.g. a controller. It makes working with cursors and results simpler, since it takes care of encoding the cursor properly for its use in URLs, and exposes the items to respond, the next cursor URL, as well as the current cursor. In addition, you can specify additional query string parameters that are appended to the next cursor URL using appends.

class CursorPaginator
{
    protected $items;
    protected $nextCursor;
    protected $params = [];

    public function __construct($items, $nextCursor = null)
    {
        $this->items = $items;
        $this->nextCursor = $nextCursor;
    }

    public static function currentCursor()
    {
        return json_decode(base64_decode(request('cursor')));
    }

    public function appends($params)
    {
        $this->params = $params;

        return $this;
    }

    public function items()
    {
        return $this->items;
    }

    public function nextCursorUrl()
    {
        return $this->nextCursor ? url()->current().'?'.http_build_query(array_merge([
            'cursor' => base64_encode(json_encode($this->nextCursor)),
        ], $this->params)) : null;
    }
}

Extend the Eloquent Builder with a cursorPaginate macro

Let's put all this stuff together I've shown you before and create a cursorPaginate macro on the Eloquent Builder.

use Illuminate\Database\Eloquent\Builder;

Builder::macro('cursorPaginate', function ($limit, $columns) {
    $cursor = CursorPaginator::currentCursor();

    if ($cursor) {
        $apply = function ($query, $columns, $cursor) use (&$apply) {
            $query->where(function ($query) use ($columns, $cursor, $apply) {
                $column = key($columns);
                $direction = array_shift($columns);
                $value = array_shift($cursor);

                $query->where($column, $direction === 'asc' ? '>' : '<', $value);

                if (! empty($columns)) {
                    $query->orWhere($column, $value);
                    $apply($query, $columns, $cursor);
                }
            });
        };

        $apply($this, $columns, $cursor);
    }

    foreach ($columns as $column => $direction) {
        $this->orderBy($column, $direction);
    }

    $items = $this->limit($limit + 1)->get();

    if ($items->count() <= $limit) {
        return new CursorPaginator($items);
    }

    $items->pop();

    return new CursorPaginator($items, array_map(function ($column) use ($items) {
        return $items->last()->{$column};
    }, array_keys($columns)));
});

Example: Cursor pagination with a JSON API

Finally, let me show you an example how to use our cursor paginator on a Laravel controller that manages a list of posts. This example should be pretty self-explanatory now. Of course you can use URL parameters to control limit and sort columns, just pass them to the macro and append them to the next cursor URL, that's up to you. But don't forget: A truly unique column has always to be specified so things don't break!

class PostsController
{
    public function index()
    {
        $paginator = Post::cursorPaginate(request('limit', 25), [
            'created_at' => 'desc',
            'id' => 'desc',
        ])->appends(request()->only('limit'));

        return [
            'data' => $paginator->items(),
            'links' => [
                'next' => $paginator->nextCursorUrl(),
            ],
        ];
    }
}

How does the generated SQL look like?

select *
from "posts"
where ("created_at" < ? or "created_at" = ? and ("id" < ?))
order by "purchased_at" desc, "id" desc
limit 26

Conclusion

Huh, this post got a lot longer than I've expected. Congratulations if you did it until here! :)

I've shown a lot of code and concepts in this post, so I'm sure you have to copy it and play around with it to fully understand everything. And you should!

I'm sure my implementation is not perfect, but it is at least more flexible than most implementations out there. Especially, the ability to sort by multiple columns in multiple directions at the same time.

I hope you can use some ideas of this post in your next projects and I'd like to hear from you what you think about it. I'm @skollro on Twitter.