phpstan-type-trace
🌐 Live examples → — read 5 real chains in 10 seconds, no install.
When PHPStan tells you:
Parameter #1 $amount of method format() expects float, float|null given.
You know the final type at the call site. You don't know which assign, which param, or which missing narrow put the null there. You scroll up, guess, get it wrong, repeat.
This extension prints the full chain — every event that shaped the variable up to that line:
$amount · App\PriceCalculator::format [src/PriceCalculator.php] (up to L25)
L16 param float|null
L20 narrow Webmozart\Assert\Assert::notNull($amount) => float via AssertTypeSpecifyingExtension
L25 read float
One command, zero source edits, third-party extensions attributed.

Above: a longer chain from real larastan code — nine events including three narrow rows that show why the type tightened.
Install
composer require --dev kayw-geek/phpstan-type-trace
Auto-registered via phpstan-extension-installer. Otherwise add to phpstan.neon:
includes:
- vendor/kayw-geek/phpstan-type-trace/extension.neon
Usage
CLI — inspect any line, no source edits
./vendor/bin/phpstan-trace inspect src/Foo.php:42 myVar
Variable name is optional — if only one variable has events at the target line, it's auto-picked. Otherwise the candidates are listed.
Pass --json for machine-readable output (handy for IDE plugins, agents, and CI). Schema is versioned and documented in docs/json-api.md; pin with --api-version=N.
traceType() — drop in a marker, get the chain on your next phpstan run
No extra command. Just call traceType($var) anywhere, then run vendor/bin/phpstan analyse like you always do — the chain shows up as a phpstan error at that line.
function compute(?float $discount = null): float
{
$discount ??= 0.1;
traceType($discount, 'after ??=');
return 1 - $discount;
}
------ -----------------------------------------------------------
Line PriceCalculator.php
------ -----------------------------------------------------------
5 Type chain for $discount in compute — after ??=
L3 param float|null
L4 assign-op float
------ -----------------------------------------------------------
traceType() is a runtime no-op (autoloaded from src/runtime.php), so leaving a stray call in production code does nothing — it only emits during static analysis.
Signature:
function traceType(mixed $value, ?string $reason = null): void
$value accepts a variable, property fetch ($this->x), or static property (Foo::$bar). For arbitrary expressions, only the snapshot type is printed. $reason is a string literal shown in the chain header.
What gets captured
| Source | Origin label | Example | via |
|---|
| Function/method params | param | function f(int $x) | |
| Closure / arrow-fn params | param | fn(int $x) => ... | |
| Variable assignment | assign | $x = 5; | ✓ |
| Compound assignment | assign-op | $x += 1; $x ??= 'def'; | ✓ |
| Reference assignment | assign-ref | $x = &$other; | |
| Array write | array-write | $x[] = 'y'; $x['k'] = $v; | |
| Property fetch | read | $this->foo | ✓ |
| Static property fetch | read | Foo::$bar | ✓ |
| Variable read | read | bare $x usage | |
| If / ternary narrowing | narrow | if (is_string($x)), $x ?? 'd', etc. | ✓ |
narrow events carry a reason showing the predicate that justified the narrowing (is_string($x), $x instanceof Foo, $x !== null, ...), anchored to the branch where the narrow takes effect. Same-line events are ordered by source position, so an inline ternary reads cause → effect: the cond-read first, then the narrow, then the then-branch read.