A few weeks ago I was staring at this:
Parameter #1 $amount of method format() expects float, float|null given.
$amount was declared float at the top of the method. I knew that. PHPStan knew that. Somewhere between line 12 and line 47 something turned it into float|null, and the error message — perfectly correct as it was — wasn't going to tell me where.
So I did what I always do: scrolled up, eyeballed every assignment, guessed wrong twice, dropped a \PHPStan\dumpType($amount) at L30, ran phpstan — still float. Moved it to L40, still float. L45, finally float|null. Bisected my way down to L43 where I'd done $amount = $row['discount'] ?? null and forgotten the fallback. Five minutes of phpstan-runs for a one-line fix.
The annoying part isn't the bug. The annoying part is that dumpType only answers what is the type at this line — to find when it widened, you re-run phpstan once per probe. The information is sitting in MutatingScope the whole time. The error message has it. dumpType has it. Nothing surfaces the delta.
I kept thinking about this for a while and eventually wrote a small PHPStan extension to see if I could get at the data.
What I actually wanted to see
Something like this:
$amount · App\PriceCalculator::format [src/PriceCalculator.php] (up to L47)
L12 param float
L31 assign-op float|null
L47 read float|null
That's it. Every event that shaped the variable, in order, with the type at each step. No more guessing which line was the culprit — L31 is.
How PHPStan exposes this (mostly)
PHPStan has a thing called Collectors. They run during analysis with full scope access, and you can dump anything you can see from Scope::getType($expr). There's one per AST event you care about: assign, assign-op, parameter binding, property read, ternary narrowing, and so on. Each collector emits (file, function, variablePath, line, type) rows.
A Rule then runs once on the virtual CollectedDataNode, joins all the rows by function + variable, sorts by line, dedups consecutive reads (otherwise the output is mostly noise), and prints the chain.
The first version was about 200 lines and worked on the trivial case. Then real codebases happened.
The things I underestimated
Three problems I didn't see coming:
Ternaries read backwards. is_string($x) ? $x : 'd' produces three events on the same line — a read of $x in the condition, a narrow inside the then-branch, a read of the narrowed $x. Sort by line alone and they collapse. You have to sort by source position within the line, and even then the order has to be cond-read → narrow → then-read, or the chain reads as if the narrow happened before the check.
Third-party extensions invisibly reshape types. Someone calls Assert::notNull($amount) from webmozart/assert. The variable narrows to float. The chain shows the narrow but doesn't say why — because the narrow didn't come from a built-in is_* call, it came from a TypeSpecifyingExtension PHPStan loaded from a third-party package. I ended up walking the DI container, finding tagged services, asking each one whether it would specify this call, and attributing the narrow to the extension's short class name. Same story for PropertiesClassReflectionExtension (larastan's magic Eloquent attributes) and dynamic return type extensions. Output now looks like:
L20 narrow Webmozart\Assert\Assert::notNull($amount) => float via AssertTypeSpecifyingExtension
The via is the part I find myself reading first now. When the inferred type surprises you, it tells you which extension to blame (or thank) without grepping the vendor tree.
Loops. I haven't really solved this one. PHPStan converges loop body types to a stable state and reports that, not per-iteration deltas — so the chain inside a loop body is a lie of omission. I document it as a limitation. If you have ideas, the repo is open.
The shape of the thing
I packaged it as phpstan-type-trace. Two ways to use it:
- A
traceType($var)marker you drop in source — runs on your nextphpstan analyseand emits the chain as a PHPStan error at that line. - A CLI (
vendor/bin/phpstan-trace inspect file.php:42 myVar) that doesn't require any source edits, useful when you don't want to touch the file or you're handing the output to a coding agent.
I'm not going to oversell it. It's a focused thing that does one focused thing, and most days you don't need it. But when you do — when you're three layers into an Eloquent model with a larastan extension typing the magic attributes and a webmozart assert narrowing a param two scopes up — seeing the chain is the difference between five minutes and an hour.
What I learned about PHPStan internals
Mostly that the Collector → CollectedDataNode → Rule pipeline is the right primitive for any cross-file static analysis tooling, not just my use case. If you ever want to build a "where is X used", "did this type ever widen", or "what extensions touched this scope" tool, that's the hook. It's not in the marketing docs but it's stable — it's how PHPStan's own dead-code detection (e.g. CallToFunctionStatementWithoutImpurePointsRule, the "called but result is ignored" warning) is built.
I keep wanting to do more with it. PhpStorm plugin maybe. We'll see.
Top comments (0)