Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Json Api Paginate Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. 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".
  2. The bundled _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.
  3. The "Upgrading from v3.x to v4.x" section of the README if you are porting an existing API — 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.

Implementation Patterns

Controller + API Resource (the bread-and-butter path)

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.

Compose with query-builder packages before paginating

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.

Per-endpoint tuning without touching config

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.

Relations

$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.

Choosing the pagination strategy

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
  • Simple: drops the count(*), but clients lose total/last_page. Fine for infinite scroll.
  • Cursor: best for large, append-mostly tables. The cursor travels as page[cursor] (configurable via cursor_parameter), and query-string parameters are appended to the generated URLs.
  • Fast: deferred-join pagination for deep offsets. Requires 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.

Testing

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);

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log