Persistent Data Structures in PHP: Functional Lists with Structural Sharing
AI generated
<?php
8.4
PHP · Functional Programming · Data Structures
Persistent Data Structures in PHP
Functional Lists with Structural Sharing

A persistent data structure keeps its old version fully intact after every change and produces a new version instead, without copying the entire content. In PHP 8.4 this principle can be implemented directly with immutable linked lists and structural sharing, with no external library required.

18 min read Persistent Lists · Structural Sharing · Immutability PHP 8.2 · 8.3 · 8.4

1. What sets persistent data structures apart from plain array copies

A persistent data structure is a data structure that keeps its previous version fully unchanged after every modification operation and returns a new version instead. The term persistent here does not refer to disk storage, but to all earlier versions continuing to exist in memory. Anyone holding a reference to the old version continues to see exactly the original state even after a change to the new version, because both versions exist side by side.

The naive way to achieve this behavior in PHP would be to copy the entire array on every change. PHP arrays do use copy-on-write internally, which makes simple assignments cheap, but as soon as an element is actually modified, added, or removed, PHP copies the entire internal array storage before applying the change. For large lists with frequent changes, this copy overhead quickly becomes a performance problem.

Persistent data structures in the functional sense solve this problem through structural sharing: a new version shares as much internal storage as possible with the old version and only copies the strictly necessary part. This article shows what such a persistent linked list looks like in PHP 8.4 and when it is actually superior to classic arrays.

2. The copying problem of classic PHP arrays under immutability

If you want to treat an array as immutable in PHP, the obvious approach is to create a copy on every change via $newArray = $oldArray; $newArray[] = $element;. For small arrays this is unproblematic, because PHP's copy-on-write mechanism only triggers the actual copy on the first write access to the copy, and this copying barely registers for a small number of elements.

For large arrays with thousands of elements and frequent changes, such as an undo history that needs a new, immutable version of the state at every step, the copying overhead adds up noticeably. Every single change copies the entire array again, with quadratic total complexity for a chain of n changes on an array with n elements. Persistent data structures avoid exactly this quadratic behavior through shared, unchanged storage between versions.


<?php

declare(strict_types=1);

// Naive immutability: full array copy on every change
function prependNaive(array $list, mixed $item): array
{
    // PHP copies the entire internal array storage here on write
    array_unshift($list, $item);

    return $list;
}

$history = [];
for ($i = 0; $i < 3; $i++) {
    $history[] = $list ?? [];
    $list = prependNaive($list ?? [], $i);
}

// Each step in $history is a full, independent copy — costly at scale
foreach ($history as $step) {
    echo count($step) . ' elements in this version' . PHP_EOL;
}

3. An immutable linked list as the base building block

The classic building block for persistent data structures is the singly-linked list, where each node holds a value and a reference to the rest of the list. In PHP such a node can be modeled as an immutable value object with readonly properties: a value and a reference to the next node, or null for the end of the list.

The decisive difference from a PHP array: the list itself is made of immutable nodes that are never modified again after creation. Every operation that produces a new list therefore does not need to copy existing nodes, it can simply reuse them and only create the actually new nodes. This property is the foundation for structural sharing in the next section.


<?php

declare(strict_types=1);

/**
 * An immutable singly-linked list node. Once created, never mutated.
 *
 * @template T
 */
final class ListNode
{
    /**
     * @param T $value
     * @param self<T>|null $next
     */
    public function __construct(
        public readonly mixed $value,
        public readonly ?self $next,
    ) {
    }
}

/**
 * A persistent, immutable list wrapper around a linked chain of nodes.
 *
 * @template T
 */
final class PersistentList
{
    private function __construct(private readonly ?ListNode $head)
    {
    }

    /**
     * @return self<mixed>
     */
    public static function empty(): self
    {
        return new self(null);
    }

    public function isEmpty(): bool
    {
        return $this->head === null;
    }
}

4. Structural sharing: why prepend() needs no copy

Prepending a new element to a persistent data structure is the cheapest of all operations, because it only requires creating a single new node that points to the previous head of the old list. The old list remains fully reachable and unchanged, because its head node continues to exist and is never modified by anyone. This technique is called structural sharing: the new version shares the entire remaining storage with the old version and only duplicates the one new node.

