zenstruck/collection
A lightweight PHP collection library by Zenstruck offering fluent, immutable-style operations for arrays and iterables. Provides map/filter/reduce, grouping, sorting, slicing, and helpful utilities to work with data sets cleanly in Laravel or standalone PHP.
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:
Collection interface with implementations
for arrays, iterables and callables.doctrine/collections instances -
including relations - without initializing them.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 |
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 die
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,LazyCollectionor any other in-memory implementation throwsZenstruck\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 |
first() |
Yes | Stops at the first item |
find() |
Yes | Stops at the first match |
Page::hasMorePages() |
Yes | One item past the end of the page |
count(), isEmpty() |
Only if the source isn't Countable |
Counts, keeping nothing |
reduce() |
Yes | All of it, keeping nothing |
eager() |
Yes | All of it, kept in memory |
[!IMPORTANT] "Runs the source" is not the same as "loads the source". Only
eager()(andArrayCollection, which is array-backed to begin with) holds the whole collection in memory - everything else streams one item at a time. That's also whyeager()is useful: it lets you iterate repeatedly without re-running an expensive source.
[!WARNING] Generators can't be rewound, so
LazyCollectionrejects them outright - wrap in a closure instead:new LazyCollection($generator); // throws \InvalidArgumentException new LazyCollection(fn() => $items()); // ok - re-invoked on each iterationA closure returning an
array/Traversableis only executed once and cached; a closure returning a generator is re-executed every time the collection is iterated.
[!TIP]
count()on a source that isn'tCountablehas to iterate all of it. If you have a cheaper way to count, useCallbackCollection.
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.
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]
FactoryCollectiondecorates anotherCollection, so wrap plain iterables incollect()first.
[!WARNING] When preserving keys with
ChainCollection, duplicate keys across the inner collections will overwrite each other if the result is converted to an array (ie viaeager()).
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, aSELECT COUNT(...)for Doctrine, and a full iteration for a generator or an API-backedLazyCollection- 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 items are fetched once and cached, so iterating the same Page more than once won't re-run the source.
[!NOTE] Ask for a page past the end and you get an empty one:
hasMorePages()andnextPage()report nothing follows,previousPage()still works. See Strict Mode to fall back to the last page instead.
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
1becomes1and a limit less than1becomes 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 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. Keepstrict()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 - copy it into your app and adjust rather than fighting it:
<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.
pages() returns a Pages object - a lazy, page-by-page view of the entire collection. Each page is a
separate query, so this is a memory-safe way to walk a large result set:
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()onPagesis the number of pages, whilecount()on aPageis the number of items on that page. UsePage::totalCount()for the total number of items.
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
EntityResultstreams the rows one at a time but never clears the entity manager, so every entity it hydrates stays in memory. For large result sets, usebatchIterate()/batchProcess()instead.
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
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 behindasInt()/asFloat()/asString()and the wrapper behindwithAggregates(). Combineas()withasArray()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. Usefirst(). 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\LogicExceptionotherwise. 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()andquery()all accept specifications - reusable filter objects that are converted into the query itself.
Three implementations are available - they differ only in what your repository also is: nothing else, or a Doctrine repository (in standard and Symfony-autowireable variants).
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 anEntityResultQueryBuilderalready scoped to your entity, using the alias you pass it (eby default). It's a DoctrineQueryBuilder, so build the query however you like -->result()is waiting at the end of the chain.
[!TIP] The protected
em()helper gives you the entity manager, for anything the query builder can't do.
[!IMPORTANT] This is not a Doctrine repository. It doesn't extend
Doctrine\ORM\EntityRepositoryand has none of its methods (findAll(),findOneBy(),matching(), ...) - just theObjectRepositoryAPI above. If you want both, use one of the bridges below.
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
EntityRepositoryBridgetrait 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 InvalidSpecification
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
;
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
EntityResultor a bridged collection treats an invokable object as a plaincallable(V,K):booland filters in PHP instead. Wrap it inSpec::callback()if you need it understood by both.
\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 - useBatch::process()if you intend to write.
[!IMPORTANT]
Batch::process()opens one transaction for the whole loop, not one per chunk. Nothing is left half-done if it fails, but the transaction is held for the entire run - worth keeping in mind when processing very large sets, where it means long-lived locks.
Both take a chunk size, defaulting to 100:
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
EntityResulthas both built in -$posts->batchIterate()and$posts->batchProcess()do the same thing without needing the entity manager passed in. Both take the same chunk size argument.
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
Criteriacan be passed directly if you prefer it to specifications - both end up in the same place.
[!TIP] This works for in-memory collections too, not just relations: Doctrine's own
ArrayCollectionisSelectable, sonew DoctrineBridgeCollection(['a', 'b'])accepts specifications whereArrayCollectionwould reject them.
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
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
EntityResultor a bridged collection passes the value straight through to theCriteria, 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 aLogicExceptionwhen compiling if it does.
[!NOTE]
#[ForObject]is an autowiring expression, so it requiressymfony/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>>
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.
How can I help you explore Laravel packages today?