# Pagination

# Introduction

Laravel JSON:API paginators allow you to return a subset ("page") of results using the JSON:API page query parameter. (opens new window)

This package supports two approaches to pagination:

  • Page-based: Laravel's paginate() and simplePaginate() pagination implementations, that use a page number and size query parameters.
  • Cursor-based: cursor pagination inspired by Stripe's implementation. This implementation pre-dates Laravel's cursorPaginate() feature, and requires the installation of the laravel-json-api/cursor-pagination package.

TIP

As Laravel's cursor-based pagination feature is relatively new, we have not yet had a chance to add support for it. We hope to add this during the 1.x release cycle.

You can choose which approach to use for each resource type, so your API can use different approaches for different resource types if needed.

# Page-Based Pagination

The page-based approach provided by this package matches Laravel's standard paging implementation.

Our implementation uses the number and size page parameters:

Parameter Description
number The page number that the client is requesting.
size The number of resources to return per-page.

To use page-based pagination, return our PagePagination class from your schema's pagination method. For example:

namespace App\JsonApi\V1\Posts;

use LaravelJsonApi\Eloquent\Pagination\PagePagination;
use LaravelJsonApi\Eloquent\Schema;

class PostSchema extends Schema
{
    // ...

    /**
     * Get the resource paginator.
     *
     * @return PagePagination
     */
    public function pagination(): PagePagination
    {
        return PagePagination::make();
    }
}

This means the following request:

GET /api/v1/posts?page[number]=2&page[size]=15 HTTP/1.1
Accept: application/vnd.api+json

Will receive a paged response:

HTTP/1.1 200 OK
Content-Type: application/vnd.api+json

{
  "meta": {
    "page": {
      "currentPage": 2,
      "from": 16,
      "lastPage": 4,
      "perPage": 15,
      "to": 30,
      "total": 50
    }
  },
  "links": {
    "first": "http://localhost/api/v1/posts?page[number]=1&page[size]=15",
    "last": "http://localhost/api/v1/posts?page[number]=4&page[size]=15",
    "next": "http://localhost/api/v1/posts?page[number]=3&page[size]=15",
    "prev": "http://localhost/api/v1/posts?page[number]=1&page[size]=15"
  },
  "data": [...]
}

TIP

The query parameters in the above examples would be URL encoded, but are shown without encoding for readability.

# Customising the Page Parameters

To change the default page parameters of "number" and "size", use the withPageKey and withPerPageKey methods.

For example, to change them to "page" and "limit":

public function pagination(): PagePagination
{
    return PagePagination::make()
        ->withPageKey('page')
        ->withPerPageKey('limit');
}

With this change, the client would need to send the following request to page the posts resource:

GET /api/v1/posts?page[page]=2&page[limit]=15 HTTP/1.1
Accept: application/vnd.api+json

# Simple Pagination

By default the page-based approach uses Laravel's length-aware pagination. To use simple pagination instead, call the withSimplePagination method:

public function pagination(): PagePagination
{
    return PagePagination::make()->withSimplePagination();
}

WARNING

Using simple pagination means the HTTP response content will not contain details of the last page and total resources available.

# Cursor-Based Pagination

The cursor-based pagination provided by this package is inspired by Stripe's pagination implementation (opens new window). Install via Composer:

composer require laravel-json-api/cursor-pagination

Cursor-based pagination is based on the paginator being given a context as to what results to return next. So rather than an API client saying it wants page number 2, it instead says it wants the items in the list after the last item it received. This is ideal for infinite scroll implementations, or for resources where rows are regularly inserted (which would affect page numbers if you used paged-based pagination).

Cursor-based pagination works by keeping the list in a fixed order. This means that if you use cursor-based pagination for a resource type, you should not support sort parameters as this can have adverse effects on the cursor pagination.

Our implementation utilizes cursor-based pagination via the "after" and "before" page parameters. Both parameters take an existing resource ID value (see below) and return resources in a fixed order. By default this fixed order is reverse chronological order (i.e. most recent first, oldest last). The "before" parameter returns resources listed before the named resource. The "after" parameter returns resources listed after the named resource. If both parameters are provided, only "before"is used. If neither parameter is provided, the first page of results will be returned.

