spatie/laravel-json-api-paginate
A Laravel package that adds a jsonPaginate method to the Eloquent query builder, so pagination reads page[number] and page[size] request parameters and returns the pagination links required by the JSON:API spec.
Install and do nothing else. The package is zero-config out of the box:
composer require spatie/laravel-json-api-paginate
The service provider is auto-discovered. jsonPaginate is registered as a macro on the Eloquent query builder, so the first use case is a one-line swap in any controller or resource:
// before: GET /posts?page=2
Post::paginate();
// after: GET /posts?page[number]=2&page[size]=10
Post::jsonPaginate();
That is genuinely the whole onboarding. The macro reads page[number] and page[size] from the request, clamps the size to a maximum, and returns a standard LengthAwarePaginator whose url()/link generation uses the page[...] parameters — so the pagination links Laravel emits already look like what the JSON:API spec expects (/posts?page%5Bnumber%5D=2&page%5Bsize%5D=10).
Where to look first, in order:
config/json-api-paginate.php — the entire package behaviour lives here (max_results, default_size, parameter names, simple/cursor/fast toggles). Publish it only when you need to change a default: php artisan vendor:publish --provider="Spatie\JsonApiPaginate\JsonApiPaginateServiceProvider" --tag="config"._ide_helper.php — the package ships stubs so PhpStorm/Intelephense recognise jsonPaginate(?int $maxResults, ?int $defaultSize, ?int $total) on models, builders, and relations. That signature tells you everything about the three override arguments.base_url was removed and there is no drop-in replacement short of URL::forceRootUrl().First realistic task: point an existing /api/v1/posts endpoint at jsonPaginate(), wrap the result in PostResource::collection(...), and hit it with ?page[number]=2&page[size]=5. You'll get data, links, and meta from Laravel's resource layer, and the links will carry the bracketed parameters.
Because the return value is a normal paginator, everything downstream is stock Laravel:
Route::get('/posts', fn () => new PostCollection(Post::jsonPaginate()));
class PostCollection extends ResourceCollection
{
public $collects = PostResource::class;
}
If the consumer is strict about JSON:API's pagination shape, remap the meta block — Laravel's default meta is flat (current_page, last_page, ...) whereas the spec wants a page object:
public function paginationInformation($request, $paginated, $default)
{
return [
'links' => $default['links'],
'meta' => [
'page' => [
'number' => $paginated['current_page'],
'size' => $paginated['per_page'],
'total' => $paginated['total'],
'last' => $paginated['last_page'],
],
],
];
}
This is the single most valuable integration tip: the package solves the request and link half of the spec; the response envelope half is yours to shape in one overridden method.
jsonPaginate is a terminal call on the builder, so it slots in at the end of any filter/sort/sparse-fieldset chain. With spatie/laravel-query-builder:
$posts = QueryBuilder::for(Post::class)
->allowedFilters(['status', 'author_id'])
->allowedSorts(['title', 'published_at'])
->defaultSort('-published_at')
->jsonPaginate();
Order matters: paginate last, always. Paginating before filtering produces wrong totals and wastes a query.
The positional arguments let you diverge per resource while keeping one global config:
Post::jsonPaginate(maxResults: 100, defaultSize: 20); // public feed, bigger pages
AuditLog::jsonPaginate(maxResults: 10, defaultSize: 10); // expensive rows
Use named arguments — with three nullable ints, positional jsonPaginate(null, null, 42) is unreadable six months later.
$user->posts()->jsonPaginate();
$user->roles()->jsonPaginate(); // BelongsToMany — dedicated macro
$country->orders()->jsonPaginate(); // HasManyThrough — dedicated macro
BelongsToMany and HasManyThrough have explicit macros registered (they don't simply forward to the builder macro), which is why they're called out in the v1.13 changelog. Anything that isn't a builder-shaped relation (e.g. morphTo, BelongsTo returning a single model) isn't a candidate for pagination anyway.
Three global toggles in the config, in increasing order of "you should know what you're doing":
// config/json-api-paginate.php
'use_simple_pagination' => true, // Next/Prev only, no COUNT(*)
'use_cursor_pagination' => true, // overrides simple; adds page[cursor]
'use_fast_pagination' => true, // requires spatie/laravel-fast-paginate
count(*), but clients lose total/last_page. Fine for infinite scroll.page[cursor] (configurable via cursor_parameter), and query-string parameters are appended to the generated URLs.spatie/laravel-fast-paginate installed first; older aaronfrancis/hammerstone forks are also detected. Signature and return type are unchanged, so it's a config flip, not a refactor.Nothing special is needed — it's an HTTP-level concern:
$this->getJson('/api/posts?page[number]=2&page[size]=5')
->assertOk()
->assertJsonPath('meta.page.number', 2)
->assertJsonPath('meta.page.size', 5)
->assertJsonCount(5, 'data');
// and the clamp
$this->getJson('/api/posts?page[size]=5000')
->assertJsonPath('meta.page.size', 30);
page[size] is silently clamped, not rejected. max_results defaults to 30. A client asking for 500 gets 30 rows and a 200. If your API documents a higher ceiling, raise max_results — otherwise integration partners will file bugs about "missing data" rather than "ignored parameter". Add a per_page-style contract test for the exact cap you advertise.
Validate the bracket parameters yourself. Laravel resolves page[number] as $request->input('page.number'), so a FormRequest rule is 'page.number' => ['nullable', 'integer', 'min:1']. Without it, ?page[number]=abc or ?page[size]=-5 reaches the paginator and you get a 500 or a nonsense offset. This is the number one source of junk errors in a JSON:API endpoint.
Other query params may disappear from links. Paginator URLs are built from the paginator's stored query array. If your links are dropping ?filter[status]=active when the client pages, chain withQueryString() on the returned paginator — links are generated lazily, so this works:
Post::jsonPaginate()->withQueryString();
$total is a trap with three of the four strategies. jsonPaginate(null, null, 42) only has an effect with basic offset pagination. With use_simple_pagination, use_cursor_pagination, or use_fast_pagination enabled, the third argument is ignored — no error. It's still useful for the DISTINCT-breaks-count(*) case or to skip a second expensive aggregate, but verify with a test rather than assuming.
Cursor pagination needs a deterministic sort. Any cursor paginator, including this one, relies on the ORDER BY columns being unique or near-unique. orderBy('created_at') with ties will skip or duplicate rows between pages. Append the primary key: ->orderBy('created_at')->orderBy('id').
The strategy is global, not per-route. use_cursor_pagination is a single config boolean, so you cannot have one cursor endpoint and one offset endpoint. Plan around it: either standardise on one strategy across the API, or skip the macro on the exception endpoint and call cursorPaginate() directly while formatting the links yourself.
method_name is a footgun. Renaming the macro to e.g. apiPaginate is supported, but it breaks every call site, breaks any published IDE stubs (they hint jsonPaginate), and breaks most Stack Overflow answers you'll find. Change it only if jsonPaginate collides with something you already own.
config:cache and the fast-pagination check. If you flip use_fast_pagination in production, run php artisan config:clear/config:cache — and install spatie/laravel-fast-paginate before enabling the flag, not after. The check for the helper package exists (fixed in 1.15.1), but enabling the flag on a server without the package is still an avoidable deploy-time surprise.
v3 → v4 removed base_url — do not re-add it manually. The old option used setPath() and overwrote the whole request path, so every endpoint generated identical links. For correct host/scheme in links behind a proxy or load balancer, use Laravel's own facilities in a service provider:
public function boot(): void
{
URL::forceScheme('https');
// or, behind TLS termination / multiple domains:
URL::forceRootUrl('https://api.example.com');
}
This preserves the resource path (/api/posts → https://api.example.com/api/posts?page[number]=2) which base_url never did.
Debugging checklist when pagination looks wrong. (1) Dump $request->query('page') — confirm the shape Laravel actually parsed. (2) dd($paginator->perPage(), $paginator->currentPage()) before the resource layer, to separate paginator problems from serialization problems. (3) Check links.first — if the host is wrong, it's forceRootUrl, not the package. (4) If totals are wrong with joins, suspect count(*) over a join fan-out and pass an explicit $total.
Extension point to know about. Everything configurable is a parameter name: number_parameter, size_parameter, cursor_parameter, pagination_parameter. If your API uses page[offset]/page[limit] instead of number/size, you can rename the first two — but the semantics remain page-number-based, so a true offset/limit API still needs its own paginator. Don't try to fake it through config.
Maturity note. The package is narrow, stable, MIT, Spatie-maintained, and released in step with each Laravel major (9, 10, 11 all landed as small PRs). Its risk surface is small precisely because it does one thing — but it has a thin dependent count, so if you need something exotic (offset/limit, keyset with a custom cursor encoder, per-route strategies), budget for wrapping or replacing it rather than hoping for a config flag.
How can I help you explore Laravel packages today?