Skip to content

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:

php
$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:

php
$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 changed

The tracked wrapper shares the same underlying store as the original collection — changes through either reference are visible to both:

php
$set = mutableSetOf([1, 2, 3]);
$tracked = $set->tracked();

$tracked->add(4);
$set->contains(4); // true - same underlying data

This is useful for conditional logic:

php
$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:

php
// 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 MutableTrackedCollection

Each 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:

php
$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:

php
$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:

php
$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:

php
$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.

php
$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:

php
$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:

php
$map->keys->filter(fn($k) => strlen($k) > 1); // ImmutableSet<string>
$map->values->toList(); // ImmutableList<int>

Converting Between Variants ​

php
$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:

php
$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 int

This 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:

php
$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:

php
$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:

php
/** @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.