Type-safe, mutable/immutable, sortable and key-preserving List/Map/Set collections for PHP 8.4+.
composer require noctud/collectionStable despite the 0.x: each 0.MINOR series is a stable major version, so 0.2.x is what other projects would call 2.x.
With the default ^0.2 constraint, updates never break your code: 0.1.x had nine releases with no breaking changes, and 0.2.x follows the same rules.
See Versioning.
- Type-safe: Full generics support. Static analyzers understand every element type through the chain.
- Key-preserving: Map keys like
float,boolor"1"retain their original types. No silent type casting. - Object keys: Use objects as map keys out of the box. Implement
Hashablefor custom identity semantics. - Mutable & Immutable: Choose the right variant. Immutable methods are marked with
#[NoDiscard]. - Lazy Init: Construct collections from closures. Uses PHP 8.4 lazy objects β materialized only on first access.
- Lazy Sequences: Process large or infinite data element by element. Chains stop as soon as the result is known.
- Interface-driven: Every type is an interface. Factory functions return contracts, not concrete classes.
- Expressive: Rich set of higher-order functions β map, filter, sorted, flatMap, groupBy, partition, and more.
- Chainable: Mutating methods return a collection β read result like
$set->tracked()->add('a')->changed. - Strict: Choose between throwing and nullable methods (
get/getOrNull,first/firstOrNull, etc.) - Inspired by Kotlin: Factory functions, mutable/immutable split,
OrNullconventions, and namings.
Collection<E> β Ordered elements, read-only
βββ List<E> β Indexed, array access
β βββ MutableList<E> β Mutating methods (write & sort)
β βββ ImmutableList<E> β Mutation returns new with #[NoDiscard]
βββ Set<E> β Unique values, no array access
βββ MutableSet<E> β Mutating methods (write & sort)
βββ ImmutableSet<E> β Mutation returns new with #[NoDiscard]
Map<K,V> β Ordered key-value pairs, array access
βββ MutableMap<K,V> β Mutating methods (write & sort)
βββ ImmutableMap<K,V> β Mutation returns new with #[NoDiscard]
Sequence<E> β Lazy pipeline, not a collectionFull architecture is shown in docs, there are also Writable interfaces for easy third party implementations.
Use factory functions from namespace Noctud\Collection.
setOf(['a', 'b']); // ImmutableSet<string>
mutableSetOf(['a', 'b']); // MutableSet<string>
listOf(['a', 'b']); // ImmutableList<string>
mutableListOf(['a', 'b']); // MutableList<string>
mapOf(['a' => 1, 'b' => 2]); // ImmutableMap<string, int>
mutableMapOf(['a' => 1, 'b' => 2]); // MutableMap<string, int>Use stringMapOf/mutableStringMapOf and intMapOf/mutableIntMapOf for better
performance and ~50% less memory β they use single-array storage and enforce key types at runtime.
Array access is strict by default β throws on missing keys/indices. Use ?? for safe fallback.
$list[0]; // throws if missing, get()
$list[0] ?? null; // null if missing
$list->getOrNull(0); // null if missing
$list->firstOrNull(); // null if empty$map['key']; // throws if missing, get()
$map['key'] ?? null; // null if missing
$map->getOrNull('key'); // null if missing
$map->values->first(); // throws if emptySets support only the contains method, they have no array access by design.
All transformation methods (filter, map, flatMap, zip, partition, ...) always return a new immutable collection, regardless of whether the source is mutable or immutable. Unlike array_filter, Lists are always reindexed β no gaps, no need for array_values().
$set->filter(fn($el) => strlen($el->property) > 3); // new Set<E>
$map->filter(fn($v, $k) => strlen($k->property) > 3); // new Map<K,V>
$map->filterValuesNotNull(); // new Map<K,V> where V is not null
$map->values->filter(fn($v) => $v > 10); // new Collection<V>Every Collection and Map is sequentially ordered, so sorting is supported everywhere.
sorted*returns a new collection,sort*sorts in place (Mutable only).*Bytakes a selector,*Withtakes a comparator. AddDescfor descending.
// Basic
$list->sort(); // also sortDesc()
$map->sortByKey(); // also sortByValue()
// Selector examples
$list->sortBy(fn ($v) => $v->score);
$map->sortByKeyDesc(fn ($k) => strlen($k));
// Comparator examples (advanced use cases)
$list->sortWith(fn ($a, $b) => $b->score <=> $a->score);
$map->sortWithKey(fn ($a, $b) => $a <=> $b); // also sortWithValue()
$map->sortWith(fn (MapEntry $a, MapEntry $b) => $a->value <=> $b->value);Every Map exposes live read-only $keys, $values, and $entries views. These are real Set and Collection objects backed by the same underlying store β mutations to the map are immediately visible through views and vice versa.
$map = mapOf(['alice' => 28, 'bob' => 35, 'carol' => 22]);
$map->values->min(); // 22
$map->keys->filter(fn($k) => strlen($k) > 3); // Set {'alice', 'carol'}
$map->entries->first(); // MapEntry { key: 'alice', value: 28 }Check if all/any or none of the elements match the predicate.
$set->all(fn($v) => strlen($v->property) > 3); // true|false
$map->any(fn($v, $k) => strlen($k->property) > 3); // true|false
$map->values->none(fn($v) => $v->isActive); // true|falseAll collections are traversable.
$set->forEach(fn($v) => print("$v->property\n"));
$map->forEach(fn($v, $k) => print("$k = $v\n"));
// Keys for Sets are generated on the fly (0, 1, 2, ...)
foreach ($collection as $k => $v) {
print("$k = $v\n");
}Mutating methods return $this (Mutable) or a new instance (Immutable). Both share the same API, but immutable methods are marked with #[NoDiscard] to prevent accidental misuse.
$new = $map->put('b', 2)
->remove('a')
->filter(fn($v, $k) => $v > 1)
->mapValues(fn($v, $k) => $v * 2)
->sortedByKey();
$mutableSet->clear()
->addAll(['a', 'b', 'c', null])
->removeIf(fn($v) => $v === null);Method tracked() wraps a mutable collection in a proxy that tracks changes. The $changed flag is available on the return value of each mutation method, not on the wrapper itself.
$map = mutableMapOf(['a' => 'b']);
if ($map->tracked()->remove('a')->changed) {
// do something only if 'a' was actually removed
}π‘οΈ Type safety
Mutable collections enforce strict typing β PHPStan warns if you try to add elements of incompatible types. Immutable collections allow type widening since they return a new instance with potentially different types.
// Mutable β strict, PHPStan warns on type mismatch
$map = mutableMapOf(['a' => 1]); // MutableMap<string, int>
$map->put('b', 'wrong'); // β PHPStan error: string is not int
// Immutable β widening allowed, returns new instance
$map = mapOf(['a' => 1]); // ImmutableMap<string, int>
$new = $map->put('b', 'text'); // β
ImmutableMap<string, int|string>π Preserving key types
$map = mutableMapOf(['1' => 'a']); // β Key '1' will be cast to int(1) before the map is created
$map = mutableMapOfPairs([['1', 'a']]); // β
Key '1' will stay as a string
$map['2'] = 'b'; // β
Key '2' will stay as string
// Enforce string keys (int are only allowed at construction time)
$map = stringMapOf(['1' => 'a', 2 => 'b']); // β
Keys '1' and '2' will be strings
// Constructing from a generator
$map = mapOf((function() {
yield '1' => 'a'; // β
Key '1' will stay as a string
})());Map will always preserve original keys, you have to only worry about constructing the map.
π€ Lazy Initialization
Construct from a closure β the callback executes only on first access. Under the hood, lazy initialization uses PHP 8.4's Lazy Objects β the internal store is a ghost proxy materialized only when first accessed.
// The query runs only if $users is actually read
$template->users = listOf(fn() => $repository->getAllUsers());
$lazyMap = mapOf(fn () => ['a' => 1]); // β
Good, callback returning an array
$lazyMap = mapOf(fn () => $generator); // β
Good, callback returning Generator
$lazyMap->values; // still lazy, no code executed yet
$lazyMap->count(); // first read - executes the callback, materializes the mapLazily initialized collections behave identically to regular ones β there is no way to tell from outside. Always pass closures, not Generator objects directly.
π Sequences
Collection transformations are eager β every step builds a new collection. A Sequence runs the chain element by element instead, and stops as soon as the result is known. Use it for large, streamed or infinite data.
// profile() runs only once - for the first active user
$users->asSequence()
->filter(fn($u) => $u->isActive())
->map(fn($u) => $api->profile($u))
->first();
// readLines() is your own generator - stops after 10 errors
sequenceOf(fn() => readLines('app.log'))
->filter(fn($line) => str_contains($line, 'ERROR'))
->takeFirst(10)
->toList();
// Infinite, but only the first 10 elements are ever computed
generateSequence(1, fn($n) => $n * 2)->takeFirst(10)->toList();A sequence stores nothing, so each terminal operation (first(), toList(), β¦) runs the chain from the source again: a closure source is called again, a Generator can be iterated only once.
γοΈ Objects as keys
Use objects as map keys out of the box. By default, objects are hashed using spl_object_id.
$map = mapOfPairs([[$user, 'data']]);
isset($map[$user]); // β
True, same object instance
isset($map[clone $user]); // β False, different instanceImplement Hashable for custom identity semantics:
class User implements \Noctud\Collection\Hashable {
public function identity(): string|int {
return "user_$this->id";
}
}
$map = mutableMapOf();
$map[$user] = 'cacheData';
isset($map[clone $user]); // β
True, same user IDEvery type you interact with is an interface β ImmutableList, MutableMap, Set, even MapEntry. Logic is encapsulated in traits, so you can turn any class into a collection.
For custom stores, database-backed collections, and more, see the Extending guide.
This library prioritizes type safety and correctness. Lists and Sets have minimal overhead compared to native arrays. The generic mapOf() uses dual-array storage to preserve any key type, which adds memory and performance overhead.
When keys are exclusively strings or integers, use the optimized variants for maximum performance:
$users = stringMapOf(['alice' => 28, 'bob' => 35]); // or mutableStringMapOf()
$scores = intMapOf([1 => 100, 2 => 85, 3 => 92]); // or mutableIntMapOf()These use single-array storage, skip key hashing entirely, and use ~50% less memory than mapOf().
Converting between mutable and immutable via toMutable()/toImmutable() uses copy-on-write β the data is shared until either side is modified, making variant switching virtually free.
Generics are fully supported by PHPStan and Psalm. PhpStorm has known limitations with generics inference.
- Getting started β Installation, architecture, basic usage
- List / Set / Map β Type guides with examples
- Mutability β Mutable vs immutable, change tracking, copy-on-write
- Sorting β Full sorting reference with quick-reference table
- Lazy initialization β Deferred initialization
- Sequence β Lazy, element-by-element pipelines
- Extending β Custom implementations, stores, traits
- API reference β All method signatures
- Versioning β Release rules, backward compatibility, supported versions