Technology Apr 28, 2026 · 14 min read

PHP collection libraries in 2026: doctrine, illuminate, loophp, noctud, ramsey

If you've written PHP for any length of time, you've fought the array. It's a hash table, a list, a tuple, and an associative store all crammed into one type, and it silently casts your keys to whatever it feels like. Every serious project eventually reaches for something better. There are a few rea...

DE
DEV Community
by delacry
PHP collection libraries in 2026: doctrine, illuminate, loophp, noctud, ramsey

If you've written PHP for any length of time, you've fought the array. It's a hash table, a list, a tuple, and an associative store all crammed into one type, and it silently casts your keys to whatever it feels like. Every serious project eventually reaches for something better. There are a few reasonable places to reach.

I wrote one of the five libraries this post covers (noctud/collection). I've tried to be fair, including pointing you at the right library when it isn't mine. Weigh that as you read.

The five contenders

doctrine/collections. The collections layer underneath Doctrine ORM. Mature, stable, present as a transitive dependency in a huge chunk of the PHP ecosystem.

illuminate/collections. Laravel's Collection class. Probably the most-installed PHP utility class that exists. Around 140 chainable helper methods, fluent API.

loophp/collection. Pol Dellaiera's lazy collection library, built around generators. FP-style, immutable, strong static analysis story.

noctud/collection. Modern PHP 8.4+ library. Separate mutable/immutable types, full generics, key-preserving Maps. Currently v0.1.1.

ramsey/collection. Ben Ramsey's standalone typed collection library. No framework dependency, runtime type enforcement, well-known beyond the Symfony/Laravel split.

A note on shape: most PHP "collections" are really maps

Before getting into the per-library breakdown, there's a structural distinction worth flagging that decides a lot of what follows.

PHP's array is a weird shape. It's both a list ([1, 2, 3]) and an associative map (['a' => 1, 'b' => 2]) at the same time, depending on what keys end up in it. Most PHP "collection libraries" inherit this and end up as a single Collection class that's really an array<TKey, TValue> wrapper underneath. There's no separate type for "list of items" or "set of unique items" or "key-value pairs"; there's just one Collection that happens to have keys you can choose to ignore.

