Skip to content

Repository files navigation

zenstruck/collection

CI codecov

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 yet

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:

\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:

  1. Collections: A lazy, countable, paginatable Collection interface with implementations for arrays, iterables and callables.
  2. Pagination: Paginate any collection, with page metadata and lazy page-by-page iteration.
  3. Doctrine:
    1. ORM: Lazy, specification-driven repositories with DTO/scalar hydration.
    2. Batch Processing: Memory-safe iteration/mutation of large result sets.
    3. Collection Bridge: Filter and paginate doctrine/collections instances - including relations - without initializing them.
    4. Specifications: Express filters/sorts as objects that are converted into native queries.
  4. Symfony Integration: Autowire a lazy-first repository for any entity.
  5. Static Analysis: Fully generic - PHPStan knows what your collections contain.
  6. Recipes: Worked examples for things that aren't a database.

Installation

composer require zenstruck/collection

The 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

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:

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.

The API

/** @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 die

Filtering

filter() 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.

Lazy vs Eager

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 iteration

A 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.

ArrayCollection

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 unchanged

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:

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

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
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()).

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:

/** @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.

Simple

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.

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:

$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).

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:

$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 items

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. Keep strict() 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:

{# 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.

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:

/* 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;
}

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:

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).

Pagerfanta

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));

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.

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.

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:

\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 unchanged
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
$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.

Single Values

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()
;
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:

$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 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:

foreach ($result->readonly() as $post) {
    // $post is not managed
}
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:

$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.

Tuning Pagination

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.

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:

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).

EntityRepository

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.

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:

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.

ORMServiceEntityRepository

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.

Finding

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"));
Filtering

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 InvalidSpecification
Querying

query() 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
;
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:

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.

Iterating and Counting
\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.

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:

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.

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:

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.

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:

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 included

String Wildcards

contains(), 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 wildcard

Warning

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.

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:

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
));

Callbacks

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),
));

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:

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()));

Symfony Integration

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}) }}

A Repository For Any Entity

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.

Custom Repositories as Services

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.

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:

/** @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>>

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:

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 there

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():

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 request

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:

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 in

Tip

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().

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.

About

Iterate, filter and paginate anything - arrays, generators or Doctrine ORM queries - without loading it all into memory.

Topics

Resources

Security policy

Stars

18 stars

Watchers

3 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages