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:
use function Zenstruck\collect;
$page = collect($users) // array, iterable, closure or doctrine collection
->filter(fn(User $user) => $user->isActive())
->map(fn(User $user) => $user->email())
->paginate(page: 2, limit: 10)
;
\count($page); // 10 (the number of items on this page)
$page->totalCount(); // 79 (the total number of items)
$page->lastPage(); // 8
foreach ($page as $email) {
// ...
}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:
use Zenstruck\Collection\Doctrine\ORM\EntityResult;
$qb = $em->createQueryBuilder()
->select('p')
->from(Post::class, 'p')
->where('p.status = :status')
->setParameter('status', 'published')
;
$published = new EntityResult($qb); // nothing has been executed yetAn 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:
\count($published); // SELECT COUNT(...)
$published->first(); // ...LIMIT 1
$published->paginate(page: 2, limit: 10); // a single, paginated query
$published->asArray('id', 'title')->first(); // ['id' => 1, 'title' => '...'] - no entity hydrated
foreach ($published as $post) {
// ...
}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.
composer require zenstruck/collectionThe core Collection API has no dependencies. The following packages unlock the optional integrations:
| Package | Required for |
|---|---|
doctrine/orm (>=2.20.7) |
ORM and Batch Processing |
doctrine/collections |
Collection Bridge |
doctrine/doctrine-bundle |
Symfony Integration |
symfony/expression-language |
The #[ForObject] autowiring attribute |
pagerfanta/pagerfanta |
PagerfantaAdapter |
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.
The Zenstruck\collect() function wraps any source in the most appropriate implementation:
use function Zenstruck\collect;
collect(['a', 'b']); // ArrayCollection
collect(new \ArrayIterator(['a'])); // LazyCollection
collect(fn() => fetch_rows()); // LazyCollection (callback not executed yet)
collect($doctrineCollection); // DoctrineBridgeCollection
collect(); // empty LazyCollection| 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.
/** @var Zenstruck\Collection<Post> $posts */
// transformations - each returns a new Collection, the original is untouched
$posts->filter(fn(Post $post) => $post->isPublished());
$posts->map(fn(Post $post) => $post->title());
$posts->keyBy(fn(Post $post) => $post->id());
$posts->take(10); // first 10
$posts->take(10, 5); // 10, starting at offset 5
// reductions
$posts->first(); // first item or null
$posts->first($default); // first item or $default
$posts->find(fn(Post $post) => $post->isPublished()); // first match or null
$posts->reduce(fn(int $count, Post $post) => $count + 1, 0);
$posts->isEmpty();
\count($posts);
foreach ($posts as $post) {
// ...
}
$posts->eager(); // load everything into an ArrayCollection
$posts->paginate(); // see "Pagination" below
$posts->dump(); // dump the items and return $this
$posts->dd(); // dump the items and diefilter() and find() take a callable(V,K):bool:
$posts->filter(fn(Post $post, int $key) => $post->isPublished());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,
LazyCollection or any other in-memory implementation throws
Zenstruck\Collection\Exception\InvalidSpecification.
LazyCollection (and the Doctrine implementations) do no work until iterated. Transformations stay lazy -
they wrap the source rather than run it:
$titles = collect(fn() => fetch_rows()) // nothing has run yet
->filter(fn(array $row) => $row['published'])
->map(fn(array $row) => $row['title'])
->take(10)
; // still nothing has run
foreach ($titles as $title) {
// NOW the source is iterated - and stops after 10 matches
}| 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() (and ArrayCollection, which is
array-backed to begin with) holds the whole collection in memory - everything else streams one item at a
time. That's also why eager() is useful: it lets you iterate repeatedly without re-running an expensive
source.
Warning
Generators can't be rewound, so LazyCollection rejects them outright - wrap in a closure instead:
new LazyCollection($generator); // throws \InvalidArgumentException
new LazyCollection(fn() => $items()); // ok - re-invoked on each iterationA closure returning an array/Traversable is 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't Countable has to iterate all of it. If you have a cheaper way to count,
use CallbackCollection.
An eager, immutable, array-backed implementation with a much larger API. "Mutations" (set(), unset(),
push()) return a new instance:
use Zenstruck\Collection\ArrayCollection;
$collection = new ArrayCollection(['a' => 1, 'b' => 2]);
$collection->set('c', 3); // new instance, $collection is unchangedNamed 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.
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:
use Zenstruck\Collection\LazyCollection;
$users = new LazyCollection(function() {
$page = 1;
while ($response = $api->get('/users', ['page' => $page++])) {
yield from $response->toArray();
}
});
$users->take(50); // only fetches as many pages as needed| 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 |
use Zenstruck\Collection\CallbackCollection;
use Zenstruck\Collection\ChainCollection;
use Zenstruck\Collection\FactoryCollection;
use function Zenstruck\collect;
new ChainCollection([$collection1, $collection2]); // keys are discarded
new ChainCollection([$collection1, $collection2], true); // keys are preserved
// count without iterating
new CallbackCollection(fn() => $api->results(), fn() => $api->totalCount());
// $post is only created for items you actually iterate over
new FactoryCollection(collect($rows), fn(array $row) => Post::fromArray($row));Note
FactoryCollection decorates another Collection, so wrap plain iterables in collect() 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 via eager()).
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:
/** @var Zenstruck\Collection<Post> $posts */
$page = $posts->paginate(); // page 1, 20 per page
$page = $posts->paginate(page: 3, limit: 50);
foreach ($page as $post) {
// only the 50 posts on page 3
}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, a SELECT COUNT(...) for Doctrine, and a full iteration for a generator or
an API-backed LazyCollection - see Lazy vs Eager.
Everything a previous/next pager needs comes from the page itself, without counting anything:
$page = $posts->paginate(page: 2, limit: 20); // takes 21 items, starting at 20
$page->currentPage(); // 2
\count($page); // 20
$page->hasMorePages(); // true
$page->nextPage(); // 3
$page->previousPage(); // 1
$page->haveToPaginate(); // true
foreach ($page as $post) {
// ...
}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() and nextPage() report nothing
follows, previousPage() still works. See Strict Mode to fall back to the last page
instead.
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:
$page->totalCount(); // 138
$page->lastPage(); // 7
$page->pageCount(); // 7
$page->firstPage(); // 1| 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 1 becomes 1 and a limit
less than 1 becomes the default (20).
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:
$page = $posts->paginate(page: 999, limit: 20)->strict();
$page->currentPage(); // 7 - the last page, not 999
\count($page); // 18 - and these are the last page's itemsThis 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. Keep strict() to Full pagers if that matters.
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:
{# previous/next - counts nothing #}
{{ include('@ZenstruckCollection/Pager/_simple.html.twig', {page: page}) }}
{# numbered - counts once #}
{{ include('@ZenstruckCollection/Pager/_full.html.twig', {page: page}) }}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:
<ul class="pager pager-simple">
<li><a href="/posts?page=2" rel="prev">Previous</a></li>
<li><a href="/posts?page=4" rel="next">Next</a></li>
</ul>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) |
{{ include('@ZenstruckCollection/Pager/_full.html.twig', {
page: page,
route: 'post_archive',
params: {year: 2026},
key: 'p',
window: 2,
}) }}Note
These require Twig and Symfony's routing (path()), and are registered by the
bundle.
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:
/* assets/styles/app.css */
@import 'tailwindcss';
.pager {
@apply flex items-center gap-1 text-sm;
}
.pager a,
.pager span {
@apply block rounded-md px-3 py-2;
}
.pager a {
@apply text-gray-700 hover:bg-gray-100;
}
.pager .active span {
@apply bg-gray-900 font-medium text-white;
}
.pager .disabled span {
@apply text-gray-400;
}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:
foreach ($posts->pages(100) as $page) {
foreach ($page as $post) {
// ...
}
}
$pages = $posts->pages(100);
$pages->get(3); // the Page for page 3
\count($pages); // the number of pages (0 when the collection is empty)Note
count() on Pages is the number of pages, while count() on a Page is the number of items on that
page. Use Page::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).
If you'd rather render pagers with Pagerfanta, any collection can be adapted:
use Pagerfanta\Pagerfanta;
use Zenstruck\Collection\Pagerfanta\PagerfantaAdapter;
$pagerfanta = new Pagerfanta(new PagerfantaAdapter($posts));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.
use Zenstruck\Collection\Doctrine\ORM\EntityResult;
use Zenstruck\Collection\Doctrine\ORM\EntityResultQueryBuilder;
// wrap any query builder
$result = new EntityResult($qb);
// ...or use the one that can create the result itself
$result = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
->where('p.status = :status')
->setParameter('status', 'published')
->result()
;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 EntityResult streams the rows one at a time but never clears the entity manager, so every
entity it hydrates stays in memory. For large result sets, use
batchIterate()/batchProcess() instead.
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:
\count($result); // SELECT COUNT(...)
$result->first(); // ...LIMIT 1 (or null when there are no rows)
$result->first($default); // ...or your default
$result->paginate(); // one paginated query - see "Pagination"
$result->take(10, 20); // 10 rows, starting at offset 20
$result->eager(); // everything, as an ArrayCollection
$result->isEmpty();
foreach ($result as $post) {
// ...
}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:
$page = $result->paginate(page: 2, limit: 20);
$page->hasMorePages(); // SELECT ... LIMIT 21 OFFSET 20
$page->nextPage(); // (already fetched)
$page->totalCount(); // SELECT COUNT(...)
$page->lastPage(); // (already counted)Because it's immutable, deriving is free and the original stays usable:
$recent = $result->filter(Spec::gt('publishedAt', $cutoff)); // $result is unchangedBy 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 |
$result->asArray(); // ['id' => 1, 'title' => 'My Post', ...]
$result->asArray('id', 'title'); // ['id' => 1, 'title' => 'My Post']
$result->asInt('id'); // 1, 2, 3...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:
// entities in, DTOs out
$dtos = $result->as(fn(Post $post) => PostDto::from($post));
// only select what the DTO needs, and never hydrate an entity at all
$dtos = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
->select('p.id, p.title')
->result()
->as(fn(array $row) => new PostDto($row['id'], $row['title']))
;
// or let asArray() pick the fields
$dtos = $result
->asArray('id', 'title')
->as(fn(array $row) => new PostDto(...$row))
;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 behind
asInt()/asFloat()/asString() and the wrapper behind withAggregates(). Combine as()
with asArray() or a field-selecting query, not with those.
A query that selects one aggregate value works the same way - ask for the scalar and take the first row:
$total = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
->select('SUM(p.views)')
->result()
->asInt()
->first()
;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:
$deleted = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
->delete()
->where('p.status = :status')
->setParameter('status', 'spam')
->result()
->asInt()
->first()
;
$updated = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
->update()
->set('p.status', ':status')
->setParameter('status', 'archived')
->where('p.publishedAt < :cutoff')
->setParameter('cutoff', $cutoff)
->result()
->asInt()
->first()
;Warning
A write query has no rows to give you, so iterating one - or calling eager() on it - throws a
\LogicException. Use first(). Note also that asking twice runs the query twice.
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:
foreach ($result->readonly() as $post) {
// $post is not managed
}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:
$result = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
->leftJoin('p.comments', 'c')
->addSelect('COUNT(c.id) AS commentCount')
->groupBy('p.id')
->result()
->withAggregates()
;
foreach ($result as $post) {
$post->title(); // proxied to the Post
$post->commentCount; // the aggregate column
$post->entity(); // the Post itself
$post->aggregates(); // ['commentCount' => 12]
}Warning
Only call withAggregates() when the query really does select extra columns - iterating throws a
\LogicException otherwise. Doctrine can't iterate aggregate results directly, so they're chunked into
groups of 20, each requiring an additional query.
Counting and paginating go through Doctrine's Paginator, which is configurable:
$result = $result->disableFetchJoins(); // faster when the query has no fetch-joined collections
$result = $result->disableOutputWalkers();
$result = $result->enableOutputWalkers(); // required for some queries (ie HAVING, complex ORDER BY)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.
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:
use Zenstruck\Collection\Spec;
/** @var Zenstruck\Collection\Doctrine\ObjectRepository<Post> $posts */
$posts->find(1); // a single Post, or null
$posts->find(['slug' => 'my-post']);
$published = $posts->filter(Spec::eq('status', 'published')); // no query yet
$published->paginate(page: 2);
$published->asArray('id', 'title');
\count($posts);
foreach ($posts as $post) {
// ...
}Tip
find(), filter() and query() 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).
The standalone implementation. Use it directly for any entity:
use Zenstruck\Collection\Doctrine\ORM\EntityRepository;
use Zenstruck\Collection\Spec;
$posts = new EntityRepository($em, Post::class);
$posts->find(1);
$posts->filter(Spec::eq('status', 'published'));Or extend it for your custom repositories, passing the entity class up to the parent constructor:
use Doctrine\ORM\EntityManagerInterface;
use Zenstruck\Collection\Doctrine\ORM\EntityRepository;
use Zenstruck\Collection\Doctrine\ORM\EntityResult;
/**
* @extends EntityRepository<Post>
*/
final class PostRepository extends EntityRepository
{
public function __construct(EntityManagerInterface $em)
{
parent::__construct($em, Post::class);
}
/**
* @return EntityResult<Post>
*/
public function published(): EntityResult
{
return $this->qb('p')
->where('p.status = :status')
->setParameter('status', 'published')
->result()
;
}
}Tip
The protected qb() helper returns an EntityResultQueryBuilder already scoped to your
entity, using the alias you pass it (e by default). It's a Doctrine QueryBuilder, 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\EntityRepository and has none of its
methods (findAll(), findOneBy(), matching(), ...) - just the ObjectRepository API above. If you want
both, use one of the bridges below.
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:
use Zenstruck\Collection\Doctrine\ORM\Bridge\ORMEntityRepository;
/**
* @extends ORMEntityRepository<Post>
*/
final class PostRepository extends ORMEntityRepository
{
}use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post
{
// ...
}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 EntityRepositoryBridge trait to it directly -
that's all these bridge classes do.
The same bridge, but extending DoctrineBundle's ServiceEntityRepository so the repository is autowireable in
a Symfony application:
use Doctrine\Persistence\ManagerRegistry;
use Zenstruck\Collection\Doctrine\ORM\Bridge\ORMServiceEntityRepository;
/**
* @extends ORMServiceEntityRepository<Post>
*/
final class PostRepository extends ORMServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Post::class);
}
}Inject it like any other service - see Symfony Integration for autowiring a repository for entities that don't have a repository class at all.
find() returns a single entity, or null if there isn't one:
use Doctrine\ORM\QueryBuilder;
use Zenstruck\Collection\Spec;
$posts->find(1); // by id
$posts->find(['slug' => 'my-post']); // by criteria
$posts->find(Spec::eq('slug', 'my-post')); // by specification
$posts->find(fn(QueryBuilder $qb, string $alias) => $qb->andWhere("{$alias}.views > 100"));filter() narrows the repository down to an EntityResult you can iterate, paginate or hydrate:
$published = $posts->filter(Spec::eq('status', 'published'));
$published = $posts->filter(['status' => 'published']);
$everything = $posts->filter(null);
$published->paginate();
$published->asArray('id', 'title');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:
$published->filter(Spec::contains('title', 'symfony')); // ok
$published->filter(DoctrineSpec::delete()); // throws InvalidSpecificationquery() accepts everything filter() does, and is the one to reach for when the specification changes the
database rather than narrowing a read:
use Zenstruck\Collection\Doctrine\DoctrineSpec;
$deleted = $posts
->query(DoctrineSpec::andX(
DoctrineSpec::lt('publishedAt', $cutoff),
DoctrineSpec::delete(),
))
->first() // executes the DELETE and returns the number of affected rows
;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:
use Doctrine\ORM\QueryBuilder;
final class Trending
{
public function __invoke(QueryBuilder $qb, string $alias): void
{
$qb
->andWhere("{$alias}.views > 1000")
->addOrderBy("{$alias}.views", 'DESC')
;
}
}
$posts->filter(new Trending());
$posts->find(new Trending());Warning
This only applies to repositories. An EntityResult or a
bridged collection treats an invokable object as a plain callable(V,K):bool and
filters in PHP instead. Wrap it in Spec::callback() if you need it understood by both.
\count($posts);
foreach ($posts as $post) {
// ...
}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.
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:
use Zenstruck\Collection\Doctrine\Batch;
/** @var iterable<Post> $posts */
// read: nothing is flushed, the entity manager is cleared after each chunk
foreach (Batch::iterate($posts, $em) as $post) {
$csvExporter->addRow([$post->id(), $post->title()]);
}
// write: flushed and cleared after each chunk
foreach (Batch::process($posts, $em) as $post) {
$post->recalculateScore(); // no persist() or flush() needed
}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 - use Batch::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:
Batch::process($posts, $em, chunkSize: 500);The items can be any iterable, including data that isn't entities at all:
foreach (Batch::process($csvRows, $em) as $row) {
$em->persist(Post::fromRow($row)); // flushed every 100 rows, in one transaction
}For a Query or QueryBuilder, iteratorFor()/processorFor() wrap it in a Doctrine Paginator first:
foreach (Batch::iteratorFor($qb) as $post) {
// ...
}
foreach (Batch::processorFor($query, chunkSize: 50) as $post) {
// ...
}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():
use Symfony\Component\Console\Style\SymfonyStyle;
/** @var SymfonyStyle $io */
// if $csvRows is countable, this is a progress bar with a limit - otherwise it's open ended
foreach ($io->progressIterate(Batch::process($csvRows, $em)) as $row) {
$em->persist(Post::fromRow($row));
}Note
An EntityResult has 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.
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:
use function Zenstruck\collect;
$comments = collect($post->getComments()); // a DoctrineBridgeCollection
// the Doctrine API
$comments->add($comment);
$comments->removeElement($comment);
$comments->containsKey(3);
// ...and this package's
$comments->paginate(page: 2, limit: 10);
$comments->filter(Spec::eq('approved', true));
$comments->map(fn(Comment $comment) => $comment->author());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:
// a single query with a WHERE, not "load all comments, then filter"
$approved = $comments->filter(Spec::eq('approved', true));
// paginated queries, not one big fetch
foreach ($comments as $comment) {
// ...
}Note
A Criteria can 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 ArrayCollection is
Selectable, so new DoctrineBridgeCollection(['a', 'b']) accepts specifications where
ArrayCollection would reject them.
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:
use Zenstruck\Collection\Spec;
$posts->filter(Spec::andX(
Spec::eq('status', 'published'),
Spec::contains('title', 'symfony'),
Spec::sortDesc('publishedAt'),
));| 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:
use Zenstruck\Collection\Specification\Filter\Between;
Spec::between('publishedAt', $start, $end); // both included
Spec::between('publishedAt', $start, $end, Between::EXCLUSIVE); // both excluded
Spec::between('publishedAt', $start, $end, Between::INCLUSIVE_BEGIN); // begin included, end excluded
Spec::between('publishedAt', $start, $end, Between::EXCLUSIVE_BEGIN); // begin excluded, end includedcontains(), startsWith() and endsWith() treat * as a wildcard anywhere within the value:
Spec::contains('title', 'my*post'); // LIKE '%my%post%'
Spec::startsWith('title', 'my*post'); // LIKE 'my%post%'
Spec::endsWith('title', 'my*post'); // LIKE '%my%post'A leading or trailing * is stripped - the specification already adds one on that side:
Spec::contains('title', '*symfony*'); // identical to Spec::contains('title', 'symfony')* is the only wildcard. SQL's own wildcards are escaped, so they match literally and user input is safe to
pass straight through:
Spec::contains('title', '50%'); // titles containing "50%"
Spec::startsWith('code', 'a_b'); // codes starting with "a_b" - the underscore isn't a wildcardWarning
All of this is repository-only. An EntityResult or a
bridged collection passes the value straight through to the Criteria, so * matches
a literal asterisk and %/_ are left to the database as wildcards.
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.
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:
use Zenstruck\Collection\Doctrine\DoctrineSpec;
$posts->filter(DoctrineSpec::andX(
DoctrineSpec::eq('status', 'published'),
DoctrineSpec::innerJoin('category')
->eager() // also SELECT the category
->scope(DoctrineSpec::eq('name', 'php')), // category.name = 'php'
DoctrineSpec::antiJoin('comments'), // ...that nobody has commented on
));Spec::callback() hands you the underlying query object when no specification fits. A repository gives you
the query builder and its root alias:
use Doctrine\ORM\QueryBuilder;
$posts->filter(Spec::callback(
fn(QueryBuilder $qb, string $alias) => $qb->andWhere("{$alias}.views > 100"),
));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:
use Doctrine\Common\Collections\Criteria;
$comments->filter(Spec::callback(
fn(Criteria $criteria) => $criteria->andWhere(Criteria::expr()->gt('score', 10)),
));
$comments->filter(Spec::callback(
fn(Criteria $criteria) => Criteria::expr()->gt('score', 10),
));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:
use Zenstruck\Collection\Spec;
use Zenstruck\Collection\Specification\Nested;
final class Published implements Nested
{
public function specification(): mixed
{
return Spec::andX(
Spec::eq('status', 'published'),
Spec::isNull('deletedAt'),
);
}
}
$posts->filter(new Published());
$posts->filter(Spec::not(new Published()));Enable the bundle:
// config/bundles.php
return [
// ...
Zenstruck\Collection\Symfony\ZenstruckCollectionBundle::class => ['all' => true],
];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:
{{ include('@ZenstruckCollection/Pager/_simple.html.twig', {page: page}) }}Not every entity deserves its own repository class. ObjectRepositoryFactory builds one on demand for any
entity you have:
use Zenstruck\Collection\Doctrine\ObjectRepositoryFactory;
final class PostController
{
public function __construct(private ObjectRepositoryFactory $repositories)
{
}
public function index(): Response
{
$posts = $this->repositories->create(Post::class); // an ObjectRepository<Post>
// ...
}
}Or skip the factory entirely and let #[ForObject] inject the repository itself:
use Zenstruck\Collection\Doctrine\ObjectRepository;
use Zenstruck\Collection\Symfony\Attributes\ForObject;
final class PostController
{
public function __construct(
#[ForObject(Post::class)]
private ObjectRepository $posts,
) {
}
}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.
ORMServiceEntityRepository is autowireable out of the box. If you'd rather
extend EntityRepository - and skip writing a constructor - put #[ForObject] on the
class instead:
use Zenstruck\Collection\Doctrine\ORM\EntityRepository;
use Zenstruck\Collection\Doctrine\ORM\EntityResult;
use Zenstruck\Collection\Symfony\Attributes\ForObject;
/**
* @extends EntityRepository<Post>
*/
#[ForObject(Post::class)]
final class PostRepository extends EntityRepository
{
/**
* @return EntityResult<Post>
*/
public function published(): EntityResult
{
return $this->qb('p')
->where('p.status = :status')
->setParameter('status', 'published')
->result()
;
}
}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 a LogicException when compiling if it does.
Note
#[ForObject] is an autowiring expression, so it requires symfony/expression-language.
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:
/** @var Collection<Post> $posts */
$posts->first(); // Post|null
$posts->map(fn(Post $post) => $post->title()); // Collection<string>
$posts->paginate(); // Page<Post,int>
$posts->eager()->all(); // array<Post>collect() narrows to the implementation it actually returns, so you keep the extra API of whatever you
passed it:
collect(['a', 'b']); // ArrayCollection<string>
collect($doctrineThing); // DoctrineBridgeCollection<Post>The Doctrine types follow the same rule - what you hydrate is what you get back:
/** @var EntityRepository<Post> $posts */
$posts->find(1); // Post|null
$posts->query(null); // EntityResult<Post>
$posts->query(null)->asInt('id'); // EntityResult<int>
$posts->query(null)->as(fn(Post $p) => $p->dto()); // EntityResult<PostDto>
$posts->query(null)->withAggregates(); // EntityResult<EntityWithAggregates<Post>>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.
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:
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Zenstruck\Collection\LazyCollection;
/** @var HttpClientInterface $client */
$query = ['q' => 'repo:symfony/symfony is:issue'];
$issues = new LazyCollection(function() use ($client, $query) {
$page = 1;
while ($items = $client->request('GET', 'https://api.github.com/search/issues', [
'query' => $query + ['per_page' => 100, 'page' => $page++],
])->toArray()['items']) {
yield from $items;
}
});Pages are requested as you iterate, and stop being requested when you stop:
foreach ($issues as $issue) {
// ...
}
$issues->first(); // one request
$issues->take(150); // two
$issues
->filter(fn(array $issue) => \str_contains($issue['title'], 'pagination'))
->map(fn(array $issue) => $issue['title'])
->take(3) // nothing requested yet
;Two things are expensive, though:
\count($issues); // 22202 - after requesting all 223 pages
$issues->take(20, 980); // 11 requests - it skipped 980 items to get therecount() 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.
The API reports the total itself, so hand it over with
CallbackCollection - the second callback is only ever called by count():
use Zenstruck\Collection\CallbackCollection;
$query = ['q' => 'repo:symfony/symfony is:issue'];
$issues = new CallbackCollection(
function() use ($client, $query) {
$page = 1;
while ($items = $client->request('GET', 'https://api.github.com/search/issues', [
'query' => $query + ['per_page' => 100, 'page' => $page++],
])->toArray()['items']) {
yield from $items;
}
},
fn() => $client->request('GET', 'https://api.github.com/search/issues', [
'query' => $query + ['per_page' => 1],
])->toArray()['total_count'],
);
\count($issues); // 22202 - one request
$issues->isEmpty(); // ...the same requestThat's the first problem gone. The second one remains: take() still skips from the front, so
$issues->take(20, 980) is still eleven requests.
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:
namespace App\GitHub;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Zenstruck\Collection;
use Zenstruck\Collection\IterableCollection;
use Zenstruck\Collection\LazyCollection;
/**
* @implements Collection<array<string,mixed>,int>
*/
final class IssueSearch implements Collection
{
/** @use IterableCollection<array<string,mixed>,int> */
use IterableCollection;
private const PER_PAGE = 100; // the API's maximum
public function __construct(private HttpClientInterface $client, private string $query)
{
}
public function getIterator(): \Traversable
{
foreach ($this->apiPages() as $items) {
yield from $items;
}
}
public function count(): int
{
return $this->request(['per_page' => 1])['total_count'];
}
public function take(int $limit, int $offset = 0): Collection
{
return new LazyCollection(function() use ($limit, $offset) {
$skip = $offset % self::PER_PAGE;
// start at the page the window begins in, instead of skipping from the front
foreach ($this->apiPages(\intdiv($offset, self::PER_PAGE) + 1) as $items) {
foreach (\array_slice($items, $skip) as $item) {
yield $item;
if (0 === --$limit) {
return;
}
}
$skip = 0;
}
});
}
/**
* @return \Traversable<int,list<array<string,mixed>>>
*/
private function apiPages(int $from = 1): \Traversable
{
while ($items = $this->request(['per_page' => self::PER_PAGE, 'page' => $from++])['items']) {
yield $items;
}
}
/**
* @param array<string,mixed> $query
*
* @return array<string,mixed>
*/
private function request(array $query): array
{
return $this->client->request('GET', 'https://api.github.com/search/issues', [
'query' => $query + ['q' => $this->query],
])->toArray();
}
}Both of the expensive operations are now a single request, however deep you reach:
$issues = new IssueSearch($client, 'repo:symfony/symfony is:issue');
\count($issues); // 22202 - one request
$issues->take(20); // one request
$issues->take(20, 980); // still one - it asks for the page the window is inTip
paginate() is built on take(), so it inherits this: any page costs one request no matter how deep, and a
Full pager gets its total from count() for one more.
Note
@implements Collection<array<string,mixed>,int> is what keeps this type-safe - PHPStan knows what
first() 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 is apiPages().
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.