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.
Short answer
Writing a class per type purely for typing is wasted boilerplate. I wrote about how far the engine’s own type declarations reach in the scalar type declarations post; the element type of an array falls exactly outside that boundary, and PHPStan fills the gap at analysis time.
Why
-
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. -
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. -
This safety exists only as long as PHPStan actually runs. Because the annotations have no runtime counterpart, the checking stops when the analysis stops: the annotations rot and give you false confidence. A concrete subclass, on the other hand, costs runtime and boilerplate — and that’s exactly where the choice between the two gets made.
What to do
-
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. The 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 */ -
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). Make the justification explicit. -
Run PHPStan strict and mandatory. Run level 10/max (the
checkGenericClassInNonGenericObjectTypeoption was removed in PHPStan 2.0, so there’s nothing to enable there anymore) and make PHPStan mandatory in CI. Generics only help under that condition.
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.