When a Laravel route returns a paginator directly, what JSON does paginate() produce, and how does it differ for simplePaginate() and cursorPaginate()?
answer
- records under data
- total, last_page, links only on paginate()
- simple: next_page_url, no total
- cursor: next_cursor / prev_cursor
- through() maps items, keeps meta
basics
~20 sReturning a paginator serialises it with the records under data plus metadata. paginate() adds total, last_page, last_page_url and a links array; simplePaginate() has page URLs but no total; cursorPaginate() has next_cursor, prev_cursor and their URLs instead of page numbers.
solid answer
~30 sPaginators implement `Jsonable` and `Arrayable`, so returning one from a route or controller sends JSON with the items under `data`. `paginate()` (a `LengthAwarePaginator`) adds `current_page`, `from`, `to`, `per_page`, `total`, `last_page`, `first_page_url`, `last_page_url`, `next_page_url`, `prev_page_url`, `path` and a `links` array of `{url, label, page, active}` entries. `simplePaginate()` returns the same page fields plus `current_page_url`, but no `total`, `last_page`, `last_page_url` or `links`. `cursorPaginate()` returns only `data`, `path`, `per_page`, `next_cursor`, `next_page_url`, `prev_cursor` and `prev_page_url`; a client scrolls by following `next_page_url` or sending `next_cursor` as `?cursor=`. To reshape each record without losing the metadata, call `through(fn ($item) => ...)` on the paginator.
code
php · 13 lines<?php
use App\Models\Activity;
Route::get('/api/feed', function () {
return Activity::latest()
->orderByDesc('id')
->cursorPaginate(20)
->through(fn (Activity $a) => [
'id' => $a->id,
'km' => $a->distance_km,
]);
});go deeper
Recall that returning a paginator gives JSON with data plus metadata, and that only paginate() includes total and last_page.
Explain which keys each paginator emits, what the links array holds, and how through() reshapes records without losing metadata.
Treat the paginator's JSON as a client contract: changing the method changes the keys, so version or wrap the response deliberately.
Decide whether public APIs expose the raw paginator shape or a stable wrapped envelope, trading convenience against long-term contract control.
## Paginators serialise themselves Every Laravel paginator implements `Arrayable`, `Jsonable` and `JsonSerializable`. When a route or controller returns one, the framework turns it into a JSON response through `toArray()`: the page's records under `data` and pagination metadata beside them. Eloquent models in `data` are serialised with their own `toArray()`, so hidden attributes stay hidden. ## paginate(): full metadata A `LengthAwarePaginator` emits these keys (at `laravel/framework` 13.34.0): - `current_page`, `per_page`, `from`, `to`: the position; `from` and `to` are the 1-based index of the first and last item on the page, or `null` when empty; - `total`, `last_page`: from the count query; - `first_page_url`, `last_page_url`, `next_page_url`, `prev_page_url`, `path`: navigation, with `next_page_url` / `prev_page_url` `null` at either end; - `links`: an array for building a numbered pager, each entry `{url, label, page, active}`, starting with a "previous" entry, then page numbers and `...` separators, then a "next" entry (the labels come from the `pagination.previous` / `pagination.next` translation keys); - `data`: the records. The Laravel 13 docs' sample response also shows a `current_page_url` key for `paginate()`, but `LengthAwarePaginator::toArray()` in the pinned source does not emit it; the simple paginator does. ## simplePaginate(): no totals A `Paginator` emits `current_page`, `current_page_url`, `data`, `first_page_url`, `from`, `next_page_url`, `path`, `per_page`, `prev_page_url` and `to`. There is **no** `total`, `last_page`, `last_page_url` or `links`, because the method never counts. A client knows it has reached the end when `next_page_url` is `null`. ## cursorPaginate(): cursors instead of pages A `CursorPaginator` emits the smallest shape: | Key | Meaning | |---|---| | `data` | the records | | `path` | the base URL | | `per_page` | page size | | `next_cursor` | encoded cursor for the next page, or `null` | | `next_page_url` | `path?cursor=...`, or `null` | | `prev_cursor` | encoded cursor for the previous page, or `null` | | `prev_page_url` | `path?cursor=...`, or `null` | For the infinite-scroll feed of a running app, the client keeps appending `data` and requests `next_page_url` until it is `null`. There is no page number to show and no total to display. ## Key presence at a glance | Key | `paginate()` | `simplePaginate()` | `cursorPaginate()` | |---|---|---|---| | `data`, `path`, `per_page` | yes | yes | yes | | `next_page_url`, `prev_page_url` | yes | yes | yes | | `current_page`, `from`, `to`, `first_page_url` | yes | yes | no | | `current_page_url` | no (source) | yes | no | | `total`, `last_page`, `last_page_url`, `links` | yes | no | no | | `next_cursor`, `prev_cursor` | no | no | yes | `from` and `to` are 1-based positions: on page 3 of 20 per page they are 41 and 60 (or less on a short last page), and both are `null` when the page is empty. A client can therefore render "41 to 60 of 312" from a `paginate()` response alone, "41 to 60" from a `simplePaginate()` response, and nothing positional from a cursor response. ## Shaping the records, not the envelope To change what each record looks like while keeping the envelope, call `through()`: `Activity::latest()->cursorPaginate(20)->through(fn ($a) => ['id' => $a->id, 'km' => $a->distance_km])`. It maps the items in place and returns the same paginator. The query string carried into the URLs follows the rules of `withQueryString()` and `appends()`. When the API needs a different envelope, for example `meta` and `links` objects, that is the job of API resource collections, which wrap a paginator and restructure its metadata; the raw paginator shape described here is what you get without them. ## Why interviewers ask A front-end or mobile client is written against this shape. Knowing which keys exist for which method tells you whether a "page 3 of 12" UI is even possible, why a cursor endpoint cannot report a total, and why switching an endpoint from `paginate()` to `simplePaginate()` is a breaking change for clients that read `total` or `links`.
- Why is switching an API endpoint from paginate() to simplePaginate() a breaking change?The JSON loses `total`, `last_page`, `last_page_url` and the `links` array, because `simplePaginate()` never counts rows. Any client that shows a page count or builds numbered buttons from `links` stops working, even though `data` and the next/previous URLs remain.
- How do you change each record's fields without losing the pagination metadata?Call `through()` with a callback on the paginator. It maps every item in the current page and returns the same paginator, so `toArray()` still emits the full metadata with the reshaped records under `data`.
saying these in an interview costs you the question
- simplePaginate() JSON includes total but not last_page
- cursorPaginate() JSON includes current_page and total
- Returning a paginator sends only the records array
- map() on the paginator keeps the metadata like through()
- The links array exists on every paginator type