noctud / collection
πΊοΈ Type-safe, key-preserving, mutable/immutable List/Set/Map for PHP
Requires
- php: >=8.4
Requires (Dev)
- phpstan/phpstan: 2.2.15
- phpunit/phpunit: ^13.0
- slevomat/coding-standard: ^8.16
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- 0.2.x-dev
- v0.2.0
- 0.1.x-dev
- v0.1.8
- v0.1.7
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- v0.1.0-beta2
- v0.1.0-beta1
- dev-fix/indent-with-spaces
- dev-fix/import-functions-drop-asserts
- dev-test/php-8.6
- dev-fix/immutable-map-widening
- dev-fix/concrete-collection-types
- dev-bridge/0.1.8
- dev-fix/hash-set-remove-first
- dev-feature/sequence-docs
- dev-fix/sequence-distinct-objects
- dev-fix/distinct-object-id-reuse
- dev-fix/remove-at-throws-docblock
- dev-fix/phpstan-2.2.15
This package is auto-updated.
Last update: 2026-10-30 18:55:02 UTC
README
Type-safe, mutable/immutable, sortable and key-preserving List/Map/Set collections for PHP 8.4+.
composer require noctud/collection
Stable 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.
β¨ Features
- 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.
πΊοΈ Architecture
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 collection
Full architecture is shown in docs, there are also Writable interfaces for easy third party implementations.
ποΈ Constructing
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.
π Accessing
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 empty
Sets support only the contains method, they have no array access by design.
πͺοΈ Filtering & transformations
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>
π Sorting
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);
ποΈ Map views
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 }
βοΈ Quantifiers
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|false
β° Iterating
All 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"); }
βοΈ Chainable
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 map
Lazily 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 instance
Implement 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 ID
π§© Extending
Every 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.
π Performance
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.
π Static analysis
Generics are fully supported by PHPStan and Psalm. PhpStorm has known limitations with generics inference.
π Documentation
- 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