<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kay W.</title>
    <description>The latest articles on DEV Community by Kay W. (@kaywgeek).</description>
    <link>https://dev.to/kaywgeek</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1824922%2F565035fb-f640-419d-89f7-ab09e0974897.jpeg</url>
      <title>DEV Community: Kay W.</title>
      <link>https://dev.to/kaywgeek</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kaywgeek"/>
    <language>en</language>
    <item>
      <title>PHPStan 'expects X, Y given' — the trace it doesn't give you</title>
      <dc:creator>Kay W.</dc:creator>
      <pubDate>Mon, 25 May 2026 06:34:18 +0000</pubDate>
      <link>https://dev.to/kaywgeek/phpstan-expects-x-y-given-the-trace-it-doesnt-give-you-40le</link>
      <guid>https://dev.to/kaywgeek/phpstan-expects-x-y-given-the-trace-it-doesnt-give-you-40le</guid>
      <description>&lt;p&gt;A few weeks ago I was staring at this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Parameter #1 $amount of method format() expects float, float|null given.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$amount&lt;/code&gt; was declared &lt;code&gt;float&lt;/code&gt; at the top of the method. I knew that. PHPStan knew that. Somewhere between line 12 and line 47 something turned it into &lt;code&gt;float|null&lt;/code&gt;, and the error message — perfectly correct as it was — wasn't going to tell me where.&lt;/p&gt;

&lt;p&gt;So I did what I always do: scrolled up, eyeballed every assignment, guessed wrong twice, dropped a &lt;code&gt;\PHPStan\dumpType($amount)&lt;/code&gt; at L30, ran &lt;code&gt;phpstan&lt;/code&gt; — still &lt;code&gt;float&lt;/code&gt;. Moved it to L40, still &lt;code&gt;float&lt;/code&gt;. L45, finally &lt;code&gt;float|null&lt;/code&gt;. Bisected my way down to L43 where I'd done &lt;code&gt;$amount = $row['discount'] ?? null&lt;/code&gt; and forgotten the fallback. Five minutes of phpstan-runs for a one-line fix.&lt;/p&gt;

&lt;p&gt;The annoying part isn't the bug. The annoying part is that &lt;code&gt;dumpType&lt;/code&gt; only answers &lt;em&gt;what is the type at this line&lt;/em&gt; — to find &lt;em&gt;when&lt;/em&gt; it widened, you re-run phpstan once per probe. The information is sitting in &lt;code&gt;MutatingScope&lt;/code&gt; the whole time. The error message has it. &lt;code&gt;dumpType&lt;/code&gt; has it. Nothing surfaces the delta.&lt;/p&gt;

&lt;p&gt;I kept thinking about this for a while and eventually wrote a small PHPStan extension to see if I could get at the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually wanted to see
&lt;/h2&gt;

&lt;p&gt;Something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$amount · App\PriceCalculator::format [src/PriceCalculator.php] (up to L47)
  L12  param      float
  L31  assign-op  float|null
  L47  read       float|null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How PHPStan exposes this (mostly)
&lt;/h2&gt;

&lt;p&gt;PHPStan has a thing called Collectors. They run during analysis with full scope access, and you can dump anything you can see from &lt;code&gt;Scope::getType($expr)&lt;/code&gt;. There's one per AST event you care about: assign, assign-op, parameter binding, property read, ternary narrowing, and so on. Each collector emits &lt;code&gt;(file, function, variablePath, line, type)&lt;/code&gt; rows.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Rule&lt;/code&gt; then runs once on the virtual &lt;code&gt;CollectedDataNode&lt;/code&gt;, joins all the rows by function + variable, sorts by line, dedups consecutive reads (otherwise the output is mostly noise), and prints the chain.&lt;/p&gt;

&lt;p&gt;The first version was about 200 lines and worked on the trivial case. Then real codebases happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things I underestimated
&lt;/h2&gt;

&lt;p&gt;Three problems I didn't see coming:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ternaries read backwards.&lt;/strong&gt; &lt;code&gt;is_string($x) ? $x : 'd'&lt;/code&gt; produces three events on the same line — a read of &lt;code&gt;$x&lt;/code&gt; in the condition, a narrow inside the then-branch, a read of the narrowed &lt;code&gt;$x&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third-party extensions invisibly reshape types.&lt;/strong&gt; Someone calls &lt;code&gt;Assert::notNull($amount)&lt;/code&gt; from webmozart/assert. The variable narrows to &lt;code&gt;float&lt;/code&gt;. The chain shows the narrow but doesn't say &lt;em&gt;why&lt;/em&gt; — because the narrow didn't come from a built-in &lt;code&gt;is_*&lt;/code&gt; call, it came from a &lt;code&gt;TypeSpecifyingExtension&lt;/code&gt; 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 &lt;code&gt;PropertiesClassReflectionExtension&lt;/code&gt; (larastan's magic Eloquent attributes) and dynamic return type extensions. Output now looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L20  narrow  Webmozart\Assert\Assert::notNull($amount)  =&amp;gt;  float  via AssertTypeSpecifyingExtension
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;via&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loops.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the thing
&lt;/h2&gt;

&lt;p&gt;I packaged it as &lt;a href="https://github.com/kayw-geek/phpstan-type-trace" rel="noopener noreferrer"&gt;phpstan-type-trace&lt;/a&gt;. Two ways to use it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;traceType($var)&lt;/code&gt; marker you drop in source — runs on your next &lt;code&gt;phpstan analyse&lt;/code&gt; and emits the chain as a PHPStan error at that line.&lt;/li&gt;
&lt;li&gt;A CLI (&lt;code&gt;vendor/bin/phpstan-trace inspect file.php:42 myVar&lt;/code&gt;) 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned about PHPStan internals
&lt;/h2&gt;

&lt;p&gt;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. &lt;code&gt;CallToFunctionStatementWithoutImpurePointsRule&lt;/code&gt;, the "called but result is ignored" warning) is built.&lt;/p&gt;

&lt;p&gt;I keep wanting to do more with it. PhpStorm plugin maybe. We'll see.&lt;/p&gt;

</description>
      <category>php</category>
      <category>phpstan</category>
    </item>
  </channel>
</rss>
