# 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()
andsimplePaginate()
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 thelaravel-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;
}
}