Parameter Description
after A cursor for use in pagination. after is a resource ID that defines your place in the list. For instance, if you make a paged request and receive 100 resources, ending with resource with id foo, your subsequent call can include page[after]=foo in order to fetch the next page of the list.
before A cursor for use in pagination. before is a resource ID that defines your place in the list. For instance, if you make a paged request and receive 100 resources, starting with resource with id bar your subsequent call can include page[before]=bar in order to fetch the previous page of the list.
limit A limit on the number of resources to be returned, i.e. the per-page amount.

To use cursor-based pagination, return our CursorPagination class from your schema's pagination method. For example:

namespace App\JsonApi\V1\Posts;

use LaravelJsonApi\CursorPagination\CursorPagination;
use LaravelJsonApi\Eloquent\Schema;

class PostSchema extends Schema
{
    // ...

    /**
     * Get the resource paginator.
     *
     * @return CursorPagination
     */
    public function pagination(): CursorPagination
    {
        return CursorPagination::make();
    }
}

This means the following request:

GET /api/v1/posts?page[limit]=10&page[after]=03ea3065-fe1f-476a-ade1-f16b40c19140 HTTP/1.1
Accept: application/vnd.api+json

Will receive a paged response:

HTTP/1.1 200 OK
Content-Type: application/vnd.api+json

{
  "meta": {
    "page": {
      "from": "bfdaa836-68a3-4427-8ea3-2108dd48d4d3",
      "hasMore": true,
      "perPage": 10,
      "to": "df093f2d-f042-49b0-af77-195625119773"
    }
  },
  "links": {
    "first": "http://localhost/api/v1/posts?page[limit]=10",
    "prev": "http://localhost/api/v1/posts?page[limit]=10&page[before]=bfdaa836-68a3-4427-8ea3-2108dd48d4d3",
    "next": "http://localhost/api/v1/posts?page[limit]=10&page[after]=df093f2d-f042-49b0-af77-195625119773"
  },
  "data": [...]
}

TIP

The query parameters in the above examples would be URL encoded, but are shown without encoding for readability.

# Customising the Cursor Parameters

To change the default parameters of "limit", "after" and "before", use the withLimitKey, withAfterKey and withBeforeKey methods as needed.

For example:

public function pagination(): CursorPagination
{
    return CursorPagination::make()
        ->withLimitKey('size')
        ->withAfterKey('starting-after')
        ->withBeforeKey('ending-before');
}

The client would need to send the following request:

GET /api/v1/posts?page[size]=25&page[starting-after]=df093f2d-f042-49b0-af77-195625119773 HTTP/1.1
Accept: application/vnd.api+json

# Customising the Cursor Column

By default the cursor-based approach uses a model's created at column in descending order for the list order. This means the most recently created model is the first in the list, and the oldest is last. As the created at column is not unique (there could be multiple rows created at the same time), it uses the resource's route key column as a secondary sort order, as this column must always be unique.

To change the column that is used for the list order use the withCursorColumn method. If you prefer your list to be in ascending order, use the withAscending method. For example:

public function pagination(): CursorPagination
{
    return CursorPagination::make()
        ->withCursorColumn('published_at')
        ->withAscending();
}

# Validating Cursor Parameters

You should always validate page parameters that sent by an API client. This is described in the query parameters chapter.

For the cursor-based approach, you must validate that the identifier provided by the client for the "after" and "before" parameters are valid identifiers, because invalid identifiers cause an error in the cursor. It is also recommended that you validate the limit so that it is within an acceptable range.

As the cursor relies on the list being in a fixed order (that it controls), you must also disable sort parameters.

For example:

namespace App\JsonApi\V1\Posts;

use LaravelJsonApi\Validation\Rule as JsonApiRule;
use LaravelJsonApi\Laravel\Http\Requests\ResourceQuery;

class PostCollectionQuery extends ResourceQuery
{

    public function rules(): array
    {
        return [
            // ...other rules

            'sort' => JsonApiRule::notSupported(),

            'page' => [
              'nullable',
              'array',
              JsonApiRule::page(),
            ],

            'page.limit' => ['filled', 'numeric', 'between:1,100'],

            'page.after' => ['filled', 'string', 'exists:posts,id'],

            'page.before' => ['filled', 'string', 'exists:posts,id'],
        ];
    }
}

# Page Size

Both the page-based and cursor-based pagination approaches have a page size, i.e. the maximum number of resources that can appear on each page. For the page-based approach, this is controlled via the size parameter; for the cursor-based approach the limit parameter is used.

