Download the PHP package zenstruck/collection without Composer
On this page you can find all versions of the php package zenstruck/collection. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download zenstruck/collection
More information about zenstruck/collection
Files in zenstruck/collection
Package collection
Short Description Helpers for iterating/paginating/filtering collections (with Doctrine ORM/DBAL implementations and batch processing utilities).
License MIT
Homepage https://github.com/zenstruck/collection
Informations about the package collection
zenstruck/collection
A Collection interface for iterating, paginating and filtering any set of data - arrays, iterators,
doctrine/collections instances and Doctrine ORM queries - all behind the same API:
It really shines with Doctrine ORM, where a custom EntityResult object makes everything lazy by
default - no more loading huge amounts of entities into memory at once:
An EntityResult is an unexecuted query you can pass around and derive from. It is lazy and immutable, so
each of these runs its own optimized query:
Features:
- Collections: A lazy, countable, paginatable
Collectioninterface with implementations for arrays, iterables and callables. - Pagination: Paginate any collection, with page metadata and lazy page-by-page iteration.
- Doctrine:
- ORM: Lazy, specification-driven repositories with DTO/scalar hydration.
- Batch Processing: Memory-safe iteration/mutation of large result sets.
- Collection Bridge: Filter and paginate
doctrine/collectionsinstances - including relations - without initializing them. - Specifications: Express filters/sorts as objects that are converted into native queries.
- Symfony Integration: Autowire a lazy-first repository for any entity.
- Static Analysis: Fully generic - PHPStan knows what your collections contain.
- Recipes: Worked examples for things that aren't a database.
Installation
The core Collection API has no dependencies. The following packages unlock the optional integrations:
| Package | Required for |
|---|---|
doctrine/orm (>=2.20.7) |
Batch Processing |
doctrine/collections |
Collection Bridge |
doctrine/doctrine-bundle |
Symfony Integration |
symfony/expression-language |
The #[ForObject] autowiring attribute |
pagerfanta/pagerfanta |
PagerfantaAdapter |
Collections
Zenstruck\Collection is the interface everything in this package implements. It is an IteratorAggregate
and Countable that adds transformation (filter(), map(), keyBy()), reduction (first(),
find(), reduce()) and pagination - and, where the implementation allows, does all of it
lazily.
Creating
The Zenstruck\collect() function wraps any source in the most appropriate implementation:
| Source | Implementation |
|---|---|
array |
ArrayCollection |
Traversable |
LazyCollection |
callable(): iterable |
LazyCollection |
Doctrine\Common\Collections\Collection |
Collection Bridge |
Zenstruck\Collection |
itself (returned as-is) |
null |
empty LazyCollection |
Each implementation can also be constructed directly if you want a specific one.
The API
Filtering
filter() and find() take a callable(V,K):bool:
Doctrine-backed collections additionally accept specification objects, which are converted into a real query instead of filtering in PHP.
[!NOTE] Specifications are only understood by Doctrine-backed collections. Passing one to
ArrayCollection,LazyCollectionor any other in-memory implementation throwsZenstruck\Collection\Exception\InvalidSpecification.
Lazy vs Eager
LazyCollection (and the Doctrine implementations) do no work until iterated. Transformations stay lazy -
they wrap the source rather than run it:
| Method | Runs the source? | How much it reads |
|---|---|---|
filter(), map(), keyBy(), take() |
No - returns a new lazy collection | Nothing |
paginate(), pages() |
No - returns a Page/Pages |
Nothing until the page is used |
first() |
Yes | Stops at the first item |
find() |
Yes | Stops at the first match |
count(), isEmpty() |
Only if the source isn't Countable |
Counts, keeping nothing |
reduce() |
Yes | All of it, keeping nothing |
eager() |
Yes | All of it, kept in memory |
[!IMPORTANT] "Runs the source" is not the same as "loads the source". Only
eager()(andArrayCollection, which is array-backed to begin with) holds the whole collection in memory - everything else streams one item at a time. That's also whyeager()is useful: it lets you iterate repeatedly without re-running an expensive source.[!WARNING] Generators can't be rewound, so
LazyCollectionrejects them outright - wrap in a closure instead:A closure returning an
array/Traversableis only executed once and cached; a closure returning a generator is re-executed every time the collection is iterated.[!TIP]
count()on a source that isn'tCountablehas to iterate all of it. If you have a cheaper way to count, useCallbackCollection.
ArrayCollection
An eager, immutable, array-backed implementation with a much larger API. "Mutations" (set(), unset(),
push()) return a new instance:
Named constructors:
| Constructor | Description |
|---|---|
ArrayCollection::for($source) |
Same as the constructor, but chainable |
ArrayCollection::wrap($value) |
Wraps a non-iterable in an array (null => empty) |
ArrayCollection::explode(',', 'a,b') |
Via explode() ('' normalizes to empty) |
ArrayCollection::range(1, 10) |
Via range() |
ArrayCollection::fill(0, 5, 'x') |
Via array_fill() |
In addition to the Collection API:
| Method | Description |
|---|---|
all() |
The underlying array |
get($key, $default = null) |
Value for $key |
has($key) / contains($v) |
Key exists / value exists (strict) |
keys() / values() |
Keys as values / re-indexed values |
set($key, $value) |
New instance with $key set |
unset(...$keys) |
New instance without $keys |
only(...$keys) |
New instance with only $keys |
push(...$values) |
New instance with $values appended |
merge(...$collections) |
Via array_merge() |
slice($offset, $length) |
Preserves keys |
reverse() |
Preserves keys |
groupBy($function) |
Group into a collection of lists |
combine($values) |
Use the items as keys for $values |
combineWithSelf() |
Use the items as both keys and values |
implode($separator = '') |
Join into a string |
sort() / sortDesc() |
By value, optional flags or comparator |
sortBy($function) |
By a computed value |
sortByDesc($function) |
By a computed value, reversed |
sortKeys() / sortKeysDesc() |
By key |
map() and filter() preserve keys. keyBy() and groupBy() accept Stringable keys and cast them to
string.
LazyCollection
Wraps a Traversable or a callable(): iterable. This is what you want for anything expensive - a generator
over a large file, an HTTP paginator, a database cursor:
Composing Collections
| Class | Purpose |
|---|---|
ChainCollection |
Iterate multiple collections as one |
CallbackCollection |
Separate callbacks for iterating and counting |
FactoryCollection |
Lazily pass each item of another collection through a factory |
[!NOTE]
FactoryCollectiondecorates anotherCollection, so wrap plain iterables incollect()first.[!WARNING] When preserving keys with
ChainCollection, duplicate keys across the inner collections will overwrite each other if the result is converted to an array (ie viaeager()).
Pagination
Any collection can be paginated with paginate(), which returns a Page - an iterable of just that page's
items, plus the metadata you need to render a pager:
What it costs depends on how much of that metadata you use:
| Mode | Renders | Counts the collection? |
|---|---|---|
| Simple | Previous / Next | no |
| Full | "Page 2 of 7", numbered links, Last | yes, once |
There's only one Page - you don't choose a mode up front, you get one by what your template asks for.
[!NOTE] "Counts the collection" means calling
count()on the source, and what that costs is up to the source: it's free for an array, aSELECT COUNT(...)for Doctrine, and a full iteration for a generator or an API-backed Lazy vs Eager.
Simple
Everything a previous/next pager needs comes from the page itself, without counting anything:
Note the 21 items for a page of 20. Knowing whether another page exists doesn't require counting the
collection - the page reads one item more than fits on it, and the presence of that extra item is the
answer. It's dropped before you see the items, which is why count($page) is still 20.
That one item is often the difference between a bounded amount of work and an unbounded one. Paging an API-backed collection this way reads a single page's worth of results; asking it for a total would walk every page the API has - the recipes work through exactly that, with request counts.
The items are fetched once and cached, so iterating the same Page more than once won't re-run the source.
[!NOTE] Ask for a page past the end and you get an empty one:
hasMorePages()andnextPage()report nothing follows,previousPage()still works. See Strict Mode to fall back to the last page instead.
Full
Rendering "Page 2 of 7", numbered links or a "Last" link needs the total, so these count the collection - the first time you ask, and once:
| Method | Description | Counts? |
|---|---|---|
currentPage() |
The current page number | no |
limit() |
Items per page | no |
count() |
Number of items on this page | no |
firstPage() |
Always 1 |
no |
hasMorePages() |
Whether another page follows this one | no |
nextPage() / previousPage() |
The adjacent page number, or null at the boundary |
no |
haveToPaginate() |
Whether there is more than one page | no |
totalCount() |
Number of items in the entire collection | yes |
lastPage() / pageCount() |
The last page number (1 when empty) |
yes |
[!NOTE] Out of range arguments are normalized rather than rejected: a page less than
1becomes1and a limit less than1becomes the default (20).
Strict Mode
A page number from a bookmark or a hand-edited URL can point past the end of the collection - and a filter that shrank the result set can do the same to a page number that used to be valid. Strict mode falls back to the last page when that happens:
This is free here: the total is already known, so clamping costs nothing extra. The fallback only re-fetches when the requested page really was out of range.
[!TIP]
strict()works on a Simple page too, but it gives up the no-counting guarantee: clamping needs the total. An in-range page still counts nothing, but an out of range one reads the empty page, counts, then reads the last page. Keepstrict()to Full pagers if that matters.
Templating
Two pager templates are bundled, matching the two modes. Both take the Page and link to the current route,
keeping whatever query parameters are already there:
Neither renders anything at all when the collection fits on one page. The markup is unstyled and deliberately plain - style the classes it emits, or copy the template into your app if you want to change the markup itself:
Both accept the same options:
| Variable | Description |
|---|---|
page |
The Page to render (required) |
route |
The route to link to (defaults to the current one) |
params |
The route/query parameters to keep (defaults to the current request's) |
key |
The page query parameter (defaults to page) |
window |
_full only: how many pages to show either side of the current one (defaults to 4) |
[!NOTE] These require Twig and Symfony's routing (
path()), and are registered by the bundle.
Styling
The class names are the styling hooks: pager on both, plus pager-simple/pager-full, and active and
disabled on the individual items. With Tailwind that's a few @apply rules and no template to maintain:
Iterating Pages
pages() returns a Pages object - a lazy, page-by-page view of the entire collection. Each page is fetched
on its own, so nothing ever holds more than one page in memory:
[!NOTE]
count()onPagesis the number of pages, whilecount()on aPageis the number of items on that page. UsePage::totalCount()for the total number of items.[!WARNING] Fetching each page independently is only cheap if the source can jump straight to an offset. That's one query per page for Doctrine, but a source that reaches an offset by skipping - a generator, an API - re-reads everything before each page: 2,000 items in pages of 100 costs 249 reads instead of 20. Just iterate the collection for that, or teach it to window itself (recipe).
Pagerfanta
If you'd rather render pagers with Pagerfanta, any collection can be adapted:
Doctrine
ORM
EntityResult
EntityResult is a Collection that wraps a query builder. Nothing is executed until you ask for something,
and every method that narrows or transforms it returns a new instance - the original is reusable.
EntityResultQueryBuilder extends Doctrine's QueryBuilder, so everything you already know still works. It
adds:
| Method | Description |
|---|---|
result() |
Create the EntityResult |
readonly() |
Don't track the results in the identity map |
cacheResult($lifetime, $key) |
Enable the result cache |
modifyQuery($callable) |
Adjust the Query before it runs (query hints, etc.) |
[!WARNING] Iterating an
EntityResultstreams the rows one at a time but never clears the entity manager, so every entity it hydrates stays in memory. For large result sets, usebatchIterate()/batchProcess()instead.
Executing
An EntityResult is a query definition. It runs when you ask it for something, and each of these runs its
own query, tailored to what you asked:
This is where pagination's counting shows up as real queries. A previous/next pager is a single query; adding a total makes it two:
Because it's immutable, deriving is free and the original stays usable:
Hydration
By default you get entities back - what the query selects, hydrated the way Doctrine normally would. These
methods return a new EntityResult that hydrates each row differently instead:
| Method | Each row becomes |
|---|---|
asArray(...$fields) |
array<string,mixed>, limited to $fields if given |
asScalar($field = null) |
bool\|float\|int\|string |
asString()/asInt()/asFloat() |
The scalar, cast to that type |
as($callable) |
Whatever $callable returns |
as() is the general case: it hands you each row and uses whatever you return. What a "row" is depends on
what the query selects and which of the above you combined it with - an entity, an array, or a scalar:
The modifier applies everywhere the result produces values - iteration, first(), take(), eager(),
paginate() and batch processing all give you PostDto objects.
[!WARNING] There is only one modifier slot:
as()replaces anything already set, including the casts behindasInt()/asFloat()/asString()and the wrapper behindwithAggregates(). Combineas()withasArray()or a field-selecting query, not with those.
Single Values
A query that selects one aggregate value works the same way - ask for the scalar and take the first row:
Write Queries
EntityResultQueryBuilder is a query builder like any other, so it can also carry a DELETE or an UPDATE.
first() executes it and returns the number of affected rows:
[!WARNING] A write query has no rows to give you, so iterating one - or calling
eager()on it - throws a\LogicException. Usefirst(). Note also that asking twice runs the query twice.
Readonly Results
readonly() detaches each entity from the entity manager as it's hydrated. Use it for anything you're only
going to read - nothing is tracked for changes, and nothing is flushed:
Aggregates
When your query selects extra columns alongside the entity, withAggregates() wraps each row in an
EntityWithAggregates, which proxies to the entity and exposes the extra columns:
[!WARNING] Only call
withAggregates()when the query really does select extra columns - iterating throws a\LogicExceptionotherwise. Doctrine can't iterate aggregate results directly, so they're chunked into groups of 20, each requiring an additional query.
Tuning Pagination
Counting and paginating go through Doctrine's Paginator, which is configurable:
Like everything else on an EntityResult, these return a new instance rather than changing the original.
[!NOTE] Output walkers are disabled automatically when a hydration mode or
as()modifier is set.
Repositories
ObjectRepository is this package's repository interface, and it is deliberately small: find() for a single
object, filter()/query() for an EntityResult, plus count() and iteration.
What's missing is the point. There is no findAll() and no findBy() - nothing in the API hands you an array
of entities, so a repository call can't be the thing that exhausts your memory. Anything that returns more
than one object returns a lazy EntityResult that you narrow, paginate or
batch iterate before it ever touches the database:
[!TIP]
find(),filter()andquery()all accept specifications - reusable filter objects that are converted into the query itself.
Three implementations are available - they differ only in what your repository also is: nothing else, or a Doctrine repository (in standard and Symfony-autowireable variants).
EntityRepository
The standalone implementation. Use it directly for any entity:
Or extend it for your custom repositories, passing the entity class up to the parent constructor:
[!TIP] The protected
qb()helper returns anEntityResultQueryBuilderalready scoped to your entity, using the alias you pass it (eby default). It's a DoctrineQueryBuilder, so build the query however you like -->result()is waiting at the end of the chain.[!TIP] The protected
em()helper gives you the entity manager, for anything the query builder can't do.[!IMPORTANT] This is not a Doctrine repository. It doesn't extend
Doctrine\ORM\EntityRepositoryand has none of its methods (findAll(),findOneBy(),matching(), ...) - just theObjectRepositoryAPI above. If you want both, use one of the bridges below.
ORMEntityRepository
A Doctrine EntityRepository and an ObjectRepository. Use it when you want the new API without giving up
the Doctrine one - findOneBy() and filter() both work:
createQueryBuilder() is overridden to return an EntityResultQueryBuilder, so ->result() is available on
the query builders you already build.
[!NOTE] If your repository already extends something else, add the
EntityRepositoryBridgetrait to it directly - that's all these bridge classes do.
ORMServiceEntityRepository
The same bridge, but extending DoctrineBundle's ServiceEntityRepository so the repository is autowireable in
a Symfony application:
Inject it like any other service - see Symfony Integration for autowiring a repository for entities that don't have a repository class at all.
Finding
find() returns a single entity, or null if there isn't one:
Filtering
filter() narrows the repository down to an EntityResult you can iterate, paginate or hydrate:
An EntityResult can be filtered further, but its specifications are converted to a Criteria rather than
applied to the query builder, so it understands a smaller set than the repository does:
Querying
query() accepts everything filter() does, and is the one to reach for when the specification changes the
database rather than narrowing a read:
Invokable Objects
find(), filter() and query() also accept any invokable object, called with the query builder and the
root alias. It's a reusable, testable place to put a query you'd otherwise inline:
[!WARNING] This only applies to repositories. An
EntityResultor a bridged collection treats an invokable object as a plaincallable(V,K):booland filters in PHP instead. Wrap it inSpec::callback()if you need it understood by both.
Iterating and Counting
[!WARNING] Iterating a repository directly uses batch iteration: the entity manager is cleared every 100 entities and nothing is ever flushed. Don't hold on to entities from a previous chunk, and don't modify them - those changes are silently discarded.
Batch Processing
Doctrine keeps every entity it hydrates in memory, so a loop over a large table grows until it dies - and mutating one raises the question of when to flush. Batch processing answers both by working in chunks:
Batch::iterate() |
Batch::process() |
|
|---|---|---|
| After each chunk | clear() |
flush() then clear() |
| Transaction | None | The entire loop, rolled back if it throws |
| Use for | Reading | Creating/updating/deleting |
[!WARNING]
Batch::iterate()never flushes. Changes you make to an entity while iterating are silently discarded when the chunk is cleared - useBatch::process()if you intend to write.[!IMPORTANT]
Batch::process()opens one transaction for the whole loop, not one per chunk. Nothing is left half-done if it fails, but the transaction is held for the entire run - worth keeping in mind when processing very large sets, where it means long-lived locks.
Both take a chunk size, defaulting to 100:
The items can be any iterable, including data that isn't entities at all:
For a Query or QueryBuilder, iteratorFor()/processorFor() wrap it in a Doctrine Paginator first:
[!WARNING] Entities hydrated in earlier chunks are detached once that chunk is done. Don't collect them in an array as you go, and don't hold a reference to one across iterations - re-fetch it instead.
[!TIP] When the batch iterator/processor source is countable, the returned iterator is also countable. This makes it super flexible for use with
SymfonyStyle::progressIterate():[!NOTE] An
EntityResulthas both built in -$posts->batchIterate()and$posts->batchProcess()do the same thing without needing the entity manager passed in. Both take the same chunk size argument.
Collection Bridge
DoctrineBridgeCollection wraps a doctrine/collections instance and implements both interfaces at once,
so it is a Doctrine\Common\Collections\Collection and a Zenstruck\Collection. Everything Doctrine's
collection can do still works, and the lazy/paginating/specification API comes along with it:
The interesting part is what happens with an uninitialized relation. Doctrine only needs the whole
collection in memory if you make it load - so filtering with a specification becomes a
Criteria (executed as a query against the relation), and iterating pages the relation instead of
initializing it:
[!NOTE] A
Criteriacan be passed directly if you prefer it to specifications - both end up in the same place.[!TIP] This works for in-memory collections too, not just relations: Doctrine's own
ArrayCollectionisSelectable, sonew DoctrineBridgeCollection(['a', 'b'])accepts specifications whereArrayCollectionwould reject them.
Specifications
A specification is an object that describes a filter or a sort. Unlike a callback, it can be inspected -
which is what lets the Doctrine implementations turn it into a query instead of loading everything and
filtering in PHP. Build them with the Spec factory:
| Factory | Description |
|---|---|
Spec::eq($field, $value) |
Matches when $field == $value |
Spec::lt($field, $value) |
Matches when $field < $value |
Spec::lte($field, $value) |
Matches when $field <= $value |
Spec::gt($field, $value) |
Matches when $field > $value |
Spec::gte($field, $value) |
Matches when $field >= $value |
Spec::in($field, $values) |
Matches when $field is one of $values |
Spec::isNull($field) |
Matches when $field is null |
Spec::contains($field, $value) |
Matches when $field contains $value |
Spec::startsWith($field, $value) |
Matches when $field starts with $value |
Spec::endsWith($field, $value) |
Matches when $field ends with $value |
Spec::between($field, $begin, $end) |
Matches when $begin <= $field <= $end |
Spec::andX(...$specs) |
Matches when every $spec matches |
Spec::orX(...$specs) |
Matches when at least one $spec matches |
Spec::not($spec) |
Matches when $spec does not match |
Spec::sortAsc($field) |
Orders by $field, ascending |
Spec::sortDesc($field) |
Orders by $field, descending |
Spec::callback($callable) |
Drops down to the underlying query object |
[!NOTE] Both
between()bounds are included unless you say otherwise:
String Wildcards
contains(), startsWith() and endsWith() treat * as a wildcard anywhere within the value:
A leading or trailing * is stripped - the specification already adds one on that side:
* is the only wildcard. SQL's own wildcards are escaped, so they match literally and user input is safe to
pass straight through:
[!WARNING] All of this is repository-only. An
EntityResultor a bridged collection passes the value straight through to theCriteria, so*matches a literal asterisk and%/_are left to the database as wildcards.
What Understands What
Specifications go through one of two interpreters, and they don't support the same things:
| Source | Understands |
|---|---|
Repositories (find()/filter()/query()) |
Everything, including the ORM-only specifications below |
EntityResult (filter()/find()) |
The table above, converted to a Criteria |
| Collection Bridge | The table above, converted to a Criteria |
| In-memory collections | Nothing - callables only |
Anything a source doesn't understand throws Zenstruck\Collection\Exception\InvalidSpecification.
ORM-only Specifications
DoctrineSpec extends Spec, so it's a drop-in replacement that adds specifications only a repository can
apply:
| Factory | Description |
|---|---|
DoctrineSpec::instanceOf($class) |
Restrict to a subclass (inheritance mapping) |
DoctrineSpec::readonly() |
Don't track the results in the identity map |
DoctrineSpec::delete() |
Turn the query into a DELETE |
DoctrineSpec::cache($lifetime, $key) |
Enable the result cache |
DoctrineSpec::innerJoin($field) |
Inner join a relation |
DoctrineSpec::leftJoin($field) |
Left join a relation |
DoctrineSpec::antiJoin($field) |
Left join a relation and require it to be empty |
Joins can be fetch-joined with eager() and narrowed with scope(), which applies a specification against
the joined alias rather than the root one:
Callbacks
Spec::callback() hands you the underlying query object when no specification fits. A repository gives you
the query builder and its root alias:
An EntityResult or a bridged collection gives you the Criteria instead. Either modify it directly or
return an Expression to have it added for you:
Custom Specifications
Implement Nested to name a specification you keep repeating. Both interpreters unwrap them recursively, so
yours works wherever the specifications it's built from work - and composes like any other:
Symfony Integration
Enable the bundle:
There is nothing to configure. When DoctrineBundle is installed, the repository services below are registered automatically.
The bundle also registers the @ZenstruckCollection Twig namespace, which is where the
pager templates live:
A Repository For Any Entity
Not every entity deserves its own repository class. ObjectRepositoryFactory builds one on demand for any
entity you have:
Or skip the factory entirely and let #[ForObject] inject the repository itself:
[!NOTE] Repositories are cached per entity class, so asking for the same one twice gives you the same instance. The cache is reset between requests in long-running runtimes.
Custom Repositories as Services
EntityRepository - and skip writing a constructor - put #[ForObject] on the
class instead:
Inject PostRepository like any other service.
[!WARNING] The attribute works by injecting the entity class into
EntityRepository's constructor, so your repository must not define one of its own - the container throws aLogicExceptionwhen compiling if it does.[!NOTE]
#[ForObject]is an autowiring expression, so it requiressymfony/expression-language.
Static Analysis
Everything in this package is generic, and the types survive the whole chain - filtering, hydrating, paginating. This library is analyzed at PHPStan level 8 and ships the annotations for your code to be as well.
Collection<V,K> is templated on both its values and its keys, but the key defaults to array-key, so
Collection<Post> is usually all you need to write:
collect() narrows to the implementation it actually returns, so you keep the extra API of whatever you
passed it:
The Doctrine types follow the same rule - what you hydrate is what you get back:
Recipes
Three passes at the same problem - iterating GitHub's issue search, which hands out 100 results at a time and
reports a total_count - each one fixing something the previous one couldn't do cheaply.
Iterating a Paginated API
Wrapping the "keep requesting until there are none left" loop in a LazyCollection turns
it into something you can iterate, filter and map, and nothing is requested until you do:
Pages are requested as you iterate, and stop being requested when you stop:
Two things are expensive, though:
count() has to walk every page to arrive at a number, and take() reaches an offset by skipping items from
the front - so paginate() gets slower the deeper you go: page 1 costs one request, page 11 costs three, page
50 costs eleven. The next two recipes deal with each in turn.
Counting It Without Walking It
The API reports the total itself, so hand it over with
CallbackCollection - the second callback is only ever called by count():
That's the first problem gone. The second one remains: take() still skips from the front, so
$issues->take(20, 980) is still eleven requests.
Wrapping It in Your Own Collection
Implementing Collection yourself is mostly free - the IterableCollection trait supplies everything except
getIterator() - and it lets you override take() as well, which is the one thing the previous two recipes
can't do:
Both of the expensive operations are now a single request, however deep you reach:
[!TIP]
paginate()is built ontake(), so it inherits this: any page costs one request no matter how deep, and a Full pager gets its total fromcount()for one more.[!NOTE]
@implements Collection<array<string,mixed>,int>is what keeps this type-safe - PHPStan knows whatfirst()returns and what the callbacks receive. Careful with helper names too:pages()is already part of the interface, which is why the private one above isapiPages().
Security Policy
If you discover a security vulnerability, please do not open a public issue or pull request. Instead, please review this repository's Security Policy for instructions on how to report it responsibly.