https://github.com/kayw-geek/phpstan-type-trace
See the full type-inference chain of any value in PHPStan, not just a single snapshot.
https://github.com/kayw-geek/phpstan-type-trace
agent claude-code debug-tool php phpstan phpstan-extension static-analysis
Last synced: 5 days ago
JSON representation
See the full type-inference chain of any value in PHPStan, not just a single snapshot.
- Host: GitHub
- URL: https://github.com/kayw-geek/phpstan-type-trace
- Owner: kayw-geek
- Created: 2026-05-22T02:05:48.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-05-22T05:36:55.000Z (about 1 month ago)
- Last Synced: 2026-05-22T11:44:31.250Z (about 1 month ago)
- Topics: agent, claude-code, debug-tool, php, phpstan, phpstan-extension, static-analysis
- Language: PHP
- Size: 613 KB
- Stars: 5
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# phpstan-type-trace
๐ **[Live examples โ](https://kayw-geek.github.io/phpstan-type-trace/)** โ 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
```bash
composer require --dev kayw-geek/phpstan-type-trace
```
Auto-registered via [phpstan-extension-installer](https://github.com/phpstan/extension-installer). Otherwise add to `phpstan.neon`:
```neon
includes:
- vendor/kayw-geek/phpstan-type-trace/extension.neon
```
## Usage
### CLI โ inspect any line, no source edits
```bash
./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`](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.
```php
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:
```php
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.
**`via` โ third-party extension attribution.** When the inferred type was shaped by a third-party PHPStan extension, the extension's short class name is appended (`via NewModelQueryDynamicMethodReturnTypeExtension`, `via AssertTypeSpecifyingExtension`, ...). Three categories are attributed today: dynamic return type (assign / assign-op), type-specifying (narrow), and properties class reflection (read). Detection is by source-file location โ official add-ons like `phpstan/phpstan-webmozart-assert` that ship under the `PHPStan\` namespace are still attributed; only classes shipped by `phpstan/phpstan` core are filtered out.
When the inferred type surprises you, `via` tells you which extension to blame (or thank) without grepping the vendor tree.
**Not attributed yet:** type-specifying calls used as bare statements (`Assert::notNull($x);` outside any `if` / ternary). PHPStan still narrows the scope but no `narrow` event is emitted. Wrap the call in an `if` if you need the attribution.
## Limitations
- Loops report the post-fixpoint type, not per-iteration deltas.
- Multiple closures inside the same enclosing function share one bucket. Same-named vars across sibling closures may collide.
- Cannot follow values across function boundaries.
- Ref-aliases (`$alias = &$x; $alias[] = 'y';`) show only the snapshot at the call.
## Use it in PhpStorm
[](https://plugins.jetbrains.com/plugin/31962-phpstan-type-trace)
Install the companion plugin to read chains inside the editor โ caret on a variable, run **Trace Type at Caret**, and the chain renders in a dedicated tool window with clickable line numbers, copyable types, and `via ` attribution pills.
The plugin shells out to this CLI, so the package above must be installed in your project's `vendor/`. Marketplace page: .
## Use it with Claude Code
When Claude Code (or any LLM agent) is chasing PHPStan errors, it usually guesses at types. With this extension installed as a [Claude Code plugin](https://docs.claude.com/claude-code), Claude invokes the trace automatically โ fixes are grounded in real upstream type evidence, not pattern-matching.
```
/plugin marketplace add kayw-geek/phpstan-type-trace
/plugin install phpstan-type-trace@kayw-geek
```
Installed into `~/.claude/plugins/cache/`, auto-discovered across every project. Updates: `/plugin marketplace update kayw-geek` then reinstall.
How it works
Two-phase PHPStan pipeline:
1. **Collectors** (one per event kind) record every relevant AST event with `(file, functionKey, path, line, pos, type, origin)`:
- Param entry: `ParamInFunctionCollector`, `ParamInMethodCollector`, `ParamInClosureCollector`, `ParamInArrowFunctionCollector` โ hooked on PHPStan's `In*Node` virtual nodes so scope is already inside the function when params are read.
- Reads: `VarReadCollector`, `PropertyFetchCollector`, `StaticPropertyFetchCollector`.
- Writes: `AssignCollector`, `AssignOpCollector` (covers all 13 compound-op subclasses), `AssignRefCollector`, `ArrayWriteCollector`.
- Narrowing: `NarrowingCollector` (if-statements), `TernaryNarrowingCollector` (ternaries) โ anchored to the branch where the narrow holds, with a reason predicate extracted from the guard.
- Call sites: `TraceCallCollector`.
2. **`TraceReportRule`** runs once at the end on the virtual `CollectedDataNode`. For each `traceType()` call it joins the recorded events on `(functionKey, path)` filtered to lines `<=` the call line, sorts by `line โ source position โ rank` (so inline ternaries read cond-read โ narrow โ then-read, not narrow first), collapses repeated reads of the same type *except* right after a narrow (since the narrow is evidence and the read is the usage โ they convey different things), and emits the delta chain as a PHPStan error.
The CLI runs the same pipeline with a dump env var set, captures every chain as a JSON sentinel error, then filters to the `(file, line, variable)` you asked about.
## License
MIT