---
title: "Pagination"
description: "One page, every page, or one item at a time, on every list that pages."
url: "https://openemail.uk/docs/php/pagination"
area: "PHP"
category: "Getting started"
---

# Pagination

One page, every page, or one item at a time, on every list that pages.

## list, listAll and iterate

Every list that pages has three methods. `list` fetches one page and returns an `OpenEmail\Result\Page`. `listAll` follows the cursor through every page and returns one array. `iterate` walks the same pages one item at a time and returns a `Generator`, which fetches the next page only when you get there. All three take the list’s filters, `limit:`, `cursor:` and `apiKey:`.

**three_ways.php**

```
$page = $client->emails->list(status: 'failed', limit: 50);

foreach ($page as $email) {
    echo $email['id'], ' ', $email['lastError'] ?? '', PHP_EOL;
}

$failures = $client->emails->listAll(status: 'failed');

foreach ($client->emails->iterate(status: 'failed') as $email) {
    echo $email['id'], PHP_EOL;
}

echo count($failures), ' ', $page->hasMore ? 'more to come' : 'that is all', PHP_EOL;
```

The same three names repeat wherever a namespace has more than one list, named after the list they walk: `listEvents`, `listAllEvents` and `iterateEvents` on `emails`, `listDeliveries`, `listAllDeliveries` and `iterateDeliveries` on `webhooks`, and so on.

## OpenEmail\Result\Page

- `items` (array): The rows of this page, lifted out of the API’s `data` envelope, each an associative array. Empty when the page holds nothing.
- `hasMore` (bool): Whether another page follows. When the API sends no `hasMore`, it is true exactly when there is a `nextCursor`.
- `nextCursor` (string or null): What to pass back as `cursor:` for the next page, and null on the last one.

A page is immutable: its properties are `readonly`. It is `IteratorAggregate` and `Countable` too, so `foreach ($page as $item)` walks its rows and `count($page)` counts them.

## A Generator

`iterate` asks for nothing until you start to walk it, and it asks for the next page only once every item of the current one has been yielded, so anything that stops early stops the requests too: `break` ends the walk, and so does returning from the function that holds the loop.

**generator.php**

```
$latest = [];

foreach ($client->emails->iterate(status: 'failed', limit: 100) as $email) {
    $latest[] = $email;

    if (count($latest) === 10) {
        break;
    }
}

$invoice = null;

foreach ($client->emails->iterate(status: 'failed') as $email) {
    if (($email['tags']['invoice'] ?? null) === 'inv_2026_09_4192') {
        $invoice = $email;

        break;
    }
}

echo count($latest), ' ', $invoice['id'] ?? 'not found', PHP_EOL;
```

Anything that needs every item, such as `iterator_to_array()` called on the Generator, reads every page before it returns, the way `listAll` does.

> A Generator can be walked only once. Walking it a second time throws, so call `iterate` again when you need the items again, or keep the items rather than the Generator.

## Resuming from a cursor

A cursor is opaque. Keep the `nextCursor` of the last page you read and pass it back as `cursor:` to carry on from there, in a later request or in another process. `listAll` and `iterate` take `cursor:` too, and start their walk after it.

**resume.php**

```
$firstPage = $client->emails->list(status: 'failed', limit: 25);
$saved = $firstPage->nextCursor;

if ($saved !== null) {
    $rest = $client->emails->listAll(status: 'failed', cursor: $saved);
    echo count($rest), PHP_EOL;
}
```

> A cursor belongs to the list and the filters it came from, so send the same filters with it. One the list cannot place is refused with `invalid_cursor`, and the answer then is to start again without one.

## limit:

`limit:` is the size of each page, not a total. On `list` it is how many rows come back. On `listAll` and `iterate` it is how many each request asks for, so a larger value means fewer round trips for the same rows. Each list has its own range and default, most often 1 to 100 with 25 when you send none, and a value outside the range is refused rather than clamped. The reference for each list gives its range.

