Should I use PHPStan generics for type-safe collections or hand-write a class per type?
Question
My domain layer passes untyped entity arrays everywhere — I pass `Order[]` through signatures like `function process(array $orders)`, but PHPStan tells me nothing when a wrong type slips in. I want compile-time (really, analysis-time) safety without runtime generics support. Is the right move to hand-write a class per entity type — `OrderCollection`, `InvoiceCollection` — or should I solve it with PHPStan generics? Where's the line between boilerplate and type safety?
Answer
Short answer: for most cases use PHPStan generics (generic docblocks and array shapes); move to a hand-written concrete collection class only when you need behavior or a runtime guarantee. Writing a class per type purely for typing is wasted boilerplate.
-
PHP has no runtime generics — PHPStan generics are purely static.
@param list<Order> $ordersgives you analysis-time type safety at zero runtime cost. That’s exactly what you asked for; the interpreter checks nothing, PHPStan catches it in CI. -
Start with typed arrays. To just pass collections around, use
list<Order>orarray<int, Order>on your signatures. No new class needed; a call passing the wrong type goes red on the PHPStan side immediately. That’s the cheapest, fastest win. -
Write a single generic Collection when you want behavior. If you want
->map(),->filter(), or invariants like “non-empty” or “unique”, write ONE@template-annotatedCollection<T>class — not one per type. Because the methods carry the type through, the IDE and PHPStan preserve it along the chain. -
Introduce a concrete subclass only with a real reason. Write
OrderCollection extends Collectioneither for domain readability, or to add a runtimeinstanceofcheck inadd()when the data comes from outside your type-checked boundary (JSON, DB, user input). It costs runtime and boilerplate; make the justification explicit. -
Functions can be generic too, not just classes. If you need a type-carrying helper (
first,map), you can make the function generic with@templatewithout writing a class at all — a function taking@param list<T> $itemsand returning@return Tpreserves the type at the call site. That covers most needs around a collection without introducing a class. -
Generics only help if PHPStan actually runs strict. Run level 8/max, enable
checkGenericClassInNonGenericObjectType, and make PHPStan mandatory in CI. Otherwise the annotations rot and give you false confidence.
The single generic collection skeleton looks like this:
/**
* @template T of object
*/
final class TypedCollection
{
/** @var list<T> */
private array $items = [];
/** @param T $item */
public function add(object $item): void
{
$this->items[] = $item;
}
/** @return list<T> */
public function all(): array
{
return $this->items;
}
}
/** @var TypedCollection<Order> $orders */
Bottom line: personally I’d default to list<Order> docblocks + strict PHPStan; add a single generic Collection<T> when I need behavior like map/filter; and hand-write concrete subclasses only at a trust boundary, where I need to validate incoming data at runtime. That keeps boilerplate minimal and gets you most of the safety almost for free. Just remember: this safety exists only as long as PHPStan actually runs — if you don’t make it mandatory in CI, no annotation protects you.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.