# Should I use PHPStan generics for type-safe collections or hand-write a class per type?

> Default to `list<Order>` docblocks with strict PHPStan in CI; add one generic `Collection<T>` for behavior, a concrete class only at a trust boundary.

- Asked: 2026-07-26
- Answered: 2026-07-31
- Asked by: Ozan
- Tags: php, static-analysis
- Source: https://muhammetsafak.com/just-ask/should-i-use-phpstan-generics-for-type-safe-collections-or-hand/
- Language: en-US
- Author: Muhammet Şafak

---
**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?


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](/blog/scalar-type-declarations-and-return-types-in-php-7/); the element type of an array falls exactly outside that boundary, and PHPStan fills the gap at analysis time.

## Why

1. **PHP has no runtime generics — PHPStan generics are purely static.** `@param list<Order> $orders` gives 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.

2. **Functions can be generic too, not just classes.** If you need a type-carrying helper (`first`, `map`), you can make the function generic with `@template` without writing a class at all — a function taking `@param list<T> $items` and returning `@return T` preserves the type at the call site. That covers most needs around a collection without introducing a class.

3. **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

1. **Start with typed arrays.** To just pass collections around, use `list<Order>` or `array<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.

2. **Write a single generic Collection when you want behavior.** If you want `->map()`, `->filter()`, or invariants like "non-empty" or "unique", write ONE `@template`-annotated `Collection<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:

   ```php
   /**
    * @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 */
   ```

3. **Introduce a concrete subclass only with a real reason.** Write `OrderCollection extends Collection` either for domain readability, or to add a runtime `instanceof` check in `add()` when the data comes from outside your type-checked boundary (JSON, DB, user input). Make the justification explicit.

4. **Run PHPStan strict and mandatory.** Run level 10/max (the `checkGenericClassInNonGenericObjectType` option 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.

## Related Reading

- [For concurrent HTTP calls, should I reach for raw Fibers, ReactPHP/AMPHP, or Swoole?](https://muhammetsafak.com/just-ask/concurrent-http-calls-raw-fibers-reactphp-amphp-swoole/) — Just Ask
- [How do I cleanly implement "withX" copy methods on readonly value objects?](https://muhammetsafak.com/just-ask/how-do-i-cleanly-implement-withx-copy-methods-on-readonly-value/) — Just Ask
- [Should I bind a Money value object to Eloquent with a custom cast or with accessors/mutators?](https://muhammetsafak.com/just-ask/should-i-bind-a-money-value-object-to-eloquent-with-a/) — Just Ask