## When a walk stops

- When a page says `hasMore` is false.
- When a page carries no `nextCursor`, since a page that claims more while naming no cursor would loop for ever.
- When the API hands back a cursor the walk has already followed, for the same reason.

Each page is a GET, so it is retried on its own like any read before anything throws. A failure that survives the retries throws out of `listAll`, and the items already fetched are discarded. With `iterate` the items of the earlier pages have already been yielded by then, so make what the loop does safe to run twice, or page with `list` and keep each `nextCursor` so a second attempt can start where the first one stopped.

## Threads and drafts

`threads->list` and `drafts->list`, with their `listAll` and `iterate`, page with the API’s `pageToken` and `nextPageToken` rather than a cursor. The client hides the difference: pass the token as `cursor:` and read it from `nextCursor`.

**page_token.php**

```
$page = $client->threads->list(folder: 'inbox', limit: 50);
$later = $page->hasMore ? $client->threads->list(folder: 'inbox', limit: 50, cursor: $page->nextCursor) : null;

echo count($page), ' ', $later === null ? 0 : count($later), PHP_EOL;
```

> The server offers a token whenever a page comes back full, so `hasMore` can be true on what turns out to be the last page, and the next call then returns no items.

## Pages that carry more

A few lists answer with more than rows, and return an object of their own from `OpenEmail\Result` in place of `Page`. Each is immutable, `IteratorAggregate` over its rows and `Countable`.

| Method | Returns | What it adds |
| --- | --- | --- |
| `addresses->list` | `AddressBookPage` | `addresses` in place of `items`, plus `unrestricted` and `domains`, with `hasMore` and `nextCursor`. |
| `addresses->listAll` | `AddressBook` | Every address in `addresses`, with `unrestricted` and `domains` as the last page reported them. It is the one `listAll` that returns the whole address book rather than an array. `addresses->iterate` yields the addresses alone. |
| `contacts->listPeople` | `PeoplePage` | `seen`, false when the key cannot read the addresses seen in mail. `listAllPeople` and `iteratePeople` return the people alone. |
| `tempMail->listMessages` | `TempMessagesPage` | `expiresAt`, when the inbox runs out. `listAllMessages` and `iterateMessages` return the messages alone. |
| `templates->listSends` | `TemplateSends` | Paged by number rather than by cursor: `items`, `total`, `page` and `pageSize`. Ask for the next page with `page:`. |
| `emails->sendBatch` | `BatchResult` | Not a page: `items`, one for each message you sent, with the `sent` and `failed` counts. |

## Lists that return the API’s array

Some lists page by offset, by page number or by a numeric cursor of their own, and return the decoded body as it came, an array with `data`, rather than a `Page`. They have no `listAll` or `iterate`, so you page them yourself.

| Method | Pages with | What comes back |
| --- | --- | --- |
| `exports->list` | `limit:` and `offset:` | `data`, `total` and `hasMore`. |
| `imports->listFailures` | `after:` and `limit:` | `data`, and `nextCursor`, an integer to pass back as `after:` that is null on the last page. |
| `subscriptions->list` and `subscriptions->listDomains` | `limit:` and `offset:` | `data`, `total`, `counts` and `hasMore`. |
| `billing->listInvoices` | `page:` and `limit:` | `data`, `total`, `page`, `limit`, `hasMore` and `metered`. |

**offset_paging.php**

```
$offset = 0;

do {
    $batch = $client->subscriptions->list(status: 'active', limit: 50, offset: $offset);

    foreach ($batch['data'] as $row) {
        echo $row['senderEmail'], ' ', $row['total'], PHP_EOL;
    }

    $offset += count($batch['data']);
} while ($batch['hasMore'] && $batch['data'] !== []);
```

> A list that is not paged at all, such as `languages->list`, `labels->listColors` or `roles->listPermissions`, returns its rows as a plain list straight away.
