Pagination
Billomat returns list endpoints (/clients, /invoices, …) page by page. The page metadata sits as @page/@per_page/@total next to the actual list entry in the outer envelope:
{
"clients": {
"@page": "1",
"@per_page": "100",
"@total": "234",
"client": [ {…}, {…} ]
}
}The SDK offers three levels, ordered by convenience:
| Method | When to use |
|---|---|
list($filters) | You only need the first page (or pass page/perPage yourself). |
listPage($filters) | You need pagination metadata for a classic UI ("page 1/12, 234 results"). |
iterateAll($filters, int $pageSize = 100) | You want to walk through all entries of a filter without dealing with page indexes. |
For clients, suppliers, articles, users and all document types (invoices, offers, confirmations, credit notes, delivery notes, letters, reminders, incoming invoices, recurring invoices), $filters is the typed filter class (ClientFilter, InvoiceFilter, …) or an array (ClientFilter|array). templates->list() takes TemplateFilter|array. Sub-resources get their parent ID as the first argument: payments take InvoicePaymentFilter/CreditNotePaymentFilter/IncomingPaymentFilter, property values ClientPropertyValueFilter/ArticlePropertyValueFilter/SupplierPropertyValueFilter/IncomingPropertyValueFilter, and items, comments, tags, email receivers and property definitions the generic SubResourceFilter (only orderBy, page, perPage, extra). The other list APIs take an array.
listPage() and iterateAll() are available on these list APIs:
- top-level resources:
clients,suppliers,articles,users,invoices,offers,confirmations,creditNotes,deliveryNotes,reminders,letters,incomings,recurrings,inboxDocuments,activities,roles, - master data lookups:
countries,currencies,countryTaxes,units,emailTemplates,freeTexts,incomingCategories, - the attribute sub-resources
properties(onclients,articles,suppliers,incomings,users) andpropertyValues(onclients,articles,suppliers,incomings), e.g.$billomat->clients->propertyValues->iterateAll($clientId), - the document sub-resources
items,comments,tagsandpayments(where the parent has them) andrecurrings->emailReceivers, e.g.$billomat->invoices->items->iterateAll($invoiceId, new SubResourceFilter(orderBy: OrderBy::asc('position'))).
contacts->list($clientId) and templates->list() return a single list without pagination helpers.
iterateAll() — auto-pagination
A lazy generator modeled on the Stripe SDK (auto_paging_iter()). It fetches page by page on demand and stops automatically once the last page is reached.
use Justpilot\Billomat\Api\ClientFilter;
use Justpilot\Billomat\BillomatClient;
$billomat = BillomatClient::create(billomatId: 'mycompany', apiKey: '…');
foreach ($billomat->clients->iterateAll(new ClientFilter(countryCode: 'DE')) as $client) {
echo $client->id, ' ', $client->name, PHP_EOL;
}Filters are sent with every page request; page/perPage (or page/per_page in an array) are overwritten, because the SDK controls them.
Tuning pageSize
The default is 100 (Billomat's maximum is 1000). Larger pages mean fewer round trips, smaller pages lower peak memory:
use Justpilot\Billomat\Api\InvoiceFilter;
$filter = new InvoiceFilter(from: new DateTimeImmutable('2026-01-01'));
foreach ($billomat->invoices->iterateAll(filters: $filter, pageSize: 500) as $invoice) {
// …
}Termination
The iterator stops in two cases:
- Preferred:
PageInfo::hasNextPage()isfalsebecausepage >= totalPages()(Billomat sends@total). - Fallback: the endpoint sends no
@totalmetadata and the current page contains fewer items thanpageSize. In that casetotalin thePageInfoisnullandhasNextPage()optimistically returnstrue— the iterator relies on thecount(items) < pageSizecheck.
Both cases are safe and stop after the first request for empty endpoints.
listPage() — one page with metadata
Same as list(), but also returns the page metadata as a Page<Model>:
use Justpilot\Billomat\Api\ClientFilter;
use Justpilot\Billomat\Pagination\Page;
$result = $billomat->clients->listPage(new ClientFilter(page: 3, perPage: 50));
// $result instanceof Page
foreach ($result->items as $client) {
// /** @var Client $client */
}
echo sprintf(
'Page %d / %d — %d results in total',
$result->info->page,
$result->info->totalPages() ?? 1,
$result->info->total ?? \count($result->items),
);Page<T> and PageInfo
Both value objects live in Justpilot\Billomat\Pagination\:
final readonly class Page // generic <T of object>
{
/** @param list<T> $items */
public function __construct(public array $items, public PageInfo $info) {}
}
final readonly class PageInfo
{
public function __construct(
public int $page,
public int $perPage,
public ?int $total, // null = unknown
) {}
public function totalPages(): ?int; // null if $total === null
public function hasNextPage(): bool; // true if $total === null (optimistic)
}PageInfo::$total is null when the endpoint sends no @total metadata (e.g. some lookup endpoints). Then totalPages() is null as well; UI code has to handle that, e.g. with ?? '?'.
Paging manually with list()
list() takes page/perPage as well. To page manually (e.g. to resume after an abort):
$pageTwo = $billomat->clients->list(new ClientFilter(page: 2, perPage: 50));
// equivalent as an array
$pageTwo = $billomat->clients->list(['page' => 2, 'per_page' => 50]);For new code, iterateAll() is the recommended choice — less boilerplate and no off-by-one risk at the end of the list.