A template calls getEmail() on an Order at three in the morning. The method returned array, the docblock said “array of User”, and eight months later someone put an Order in it. Everything was fine until it was not.
PHP still has no generics and will not get them soon. Meanwhile PHPStan and Psalm shipped them anyway, in docblocks. Checked at analysis, not at runtime. For everyday backend work that turns out to be most of the value.
With template types the container knows what it holds:
/**
* @template T
*/
class Collection
{
/** @param T $item */
public function add($item): void { /* ... */ }
/** @return list<T> */
public function all(): array { /* ... */ }
}
/** @var Collection<User> $users */
$users->add($order);
PHPStan rejects that last line before any test runs. The type flows through all() too, so a foreach over the result knows it iterates users, and a rename of getEmail() gets checked at every real call site. This is the quiet payoff: refactoring a large codebase stops being archaeology. The analyzer knows what is inside every array, so it can tell you what your change breaks.
There is a cost, and it is readability. Docblock generics live in comments with their own dialect. Past some point, @template bounds on top of conditional types on top of class-string<T>, the annotation gets harder to understand than the code it describes. I saw a repository base class where the docblock was longer than the class. Annotation for the sake of annotation.
So, a rule of appetite. Start with the cheap forms: list<User>, array<int, Order>, array shapes. They cover most real containers and everyone can read them. Reach for @template only when a class really works with many types, collections, repositories, result wrappers. When a plain typed array does the job, let it.
The repository base class with the long docblock was mine. I was proud of it for about a month.