Type-safe
Full generics support. Static analyzers understand every element type through the entire chain.
Type-safe, clean, mutable/immutable, sortable and key-preserving collections for PHP 8.4+
Key preservation
Native PHP arrays silently cast keys — "1" becomes int(1), true becomes 1, and objects cannot be used as keys at all. Even array<string, mixed> in PHPDoc is not an option — insert "123" and it becomes int(123). Map preserves every key exactly as written.
Noctud Collection
$map = mutableMapOf();
$map[true] = 'a';
$map[false] = 'b';
$map[1.1] = 'c';
$map['1'] = 'd';
$map[new User()] = 'e';
$map->keys->first(); // true
$map->keys; // [true, false, 1.1, "1", User]
$map->values; // ['a', 'b', 'c', 'd', 'e']Native PHP
$array = [];
$array[true] = 'a'; // key cast to 1
$array[false] = 'b'; // key cast to 0
$array[1.1] = 'c'; // deprecated, cast to int 1
$array['1'] = 'd'; // cast to int 1, overwrites
$array[new User()] = 'e'; // error
array_key_first($array); // 1
array_keys($array); // [1, 0]
array_values($array); // ['d', 'b']Filtering
Filtering a PHP array leaves gaps in integer keys. Forgetting array_values() leads to bugs where $result[0] is suddenly undefined. Collections always reindex automatically.
Noctud Collection
$list = listOf([10, 20, 30, 40]);
$filtered = $list->filter(fn($n) => $n > 15);
// [20, 30, 40] - indices 0, 1, 2Native PHP
$array = [10, 20, 30, 40];
$filtered = array_filter($array, fn($n) => $n > 15);
// $filtered[0] is undefinedType safety
PHPStan doesn't warn when you assign incompatible types to a PHP array — the type silently widens. Mutable collections enforce strict typing, catching bugs at analysis time. Type is only widened in immutable collections because they produce new instances.
Noctud Collection
$mut = mutableMapOf(['a' => 1]);
$mut['b'] = 'wrong'; // PHPStan error
// Immutable — type widens for a new map
$new = mapOf(['a' => 1])->put('b', 'ok');Native PHP
/** @var array<string, int> $array */
$array = ['a' => 1];
$array['b'] = 'wrong'; // No warning, type widensAccessing elements
Array access is strict — $list[99] throws when the index is out of bounds, just like get(). Use ?? for safe fallback, or getOrNull() for the nullable variant. Same pattern for first, last, random, and single.
Noctud Collection
$list[0]; // throws if missing
$list[0] ?? null; // null if missing$list->last(); // throws if empty
$list->lastOrNull(); // null if emptyNative PHP
array_key_exists(0, $array) ? $array[0]
: throw new Exception();
$array[0] ?? null;count($array) > 0 ? $array[array_key_last($array)]
: throw new Exception();
count($array) > 0 ? $array[array_key_last($array)]
: null;Sorting
The sorted* methods are on all collections and Mutable has also sort* methods for in-place sorting.
Noctud Collection
// Creates a new collection
$byAge = $users->sortedBy(fn($u) => $u->age);// In-place DESC by name (Mutable only)
$users->sortByDesc(fn($u) => $u->name);Native PHP
// Creates a new array
$byAge = $users;
usort($byAge, fn($a, $b) => $a->age <=> $b->age);// In-place DESC by name
usort($users, fn($a, $b) => $b->name <=> $a->name);Learn more about Lists, Sets, and Maps →
Both variants share the same API. The difference is how mutating methods behave. Immutable mutating methods are marked with #[NoDiscard] — PHP 8.5 will warn you if you forget to capture the return value.
$set = setOf([1, 2, 3]); // ImmutableSet<int>
$set->add(4); // PHP Warning: return value is unused
$new = $set->add(4)->reversed(); // Ok
$list = mutableListOf([1, 2, 3]); // MutableList<int>
$list->add(4)->shuffle()->sort(); // OkTrack state of Mutable collections, tracked() returns mutable collection wrapped in a proxy that tracks changes.
if ($set->tracked()->add('a')->changed) // do something only if 'a' was not in setBuild with mutable, then freeze with toImmutable() — conversion is virtually free thanks to copy-on-write. The data is only duplicated when either side is modified.
Every Map exposes live read-only $keys, $values, and $entries views. These are real collection objects, not plain arrays, and they share memory space with the Map. Modifying the map updates the views.
$map = mapOf(['rodney' => 28, 'sheppard' => 35, 'teyla' => 22]);
$map->values->min(); // 22
$map->values->avg(); // 28.33
$map->keys->filter(fn($k) => strlen($k) > 5); // Set {'rodney', 'sheppard'}
$map->entries->first(); // MapEntry { key: 'rodney', value: 28 }Thanks to views, the Map interface doesn't need to be polluted with randomKey(), randomValue(), randomEntry() (and first, last, single etc. and all their nullable variants), you can simply access it via the corresponding view.
Construct from a closure — the callback executes only on first access.
// The query runs only if $users is actually read
$template->users = listOf(fn() => $repository->getAllUsers());
// Lazy property that loads config on demand
private ImmutableMap $config {
get => $this->config ??= mapOf(fn() => $this->loadConfig());
}Lazy collections behave identically to eager ones — there is no way to tell from outside.
How it works
Under the hood, lazy collections use PHP 8.4's Lazy Objects feature. The internal store is wrapped in a ghost proxy via ReflectionClass::newLazyProxy() — the real store object is only created when first accessed. This is a native language feature with zero userland overhead once initialized.
Read about lazy initialization →
Every type you interact with is an interface — ImmutableList, MutableMap, Set, even MapEntry. Factory functions return interfaces, never concrete classes:
$list = listOf([1, 2, 3]); // ImmutableList<int> - not ImmutableArrayList
$map = mutableMapOf(['a' => 1]); // MutableMap<string, int> - not MutableHashMap
$entry = $map->entries->first(); // MapEntry<string, int> - not SimpleMapEntryThis is possible thanks to PHP 8.4 interface properties. Map views like $keys, $values, and $entries are declared directly on the Map interface — no abstract class needed.
Why this matters: your code depends only on contracts, not on the internal storage or implementation details. You can swap mapOf() for stringMapOf() without changing a single type-hint. And if you need a custom collection backed by a database cursor or a Redis sorted set, you can implement the interface directly — the rest of your codebase doesn't need to know.
Read about extending collections →
This library prioritizes type safety and correctness. For hot loops over millions of elements, use plain arrays. For domain logic, business rules, and API boundaries, this gives you guarantees arrays can't. Lists and Sets have minimal overhead compared to native arrays. The generic mapOf() uses dual-array storage (keys + values) to preserve any key type (objects, float, bool), which adds memory and performance overhead compared to native arrays.
Optimized maps for string/int keys
When keys are exclusively strings or integers, the type-specific variants offer maximum performance:
$users = stringMapOf(['rodney' => 28, 'sheppard' => 35]); // or mutableStringMapOf()
$scores = intMapOf([1 => 100, 2 => 85, 3 => 92]); // or mutableIntMapOf()These use single-array storage and skip key hashing entirely, but still preserve key types.
mapOf() (single array vs dual array)Converting between mutable and immutable variants via toMutable() / toImmutable() uses copy-on-write — the underlying data is shared until either side is modified, making variant switching virtually free.
The same applies when constructing a new collection from an existing one — even across mutable/immutable boundaries.
Data is only duplicated when either (mutable) side is actually modified — collections can be freely passed between layers or converted between mutable and immutable without any memory or performance penalty.
PhpStorm doesn't fully understand PHP generics — type inference breaks in callbacks and there's no autocomplete through view properties. The Noctud plugin fixes these IDE limitations so collections work seamlessly in the editor.
Type $map->random and the plugin suggests methods from values, keys, and entries views directly — no need to type the full property path.

The API is heavily inspired by Kotlin Collections — factory functions, the mutable/immutable split, OrNull conventions, and method naming all follow Kotlin patterns. The type hierarchy draws from the Java Collections Framework with clear separation of mutability/immutability.