Every paginated collection in the SDK is exposed three ways. They hit the same endpoint and differ only in how much they buffer.
| Method | Returns | Requests | Use when |
|---|---|---|---|
list | Envless\Result\Page | Exactly one | You are rendering a page, or you only need a count |
listAll | list of items | As many as it takes | You need the whole set in memory anyway |
iterate | Generator | As many as it takes, lazily | The set is large, or you can stop early |
use Envless\Envless;
$envless = new Envless();
$page = $envless->products->list(limit: 50);
echo count($page->items), PHP_EOL;
echo $page->pagination['limit'], ' ', $page->pagination['offset'], PHP_EOL;
echo $page->totalCount(), ' ', $page->hasMore() ? 'more' : 'last page', PHP_EOL;use Envless\Envless;
$envless = new Envless();
$everything = $envless->products->listAll();
echo count($everything), PHP_EOL;use Envless\Envless;
$envless = new Envless();
foreach ($envless->products->iterate() as $product) {
if ($product['slug'] === 'billing') {
break;
}
echo $product['name'], PHP_EOL;
}iterate is the only one that can stop early. Breaking out of the foreach stops making requests, so scanning for one item in a large workspace costs one page, not all of them.
A Generator fetches the next page only when the loop gets there, and nothing at all is requested until the loop starts. That makes it the right shape for a long export: memory stays at one page however large the collection is.
use Envless\Envless;
$envless = new Envless();
$names = [];
foreach ($envless->products->iterate() as $product) {
$names[] = $product['name'];
if (count($names) === 10) {
break;
}
}
echo implode(', ', $names), PHP_EOL;An Envless\Result\Page has items and pagination as readonly properties, plus hasMore() and totalCount(). It can be counted and looped over directly, and json_encode() turns it back into items and pagination. Its PHPStan type is generic over the item, so $page->items is a list<ProductResource> to PHPStan and Psalm.
Options
List methods take these as named arguments, after their positional slugs. Each list accepts only the options its route reads, so an option a route ignores is an Error for an unknown named parameter:
| Option | Type | Notes |
|---|---|---|
limit: | int | 1 to 100. Defaults to 25 on the API. iterate and listAll default to 100 to minimise round trips, and clamp anything higher. |
offset: | int | Rows to skip. iterate and listAll use it as a starting point and then advance it themselves. |
search: | string | Products, product projects, projects, environments, invites, members, roles, keys and webhooks. Each method page says what it matches. |
order: | string | name, asc or desc, on products, product projects, projects, environments, keys and webhooks. |
sortBy: | string | Roles only: name (the default), updated or created. |
roleId: | string | Members only: keeps the members holding that role. |
status: | string | Webhook deliveries only: pending, processing, succeeded or failed. |
product: | string | Projects, environments, variables, variable history and versions: disambiguates a project slug that exists in more than one product. |
timeout: | int or float | Bounds each request of the walk, not the whole walk. |
A limit: outside 1 to 100 is refused by the API with 400 request.invalid_pagination rather than being clamped server-side, and field on the exception names the offending parameter.
How the walk terminates
listAll and iterate stop on either of two conditions, which matters because it makes them safe against a server that keeps saying hasMore:
hasMore is false
The normal case.
A page comes back empty
Even if hasMore is still true. Without this a bad cursor would loop forever.
The offset advances by the number of items actually received, not by limit, so a short page does not skip rows. A failure on any page fails the whole listAll call, and the rows it had already collected are lost; iterate has already handed over the earlier items, so it throws at the page that failed.
Secondary collections
A namespace can paginate more than one collection. Those get their own trio, named after what they read.
| Namespace | Primary | Secondary |
|---|---|---|
products | list, listAll, iterate | listProjects, listAllProjects, iterateProjects and access->list, access->listAll, access->iterate |
variables | list, listAll, iterate | listHistory, listAllHistory, iterateHistory |
versions | list, listAll, iterate | listEntries, listAllEntries, iterateEntries |
webhooks | list, listAll, iterate | listDeliveries, listAllDeliveries, iterateDeliveries |
Two endpoints look like lists but are not paginated, because the whole set is small and fixed. Both return a plain array rather than a page:
| Method | Returns |
|---|---|
roles->listPermissions() | PermissionsResource with the full catalogue, a byResource index, and the system role sets |
webhooks->listEventTypes() | EventTypesResource with eventTypes and totalCount |
One collection carries an extra field
products->access->list() returns an Envless\Result\ProductAccessPage, which is a normal page plus the product's privacy flag, read as isPrivate. That flag tells you whether the grants matter at all: on a public product every member has access and the list is informational.
use Envless\Envless;
$envless = new Envless();
$access = $envless->products->access->list('internal-tools');
if (!$access->isPrivate) {
echo 'Public product, every member already has access', PHP_EOL;
}
foreach ($access->items as $entry) {
echo $entry['id'], PHP_EOL;
}access->listAll() and access->iterate() only stream items, so they drop the flag. If you need it, use access->list().
Counting without fetching
totalCount() is the size of the full filtered set, not the page, so a single one-row request is enough to count.
use Envless\Envless;
$envless = new Envless();
echo $envless->members->list(limit: 1)->totalCount(), ' members', PHP_EOL;