<?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: MikiBuilder</title>
    <description>The latest articles on DEV Community by MikiBuilder (@mikibuilder).</description>
    <link>https://dev.to/mikibuilder</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4052068%2Fb69e4ff2-fe32-4784-8f59-0f7e97911723.png</url>
      <title>DEV Community: MikiBuilder</title>
      <link>https://dev.to/mikibuilder</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mikibuilder"/>
    <language>en</language>
    <item>
      <title>I added one object and broke 25 tests without changing a single assertion</title>
      <dc:creator>MikiBuilder</dc:creator>
      <pubDate>Sun, 20 Sep 2026 15:59:25 +0000</pubDate>
      <link>https://dev.to/mikibuilder/i-added-one-object-and-broke-25-tests-without-changing-a-single-assertion-11p</link>
      <guid>https://dev.to/mikibuilder/i-added-one-object-and-broke-25-tests-without-changing-a-single-assertion-11p</guid>
      <description>&lt;p&gt;Last month I was finishing a contribution to Symfony AI: redacting secrets and personal data from recorded HTTP cassettes before they get committed. The design had been agreed in the issue, reviewed, and approved. The platform test suite was green — 957 tests.&lt;/p&gt;

&lt;p&gt;Then CI came back with 25 failures in a job I had never had to think about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;Example "mistral/structured-output-math.php" produced output differing from its recorded fixture.
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;--- Expected
&lt;/span&gt;&lt;span class="gi"&gt;+++ Actual
&lt;/span&gt;&lt;span class="gd"&gt;-MathReasoning {#475
&lt;/span&gt;&lt;span class="gi"&gt;+MathReasoning {#477
&lt;/span&gt;   +steps: array:4 [
&lt;span class="gd"&gt;-    0 =&amp;gt; Step {#491
&lt;/span&gt;&lt;span class="gi"&gt;+    0 =&amp;gt; Step {#493
&lt;/span&gt;       +explanation: "Start with the given equation: 8x + 7 = -23."
       +output: "8x + 7 = -23"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it twice. Every asserted value is identical. Same explanation, same output, same number of steps.&lt;/p&gt;

&lt;p&gt;What changed is &lt;code&gt;{#475}&lt;/code&gt; versus &lt;code&gt;{#477}&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What those numbers are
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;{#475}&lt;/code&gt; is VarDumper's object handle — a &lt;code&gt;spl_object_id()&lt;/code&gt; value. When you &lt;code&gt;dump()&lt;/code&gt; something, Symfony's dumper labels each object with an id that comes from a process-wide counter.&lt;/p&gt;

&lt;p&gt;The test suite compares a full dump against a committed &lt;code&gt;.out&lt;/code&gt; file. Those files contain the ids.&lt;/p&gt;

&lt;p&gt;Which means: &lt;strong&gt;any object constructed anywhere earlier in the process shifts every id that comes after it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My change added one line to a constructor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;redactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$redactor&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BodyRedactor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One object per cassette. Two cassettes in an example, ids shift by two. Four cassettes, they shift by four. The deltas in the failure log matched exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that was not a fix
&lt;/h2&gt;

&lt;p&gt;The obvious move is to not build the object until you need it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?BodyRedactor&lt;/span&gt; &lt;span class="nv"&gt;$redactor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;?BodyRedactor&lt;/span&gt; &lt;span class="nv"&gt;$redactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;redactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$redactor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;redactor&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BodyRedactor&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;redactor&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BodyRedactor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cassette that only replays never redacts anything, so it never needs a redactor. This is better code regardless of the test problem — you do not instantiate a rule set nobody asked for.&lt;/p&gt;

&lt;p&gt;It took the failures from 25 to 21, and the shift from +2 to +1.&lt;/p&gt;

&lt;p&gt;Better. Not fixed.&lt;/p&gt;

&lt;p&gt;The remaining instantiation came from the verification path: when a live request does not match the recorded signature on the first attempt, the code retries against the redacted body, and that retry needs the redactor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is actually interesting
&lt;/h2&gt;

&lt;p&gt;At that point I stopped trying to be clever, because the problem was never the initialisation strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fixture that captures object ids couples your test to things you are not testing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those 251 fixtures assert, implicitly and without anyone deciding it, that the number of objects PHP allocates before the dump will never change. Not the values. Not the structure. The allocation order of the entire process.&lt;/p&gt;

&lt;p&gt;Nobody wrote that assertion. It came for free with &lt;code&gt;dump()&lt;/code&gt;, and it sat there until someone added a collaborator to a class three layers down.&lt;/p&gt;

&lt;p&gt;That is the same failure mode I spend my time on in &lt;code&gt;llm-vcr&lt;/code&gt;: a recording that captures more than the thing under test. A cassette that stores a timestamp fails tomorrow. One that stores a request id fails on the next run. A fixture that stores object ids fails when anyone allocates an object upstream.&lt;/p&gt;

&lt;p&gt;The recording is too faithful. Fidelity sounds like a virtue until it starts asserting things you never meant to assert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did about it
&lt;/h2&gt;

&lt;p&gt;I could have forced my way through. Make the built-in rules static functions and only construct a &lt;code&gt;BodyRedactor&lt;/code&gt; when someone passes one in explicitly. The ids would stay put and CI would go green.&lt;/p&gt;

&lt;p&gt;That would also have thrown away the design the maintainer and I had agreed on: an optional injectable collaborator, so a project can plug in its own redaction rules. Making the default path object-free to satisfy a fixture would have been solving the wrong problem loudly.&lt;/p&gt;

&lt;p&gt;So I wrote it up instead — the measurements, the cause, and three ways out with what each one costs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Re-record the fixtures.&lt;/strong&gt; Needs API credentials for nine providers. I have none of them, and guessing at model output is not re-recording.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the comparison ignore object ids.&lt;/strong&gt; A change to the test harness, not to my feature, and it protects the suite from every future collaborator rather than just mine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the default path object-free.&lt;/strong&gt; Preserves the ids, gives up the design.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then I said I would pick 2, explained why, and offered to do the work either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it ended
&lt;/h2&gt;

&lt;p&gt;The next morning, Christopher Hertel landed a separate PR:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;code&gt;{#123}&lt;/code&gt; handles VarDumper prints next to an object are &lt;code&gt;spl_object_id()&lt;/code&gt; values, so they shift whenever anything allocates one more object earlier in the process. (...) Instead of resyncing the ids, &lt;code&gt;bootstrap.php&lt;/code&gt; now dumps with &lt;code&gt;withRefHandles(false)&lt;/code&gt;, so the handles never enter the output.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One line in the bootstrap. Twenty-eight fixture files regenerated from the existing cassettes — no re-recording, no cassette touched, and every changed line was a handle line.&lt;/p&gt;

&lt;p&gt;Two things in that PR description are worth sitting with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It was not my change that broke it.&lt;/strong&gt; &lt;code&gt;main&lt;/code&gt; was already red. Adding &lt;code&gt;Capability::REALTIME_SESSION&lt;/code&gt; in an unrelated feature had been enough to break the same 21 examples days earlier. I had not caused the problem; I had walked into it, and my diff happened to be holding the bag when the CI ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the fix was the boring one.&lt;/strong&gt; Not clever, not defensive, not a workaround in my class. One flag, in the one place that owns the output format.&lt;/p&gt;

&lt;p&gt;My contribution merged three days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If your test compares a dump, strip what you are not asserting.&lt;/strong&gt; Object ids, memory addresses, timestamps, autoincrement keys. Anything the runtime assigns rather than your code producing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A test that fails for a reason unrelated to its name is worse than no test.&lt;/strong&gt; Twenty-five red builds told me nothing about whether redaction works. They told me about PHP's allocator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And when the fix belongs to someone else's file, say so.&lt;/strong&gt; I spent two CI cycles trying to make the problem disappear from inside my own diff. The useful move was to measure it, write it down, and hand the decision to the people who own those fixtures.&lt;/p&gt;

&lt;p&gt;That last one took me longer to learn than it should have.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The contribution is &lt;a href="https://github.com/symfony/ai/pull/2487" rel="noopener noreferrer"&gt;symfony/ai#2487&lt;/a&gt;. The fix for the fixtures is &lt;a href="https://github.com/symfony/ai/pull/2531" rel="noopener noreferrer"&gt;symfony/ai#2531&lt;/a&gt;, by Christopher Hertel. I maintain &lt;a href="https://github.com/MikiBuilder/llm-vcr" rel="noopener noreferrer"&gt;llm-vcr&lt;/a&gt;, a record and replay library for testing AI features in PHP.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to test AI features in PHP without spending a cent</title>
      <dc:creator>MikiBuilder</dc:creator>
      <pubDate>Wed, 29 Jul 2026 07:53:24 +0000</pubDate>
      <link>https://dev.to/mikibuilder/how-to-test-ai-features-in-php-without-spending-a-cent-1pga</link>
      <guid>https://dev.to/mikibuilder/how-to-test-ai-features-in-php-without-spending-a-cent-1pga</guid>
      <description>&lt;p&gt;I have been writing PHP for twenty years. For the last two I have been putting language models into real applications: ticket classifiers, document analysis, conversational assistants.&lt;/p&gt;

&lt;p&gt;And there is one question I could not answer well: &lt;strong&gt;how do you test this?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that does not work
&lt;/h2&gt;

&lt;p&gt;Say you have a service that classifies support tickets. You give it text, it returns JSON with a category, a sentiment and an urgency level. You want a test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;testClassifiesAnAccessProblem&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;analyzer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'I cannot access my account'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'access'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks reasonable. It is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: it is not deterministic.&lt;/strong&gt; The same prompt returns different text on every run. Today &lt;code&gt;"access"&lt;/code&gt;, tomorrow &lt;code&gt;"Account access"&lt;/code&gt;, the day after &lt;code&gt;"access_problem"&lt;/code&gt;. Your test fails randomly, and a test that fails randomly is worse than no test at all: it teaches the team to ignore red builds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: it costs money.&lt;/strong&gt; Every run burns tokens. Multiply by the number of tests, by every push, by every developer on the team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third: it is slow.&lt;/strong&gt; Between 200 ms and 3 seconds per call. A suite with 200 AI tests takes ten minutes. And a ten-minute suite stops being run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fourth: it needs network access and a production API key in CI.&lt;/strong&gt; If your provider has an incident, your build turns red without you breaking anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then there is the problem nobody talks about
&lt;/h2&gt;

&lt;p&gt;Those four are annoying. The fifth one wakes you up at three in the morning.&lt;/p&gt;

&lt;p&gt;Your classifier returns this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sentiment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"frustrated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"urgency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And you, being tidy, map it to a typed DTO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketDto&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$sentiment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$urgency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// ← here&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On some random Tuesday, the provider updates the model. Nobody tells you: from their side it is a minor version bump. And the model starts returning this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sentiment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"frustrated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"urgency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;urgency&lt;/code&gt; went from &lt;code&gt;int&lt;/code&gt; to &lt;code&gt;string&lt;/code&gt;. Your DTO blows up in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And you never touched a single line of code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is the important part: &lt;strong&gt;no test catches this&lt;/strong&gt;. The mocks you wrote by hand still return &lt;code&gt;4&lt;/code&gt;, because you froze that value six months ago. Your suite is green while production burns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What already exists
&lt;/h2&gt;

&lt;p&gt;Before writing anything, I looked at what was out there. This matters, because the gap might not be a gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hand-written mocks.&lt;/strong&gt; Symfony AI ships &lt;code&gt;InMemoryPlatform&lt;/code&gt; and &lt;code&gt;MockPlatformFactory&lt;/code&gt;, and they are good. But they return values you made up. They are the right tool for testing your own logic — what happens if the model fails, if it returns empty — and the wrong tool for knowing what the model actually returns. By definition, &lt;strong&gt;they can never detect drift&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;php-vcr&lt;/code&gt;.&lt;/strong&gt; Excellent library, over three million installs. It records HTTP requests and replays them. The catch is that it matches requests by &lt;strong&gt;exact body&lt;/strong&gt;, and a real prompt carries timestamps, UUIDs, order IDs and RAG context that change on every run. The cassette is invalidated the moment a comma moves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eval packages.&lt;/strong&gt; There are several on Packagist and they are useful, but they answer a different question: &lt;em&gt;is the answer good?&lt;/em&gt;. They do not solve determinism, cost or drift. They are complementary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Promptfoo, DeepEval, Langfuse.&lt;/strong&gt; The whole mature ecosystem lives in Node and Python. They need a separate runtime and do not plug into PHPUnit.&lt;/p&gt;

&lt;p&gt;I searched Packagist for &lt;code&gt;llm cassette record replay&lt;/code&gt;. Zero results.&lt;/p&gt;

&lt;h3&gt;
  
  
  And something I found later
&lt;/h3&gt;

&lt;p&gt;While preparing this article I went back and looked at the Symfony repositories, not just Packagist. There is work in progress:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/symfony/ai/pull/2129" rel="noopener noreferrer"&gt;symfony/ai#2129&lt;/a&gt; — a &lt;code&gt;RecordingProvider&lt;/code&gt; that records cassettes at the provider level&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/symfony/ai/pull/2128" rel="noopener noreferrer"&gt;symfony/ai#2128&lt;/a&gt; — cassettes at the HTTP boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/symfony/symfony/pull/63781" rel="noopener noreferrer"&gt;symfony/symfony#63781&lt;/a&gt; — a &lt;code&gt;RecorderHttpClient&lt;/code&gt; in php-vcr style&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are open drafts at the time of writing, and I only found them after building my own. Honestly, that felt like a good sign rather than a bad one: two core contributors arriving independently at the same conclusion means the problem is real.&lt;/p&gt;

&lt;p&gt;They are also solving a different layer. Quoting #2129 directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Interactions are matched by a &lt;strong&gt;signature over (model, input, options)&lt;/strong&gt; and consumed FIFO.&lt;/p&gt;

&lt;p&gt;Result metadata and &lt;strong&gt;token usage are not preserved&lt;/strong&gt; in this first version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Exact-signature matching brings back the &lt;code&gt;php-vcr&lt;/code&gt; problem: a prompt with a different timestamp does not match. And none of the three does what I needed most — telling me when the provider's behaviour changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;A recorder. Like &lt;code&gt;php-vcr&lt;/code&gt;, but one that understands prompts.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Locally&lt;/strong&gt; it records real responses into versionable JSON files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In CI&lt;/strong&gt; it replays them from disk: no network, no API key, no cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every night&lt;/strong&gt; it replays them against the real provider and warns you if anything changed.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RecordingPlatform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GroqPlatform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$apiKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;// your real provider&lt;/span&gt;
    &lt;span class="n"&gt;cassetteDir&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/cassettes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromEnv&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;              &lt;span class="c1"&gt;// record locally, replay in CI&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your code does not change: &lt;code&gt;RecordingPlatform&lt;/code&gt; implements the same interface. It is a decorator, not a fork.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three decisions that matter
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Matching is semantic, not a hash
&lt;/h3&gt;

&lt;p&gt;This is what kills &lt;code&gt;php-vcr&lt;/code&gt; for this use case. A real prompt looks 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;Incident 445566 from 2026-01-10: I cannot access my account.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the next run, 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;Incident 998877 from 2026-07-25: I cannot access my account.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Semantically they are the same case. To a hash they are different universes.&lt;/p&gt;

&lt;p&gt;The fix: normalise the volatile noise (&lt;code&gt;&amp;lt;id&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;date&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;uuid&amp;gt;&lt;/code&gt;) and compare using &lt;strong&gt;cosine similarity&lt;/strong&gt; over a bag of words. No embeddings, no network, no dependencies. The two prompts above score &lt;strong&gt;0.89&lt;/strong&gt; and match.&lt;/p&gt;

&lt;p&gt;If you want something stricter, you can declare which parts vary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PlaceholderMatcher&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'order_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/ORD-\d+/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'amount'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/\d+\.\d{2} ?€/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still an exact, deterministic comparison — but immune to what you mark as variable. If something you did &lt;em&gt;not&lt;/em&gt; declare changes, it does not match. And that is the correct behaviour.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Cassettes get committed, so they get redacted
&lt;/h3&gt;

&lt;p&gt;This one is not negotiable. If cassettes go into git and contain real prompts, they contain real data: emails, phone numbers, national ID numbers, sometimes an API key someone pasted by mistake.&lt;/p&gt;

&lt;p&gt;That is why redaction is &lt;strong&gt;on by default&lt;/strong&gt;. Not opt-in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I'm &amp;lt;REDACTED:EMAIL&amp;gt;, tel &amp;lt;REDACTED:PHONE&amp;gt;, cannot log in."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Secure defaults matter more in development tooling than almost anywhere else. Nobody reads the configuration reference of a dev dependency. If the safe behaviour is something you have to switch on, most people will never switch it on — and the failure is silent until it is public.&lt;/p&gt;

&lt;p&gt;Building this I hit a nice bug. The cassette stores &lt;code&gt;&amp;lt;REDACTED:EMAIL&amp;gt;&lt;/code&gt;, but the incoming request carries the original email. If you compare the raw prompt against the sanitised cassette, &lt;strong&gt;they never match&lt;/strong&gt;, and it fails precisely on the requests that contain personal data. The fix: run the matching on already-redacted text, so both sides live in the same space.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Drift is detected by comparing shape, not text
&lt;/h3&gt;

&lt;p&gt;Back to the &lt;code&gt;int&lt;/code&gt; that became a &lt;code&gt;string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Comparing textual similarity is not enough: &lt;code&gt;{"urgency":4}&lt;/code&gt; and &lt;code&gt;{"urgency":"high"}&lt;/code&gt; look fairly similar. You have to compare the &lt;strong&gt;shape&lt;/strong&gt; of the JSON: which keys exist and what type each one has.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔴 CRITICAL   sim 0.79   type change in "urgency": int -&amp;gt; string
                         | new field: "confidence" (float)
🟢 OK         sim 1.00   no schema changes
🟡 MEDIUM     sim 0.60   no schema changes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command, one nightly cron, and an issue opened automatically when something moves. It exits with a non-zero code, so it breaks the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it feels like to use
&lt;/h2&gt;

&lt;p&gt;In PHPUnit, a trait and assertions that talk about the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithLlm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;testClassifiesAnAccessProblem&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;recordLlm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GroqPlatform&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromEnv&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'llama-3.1-8b-instant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'system'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Classify tickets. Respond with JSON.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'I cannot access my account.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertNoLiveLlmCalls&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertLlmJsonShape&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'category'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'urgency'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'int'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what is &lt;strong&gt;not&lt;/strong&gt; asserted: nowhere does it say the category must be exactly &lt;code&gt;"access"&lt;/code&gt;. It asserts the &lt;strong&gt;contract&lt;/strong&gt;. An LLM's value is not deterministic, but its shape has to be.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;assertNoLiveLlmCalls()&lt;/code&gt; is my favourite. Put it in your suite and CI will tell you the day someone adds a test that escapes to the real API.&lt;/p&gt;

&lt;p&gt;In Pest, the same thing with expectations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toHaveMadeNoLiveCalls&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeLlmJson&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toMatchLlmShape&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'category'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'urgency'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'int'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  And in Symfony, the panel
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsc9upl3foygj2mrcx0tx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsc9upl3foygj2mrcx0tx.png" alt="Symfony Web Profiler panel showing 4 invocations served from cassette, 0 live API calls, 100% hit rate and 1,280 ms of latency avoided" width="493" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is where you see it at a glance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;From cassette&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live calls&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens saved&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Time saved&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,280 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero API calls. Almost a second and a half saved on a single request. And in the same toolbar you can read that the whole page took &lt;strong&gt;3 ms&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The badge turns red if live calls were made while in &lt;code&gt;replay&lt;/code&gt; mode, which usually means a cassette is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, honestly
&lt;/h2&gt;

&lt;p&gt;Measured against cassettes recorded from Llama 3.1 on Groq, with real latencies of 437, 227 and 235 ms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Without llm-vcr&lt;/th&gt;
&lt;th&gt;With llm-vcr&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API calls per run&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens consumed&lt;/td&gt;
&lt;td&gt;426&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time waiting for the API&lt;/td&gt;
&lt;td&gt;~900 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost in CI&lt;/td&gt;
&lt;td&gt;provider-dependent&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One caveat worth stating: &lt;strong&gt;the time saved depends on your real latency.&lt;/strong&gt; If your provider answers in 200 ms, you save 200 ms per call. The number that matters is not a spectacular multiplier — it is this: &lt;strong&gt;zero network and zero tokens in CI&lt;/strong&gt;, every time, deterministically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;Groq gives you a free API key with no credit card, so you can try the whole thing without spending anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="nt"&gt;--dev&lt;/span&gt; mikibuilder/llm-vcr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RecordingPlatform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GroqPlatform&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromEnv&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;cassetteDir&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/cassettes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Replay&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run once with &lt;code&gt;LLM_VCR_MODE=record&lt;/code&gt;, commit the cassettes, and from then on CI runs for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned building it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The tests found bugs I would never have seen.&lt;/strong&gt; The redacted-text matching one, for instance, only showed up on requests containing personal data. Without a test that deliberately included an email address, it would have shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actually installing it found the ones the tests could not.&lt;/strong&gt; I had 112 green tests and the Symfony Profiler panel &lt;strong&gt;never registered&lt;/strong&gt;. The cause: the default value arrived as the unresolved string &lt;code&gt;"%kernel.debug%"&lt;/code&gt;, and comparing it to &lt;code&gt;true&lt;/code&gt; was always false. My tests missed it because every single one passed the parameter explicitly. It only surfaced when I created a fresh Symfony project and installed the package like any user would.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing on another operating system found more.&lt;/strong&gt; The suite took 0.8 seconds on Linux and &lt;strong&gt;25 seconds on Windows&lt;/strong&gt;: fifteen Symfony kernels writing the container to disk. Rewritten on top of &lt;code&gt;ContainerBuilder&lt;/code&gt;, the full suite dropped to 2.5 seconds on the same machine.&lt;/p&gt;

&lt;p&gt;The lesson, if there is one: &lt;strong&gt;correct code and a usable product are not the same thing&lt;/strong&gt;, and the only way to tell them apart is to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it lives
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Packagist:&lt;/strong&gt; &lt;code&gt;mikibuilder/llm-vcr&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/MikiBuilder/llm-vcr" rel="noopener noreferrer"&gt;github.com/MikiBuilder/llm-vcr&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is version 0.3.x. It works, it has 116 tests and level 9 static analysis, but there is road ahead: embedding-based matching with a cache, streaming and tool call support.&lt;/p&gt;

&lt;p&gt;If you try it and something does not fit your case, open an issue. Especially if the way you build prompts breaks the matcher — that is exactly what I need to know.&lt;/p&gt;

&lt;p&gt;Twenty years in, PHP is still the language I reach for first. It is worth having the same testing tools everyone else takes for granted.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>testing</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
