spatie/laravel-json-api-paginate
Adds a jsonPaginate method to Laravel's Eloquent query builder that reads page[number] and page[size] request parameters and generates the pagination links required by the JSON:API spec.
Install and publish config in one go.
composer require spatie/laravel-json-api-paginate
php artisan vendor:publish --provider="Spatie\JsonApiPaginate\JsonApiPaginateServiceProvider" --tag="config"
Auto-discovery handles the service provider on Laravel 5.5+. Commit config/json-api-paginate.php (don't leave it unmanaged) and run php artisan config:cache in deployments — config changes are invisible otherwise.
The smallest useful call. Replace paginate() with jsonPaginate() on any Eloquent query:
// GET /api/posts?page[number]=2&page[size]=15
return Post::query()
->where('published', true)
->orderByDesc('published_at')
->jsonPaginate();
That's the entire value proposition: the query builder now reads page[number] / page[size] instead of page, and the generated first/last/prev/next URLs carry those bracketed keys so clients can follow the JSON:API pagination convention without custom logic.
Where to look first:
config/json-api-paginate.php — this is your API's pagination contract. Read all ten keys before writing endpoints; max_results, default_size, and the parameter names affect every endpoint at once._ide_helper.php stub — jsonPaginate() is a runtime macro, so without this stub your IDE (PhpStorm/Intelephense) will flag "method not found" on every model and relationship chain.base_url was removed and that will bite you behind a proxy.First real use case: a read-only REST collection endpoint that a client already consumes with page[number]/page[size] (Ember Data, JSON:API-compliant SDKs, or a frontend team that has standardised on the spec).
The package shapes the paginator's URLs; it does not emit a JSON:API links/meta document. Treat serialization as your responsibility and keep it in one place:
// app/Http/Resources/PaginatedCollection.php
class PaginatedCollection extends ResourceCollection
{
public function toArray($request): array
{
return [
'data' => $this->collection,
'links' => [
'first' => $this->firstPageUrl(),
'last' => $this->lastPageUrl(),
'prev' => $this->previousPageUrl(),
'next' => $this->nextPageUrl(),
],
'meta' => [
'current_page' => $this->currentPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
],
];
}
}
Do this once; every endpoint inherits the same envelope. Skipping it is the most common reason people assume the package "doesn't work" — the query params are right, but toArray() on a plain paginator returns Laravel's default keys.
Keep the config conservative (max_results => 30) and raise it only where a client genuinely needs it:
// Heavy export-style endpoint, capped at 100, defaulting to 50
$query->jsonPaginate(maxResults: 100, defaultSize: 50);
This avoids editing global config for one endpoint, and keeps a hostile ?page[size]=100000 from becoming a memory incident anywhere else.
Relationship endpoints are first-class (BelongsToMany/HasManyThrough macros added in 1.13.0):
$post->comments()->where('approved', true)->jsonPaginate();
This is the pattern that saves the most hand-written code — the alternative is manually reading page[number] in every relationship controller.
Set use_cursor_pagination => true (it overrides use_simple_pagination) or keep it off and switch per-deployment. Cursor pagination eliminates the deep-offset slowdown, but it changes your client contract: no page numbers, no total, clients must follow next.
// Requires a deterministic, unique ordering — this is not optional
Event::orderBy('created_at')->orderBy('id')->cursorPaginate(...);
In practice, prefer use_cursor_pagination => true only for append-only/high-volume resources (logs, events, feeds). Mixing cursor and offset pagination across endpoints in one API is fine as long as each endpoint is documented, but don't flip it globally after clients ship.
Install spatie/laravel-fast-paginate, flip use_fast_pagination => true, and nothing else changes. The deferred-join strategy buys you back the offset cost for the common LIMIT/OFFSET case without changing your client contract — the sweet spot for a mature API where changing to cursor pagination isn't an option.
Assert on the URL keys, since those are what break clients:
$this->getJson('/api/posts?page[number]=2&page[size]=5')
->assertOk()
->assertJsonPath('meta.current_page', 2)
->assertJsonPath('meta.per_page', 5);
$this->assertStringContainsString('page%5Bnumber%5D=3', $response->json('links.next'));
max_results vs default_size are different knobs. default_size applies when the client sends no page[size]; max_results caps what they can ask for. Setting them to the same number is the safe default. A request above max_results is silently capped, not rejected — if you want a 422, validate page.size in a FormRequest yourself.
No total with simple or cursor pagination. use_simple_pagination skips the count query (faster) but total()/lastPage() are meaningless. If your envelope always emits meta.total, simple/cursor modes will break it — make the meta block conditional.
The $total argument only works on basic pagination. jsonPaginate(null, null, $total) is documented as having no effect with cursor, simple, or fast pagination. It's the escape hatch for DISTINCT/groupBy count bugs — worth reaching for before you rewrite a query with selectRaw.
base_url is gone in v4.x. If you're upgrading, replace it with URL::forceScheme('https') / URL::forceRootUrl(...) in AppServiceProvider::boot(). The old option overwrote the entire path, producing identical pagination URLs for every resource — the symptom is /api/posts?page=2 pointing at /api/users, which is confusing to debug late.
Behind a load balancer you need TrustProxies configured, otherwise the generated absolute pagination URLs will use http:// and an internal hostname even after forceScheme. This is a Laravel-level fix, not a package one.
The macro is registered at runtime — your IDE and static analysis don't know about it. The bundled stub helps modern IDEs; if you set method_name to something custom, copy the stub and adapt it. Rector/PHPStan will still need a stub or an @method docblock.
method_name exists because macro collisions are real. If you or another package already defined jsonPaginate on the query builder, whichever service provider boots last wins. Rename via config rather than debugging silent behaviour differences.
Client-side param encoding matters. page[number] becomes page%5Bnumber%5D on the wire. Anything that string-matches your next link (cache keys, nginx rules, signature checks, allowlists) must account for encoding — this is a frequent cause of "works in Postman, fails through the CDN".
Validate the shape, don't trust it. Edge inputs (page[number]=0, negative, abc, missing, page[size]=0) are resolved by the package to defaults/caps, but that resolution is your public API contract. Pin it with a handful of tests so a package minor bump can't silently change behaviour your clients depend on.
Pagination is a query concern — order before you paginate. Every jsonPaginate() call should have a deterministic orderBy (tie-break on the primary key). Without it, users see duplicated and missing rows between pages, which looks like a package bug and isn't.
Scope of the package is deliberately tiny — one macro, one config file, no middleware, no response formatting, no JSON:API resource resolution. That's an adoption advantage: the blast radius of an upgrade is small and the README is effectively the whole surface area. The trade-off is that it solves only the query-parameter-naming half of JSON:API pagination; the links/meta document, validation, and error shapes remain yours to build and test.
How can I help you explore Laravel packages today?