Skip to content
Muhammet Şafak
tr
Asked by: Ozan Answered:

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

  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:

    /**
     * @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.

Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

More Questions

All questions

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind