Mutability
Every collection type in the library has both a mutable and an immutable variant. The choice affects how mutating operations (add, removeElement, sort, clear) behave.
Mutable Collections
Mutable collections modify themselves in-place. Mutating methods return $this, enabling method chaining:
$list = mutableListOf([3, 1, 2]);
$list->add(4)->sort()->removeElement(1);
// $list is now [2, 3, 4]The tracked() Method
Mutable collections can track whether operations actually change the data. To enable tracking, call tracked() to get a tracked wrapper:
$tracked = mutableSetOf([1, 2, 3])->tracked();
$tracked->add(4)->changed; // true - element was added
$tracked->add(4)->changed; // false - already present
$tracked->removeElement(99)->changed; // false - not in set
$tracked->reverse()->changed; // true - order changedThe tracked wrapper shares the same underlying store as the original collection — changes through either reference are visible to both:
$set = mutableSetOf([1, 2, 3]);
$tracked = $set->tracked();
$tracked->add(4);
$set->contains(4); // true - same underlying dataThis is useful for conditional logic:
$tracked = $cache->tracked();
if ($tracked->remove($expiredKey)->changed) {
$logger->info("Evicted cache entry: $expiredKey");
}$changed is only on mutation results
The $changed property is available on the return value of each mutation method, not on the tracked wrapper itself. This is by design — it prevents a common mistake where tracked() is called after a mutation and $changed is read from the wrong object:
// Correct - $changed is on the mutation result
$tracked->add(4)->changed; // true
// Also correct - capture the result
$result = $tracked->add(4);
$result->changed; // true
// Wrong - $tracked itself does not expose $changed
$tracked->add(4);
$tracked->changed; // PHPStan error - $changed is not on MutableTrackedCollectionEach mutation method returns a fresh $changed value reflecting whether that specific call modified the data, not cumulative changes.
Immutable Collections
Immutable collections never modify themselves. Every mutating method returns a new instance:
$original = listOf([1, 2, 3]);
$modified = $original->add(4)->removeElement(1);
// $original is still [1, 2, 3]
// $modified is [2, 3, 4]The #[NoDiscard] Attribute
All mutating methods on immutable collections are marked with #[NoDiscard]. This prevents a common mistake — calling a method and forgetting to use the return value:
$list = listOf([1, 2, 3]);
// Warning: this does nothing - return value is discarded
$list->add(4);
// Correct
$list = $list->add(4);Static analyzers and IDEs that support #[NoDiscard] will flag the first form as a likely bug.
Choosing Between Mutable and Immutable
Use immutable when:
- Building a value that should not change after construction
- Returning collections from public APIs
- Passing collections to code that should not modify them
- Working with shared state where predictability matters
Use mutable when:
- Building up a collection incrementally (loops, batch processing)
- Performance matters and copying is wasteful
- Tracking whether an operation had an effect (via
tracked()) - The collection is local to a method and not shared
Transformation Methods Always Return Immutable
All transformation methods (filter, map, sorted, reversed, distinct, etc.) always return immutable collections, regardless of whether the source is mutable or immutable:
$mutableList = mutableListOf([3, 1, 2]);
$filtered = $mutableList->filter(fn($v) => $v > 1); // ImmutableList<int>
$sorted = $mutableList->sorted(); // ImmutableList<int>
$mutableMap = mutableMapOf(['a' => 1, 'b' => 2]);
$filtered = $mutableMap->filter(fn($v, $k) => $v > 1); // ImmutableMap<string, int>Transformation methods are marked #[NoDiscard], you must capture the return value — making it clear that you're working with a new, immutable result rather than a modified original.
Why always immutable?
Transformation methods create derived data. Making the result immutable by default:
- Prevents accidental mutation of derived collections
- Makes the API predictable — you always know what you're getting
- Simplifies reasoning about data flow
Conversion Methods Return Immutable
Conversion methods like toList(), toSet(), and toMap() also return immutable types:
$mutableSet = mutableSetOf([1, 2, 3]);
$mutableSet->toList(); // ImmutableList<int>
$mutableSet->toMap(fn($v) => "k$v"); // ImmutableMap<string, int>
$mutableList = mutableListOf([1, 2, 3]);
$mutableList->toSet(); // ImmutableSet<int>
$mutableList->toIndexedMap(); // ImmutableMap<int, int>If you need a mutable result, chain with toMutable(), it has zero memory overhead, because it utilizes copy-on-write.
$mutableSet->toList()->toMutable(); // MutableList<int>Map Views
Map views ($keys, $values, $entries) are read-only regardless of whether the map is mutable or immutable. They expose query and iteration methods but no mutating operations:
$map = mutableMapOf(['a' => 1, 'b' => 2]);
$map->keys; // read-only Set<string>
$map->values; // read-only Collection<int>
$map->entries; // read-only Set<MapEntry<string, int>>Transformation and conversion methods on views also return immutable collections:
$map->keys->filter(fn($k) => strlen($k) > 1); // ImmutableSet<string>
$map->values->toList(); // ImmutableList<int>Converting Between Variants
$mutable = mutableListOf([1, 2, 3]);
$immutable = $mutable->toImmutable(); // new ImmutableList
$immutable = listOf([1, 2, 3]);
$mutable = $immutable->toMutable(); // new MutableList (always a copy)toMutable() always creates a new copy, even if the source is already mutable. toImmutable() may return the same instance if the source is already immutable.
Copy-on-write
Methods toMutable(), toImmutable(), and factory functions like listOf($mutableList) all share data via copy-on-write — memory is only duplicated when either side is modified.
Type Safety and Widening
Mutable and immutable collections handle generic types differently when adding elements.
Mutable: Strict Typing
Mutable collections enforce strict typing. You can only add elements that match the declared type. PHPStan will warn if you try to add incompatible types:
$map = mutableMapOf(['a' => 1]); // MutableMap<string, int>
$map['b'] = 2; // OK - int matches int
$map['c'] = 'text'; // PHPStan error: string is not int
$list = mutableListOf([1, 2, 3]); // MutableList<int>
$list[] = 'text'; // PHPStan error: string is not intThis catches bugs at analysis time — you declared a type, and the mutable collection holds you to it.
Immutable: Type Widening
Immutable collections allow type widening. Since mutating operations return a new instance, that instance can have a wider type:
$map = mapOf(['a' => 1]); // ImmutableMap<string, int>
$new = $map->put('b', 'text');
// $new is ImmutableMap<string, int|string>
// $map is still ImmutableMap<string, int>
$list = listOf([1, 2, 3]); // ImmutableList<int>
$extended = $list->add('text');
// $extended is ImmutableList<int|string>This is useful for building up data structures incrementally:
$base = mapOf(['id' => 1]);
$withName = $base->put('name', 'Rodney');
$complete = $withName->put('active', true);
// $complete is ImmutableMap<string, int|string|bool>Why the Difference?
- Mutable: You declared the type upfront. Adding incompatible data corrupts your declared contract — likely a bug.
- Immutable: You're creating a new value. The new value can have whatever type makes sense for its contents.
Compared to Native Arrays
PHP arrays don't enforce types on assignment — PHPStan silently widens the type:
/** @var array<string, int> $array */
$array = ['a' => 1];
$array['b'] = 'text'; // No warning! Type becomes array<string, int|string>Mutable collections catch this class of bugs that native arrays miss.
This is not just about static analysis
Maps created via stringMapOf() / intMapOf() enforce key types at runtime — inserting a wrong key type throws InvalidKeyTypeException. The generic mapOf() accepts any key type and has no runtime checks. This only applies when explicitly choosing a type-specific factory function.