doctrine/collections
Doctrine Collections is a PHP library providing powerful collection abstractions, including ArrayCollection, typed collections, Criteria filtering, expression-based matching, selectors, and iterators for flexible data manipulation.
doctrine/collections is almost certainly already in your vendor/ directory — it arrives transitively with doctrine/dbal, doctrine/orm, laravel-doctrine, and a long tail of packages that need a typed collection or Criteria. Verify before doing anything:
composer why doctrine/collections
composer show doctrine/collections
Check the version constraint first. The 3.x line requires PHP 8.4 and makes every class final and readonly. Most Laravel apps on PHP 8.1–8.3 will resolve to 2.x. Don't add "doctrine/collections": "^3.0" to composer.json unless your runtime is 8.4+ and nothing else pins you down.
Where to look first, in order:
Doctrine\Common\Collections\ArrayCollection — the only class you'll instantiate directly 95% of the time.Doctrine\Common\Collections\Criteria + Doctrine\Common\Collections\ExpressionBuilder — the filtering DSL.ReadableCollection / Selectable — the interfaces to type-hint against (so you accept both ArrayCollection and lazy/persistent collections).First use case — replace a foreach filter with a reusable Criteria:
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Criteria;
$orders = new ArrayCollection($orders); // or $customer->getOrders()
$criteria = Criteria::create()
->where(Criteria::expr()->eq('status', 'paid'))
->andWhere(Criteria::expr()->gte('total', 100_00))
->orderBy(['createdAt' => Criteria::DESC])
->setMaxResults(20);
$recent = $orders->matching($criteria);
The payoff is that the same Criteria object can be handed to a Doctrine repository ($repo->matching($criteria)) and executed as SQL. That portability is the main reason to reach for this package inside a Laravel app rather than collect().
One Criteria, two execution engines. Build filter objects on Criteria in a service or form request, then use them in-memory in unit tests and against the DB in production:
final class PaidOrders
{
public static function criteria(int $minCents): Criteria
{
return Criteria::create()
->where(Criteria::expr()->eq('status', 'paid'))
->andWhere(Criteria::expr()->gte('total', $minCents))
->orderBy(['createdAt' => Criteria::DESC]);
}
}
// repository (SQL)
$orders = $this->em->getRepository(Order::class)->matching(PaidOrders::criteria(100_00));
// tests / in-memory (array walk)
$orders = (new ArrayCollection($fixtures))->matching(PaidOrders::criteria(100_00));
Doctrine entity relations. Any OneToMany/ManyToMany property must be typed Collection and initialized in the constructor. This is the surface you'll touch daily even if you never write a Criteria:
/**
* @var Collection<int, OrderLine>
*/
#[OneToMany(targetEntity: OrderLine::class, mappedBy: 'order')]
private Collection $lines;
public function __construct()
{
$this->lines = new ArrayCollection();
}
public function addLine(OrderLine $line): void
{
if (!$this->lines->contains($line)) {
$this->lines->add($line);
}
}
Always add the Collection<int, X> docblock — Larastan/PHPStan resolve it, and without it every ->first() is mixed.
Composition, not inheritance. In 3.x ArrayCollection is final, so a domain collection must wrap it rather than extend it. Do this from day one even on 2.x so the upgrade is a no-op:
final class OrderLines implements ReadableCollection
{
/** @param ArrayCollection<int, OrderLine> $lines */
public function __construct(private readonly ArrayCollection $lines) {}
public function total(): int { /* domain logic */ }
public function matching(Criteria $c): Collection { return $this->lines->matching($c); }
// ...delegate the rest
}
Bridging to Laravel collections. Doctrine collections are deliberately thin. For anything beyond map/filter, convert:
$lines = collect($order->getLines()->toArray());
$invoices = $lines->groupBy('taxRate')->map(fn ($g) => $g->sum('amount'));
Convert back when writing to an entity: new ArrayCollection($array). Don't try to pass an Illuminate\Support\Collection into a Doctrine setter.
Lazy collections for expensive hydration. Extend AbstractLazyCollection when the backing data is costly (a report query, an API call) and you want the collection to look eager:
final class LazyReportRows extends AbstractLazyCollection
{
protected function doInitialize(): void
{
$this->collection = new ArrayCollection($this->fetchRows());
}
}
isInitialized() is Psalm-asserted, so if (!$c->isInitialized()) { ... } narrows types properly.
ExpressionBuilder for composable filters. andX() / orX() are variadic (since 1.6.4) and take arrays or expressions, which makes dynamic filter building readable:
$expr = new ExpressionBuilder();
$where = $expr->andX(
$expr->in('status', $statuses),
$expr->isNotNull('paidAt'),
$expr->orX(
$expr->gte('total', $min),
$expr->startsWith('reference', 'VIP-'),
),
);
$collection->matching(Criteria::create()->where($where));
CompositeExpression also supports a NOT type (2.1+), useful for exclusion filters: new CompositeExpression(CompositeExpression::TYPE_NOT, $inner).
Custom matching with closures. ArrayCollection::matching() resolves a closure used as a comparison value against the field value, which lets you express predicates that have no SQL analogue while keeping the Criteria shape:
$expr->eq('shippedAt', static fn ($value) => $value?->isWeekend() === false);
Keep these out of Criteria that you also hand to a repository — SQL won't understand them.
Use it as a test double. When a service depends on ReadableCollection, inject a real ArrayCollection in tests. You get real filtering semantics without a database, and you exercise the same Criteria your production code builds.
add() returns void since 2.0. Code like if ($collection->add($x)) is dead or broken. Same for any old 1.x return true pattern.firstResult/maxResults preserve keys. matching() does not reindex. toArray() then serializes as a JSON object, not an array. Wrap in array_values() before returning from a controller or resource.in/contains on arrays of scalars use strict comparison — '1' will not match 1. Cast IDs coming out of HTTP requests, or you get silently empty results.matching() on ArrayCollection never validates field names. A typo in 'satuts' yields an empty collection; the same Criteria against a repository throws a DB error. Never conclude a Criteria is correct because an in-memory test passed — add one integration test that runs it through the ORM.eq('total', …) resolves to getTotal(), and since 1.7 a bare total() is also accepted. Embeddables work with dot notation ('address.city', 1.7+); arbitrary nested arrays do not.Order enum is deprecated as of 3.1. Use the Criteria::ASC / Criteria::DESC string constants for forward compatibility.null as an offset is deprecated (1.7) and gone in 3.0. $collection[null] and get(null) will bite during upgrade; prefer containsKey().matching() is O(n) per call and re-walks the array. Calling it inside a loop over a large collection is a classic quadratic blowup. Push filtering into the repository, or filter once and reuse the result.__sleep/__wakeup. If you cache or queue an ArrayCollection containing entities, you're serializing the whole object graph. Prefer caching IDs and rehydrating; if you must unserialize(), pass explicit allowed_classes.Criteria::expr() on an ArrayCollection goes through ClosureExpressionVisitor. Step through that single class — it's ~100 lines and explains every "why did this match / not match" question.AbstractLazyCollection::doInitialize() for deferred loading, ExpressionVisitor for a custom Criteria backend, and Selectable::matching() (now on ReadableCollection in 3.0) as the interface to type-hint when you accept both arrays and DB-backed sets.Illuminate\Support\Collection. Use doctrine/collections only where interop demands the Collection interface (Doctrine entities, repositories, third-party signatures) or where a Criteria needs to be portable between memory and SQL. Everywhere else, Laravel's collection is richer and faster to write.How can I help you explore Laravel packages today?