PHP has no generic types: array cannot say "array of Money". A bound-erased generics RFC of May 2026 is marked Declined, so docblock generics remain the convention: PHPStan 376,908 and Psalm (Debugging and Analysis) read @template T and list<T> and check every call, while the engine ignores them. Pair them with value objects, small readonly classes compared by value and changed only by returning a copy.
<?php
final readonly class Money {
public function __construct(public int $cents, public string $currency = 'USD') {}
public function plus(Money $o): static {
if ($o->currency !== $this->currency) throw new LogicException('Currency mismatch');
return clone($this, ['cents' => $this->cents + $o->cents]); // a new object
}
}
/** @template T */
final class TypedList {
/** @param list<T> $items */
public function __construct(public private(set) array $items = []) {}
/**
* @param T $item
* @return self<T>
*/
public function with(mixed $item): self { return new self([...$this->items, $item]); }
}
/** @var TypedList<Money> $cart */
$cart = new TypedList()->with(new Money(1999))->with(new Money(500));
$total = array_reduce($cart->items, fn(Money $a, Money $b) => $a->plus($b), new Money(0));
var_dump($total->cents, $total == new Money(2499)); // == compares values
$broken = $cart->with('ten dollars'); // runs, but violates TypedList<Money>Output
int(2499) bool(true)
The last line runs, because T is erased at run time. PHPStan (composer require --dev phpstan/phpstan) catches it:
vendor/bin/phpstan analyse --level=9 --no-progress --error-format=raw money.phpOutput
/tmp/ch04-11/money.php:23:Parameter #1 $item of method TypedList<Money>::with() expects Money, string given.