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. Writing a class per type purely for typing is wasted boilerplate.

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

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

  4. 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). It costs runtime and boilerplate; make the justification explicit.

  5. 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.

  6. 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.

Tags: #php#generics#static-analysis
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