In both implementations it is possible for a client to send page parameters, but omit the page size. For example:

GET /api/v1/posts?page[number]=1 HTTP/1.1
Accept: application/vnd.api+json

In this scenario the client will receive a paged response, with the size defaulting to the per-page value specified on the Eloquent model. This is set on the Model::$perPage property, and returned from the Model::getPerPage() method.

By default, Laravel sets this value to 15.

If you want your JSON:API resource to have a different default page size, use the withDefaultPerPage() method. This works on both the page-based and cursor-based paginators. For example, to change the default to 25 per-page:

public function pagination(): PagePagination
{
    return PagePagination::make()->withDefaultPerPage(25);
}

TIP

Generally you will want to limit the maximum number of resources a client can request per-page, so that they do not request too many resources in one request. Use the between validation rule when validating the page.size or page.limit query parameters. For example, between:1,100.

# Page Meta

Both the page-based and cursor-based pagination approaches add information about the page to the response document's top-level meta member. This is useful for API clients to understand the properties of the page.

# Key Case

As shown in the response examples in this chapter, by default the meta information about the page uses camel-case keys.

If you want to snake-case the meta keys (e.g. current_page), use the withSnakeCaseMeta method:

public function pagination(): PagePagination
{
    return PagePagination::make()->withSnakeCaseMeta();
}

If you need to dash-case the meta keys (e.g. current-page), use the withDashCaseMeta method:

public function pagination(): PagePagination
{
    return PagePagination::make()->withDashCaseMeta();
}

# Nesting

As shown in the response examples in this chapter, by default the page meta is nested under a "page" key. This is to prevent any collisions with other meta you may add either now or in the future.

If you want to use a different key for the nested page meta, then use the withMetaKey method:

public function pagination(): PagePagination
{
    return PagePagination::make()->withMetaKey('paginator');
}

This would result in the following meta in your HTTP response (using the page-based approach as an example):

{
    "meta": {
        "paginator": {
              "currentPage": 2,
              "from": 16,
              "lastPage": 4,
              "perPage": 15,
              "to": 30,
              "total": 50
        }
    },
    "data": [...]
}

If you want to disable nesting of the page details in the top-level meta, you can use the withoutNestedMeta method:

public function pagination(): PagePagination
{
    return PagePagination::make()->withoutNestedMeta();
}

This will result in the following:

{
    "meta": {
        "currentPage": 2,
        "from": 16,
        "lastPage": 4,
        "perPage": 15,
        "to": 30,
        "total": 50
    },
    "data": [...]
}

# Removing Meta

If you do not want page meta added to your response document, use the withoutMeta method:

public function pagination(): PagePagination
{
    return PagePagination::make()->withoutMeta();
}

# Default Customisation

If you want to override the defaults for many resource types, then you can extend either the page-based or cursor-based approaches. For example:

namespace App\JsonApi\V1;

use LaravelJsonApi\Eloquent\Pagination\PagePagination as BasePagination;

class PagePagination extends BasePagination
{

    public function __construct()
    {
        parent::__construct();
        $this->withPageKey('page')
          ->withPerPageKey('limit')
          ->withoutNestedMeta();
    }
}

Then in your schema:

use App\JsonApi\V1\PagePagination;

public function pagination(): ?Paginator
{
    return PagePagination::make();
}

# Forcing Pagination

There are some resources that you will always want to be paginated - because without pagination, your API would return too many resources in one request.

For example, if the client sends this request:

GET /api/v1/posts HTTP/1.1
Accept: application/vnd.api+json

This would return all posts resources. In a large blog, that would potentially result in a response with hundreds or thousands of posts resources. In this scenario, we would want to always return a paginated response.

We can use one of two approaches to achieve this:

  • Specify default pagination parameters that the server uses when none are provided by the client; or
  • Force the client to provide page parameters by making them required in our validation rules.

# Default Pagination

Default pagination parameters allow the client to send a request without page parameters, but ensure the server always returns a paged response.

To do this we set the page parameters that the server should use when none are provided by the client, using the $defaultPagination property on the schema. For example, if our posts resource used page-based pagination:

class PostSchema extends Schema
{

    protected ?array $defaultPagination = ['number' => 1];

    // ...

}

Or if it used the cursor-based approach:

class PostSchema extends Schema
{

    protected ?array $defaultPagination = ['limit' => 15];

    // ...

}

If you need to programmatically work out the default paging parameters, overload the defaultPagination() method. For example, if you had written a custom date-based pagination approach:

class PostSchema extends Schema
{

    // ...

    protected function defaultPagination(): ?array
    {
        return [
            'start' => now()->subMonth(),
            'end' => now(),
        ];
    }

}

# Default Pagination for To-Many Relationships

When you set default pagination on a schema, the default values will also be used when querying the resource in a to-many relationship.

There may be times when you do not want the default pagination to be used on a specific relationship. For example, imagine you have a comments resource. When querying all the comments resource, you may want to force pagination as your API could contain hundreds of comments. However, when retrieving a posts resource's comments relationship, you may not want the comments resource to be paginated, as a single post may not be expected to have a lot of related comments.

In this scenario, you can remove default pagination from the relationship by calling the relationship's withoutDefaultPagination method. We would configure this scenario with the following on our posts schema:

class PostSchema extends Schema
{

    protected array $defaultPagination = ['number' => 1];

    public function fields(): iterable
    {
        return [
            // ...other fields

            HasMany::make('comments')->withoutDefaultPagination(),
        ];
    }

    public function pagination(): PagePagination
    {
        return PagePagination::make();
    }

}

# Requiring Page Parameters

The alternative to using default pagination parameters is to force the client to always provide page parameters. We do this via our validation rules on the collection query class, for example our PostCollectionQuery.

For the page-based approach:

namespace App\JsonApi\V1\Posts;

use LaravelJsonApi\Validation\Rule as JsonApiRule;
use LaravelJsonApi\Laravel\Http\Requests\ResourceQuery;

class PostCollectionQuery extends ResourceQuery
{

    public function rules(): array
    {
        return [
            // ...other rules

            'page.number' => ['required', 'integer', 'min:1'],
            'page.size' => ['integer', 'between:1,200'],
        ];
    }
}

Or for the cursor-based approach:

namespace App\JsonApi\V1\Posts;

use LaravelJsonApi\Validation\Rule as JsonApiRule;
use LaravelJsonApi\Laravel\Http\Requests\ResourceQuery;

class PostCollectionQuery extends ResourceQuery
{

    public function rules(): array
    {
        return [
            // ...other rules

            'page.limit' => ['required', 'integer', 'between:1,200'],
        ];
    }
}

# Required Page Parameters for To-Many Relationships

When requiring page parameters in your validation, this will also apply when querying the resource in a to-many relationship.

Imagine our users resource has a posts relationship. The validation rules shown in the above examples would apply when the client makes this request, which would fail the validation:

GET /api/v1/users/123/posts HTTP/1.1
Accept: application/vnd.api+json

If we wanted to allow the client to omit page parameters when retrieving posts resources via a relationship, we would need to conditionally add the required rule:

namespace App\JsonApi\V1\Posts;

use LaravelJsonApi\Validation\Rule as JsonApiRule;
use LaravelJsonApi\Laravel\Http\Requests\ResourceQuery;

class PostCollectionQuery extends ResourceQuery
{

    public function rules(): array
    {
        return [
            // ...other rules

            'page.limit' => array_filter([
                $this->isNotRelationship() ? 'required' : null,
                'integer',
                'between:1,200'
            ]),
        ];
    }
}

# Disallowing Pagination

If your resource does not support pagination, you should reject any request that contains the page parameter. This can easily be done by using our not supported rule in your query parameters validation.

For example, if we wanted to prevent pagination on our posts resource:

namespace App\JsonApi\V1\Posts;

use LaravelJsonApi\Validation\Rule as JsonApiRule;
use LaravelJsonApi\Laravel\Http\Requests\ResourceQuery;

class PostCollectionQuery extends ResourceQuery
{

    public function rules(): array
    {
        return [
            // ...other rules

            'page' => JsonApiRule::notSupported(),
        ];
    }
}

On your schema, you can then return null from the pagination method:

namespace App\JsonApi\V1\Posts;

use LaravelJsonApi\Contracts\Pagination\Paginator;
use LaravelJsonApi\Eloquent\Schema;

class PostSchema extends Schema
{
    // ...

    /**
     * Get the resource paginator.
     *
     * @return Paginator|null
     */
    public function pagination(): ?Paginator
    {
        return null;
    }
}
Last Updated: 8/1/2021, 6:22:11 PM