DEV Community

Cover image for Typed Collections of Value Objects in PHP: Beyond array<int, Money>
Gabriel Anhaia
Gabriel Anhaia

Posted on

Typed Collections of Value Objects in PHP: Beyond array<int, Money>


You write a function that sums a list of prices. You give it a good docblock and move on.

/** @param array<int, Money> $prices */
function invoiceTotal(array $prices): Money
{
    $total = Money::zero($prices[0]->currency);
    foreach ($prices as $price) {
        $total = $total->add($price);
    }
    return $total;
}
Enter fullscreen mode Exit fullscreen mode

PHPStan reads the @param and stays green. The signature looks typed. Then production hands you three failures the type never promised to catch. An empty array throws on $prices[0]. A list with EUR and USD mixed together blows up somewhere inside add. And because the annotation is a comment, not a runtime type, a caller who passes ['1500', '2000'] sails right past the parser and dies on the first method call.

The docblock described a shape. It never enforced one. That gap is what a first-class collection closes.

The type that isn't

array<int, Money> is a promise made to static analysis, not to the runtime. PHP erases it the moment the code runs. There is no class, no constructor, no place to hang a rule. The array is still a bag that will hold anything you put in it, and the Money part exists only for as long as every caller keeps its side of an unwritten deal.

That is fine for scratch code. It stops being fine the moment two invariants matter: this list must not be empty, and every element must share one currency. Both are real rules about a real concept in your domain. Neither has a home in a bare array.

What leaks out of a raw array

When the collection is a raw array, the rules about it live everywhere it is used. The empty check gets copied into invoiceTotal, and again into the discount calculator, and again into the CSV exporter. The currency check gets written once, forgotten twice, and rediscovered during an incident. Each call site re-implements the same guards, slightly differently, and one of them is always wrong.

The responsibility belongs to the concept "a list of prices." Spreading it across every consumer is the leak. You want one object that knows what a valid list of money looks like, so nothing downstream has to ask.

A collection that holds its own rules

Give the concept a class. It validates once, in the constructor, and every instance that exists is already valid.

<?php

declare(strict_types=1);

namespace App\Domain\Shared;

use ArrayIterator;
use Countable;
use IteratorAggregate;
use Iterator;

/**
 * @implements IteratorAggregate<int, Money>
 */
final class MoneyList implements IteratorAggregate, Countable
{
    /** @var list<Money> */
    private array $items;

    /** @param list<Money> $items */
    public function __construct(array $items)
    {
        if ($items === []) {
            throw new EmptyMoneyList();
        }

        $currency = $items[0]->currency;
        foreach ($items as $money) {
            if ($money->currency !== $currency) {
                throw new MixedCurrencies($currency);
            }
        }

        $this->items = array_values($items);
    }

    // getIterator() and count() follow below.

    public function sum(): Money
    {
        $total = Money::zero($this->items[0]->currency);
        foreach ($this->items as $money) {
            $total = $total->add($money);
        }
        return $total;
    }
}
Enter fullscreen mode Exit fullscreen mode

EmptyMoneyList and MixedCurrencies are two-line classes that extend a domain exception. The point is where they get thrown: inside the constructor, before any invalid MoneyList can exist. sum() no longer defends itself. It cannot be called on an empty list, because an empty list cannot be built. It cannot mix currencies, because a mixed list never made it past new. The guards that used to sit in every caller now sit in one place, and every caller is shorter for it.

Money itself stays a small readonly value object:

<?php

declare(strict_types=1);

namespace App\Domain\Shared;

final readonly class Money
{
    public function __construct(
        public int $minorUnits,
        public string $currency,
    ) {}

    public static function zero(string $currency): self
    {
        return new self(0, $currency);
    }

    public function add(Money $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new MixedCurrencies($this->currency);
        }
        return new self(
            $this->minorUnits + $other->minorUnits,
            $this->currency,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Iteration and counting for free

The reason people reach for a raw array is ergonomics. They want foreach and count(). You keep both by implementing two interfaces the language already understands.

public function getIterator(): Iterator
{
    return new ArrayIterator($this->items);
}

public function count(): int
{
    return count($this->items);
}
Enter fullscreen mode Exit fullscreen mode

IteratorAggregate makes foreach ($prices as $money) work. Countable makes count($prices) work. The collection reads like an array at the call site and behaves like a guarded object underneath. Callers get the syntax they wanted without the bag that holds anything.

Mapping without losing the type

The next thing callers want is transformation. Apply tax to every price, keep the ones over a threshold, and get another price list back. If map returns a plain array, you have leaked straight back into array<int, Money> and lost every guarantee. So the transforms return a new MoneyList.

/** @param callable(Money): Money $fn */
public function map(callable $fn): self
{
    return new self(array_map($fn, $this->items));
}

/** @param callable(Money): bool $fn */
public function filter(callable $fn): self
{
    return new self(
        array_values(array_filter($this->items, $fn)),
    );
}
Enter fullscreen mode Exit fullscreen mode

map runs each element through a Money-to-Money callback and wraps the result. Because it builds a new MoneyList, the currency invariant is re-checked on the way out. filter is more interesting: if your predicate removes every element, the constructor throws EmptyMoneyList. That is not a bug to work around. It is the invariant telling you that an empty price list is not a thing your domain allows, and forcing the caller to decide what an empty result means before it spreads.

If you genuinely need to project into another type, expose the raw data through one narrow door and let the caller array_map from there:

/** @return list<Money> */
public function toArray(): array
{
    return $this->items;
}
Enter fullscreen mode Exit fullscreen mode

Immutable methods that refuse to be ignored

map, filter, and an append helper all return a new instance and leave the original untouched. That immutability has a classic trap: someone writes $prices->add($shipping); on its own line, expecting mutation, and throws the result away. The list they keep using is the old one.

PHP 8.5 gives you an engine-level guard for exactly this. Mark the method #[\NoDiscard] and the engine emits a warning whenever the return value goes unused.

#[\NoDiscard('MoneyList is immutable; use the returned list')]
public function add(Money $money): self
{
    return new self([...$this->items, $money]);
}
Enter fullscreen mode Exit fullscreen mode

Now $prices->add($shipping); warns at runtime, and the fix is to assign the result. If you ever mean to discard it, (void) $prices->add($shipping); says so out loud. The attribute turns a silent immutability mistake into a loud one, which is the right trade for a value-object collection where every method hands you a fresh instance.

The same version adds the pipe operator, which reads well against these chainable methods:

$net = $prices
    |> (fn($l) => $l->filter($isPositive))
    |> (fn($l) => $l->map($withTax));
Enter fullscreen mode Exit fullscreen mode

Each step takes the list, returns a list, and passes it along. No temporary variables, no nesting.

Where the collection belongs

MoneyList is domain code. It imports nothing from Doctrine, Symfony, or Laravel, and it should stay that way. The repository hydrates rows into Money objects and hands the constructor a list<Money>; the HTTP controller parses JSON into the same shape. The invariants live at the center, and the framework code at the edge only ever produces or consumes valid lists. Keeping the rule about "what a list of prices is" inside the domain, instead of scattered through adapters, is the same discipline the whole book is built on: your concepts own their invariants, and the framework is a delivery mechanism that borrows them.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)