noctud/collection

Installs: 4

Dependents: 0

Suggesters: 0

Security: 0

Stars: 27

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/noctud/collection

v0.1.0-beta1 2026-02-15 18:13 UTC

This package is not auto-updated.

Last update: 2026-02-16 18:13:49 UTC


README

Type-safe, mutable/immutable, sortable and key-preserving List/Map/Set collections for PHP 8.4+.

Docs codecov Latest Stable Version License: MIT Discord

composer require noctud/collection:0.1.0-beta1

✨ Features

  • Type-safe: Full generics support. Static analyzers understand every element type through the chain.
  • Key-preserving: Map keys like null, float, bool or "1" retain their original types. No silent type casting.
  • Object keys: Use objects as map keys out of the box. Implement Hashable for 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.
  • 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, OrNull conventions, 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]

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

Always two options β€” nullable or throwing. The need for array_key_exists() checks or ?? null is eliminated.

$list[0]; // null if missing, getOrNull()
$list(0); // throws if missing, get()
$list->firstOrNull(); // null if empty
$map['key']; // null if missing, getOrNull()
$map('key'); // throws if missing, get()
$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).
  • *By takes a selector, *With takes a comparator. Add Desc for 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 collections use 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

Lazy collections behave identically to eager ones β€” there is no way to tell from outside. Always construct lazy collections using closures, not Generator objects directly.

γŠ™οΈ 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