Real collection design (the kind you see in Java, Kotlin, C#, Python, Rust) keeps these shapes separate:

  • List. Positional, indexed by 0-based int, duplicates allowed.
  • Set. Unique elements, no positional access.
  • Map. Key-value pairs, keys are unique.

The distinction isn't pedantic. It shapes the API in real ways:

// Single-Collection style (the array-wrapper approach)
$col = collect([1, 2, 3, 2]);
$col->forget(1); // removes AT INDEX 1, leaving [0 => 1, 2 => 3, 3 => 2]

// Distinct types
$list = listOf([1, 2, 3, 2]);
$list->removeAt(1); // [1, 3, 2] - by position
$list->removeElement(2); // [1, 3, 2] - by value, removes first match

$set = setOf([1, 2, 3]);
$set->removeElement(2); // {1, 3} - sets only do value-removal

$map = mapOf(['a' => 1, 'b' => 2]);
$map->remove('a'); // ['b' => 2] - by key, the only sensible meaning here

When a library only has one Collection type, it has to commit to one semantic for ambiguous operations and live with the confusion. Either remove() removes by key (which surprises List users) or by value (which surprises Map users). Iteration is the same: a Map should yield ($value, $key) pairs, a Set should yield $value only, a List should yield ($value, $index). Single-Collection libraries tend to yield ($value, $key) everywhere because that's the array shape, and the Set/List meaning gets lost.

Of the five libraries in this post, only ramsey/collection and noctud/collection are built around the real Set/List/Map separation. The other three are single-Collection types, varying in lazy-vs-eager and ergonomics but all sharing the array-wrapper shape underneath. That distinction is going to come up repeatedly in the per-library notes below.

Quick feature matrix

Feature doctrine illuminate loophp noctud ramsey
PHP min 8.4 (3.x) 8.3 8.1 8.4 8.1
Real List/Set/Map types
Sub-collections compose (keys/values are real collections)
Full generic propagation
Runtime type checks ⚠️
Mutable variant
Immutable variant
Non-string Map keys
Lazy by default
Change tracking ⚠️
Indices shift on removal ⚠️

✅ yes  ·  ⚠️ partial or with caveats (see per-library notes)  ·  ❌ no

The ⚠️ cells specifically: noctud's runtime type checks only apply to stringMapOf/intMapOf. Doctrine's change tracking only fires when wired into the ORM. Illuminate keeps index gaps after filter until you call ->values().

A note on the doctrine row: 3.x is the default branch and requires PHP 8.4. Generics propagation is genuinely full on the modern API (map, filter, partition, findFirst, reduce all carry types through), with slice() as the one exception that drops back to a plain array. If you're stuck on doctrine 2.x for ORM compatibility reasons, the propagation story is weaker.

doctrine/collections

Around since the Doctrine 2 days, present as a transitive dependency in a huge chunk of PHP code on Packagist. The API is what you'd expect:

use Doctrine\Common\Collections\ArrayCollection;

$users = new ArrayCollection([$user1, $user2, $user3]);
$active = $users->filter(fn($u) => $u->isActive());
$first = $active->first();
$count = $active->count();

The thing nothing else here can replicate is the ORM tie-in. When you reassign $user->tags = new ArrayCollection([...]), Doctrine knows what was added and removed and writes the SQL to sync. Invisible if you're not using Doctrine, but it's why this library exists.

Outside that use case, the limits show up fast. Single Collection type, no Set/List/Map split. No immutable variant. No real Map with arbitrary keys (keys are constrained to array-key, same as PHP arrays). The generics story on the 3.x branch is actually solid; map/filter/partition carry types through cleanly, with slice() as the one method that drops back to a plain array.

The other rough edge is the false-on-failure pattern. first() and last() return T|false instead of T|null, so they don't compose with ??. findFirst() returns T|null (correct). The inconsistency within the same interface is annoying, and the false sentinel collides with collections that legitimately hold booleans. A coherent design has first() throw on empty and firstOrNull() return null for ?? chains; doctrine doesn't split it that way.

If you're using Doctrine ORM, this is the answer and you already have it. If you're not, the rest of this post is more useful to you.

illuminate/collections

If you've written Laravel, you've used Collection. It's effectively the standard utility library for PHP at this point. Filter, map, groupBy, partition, pluck, where, sortBy, take, plus 130-something more, all chainable.

use Illuminate\Support\Collection;

$users = collect([$user1, $user2, $user3]);
$active = $users
    ->filter(fn($u) => $u->isActive())
    ->sortBy('createdAt')
    ->take(10);

The good parts are real. Method chaining is smooth, the API surface is enormous, the docs are excellent, every Laravel package speaks Collection, and PhpStorm with Laravel Idea has solid inference. There's also a LazyCollection for streaming over big datasets without loading the whole thing into memory.

The structural limits are also real. Single Collection type, so the shape critique applies here directly. Underneath, a Collection is array<mixed, mixed>. The generic annotations (Collection<TKey, TValue>) exist but PHPStan doesn't reliably propagate them through long chains. There's no real Set, you call ->unique(). No real Map either; if you want non-string keys you're back to fighting PHP. No immutable variant; some methods return new instances, others mutate the original, and the type system doesn't tell you which is which. Filtering leaves index gaps until you remember to call ->values().

There's also the column-oriented magic to grapple with. pluck('name'), where('active', true), keyBy('id'), sortBy('created_at'), groupBy('category'): these all assume your items are arrays or objects with named columns, and they reach inside the items via string keys to read those columns. Powerful for the database-row case Laravel was built around. Awkward outside it, and the type system can't help you here; rename a property and all the where('oldName', ...) calls keep compiling and silently break. It's the kind of API surface that makes Collection feel like a query builder for in-memory rows rather than a generic data structure.

If you're in a Laravel project, this is the right answer. The framework includes it, the ecosystem speaks it, fighting that is a waste of energy. Outside Laravel, the dependency footprint is heavier than it should be and you're paying for an ecosystem you're not using.

loophp/collection

Pol Dellaiera's collection library, built around lazy evaluation via PHP generators. The fluent API has roughly the surface area of Laravel's collections, but every operation defers until you actually consume the chain.

use loophp\collection\Collection;

$active = Collection::fromIterable($users)
    ->map(fn($u) => $u->refresh())
    ->filter(fn($u) => $u->isActive() && $u->isAdmin())
    ->all();

The lazy-by-default model is the win here. Long chains of transformations don't materialize intermediate arrays; you get one walk through the data with all operations composed. For streaming inputs (file lines, paginated query results, anything where you don't want the whole thing in memory at once), this is the right model.

