A Collection class, used everywhere, whose first() returns mixed — so every one of the forty call sites either asserted the type by hand or let the analyser lose track of it entirely. PHP has no generics and is not getting them soon, and the analyser has had them for years.
The symptom
$orders = $this->repository->pending(); // Collection
$first = $orders->first(); // mixed
assert($first instanceof Order); // 40 of these
$first->total(); // now it knows
$ vendor/bin/phpstan analyse --level=8 src/
Cannot call method total() on mixed. 22
Parameter #1 expects Order, mixed given. 14
Method returns mixed but should return Order. 9
[ERROR] 45 errors
# and 40 assert() calls whose only purpose is to silence
# the ones that would otherwise be here.The assertions are the tell. Forty runtime checks that can never fail, written to give a static tool information it could have had from a docblock, and each one is a line somebody has to read.
Why it happens
A container class cannot express what it contains in PHP’s type system. The signature says mixed because that is the only honest thing it can say, and everything downstream inherits the ignorance.
The fix
@template, and what the analyser can then prove
/**
* @template T
*/
final class Collection implements IteratorAggregate, Countable
{
/** @param list<T> $items */
public function __construct(private array $items) {}
/** @return T|null */
public function first(): mixed
{
return $this->items[0] ?? null;
}
/**
* @template U
* @param callable(T): U $fn
* @return self<U>
*/
public function map(callable $fn): self
{
return new self(array_map($fn, $this->items));
}
}
The map signature is where this stops being decoration: the analyser now knows that mapping a collection of orders through a function returning money produces a collection of money, without anything being written at the call site. That is the entire value proposition and it is real.
/** @return Collection<Order> */
public function pending(): Collection { /* ... */ }
// at the call site, with no assertion
$totals = $this->repository->pending()
->map(fn (Order $o): Money => $o->total());
// Collection<Money>, and the analyser knows it
The variance question
/**
* @template-covariant T
*/
final class ReadOnlyCollection { /* ... */ }
// covariant: Collection<Cat> is acceptable where
// Collection<Animal> is expected — SAFE only if nothing
// writes to it.
// this is why the mutable version must NOT be covariant:
function add(Collection<Animal> $c): void {
$c->push(new Dog()); // and $c was a Collection<Cat>
}
Marking a mutable collection covariant is the mistake that produces confident wrong answers, and the analyser will not stop you. Splitting the read-only case into its own class was clearer than annotating carefully, and it turned out that two thirds of the uses never wrote to the collection anyway.
What breaks: right in the docblock, wrong at runtime
/** @return Collection<Order> */
public function fromRows(array $rows): Collection
{
return new Collection($rows); // rows are arrays, not Orders
}
// the analyser believes the docblock and reports nothing.
// every consumer is now wrong, silently, and the failure
// appears wherever the first method call happens.
The runtime enforces nothing, so a docblock that lies is a lie that propagates. The defence is that the boundaries — where data enters the type system from a database row, a JSON payload or a form — are where the construction has to be checked, and those are a countable number of places rather than every method.
Where to stop
worth it:
a collection type used everywhere
a result wrapper: Result<TSuccess, TError>
a builder that returns the thing it builds
not worth it:
a repository interface with one entity type
— write Order, not @template
a service with a generic dependency it uses once
anything where the annotation is longer than the
method
we annotated 4 classes. the fifth was reverted.The fifth was a generic event dispatcher where the annotations came to eleven lines for a class with two methods, and nobody could read the signature afterwards. A type-level abstraction that requires more explanation than the thing it abstracts has failed at the only job it had.
Verifying it worked
$ vendor/bin/phpstan analyse --level=8 src/
[OK] No errors
$ grep -rc 'assert($' src/ | awk -F: '{s+=$2} END {print s}'
2 # was 40
# and the deliberate check: an intentionally wrong
# annotation, to confirm the analyser catches it
$ vendor/bin/phpstan analyse src/Broken.php
Method pending() should return Collection<Order> but
returns Collection<Invoice>.Writing a deliberately wrong annotation and confirming the tool objects is worth doing once — it establishes that the annotations are being read rather than ignored, which is not obvious when everything passes. The two remaining assertions are at boundaries where data genuinely arrives untyped.
What this costs
A type system that only one tool enforces, and which is therefore only as good as the discipline of running it. A colleague who has not internalised that the docblocks are load-bearing will edit one without thinking, and nothing at runtime will object.
It also raises the cost of reading the class. The annotated Collection has more docblock than code, which is the honest price of generics in a language without them — worth paying for the one class used four hundred times, and not worth paying anywhere else.