The runtime cost of prepend is therefore constant regardless of list size, O(1), while a naive array copy via array_unshift on an immutable copy always has to touch the entire dataset, O(n). For frequent prepend operations on large histories, such as building an event log from newest to oldest, this difference is clearly noticeable in practice.


<?php

declare(strict_types=1);

/**
 * @template T
 */
final class PersistentList
{
    private function __construct(private readonly ?ListNode $head)
    {
    }

    /**
     * @return self<mixed>
     */
    public static function empty(): self
    {
        return new self(null);
    }

    /**
     * O(1): only one new node is allocated, the old list is fully reused.
     *
     * @param T $value
     * @return self<T>
     */
    public function prepend(mixed $value): self
    {
        return new self(new ListNode($value, $this->head));
    }

    /**
     * @return list<mixed>
     */
    public function toArray(): array
    {
        $items = [];
        $node = $this->head;

        while ($node !== null) {
            $items[] = $node->value;
            $node = $node->next;
        }

        return $items;
    }
}

$v1 = PersistentList::empty();
$v2 = $v1->prepend('a');
$v3 = $v2->prepend('b');

// $v2 remains fully intact and unaffected by the creation of $v3
var_dump($v2->toArray()); // ['a']
var_dump($v3->toArray()); // ['b', 'a']

5. Functional operations: map, filter and fold on the list

For a persistent data structure to become practically useful, it needs the same functional operations known from arrays: map to transform every element, filter to keep only certain elements, and fold to reduce to a single value. All three operations produce a new, immutable list or a single value, without ever changing the original list, consistent with the core principle of immutability.

When implementing map and filter on a linked list, one must consider that a naive recursive implementation can blow the stack for very long lists, because every recursive call consumes a new stack frame. An iterative implementation with an explicit loop avoids this risk and stays safe for lists of any length.


<?php

declare(strict_types=1);

/**
 * @template T
 */
final class PersistentList
{
    private function __construct(private readonly ?ListNode $head)
    {
    }

    /**
     * @return self<mixed>
     */
    public static function empty(): self
    {
        return new self(null);
    }

    /**
     * @param T $value
     * @return self<T>
     */
    public function prepend(mixed $value): self
    {
        return new self(new ListNode($value, $this->head));
    }

    /**
     * Iterative map — avoids stack overflow on very long lists.
     *
     * @param Closure(mixed): mixed $fn
     * @return self<mixed>
     */
    public function map(Closure $fn): self
    {
        $items = array_map($fn, $this->toArray());
        $result = self::empty();

        foreach (array_reverse($items) as $item) {
            $result = $result->prepend($item);
        }

        return $result;
    }

    /**
     * @param Closure(mixed): bool $predicate
     * @return self<mixed>
     */
    public function filter(Closure $predicate): self
    {
        $items = array_filter($this->toArray(), $predicate);
        $result = self::empty();

        foreach (array_reverse($items) as $item) {
            $result = $result->prepend($item);
        }

        return $result;
    }

    /**
     * @return list<mixed>
     */
    public function toArray(): array
    {
        $items = [];
        $node = $this->head;

        while ($node !== null) {
            $items[] = $node->value;
            $node = $node->next;
        }

        return $items;
    }
}

$numbers = PersistentList::empty()->prepend(3)->prepend(2)->prepend(1);

$doubled = $numbers->map(fn (int $n): int => $n * 2);
$evens = $numbers->filter(fn (int $n): bool => $n % 2 === 0);

var_dump($numbers->toArray()); // [1, 2, 3], unaffected by map/filter above
var_dump($doubled->toArray()); // [2, 4, 6]

6. The append problem: why append() stays more expensive than prepend()

While prepend on a singly-linked list works in constant time, appending an element to the end of the list, append, is structurally more expensive. To attach a new node at the end without changing existing nodes, every node along the way to the end would need to be newly created, because each immutable node points to its successor, and that successor would change through the append. This makes append cost O(n) instead of O(1) on this simple structure.

Real functional languages and libraries solve this problem with more advanced structures such as balanced trees or finger trees, which allow both prepend and append in amortized constant or logarithmic time. For most practical PHP use cases, where insertion mostly happens at the front or a complete new iteration is done, the simple linked list with its O(n) append is entirely sufficient, as long as append is not the dominant use case.

7. Real-world cases: undo history and shared state without copies