Static analysis is a focus, the codebase is well-typed, and PHPStan can follow the chain types better than most. The library is actively maintained with a thoughtful design.

The trade-offs are the usual lazy-evaluation ones: debugging is harder (the chain doesn't run until you ask for results), some operations require materialization implicitly, and the mental model is less friendly to people who haven't worked with generators or FP languages. It's also a single Collection type, so the shape critique applies; no separate Set/List/Map.

If your code is heavy on data pipelines and the eager/lazy distinction is something you want to control explicitly, loophp/collection is a strong choice. For mostly-small in-memory collections, the lazy machinery is overhead you don't need.

Note on DusanKasan/Knapsack, in case you've seen it mentioned in older PHP collection comparisons: it covers similar Clojure-shaped lazy territory but the last release was in 2017. It's effectively abandoned and shouldn't be picked for new work today.

noctud/collection

I built this one. PHP 8.4+, takes the Kotlin and C# collection designs and adapts them to what PHP 8.4 can actually do well.

use function Noctud\Collection\listOf;
use function Noctud\Collection\mutableMapOf;

$users = listOf($userArray);
$active = $users
    ->map(fn(User $u) => $u->refresh())
    ->filter(fn(User $u) => $u->isActive());

$user = new User('alice');
$roles = mutableMapOf();
$roles[$user] = 'admin'; // object as key, no Fatal error

A few things that don't show up in the others:

Mutable and immutable are separate types (MutableList<T> vs ImmutableList<T> etc.). Immutable mutations are annotated with #[NoDiscard], which becomes a real warning in PHP 8.5 if you forget to capture the result. Combined with the real List/Set/Map split (see the shape section above), the type system carries a lot more information than in the array-wrapper libraries.

Generics actually propagate. PHPStan level 9 clean across the codebase, and the types reach into your code so you're not narrowing mixed returns to assert.

Maps preserve key types. '1' stays a string, true stays a bool, objects work as keys via spl_object_id or your own Hashable implementation. There are also stringMapOf() and intMapOf() variants if you want a single key type enforced at construction.

$map->keys, $map->values, and $map->entries are real Set and List instances backed by the same store. Not arrays, not copies. Full collection API on each:

$map = mapOf(['a' => 1, 'b' => 2, 'c' => 3]);

$map->keys; // ImmutableSet {'a', 'b', 'c'}
$map->keys->first(); // 'a'
$map->keys->sorted()->joinToString(', '); // 'a, b, c'

$map->values->sum(); // 6
$map->values->any(fn($v) => $v > 2); // true

$map->entries->first(); // MapEntry('a', 1)

In ramsey, doctrine, illuminate, and loophp, $map->keys() (or equivalent) is a plain PHP array and you'd be wrapping it back into a collection by hand to chain anything.

Lazy initialization uses PHP 8.4's actual lazy-objects feature, not a wrapper class. Pass a closure to any factory and materialization waits until you read.

Indices shift correctly on removal from indexed lists, which solves the ramsey #133 case.

What's not great about it: PHP 8.4+ only, so if you're on 8.2 or 8.3 you can't use it. v0.1.1 right now, so it hasn't seen the production hours that doctrine and illuminate have. No third-party plugins beyond the official PhpStorm one, no Stack Overflow corpus to search; you'll be reading the docs and the source. I maintain it solo on two days a week, so feature work moves slower than at the bigger libraries.

If you're on PHP 8.4+ and want the best static-analysis story available, plus immutability, key preservation, and change tracking, it's the right answer. If not, one of the others probably fits better.

ramsey/collection

Focused, framework-agnostic, built around runtime type enforcement. Has separate Set, List, and Map types rather than a single Collection class, which puts it on the same architectural side as noctud. The constructor takes a type string and the collection rejects anything that doesn't match.

use Ramsey\Collection\Collection;

$users = new Collection(User::class);
$users->add($user); // OK
$users->add('string'); // InvalidArgumentException

There's also Set, Map, Queue, Stack, and a few specialized variants. PHPDoc generics are well-developed; Collection<int, User> propagates through most operations.

The thing that bites people eventually is issue #133: when you remove an element from an indexed collection, the remaining indices don't shift down.

$col = new Collection('string', ['a', 'b', 'c']);
$col->remove($col[1]); // [0 => 'a', 2 => 'c']

You can call array_values($col->toArray()) and re-wrap, but at that point you're working around the library, not with it. Issue's been open since 2020.

The API surface is intentionally minimal, and that's where it shows. MapInterface has 10 methods. Sub-collections don't compose: $map->keys() returns a plain list<K>, not a Set<K>. There's no values() or entries() method on the Map interface; values come out only via toArray() from the parent. Keys are constrained to PHP's array-key (int or string), so no object keys, no preserved bool/float/numeric-string keys. The type-enforcement story is good. The type-composition story stops at the first hop. If you want to chain $map->keys->filter(...)->sorted() the way Java or Kotlin let you, you're going to wrap arrays back into collections by hand a lot.

No immutable variant either, and runtime checks have a measurable cost on hot paths.

If you want runtime type enforcement, framework-free, on a project that's not on PHP 8.4 yet, this is still your library. The runtime checks earn their keep when you're consuming data from sources you don't fully trust. Just go in expecting a thin layer rather than a full collections framework.

Picking one

  • Already on Doctrine ORM: doctrine/collections, no contest.
  • Laravel project: illuminate/collections, don't fight the framework.
  • Heavy data pipelines, lazy-by-default ergonomics, framework-free: loophp/collection.
  • Framework-free, PHP 8.1 to 8.3, want runtime type checks: ramsey/collection. Expect a minimal API surface (sub-collections don't compose) and #133 for indexed removals.
  • Framework-free, PHP 8.4+, prioritize static analysis and the real Set/List/Map split: noctud/collection.

These libraries don't really compete the way the framing of this post implies. Each one solved a specific problem for the people who built it. Doctrine needed an ORM-aware collection layer. Laravel needed a fluent utility class. loophp wanted Clojure-style lazy pipelines. Ramsey wanted type-safe collections without a framework dependency. I wanted a PHP 8.4-shaped library with real Set/List/Map separation that wasn't there.

The shape question is the one I'd think about first. If your code lives mostly in the array-wrapper world (Laravel, Doctrine, FP pipelines), the single-Collection libraries fit naturally. If you keep finding yourself wanting a Set that enforces uniqueness or a Map that respects key types, you're going to want one of the libraries that takes those distinctions seriously, and there are exactly two: ramsey/collection (PHP 8.1+, runtime checks) and noctud/collection (PHP 8.4+, static-analysis-first).

If you're starting a greenfield PHP 8.4+ project today and your default is to install whatever you used last time out of habit, look around first. The right collection library saves you a lot of array_values(array_filter(...)) over the lifetime of a codebase.

Read more:

DE
Source

This article was originally published by DEV Community and written by delacry.

Read original article on DEV Community
Back to Discover

Reading List