Skip to content

Static analysis

A specification closure is a real call, which is what makes understudy typed at all. It also means one thing an analyser sees is genuinely odd:

php
when(fn () => $repository->find(Arg::int(min: 1)))->returns($book);

Arg::int() is declared mixed, because a matcher has to be passable wherever a contract declares anything at all. Psalm and PHPStan both report that as an argument-type error — correctly in general, wrongly here.

Two packages fix it, one per analyser:

rasuvaeff/understudy-psalma Psalm plugin
rasuvaeff/understudy-phpstana PHPStan extension

They are independent of the runner adapters and of each other. Install whichever analyser your project already runs.

What both of them do

A matcher fits whatever the contract declaresinside a specification closure, and only there
Arg::rest() may stop before the arity doesso the "too few arguments" report goes quiet on that call
$captor->capture() counts as a matchernot as a second call in the closure
returns() is checked against the method being specifiedthe builder's template parameter is filled in from the closure
wire() has the shape of the class it wiredan unknown key is an error, and each double is typed as its contract
A specification that can never work is reportedsee the table below

What is deliberately still an error

php
$repository->find(Arg::int());   // a real call, not a specification

A matcher reaching a real call raises MatcherLeaked at run time, and an analyser package that hid it would be worse than no package at all. Both report it — PHPStan under the identifier understudy.matcherLeak.

Everything else around a specification keeps its reports too: a wrong argument beside a matcher, a method the double does not have, the statements around the closure.

The findings

Identifier (PHPStan)Reported when
understudy.closurethe closure specifies nothing, makes more than one call, or calls a static method a double cannot intercept
understudy.cardinalitytimes(5, 2), a negative bound, verify(…, never: true, times: 3), times beside a minimum
understudy.matchera matcher whose kind the parameter can never accept: Arg::int() where a string is declared
understudy.returnsreturns() on a method declared void, where no value is ever observed
understudy.matcherLeaka matcher written outside a specification, where it reaches the code as a value

Psalm reports the same family under one issue type, UnderstudyMisuse.

Both are silent when unsure

A refined parameter type — non-empty-string, an int range — answers "maybe" to its plain kind, and a matcher can produce a value that fits it, so nothing is reported.

A false accusation costs more than a missed one here, because the engine still catches at run time what static analysis misses.