An obvious use case for persistent data structures is an undo history in an interactive application: every user action produces a new version of the state, while all previous versions remain unchanged in memory and can be reactivated instantly if needed. With a naive array copy on every action, a long undo chain over a large state would quickly consume excessive memory, because every version duplicates the entire state.

A second use case concerns concurrent or parallel code, for example in a Swoole worker with multiple coroutines: if several coroutines share a reference to the same persistent data structure, none of them can accidentally change the state seen by another, because every change always returns a new version instead of mutating the shared structure. This property makes persistent structures inherently safe against race conditions on the data state itself.

8. Limits: memory overhead and when classic arrays fit better

The price for structural sharing is a higher memory overhead per element compared to a classic PHP array, because each node is its own object with a reference to the next node, instead of sitting in a contiguous block of memory. For very large datasets that are rarely modified and mostly just read, a classic array with its more compact storage and faster sequential access is often the better choice.

Random access to an element by index is also structurally slower for a linked list, O(n) instead of O(1) as with an array, because one must walk node by node from the head. Persistent data structures therefore pay off specifically where immutability, many versions, and shared state matter more than fast index access, not as a generic replacement for every PHP array.

9. Persistent list compared to array and SplDoublyLinkedList

The following table compares the persistent list with the common alternatives in PHP.

Criterion PHP Array (copied) Persistent List SplDoublyLinkedList
Prepend cost O(n) with a full copy O(1) O(1), but mutating
Old version preserved Only with an explicit copy Automatic, no extra code No, mutates directly
Index access O(1) O(n) O(n)
Memory per element Compact, contiguous Higher, one object per node Higher, two references per node
Safe with shared state Only safe with explicit copy Safe by nature Not safe, mutates shared state

The rule of thumb: a persistent list pays off as soon as multiple versions of a state must stay alive at the same time, such as with undo histories or shared state between coroutines. For pure sequential data traversal without a versioning need, the classic PHP array remains the better choice most of the time, thanks to its lower memory overhead and faster index access.

Mironsoft

PHP architecture, data structures and functional patterns in everyday team work

Expensive array copies on every state change?

We review existing PHP code for unnecessarily expensive copy operations and show where persistent data structures with structural sharing noticeably reduce memory and runtime.

Code Review

Analysis for expensive array copies in versioning and undo histories

Implementation

Introducing persistent data structures with structural sharing where it matters

Training

Introducing and documenting functional data structures hands on within the team

10. Summary

Persistent data structures keep their previous version fully unchanged after every modification and produce a new version through structural sharing instead of a full copy. An immutable, singly-linked list with readonly nodes makes prepend() possible in constant time, because only a single new node is created and the rest of the old list is reused unchanged. Functional operations such as map, filter and fold can be implemented on this structure without ever changing the original list.

The price is a higher memory overhead per element and slower index access compared to a classic array. Persistent data structures therefore pay off specifically for undo histories, version management and shared state between concurrent code, not as a generic replacement for every PHP array in everyday code.

Persistent Data Structures in PHP — The Key Takeaways

Definition

The previous version stays fully intact after every change, a new version emerges instead of a mutation.

Structural Sharing

The new version shares most of the storage with the old one, only the new node is created in addition.

Costs

prepend() is O(1), while append() and index access remain O(n) on the simple list.

Use Cases

Undo histories, version management, and shared state in concurrent code.

11. FAQ: Persistent Data Structures in PHP

1What is a persistent data structure?
Keeps the previous version fully unchanged after every modification and returns a new version.
2What is structural sharing?
A new version shares most storage with the old version, only the new part is created in addition.
3Why aren't arrays optimal for immutability?
PHP copies the entire internal storage on actual change once more than one reference exists.
4Why is prepend() O(1)?
Only one new node is created that points to the old head, the rest of the list stays unchanged.
5Why is append() more expensive?
Every node up to the end would need to be recreated, costing O(n) instead of O(1) like prepend().
6map and filter on the list?
Yes, best implemented iteratively to avoid stack overflows on very long lists.
7When does it pay off?
For undo histories, version management and shared state between concurrent code.
8What does structural sharing cost?
Higher memory overhead per element and slower index access at O(n) instead of O(1).
9Safe with concurrent code?
Yes, every change returns a new version, no coroutine can change another's state.
10Replace arrays entirely?
No, for pure sequential access, the classic array remains the better choice most of the time.