<?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: Szj</title>
    <description>The latest articles on DEV Community by Szj (@_er2es_).</description>
    <link>https://dev.to/_er2es_</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%2F4147841%2F3ae5b387-7d4e-40ee-9465-acf759bca506.jpg</url>
      <title>DEV Community: Szj</title>
      <link>https://dev.to/_er2es_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_er2es_"/>
    <language>en</language>
    <item>
      <title>Your architecture rules are code too: mutation-testing a DDD ruleset in PHP 8.4</title>
      <dc:creator>Szj</dc:creator>
      <pubDate>Thu, 05 Nov 2026 08:36:35 +0000</pubDate>
      <link>https://dev.to/_er2es_/your-architecture-rules-are-code-too-mutation-testing-a-ddd-ruleset-in-php-84-3p33</link>
      <guid>https://dev.to/_er2es_/your-architecture-rules-are-code-too-mutation-testing-a-ddd-ruleset-in-php-84-3p33</guid>
      <description>&lt;p&gt;In September 2017 Lukas Taake published &lt;a href="https://www.ltaake.com/blog/2017/09/static-code-analysis-for-domain-driven-design-part-1/" rel="noopener noreferrer"&gt;the first part of a write-up&lt;/a&gt; of his master's thesis about static code analysis for Domain-Driven Design. It was built as a SonarQube plugin for Java, and its thesis is the most honest sentence in the genre: &lt;strong&gt;static analysis cannot show that DDD is done correctly; it can help keep some basic principles intact over time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I wanted to know what that looks like in PHP today, nine years later. Two things have changed: the language now enforces a lot by itself (&lt;code&gt;readonly&lt;/code&gt;, enums, asymmetric visibility, attributes), and we have mutation testing, which gives us a way to ask the question the 2017 post could not: &lt;strong&gt;if someone breaks a rule, would your checks notice?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything below was run, not described. The demo domain is small (13 files), the mutants are hand-written, and I list the limits at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eight rules, then and now
&lt;/h2&gt;

&lt;p&gt;Taake's rules, as I read them in his post:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every entity type has an identity.&lt;/li&gt;
&lt;li&gt;Value objects are immutable.&lt;/li&gt;
&lt;li&gt;No anemic domain model (entities that are only getters and setters).&lt;/li&gt;
&lt;li&gt;Aggregates are trees and link to other aggregates only by identity.&lt;/li&gt;
&lt;li&gt;An aggregate has a maximum size.&lt;/li&gt;
&lt;li&gt;Domain services exist only where behaviour cannot sit in an entity.&lt;/li&gt;
&lt;li&gt;Repository implementations live outside the domain model.&lt;/li&gt;
&lt;li&gt;The domain model depends on nothing else in the application.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 2017 approach discovers the "model parts" by class hierarchy, name patterns or annotations, and the author admits the weak spot: you must tell the tool what is an entity and what is a value object, and the analysis is only as good as that configuration. In PHP 8 an attribute is the natural answer, because it is part of the class and the compiler sees 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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Sales\Domain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;\Attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Attribute&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TARGET_CLASS&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AggregateRoot&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;\Attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Attribute&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TARGET_CLASS&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Entity&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;\Attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Attribute&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TARGET_CLASS&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ValueObject&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="na"&gt;#[ValueObject]&lt;/span&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;Money&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;int&lt;/span&gt; &lt;span class="nv"&gt;$amount&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;$currency&lt;/span&gt;&lt;span class="p"&gt;)&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;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;self&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&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;amount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;amount&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;currency&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="na"&gt;#[AggregateRoot]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Customer&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="kt"&gt;CustomerId&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&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;id&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;CustomerId&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;id&lt;/span&gt;&lt;span class="p"&gt;;&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;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\DomainException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'empty name'&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$name&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;The demo domain has two aggregate roots (&lt;code&gt;Customer&lt;/code&gt; with &lt;code&gt;Address&lt;/code&gt; value objects, &lt;code&gt;Order&lt;/code&gt; with &lt;code&gt;OrderLine&lt;/code&gt; entities), value objects for money, ids and the order number, an &lt;code&gt;OrderRepository&lt;/code&gt; interface in the domain and an implementation in &lt;code&gt;Infrastructure&lt;/code&gt;. Customer and order refer to each other &lt;strong&gt;by id&lt;/strong&gt; (&lt;code&gt;CustomerId&lt;/code&gt;, &lt;code&gt;OrderId&lt;/code&gt;), as rule 4 asks.&lt;/p&gt;

&lt;p&gt;Versions used: PHP 8.4.16, PHPStan 2.3.5, phpat 0.12.5, Deptrac 4.7.2, PHP Arkitect 1.3.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2 first, because PHP does half of it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;final readonly class Money&lt;/code&gt; makes the compiler and the engine refuse most of what rule 2 was written for. Writing to a readonly property from outside, or from a setter, throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Cannot modify readonly property Money::$amount
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats, both worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This is a &lt;strong&gt;runtime&lt;/strong&gt; error. The code that does it is still valid PHP; the engine only complains when it runs. A static check is still useful (PHPStan reports an assignment to a readonly property outside the constructor on its own, with no custom rule).&lt;/li&gt;
&lt;li&gt;Asymmetric visibility (&lt;code&gt;public private(set) int $amount&lt;/code&gt;, PHP 8.4) is &lt;strong&gt;not&lt;/strong&gt; immutability. The class itself may still change the value: &lt;code&gt;Cannot modify private(set) property&lt;/code&gt; appears only for a write from outside.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one that no keyword fixes: &lt;code&gt;readonly&lt;/code&gt; is shallow. A readonly value object holding an &lt;code&gt;ArrayObject&lt;/code&gt; is still mutable through it. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules 1 and 4: where only a custom rule helps
&lt;/h2&gt;

&lt;p&gt;Layer tools know about namespaces; they know nothing about aggregates or identity. For rule 4 (an aggregate root must not hold another aggregate root), a PHPStan rule is about twenty lines:&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="cd"&gt;/** @implements Rule&amp;lt;InClassNode&amp;gt; */&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AggregateReferencesByIdRule&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Rule&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="kt"&gt;ReflectionProvider&lt;/span&gt; &lt;span class="nv"&gt;$rp&lt;/span&gt;&lt;span class="p"&gt;)&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;getNodeType&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;InClassNode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;;&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;processNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Node&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Scope&lt;/span&gt; &lt;span class="nv"&gt;$scope&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getClassReflection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;Marker&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'AggregateRoot'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getNativeReflection&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;getProperties&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$scope&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;getReadableType&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getReferencedClasses&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;rp&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;Marker&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;has&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;rp&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'AggregateRoot'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nv"&gt;$errors&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RuleErrorBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="s1"&gt;'Aggregate root %s holds another aggregate root %s in $%s; reference it by id.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&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;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ddd.aggregateByObject'&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;build&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="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$errors&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;&lt;code&gt;Marker::has()&lt;/code&gt; is a three-line helper that looks for the attribute on a class. Rule 1 (an entity has a public &lt;code&gt;id()&lt;/code&gt; method) is the same shape: if a class is marked &lt;code&gt;#[Entity]&lt;/code&gt; or &lt;code&gt;#[AggregateRoot]&lt;/code&gt; and has no public &lt;code&gt;id()&lt;/code&gt;, report it. The rules are registered in &lt;code&gt;phpstan.neon&lt;/code&gt; like any other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parameters:
    level: 8
    paths: [src]
services:
    - { class: Rules\AggregateReferencesByIdRule, tags: [phpstan.rules.rule] }
    - { class: Rules\EntityHasIdentityRule, tags: [phpstan.rules.rule] }
    - { class: Rules\ValueObjectIsImmutableRule, tags: [phpstan.rules.rule] }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rules 7 and 8: layers, with two tools that disagree
&lt;/h2&gt;

&lt;p&gt;Deptrac states the dependency direction as data (use the &lt;code&gt;deptrac/deptrac&lt;/code&gt; package; the old &lt;code&gt;qossmic/deptrac&lt;/code&gt; is flagged as abandoned by Composer):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;deptrac&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;./src&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;layers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Domain&lt;/span&gt;
      &lt;span class="na"&gt;collectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;classLike&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^App\\Sales\\Domain\\.*'&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
      &lt;span class="na"&gt;collectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;classLike&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^App\\Sales\\Application\\.*'&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Infrastructure&lt;/span&gt;
      &lt;span class="na"&gt;collectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;classLike&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^App\\Sales\\Infrastructure\\.*'&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Doctrine&lt;/span&gt;
      &lt;span class="na"&gt;collectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;classLike&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^Doctrine\\.*'&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
  &lt;span class="na"&gt;ruleset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~&lt;/span&gt;
    &lt;span class="na"&gt;Application&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Domain&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;Infrastructure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Domain&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Application&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Doctrine&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;phpat expresses architecture rules as PHP, as a PHPStan extension:&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;testDomainDependsOnNothing&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;PHPatRule&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PHPat&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;rule&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;classes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Selector&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;inNamespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Sales\Domain'&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;canOnlyDependOn&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;classes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;Selector&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;inNamespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Sales\Domain'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;Selector&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;classname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^[^\\\\]+$/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// PHP built-ins in the global namespace&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;because&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'the domain depends on nothing else'&lt;/span&gt;&lt;span class="p"&gt;);&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;testRepositoryImplementationIsNotInDomain&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;PHPatRule&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PHPat&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;rule&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;classes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Selector&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;inNamespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App\Sales\Domain'&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;shouldNotImplement&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;classes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Selector&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;classname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderRepository&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&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;because&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'repository implementations live outside the domain model'&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;(The regular expression lets &lt;code&gt;DomainException&lt;/code&gt; and other global classes through, otherwise phpat flags them as outside dependencies.) I ran PHP Arkitect on the dependency and immutability rules too; it caught the same mutants as the other two there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules 3 and 5: a number you choose
&lt;/h2&gt;

&lt;p&gt;I did not find a standard tool for "anemic entity" or "aggregate too big", so the demo has a thirty-line script. It counts, per &lt;code&gt;#[Entity]&lt;/code&gt; or &lt;code&gt;#[AggregateRoot]&lt;/code&gt; class, behaviour methods against getters and setters, and the members (entities and value objects) reachable inside an aggregate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;anemia  App\Sales\Domain\Customer   behaviour=3 accessors=3 share=0.50 ok
anemia  App\Sales\Domain\Order      behaviour=2 accessors=3 share=0.40 ok
size    App\Sales\Domain\Order      members=3 (max 5) ok [Money,OrderLine,OrderNumber]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both thresholds are arbitrary and that is the point of rules 3 and 5: they are &lt;em&gt;project decisions&lt;/em&gt;, written down and enforced, not facts about DDD.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question the rules cannot answer about themselves
&lt;/h2&gt;

&lt;p&gt;All of the above is green on the clean domain. That says nothing about whether the rules would go red on a violation. This is exactly the question mutation testing answers for code: change the code a little, run the tests, and count the changes nobody noticed. The idea carries over to a ruleset. A &lt;em&gt;mutant&lt;/em&gt; here is a violation of one rule, applied to a copy of the domain as a textual patch, and the check is whether any tool exits with an error.&lt;/p&gt;

&lt;p&gt;The runner is short: copy the clean &lt;code&gt;src/&lt;/code&gt;, apply one patch, run every tool, record "caught" when its exit code is not zero, restore, repeat. A mutant looks like this:&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="s1"&gt;'M3  Customer holds an Order object'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'Customer.php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public function id(): CustomerId'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'private ?Order $lastOrder = null;'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;    "&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'public function lastOrder(): ?Order { return $this-&amp;gt;lastOrder; }'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;    "&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'public function id(): CustomerId'&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;Twelve mutants, seven checks (&lt;code&gt;php -l&lt;/code&gt;, a runtime probe that instantiates the value object and tries to change it, PHPStan with the three custom rules, Deptrac, phpat, Arkitect, the metrics script):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mutant&lt;/th&gt;
&lt;th&gt;probe&lt;/th&gt;
&lt;th&gt;PHPStan + rules&lt;/th&gt;
&lt;th&gt;Deptrac&lt;/th&gt;
&lt;th&gt;phpat&lt;/th&gt;
&lt;th&gt;Arkitect&lt;/th&gt;
&lt;th&gt;metrics&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;M1 setter on a (non-readonly) VO&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M1b setter on a &lt;strong&gt;readonly&lt;/strong&gt; VO&lt;/td&gt;
&lt;td&gt;the language&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M2 public mutable VO property&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M3 Customer holds an Order object&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M4 domain imports Doctrine&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M5 entity without identity&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M6 repository implementation in the domain&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M7 anemic entity&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M8 aggregate over the size limit&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;caught&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M9 an equivalent rewrite (&lt;code&gt;+=&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M10 readonly VO holding a mutable object&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M11 an invariant dropped&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;php -l&lt;/code&gt; caught nothing, because every mutant is valid PHP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the table says
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different tools see different mutants.&lt;/strong&gt; M3 and M5 are caught only by my two custom PHPStan rules; the layer tools know nothing about aggregates or identity. M7 and M8 are caught only by the metrics script. M6, a repository implementation put into the domain namespace, is caught only by phpat: Deptrac sees a domain class implementing a domain interface, which is an allowed dependency. I did not write an Arkitect rule for it, and it can probably do the same. No single tool has the whole picture, which is an argument for a small set instead of a big one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The language is the cheapest rule.&lt;/strong&gt; M1b is stopped by PHP at runtime and by PHPStan statically without any custom rule; &lt;code&gt;final readonly class&lt;/code&gt; plus an attribute is enough for most of rule 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three mutants slip through everything&lt;/strong&gt;, and each has a different reason:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;M9&lt;/strong&gt; is &lt;em&gt;equivalent&lt;/em&gt;: a rewrite that changes nothing observable. Correctly caught by nothing. This is the same class as in ordinary mutation testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;M10&lt;/strong&gt; is a real blind spot: &lt;code&gt;readonly&lt;/code&gt; is shallow, and a value object that holds a mutable object is still mutable. A rule could look at property types, but then you have to decide what counts as mutable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;M11&lt;/strong&gt; drops an invariant (a validation in the constructor). That is &lt;em&gt;behaviour&lt;/em&gt;, and static rules cannot see it. Only unit tests, or mutation testing of the domain's own tests, will.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is where the two kinds of mutation meet: architecture rules protect the &lt;em&gt;shape&lt;/em&gt; of the model, and classic mutation testing (Infection in PHP) protects the &lt;em&gt;behaviour&lt;/em&gt; inside it. A rule set with a high detection rate and a domain test suite with a high mutation score cover different failures. I did not run Infection on this toy domain; with 13 files it would prove nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;13 files and 12 hand-written mutants: this is a demonstration of a method, not a benchmark of the tools. Do not read the table as "phpat beats Deptrac".&lt;/li&gt;
&lt;li&gt;The thresholds (an anemia share, five members per aggregate) are arbitrary.&lt;/li&gt;
&lt;li&gt;The mutants are textual patches. Real violations are subtler, and generating them automatically, the way mutation tools do for code, is the interesting next step.&lt;/li&gt;
&lt;li&gt;Everything ran on one machine (Windows, PHP 8.4). The runner (&lt;code&gt;php bin/mutants.php&lt;/code&gt;, about three minutes) restores the sources afterwards.&lt;/li&gt;
&lt;li&gt;The 2017 post's own limitation stands: no tool tells you that your aggregate boundaries are &lt;em&gt;right&lt;/em&gt;. These checks keep a model from drifting; they do not design it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Lukas Taake, "Static code analysis for Domain-Driven Design, part 1", 2017: &lt;a href="https://www.ltaake.com/blog/2017/09/static-code-analysis-for-domain-driven-design-part-1/" rel="noopener noreferrer"&gt;https://www.ltaake.com/blog/2017/09/static-code-analysis-for-domain-driven-design-part-1/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Eric Evans, &lt;em&gt;Domain-Driven Design&lt;/em&gt; (2003); Vaughn Vernon, &lt;em&gt;Implementing Domain-Driven Design&lt;/em&gt; (2013).&lt;/li&gt;
&lt;li&gt;Yue Jia and Mark Harman, "An Analysis and Survey of the Development of Mutation Testing", IEEE Transactions on Software Engineering, 2011.&lt;/li&gt;
&lt;li&gt;Tools: &lt;a href="https://phpstan.org" rel="noopener noreferrer"&gt;PHPStan&lt;/a&gt;, &lt;a href="https://github.com/deptrac/deptrac" rel="noopener noreferrer"&gt;Deptrac&lt;/a&gt;, &lt;a href="https://github.com/carlosas/phpat" rel="noopener noreferrer"&gt;phpat&lt;/a&gt;, &lt;a href="https://github.com/phparkitect/arkitect" rel="noopener noreferrer"&gt;PHP Arkitect&lt;/a&gt;, &lt;a href="https://infection.github.io" rel="noopener noreferrer"&gt;Infection&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I maintain a PostgreSQL search library, &lt;a href="https://github.com/er2es/fuzzphony" rel="noopener noreferrer"&gt;Fuzzphony&lt;/a&gt;, and the habit of asking "would a test notice if this were wrong?" came from there; I wrote about what a 95% mutation score still misses in the previous post. If you run something like this on a real codebase, I would like to hear which mutants your rules missed.&lt;/p&gt;

</description>
      <category>php</category>
      <category>ddd</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>95% mutation score, and a review still found a cross-tenant leak: Fuzzphony 0.11</title>
      <dc:creator>Szj</dc:creator>
      <pubDate>Thu, 05 Nov 2026 08:06:17 +0000</pubDate>
      <link>https://dev.to/_er2es_/95-mutation-score-and-a-review-still-found-a-cross-tenant-leak-fuzzphony-011-27c5</link>
      <guid>https://dev.to/_er2es_/95-mutation-score-and-a-review-still-found-a-cross-tenant-leak-fuzzphony-011-27c5</guid>
      <description>&lt;p&gt;Last time I wrote about &lt;a href="https://dev.to/_er2es_/i-couldnt-touch-the-database-so-i-built-search-next-to-it-1pjn"&gt;building search next to a database I couldn't touch&lt;/a&gt;. Fuzzphony was at v0.4 then. It is v0.11.1 now, and the post ended with a section called "What I haven't solved yet". So this one starts there.&lt;/p&gt;

&lt;p&gt;The short version: a typo-tolerant, ranked search for a PostgreSQL database you may not be allowed to change (a sidecar table, &lt;code&gt;pg_trgm&lt;/code&gt;, full-text search, no new infrastructure). The longer version is below, including the part I find more interesting than the features: &lt;strong&gt;a 95% mutation score did not stop three real bugs, and an independent review found them the week before the release.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I said I hadn't solved
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Short words matched the wrong thing.&lt;/strong&gt; &lt;code&gt;mouse&lt;/code&gt; matched &lt;code&gt;monitor&lt;/code&gt;, because a flat trigram threshold is too generous for a five-letter word and too strict for a fifteen-letter one. Since 0.7 the tolerance grows with the length of the word, so &lt;code&gt;mouse&lt;/code&gt; stays &lt;code&gt;mouse&lt;/code&gt;, and &lt;code&gt;hedphones&lt;/code&gt; still finds headphones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Very common words lost their best match.&lt;/strong&gt; The candidate window (&lt;code&gt;candidate_limit&lt;/code&gt;, 2000 by default) is cut before ranking, so for a word that appears in half the catalogue the best hit could fall off the edge. 0.9.8 changed how candidates are chosen so the best match of a very common word is no longer lost above the limit. The same release added tolerance for swapped letters (&lt;code&gt;recieve&lt;/code&gt;) and a zero-result rescue: when a search finds nothing, it retries the closest spelling and tells you it did (&lt;code&gt;correctedTo&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operations.&lt;/strong&gt; Reindexing without a gap (a shadow table that is swapped in), Prometheus metrics with sample Grafana dashboards, synonyms from Solr/Elasticsearch files that can be swapped at runtime, and "did you mean" from a vocabulary table all landed between 0.5 and 0.7. The rest of the rough edges I listed then are in the changelog.&lt;/p&gt;

&lt;h2&gt;
  
  
  0.10: secure by default
&lt;/h2&gt;

&lt;p&gt;The part of search libraries I trust least is the part that decides &lt;strong&gt;who may see what&lt;/strong&gt;, so 0.10 was the breaking release that made the safe thing the default.&lt;/p&gt;

&lt;p&gt;Without a token the caller is anonymous. A system caller has to say so:&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="c1"&gt;// a console command, a cron job: explicitly the system&lt;/span&gt;
&lt;span class="nv"&gt;$hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$fuzzphony&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customers'&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;asSystem&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;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'kovacs'&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// a web request: the ambient actor is whoever is logged in&lt;/span&gt;
&lt;span class="nv"&gt;$hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$fuzzphony&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customers'&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;forTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&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;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'kovacs'&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is one tenant rule: an actor of tenant A cannot ask for tenant B, unless the call is &lt;code&gt;asSystem()&lt;/code&gt;. Fields can be &lt;code&gt;granted&lt;/code&gt; to a role, and a search by a viewer who does not hold the grant must not change because of a field they cannot see. I call that hidden-field independence: ids, ranks, totals, facets and highlights are the same whatever the hidden fields contain. There are also two database roles (a worker and an admin) with &lt;code&gt;--dump-grants&lt;/code&gt; so the search process never needs DDL rights, and &lt;code&gt;code: true&lt;/code&gt; fields for invoice numbers and SKUs (&lt;code&gt;INV-001&lt;/code&gt;-style values are matched exactly, not "fuzzily"). The public API went from 102 types to 77, so the surface you can rely on is smaller and written down.&lt;/p&gt;

&lt;h2&gt;
  
  
  0.11: several sources, several values, one row to render
&lt;/h2&gt;

&lt;p&gt;Real systems have a customer table, a company table, and orders. 0.11 lets one index read all of them (a simplified version of the demo's &lt;code&gt;site&lt;/code&gt; index):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;fuzzphony&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;indexes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;person&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;id,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;AS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;title,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;AS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;body,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;account_id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;AS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;account&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;FROM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;erp_customer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;WHERE&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'person'"&lt;/span&gt;
          &lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;erp_customer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;:id"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;order&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;id,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;number&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;AS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;title,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;note&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;AS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;body,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;account_id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;AS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;account&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;FROM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;erp_order"&lt;/span&gt;
          &lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;erp_order&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;:id"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;A&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fuzzy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;},&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;C&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;account&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;int&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;tenant&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;account&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hit says which source it comes from (&lt;code&gt;$hit-&amp;gt;kind&lt;/code&gt;) and carries that source's own typed id. Each source has its own watch, so changing a row refreshes only the right document, and the doctor now warns when the source query reads a table no watch covers.&lt;/p&gt;

&lt;p&gt;A customer has several addresses. Instead of a folded &lt;code&gt;string_agg(...)&lt;/code&gt; column, a field can say where the values live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;C&lt;/span&gt;
  &lt;span class="na"&gt;fuzzy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;many&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;customer_id&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;column&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;city&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;50&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the explanation tells you which address matched ("found in the address: Budapest"). The next release is going to rank by the best single value instead of the whole field; that is a design I would rather finish properly than rush.&lt;/p&gt;

&lt;p&gt;And the thing every search page needs: a result row. &lt;code&gt;with()&lt;/code&gt; returns columns of the hit's source row, so a page can be rendered and linked without loading entities:&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;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$fuzzphony&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products'&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;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mouse'&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;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&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;hits&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'price'&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;It is opt-in, reads the current value (nothing extra is copied into the index), and a column behind a &lt;code&gt;granted&lt;/code&gt; field is returned only to someone who holds the grant. Remember that last sentence; it comes back below.&lt;/p&gt;

&lt;p&gt;The demo grew an ERP data set (about half a million rows: customers, companies, addresses, orders, order lines), an ERP page with separate and combined search boxes, a live demonstration of the sync (move an address to another city, watch which indexes refreshed), and a search box in the header that searches every index of the demo at once. With about seven indexes the engine answers in 40 to 170 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I know it works
&lt;/h2&gt;

&lt;p&gt;The test suite has about 3,700 tests, PHPStan at max level, and coverage close to 100%. Coverage tells you which lines ran. &lt;strong&gt;Mutation testing&lt;/strong&gt; tells you whether the tests would notice if a line were wrong: the tool changes the code a little (&lt;code&gt;&amp;gt;&lt;/code&gt; becomes &lt;code&gt;&amp;gt;=&lt;/code&gt;, &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; becomes &lt;code&gt;||&lt;/code&gt;, a line disappears) and runs the tests; a mutant that survives is a place where nothing checks the behaviour.&lt;/p&gt;

&lt;p&gt;The numbers, from CI:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Mutants&lt;/th&gt;
&lt;th&gt;Covered-code score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First full run (September)&lt;/td&gt;
&lt;td&gt;3,944&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After 0.8.1&lt;/td&gt;
&lt;td&gt;5,989&lt;/td&gt;
&lt;td&gt;96%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.11.0 branch&lt;/td&gt;
&lt;td&gt;about 11,900&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last full run takes 1 hour 45 minutes on CI, so it always lags one step behind the code; I run it once per release and work with the result. Of the 486 survivors in 0.11 I killed the ones in the new code with focused tests (assert what the code must do, never a mirror of the code), and I sorted the rest into a few classes that cannot be killed because the mutant is equivalent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a limit that the validator already guarantees (a replace "limited to 1" versus "limited to 2" when exactly one placeholder can exist),&lt;/li&gt;
&lt;li&gt;a cast on an array key that PHP normalises anyway,&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;array_values()&lt;/code&gt; on a list that is only iterated,&lt;/li&gt;
&lt;li&gt;a branch the code itself marks as unreachable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It sounds boring, and it is, until you read the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a mutation score cannot see
&lt;/h2&gt;

&lt;p&gt;Before tagging 0.11.0 I asked for an independent, adversarial review of everything new in &lt;code&gt;src/&lt;/code&gt; (about 2,700 added lines), with one instruction: try to break the two promises that matter, hidden-field independence and no tenant crossing. It found three real bugs, behind a 95% score and a green suite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A hit of the wrong kind became someone else's row.&lt;/strong&gt; In a multi-source index the hits are &lt;code&gt;emp:1&lt;/code&gt; and &lt;code&gt;post:2&lt;/code&gt;. The helper that turned a result into ids dropped the kind, so loading the &lt;code&gt;Emp&lt;/code&gt; entity from the result looked up &lt;code&gt;[1, 2]&lt;/code&gt;, and &lt;code&gt;Emp&lt;/code&gt; 2 is a row of another tenant that did not match anything:&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;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$fuzzphony&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'site'&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;forTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ada'&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// hits: emp:1, post:2&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="nf"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                                         &lt;span class="c1"&gt;// [1, 2]  (the kind is gone)&lt;/span&gt;
&lt;span class="nv"&gt;$loader&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;load&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="nc"&gt;Emp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                     &lt;span class="c1"&gt;// loads Emp 1 and Emp 2 (tenant 2)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix uses only the hits whose source reads the requested class. I never would have written a test for it, because the single-source world, where ids are enough, was the only one I was thinking about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;with()&lt;/code&gt; leaked a granted column under another name.&lt;/strong&gt; The grant check looked at the &lt;em&gt;field&lt;/em&gt; name; &lt;code&gt;returnable&lt;/code&gt; lists &lt;em&gt;column&lt;/em&gt; names. A field &lt;code&gt;pay&lt;/code&gt; reading the column &lt;code&gt;salary&lt;/code&gt; and granted to &lt;code&gt;ROLE_HR&lt;/code&gt; was refused when asked as &lt;code&gt;with('pay')&lt;/code&gt;, and returned to everybody when asked as &lt;code&gt;with('salary')&lt;/code&gt;. The check now follows the column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The ORM sync refreshed only the first index of an entity.&lt;/strong&gt; A second index of the same entity, or a multi-source index reading it, was never refreshed: deleted rows stayed findable, and a row moved to another tenant stayed in the old tenant's results.&lt;/p&gt;

&lt;p&gt;None of these has a surviving mutant to point at. The code for each was covered, the lines ran, and the tests asserted what I had thought of. A mutation score finds the tests I forgot to write for code I wrote; it cannot find the requirement I forgot. Only someone (or something) with a different mental model can, and so I now treat an independent review as part of the release, next to the suite, not after it.&lt;/p&gt;

&lt;p&gt;Other safety nets that earned their place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A differential harness.&lt;/strong&gt; For anything that touches ranking, a script runs 1,854 searches against the previous release and the new code and demands byte-identical results and statements, with the sequential-scan synchronisation switched off so timings are comparable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI jobs that are not the happy path.&lt;/strong&gt; The tests run against several PostgreSQL versions, with the oldest allowed dependencies, and against the next Symfony. In this release the first CI run was red for three reasons my machine had not shown: a demo smoke job that did not seed the new tables, a &lt;code&gt;NAN&lt;/code&gt; in an error message that PHP turned into a warning (and PHPUnit into a failure), and an old Doctrine whose stub returned &lt;code&gt;null&lt;/code&gt; where the new one returned an array.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;0.12 is ranking by the best value of a field with several values (with a per-search switch for "one value must hold all the words"), a first step to a smaller sidecar, and the remaining mutation gaps. After that, 1.0: a stable API and a backward-compatibility promise.&lt;/p&gt;

&lt;p&gt;The library is on GitHub (&lt;a href="https://github.com/er2es/fuzzphony" rel="noopener noreferrer"&gt;er2es/fuzzphony&lt;/a&gt;), and the demo runs with one &lt;code&gt;docker compose up&lt;/code&gt;. If you have a database you cannot change and a search box that disappoints your users, I would like to hear what breaks first.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>php</category>
      <category>symfony</category>
      <category>testing</category>
    </item>
    <item>
      <title>The State of Mutation Testing (Infection) in the PHP Ecosystem: A Scientific and Practical Analysis</title>
      <dc:creator>Szj</dc:creator>
      <pubDate>Sun, 11 Oct 2026 05:33:02 +0000</pubDate>
      <link>https://dev.to/_er2es_/the-state-of-mutation-testing-infection-in-the-php-ecosystem-a-scientific-and-practical-analysis-dip</link>
      <guid>https://dev.to/_er2es_/the-state-of-mutation-testing-infection-in-the-php-ecosystem-a-scientific-and-practical-analysis-dip</guid>
      <description>&lt;h2&gt;
  
  
  Abstract
&lt;/h2&gt;

&lt;p&gt;For decades, Code Coverage has served as the primary quality assurance metric in the software engineering industry. However, this metric often provides a false sense of security: it merely proves that a specific line of code was executed during a test run, but it does not guarantee that the test contains relevant and rigorous assertions. Mutation Testing provides an exact solution to this anomaly, with the Infection framework becoming the de facto standard in the PHP ecosystem. This analysis examines the efficacy, exponential resource requirements, theoretical limitations, CI/CD applicability, and the Return on Investment (ROI) of the methodology in enterprise environments.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Theoretical Background and Methodology
&lt;/h2&gt;

&lt;p&gt;The fundamental question of mutation testing—and a core paradox of quality assurance—is: &lt;em&gt;"Who guards the guards?"&lt;/em&gt; In other words, what mechanism can validate the reliability of the test suite itself?&lt;/p&gt;

&lt;p&gt;The Infection PHP framework operates through the dynamic manipulation of the Abstract Syntax Tree (AST). During execution, the program injects syntactically correct but semantically incorrect modifications (mutations) into the source code. &lt;/p&gt;

&lt;p&gt;Typical mutation operators include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Arithmetic mutations:&lt;/strong&gt; Replacing the &lt;code&gt;+&lt;/code&gt; operator with the &lt;code&gt;-&lt;/code&gt; operator.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Relational mutations:&lt;/strong&gt; Degrading a &lt;code&gt;&amp;gt;&lt;/code&gt; (strictly greater than) condition to a &lt;code&gt;&amp;gt;=&lt;/code&gt; (greater than or equal to) condition.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Return value and boolean mutations:&lt;/strong&gt; Replacing &lt;code&gt;return true&lt;/code&gt; with &lt;code&gt;return false&lt;/code&gt;, or removing method calls entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each autonomously generated, modified version of the code is called a &lt;strong&gt;Mutant&lt;/strong&gt;. Following generation, the system iteratively runs the existing test suite against the given mutant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  If the test fails, the mutant is considered &lt;strong&gt;Killed&lt;/strong&gt;. This is the ideal scenario, proving that the test suite is sufficiently defensive.&lt;/li&gt;
&lt;li&gt;  If the test passes successfully, the mutant has &lt;strong&gt;Escaped&lt;/strong&gt; the assertions. This clearly indicates that the test suite is blind to that specific change in the business logic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From these results, the framework aggregates the &lt;strong&gt;MSI (Mutation Score Indicator)&lt;/strong&gt;, which represents the percentage of successfully killed mutants.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The Value Proposition (ROI)
&lt;/h2&gt;

&lt;p&gt;Based on scientific research and industry experience, the integration of Infection results in a significant paradigm shift regarding software quality.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Empirical Measurement of Software Quality:&lt;/strong&gt; A traditional 100% Line Coverage can be achieved even if the tests contain absolutely no &lt;code&gt;assert&lt;/code&gt; statements. In contrast, the MSI metric cannot be manipulated. Mutation testing ruthlessly exposes unhandled edge cases, the over-preference for the "happy path," and the lack of robustness in the code.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Identification of Redundancy and "Dead Code":&lt;/strong&gt; The methodology identifies overly complex control flows with surgical precision. If a mutant continuously survives tests within a complex branch, it most often indicates that the specific code block is unreachable based on business logic (dead code), or it is so redundant that it requires immediate refactoring.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Cultural and Developer Education:&lt;/strong&gt; The methodology proactively shapes the test-writing culture of engineering teams. Engineers learn to write higher-quality, defensive code and more rigorous assertions, minimizing the ratio of superficial tests.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  3. Technological Limitations and Critical Analysis
&lt;/h2&gt;

&lt;p&gt;While the methodology is theoretically sound, its practical implementation—especially in large-scale enterprise environments—faces severe technological and resource constraints.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1. Combinatorial Explosion and Computational Overhead
&lt;/h3&gt;

&lt;p&gt;The most significant obstacle to mutation testing is its exponential resource requirement. If a project has 1,000 tests and Infection generates 5,000 mutants, it theoretically requires the test suite to be executed 5,000 times.&lt;/p&gt;

&lt;p&gt;The continuous rebuilding of the AST and the thousands of initializations of the test runner (PHPUnit/Pest) introduce extreme CPU and memory loads. A standard 2-minute Continuous Integration (CI) test run can easily balloon to 40-60 minutes with full-scale Infection application, which is incompatible with the fast feedback loops expected in modern Agile/DevOps pipelines.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2. The Equivalent Mutants Problem
&lt;/h3&gt;

&lt;p&gt;Academic studies unanimously refer to the phenomenon of &lt;em&gt;equivalent mutants&lt;/em&gt; as the deepest, still only partially solved challenge of mutation testing. These are syntactic transformations that do not mathematically or logically alter the final semantics of the code.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Example:&lt;/em&gt; An operator change made for optimization purposes in a loop's indexing creates a mutation, but the algorithm's output remains invariant.&lt;/p&gt;

&lt;p&gt;Because these mutants naturally always survive the tests, they falsely degrade the MSI score. Developers can lose valuable work hours manually analyzing these false positives, leading to what is known as "Developer Fatigue."&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3. Implementation Challenges in Legacy Systems
&lt;/h3&gt;

&lt;p&gt;Introducing Infection into a monolithic PHP application with high technical debt and low (sub-50%) test coverage is counterproductive. The system will generate surviving mutants in the tens of thousands, creating unprocessable informational noise for the team. The methodology only provides true value in codebases that already possess dedicated, high coverage (ideally above 85%).&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Modern Optimization Strategies (CI/CD Integration)
&lt;/h2&gt;

&lt;p&gt;To ensure sustainable usage, the PHP community and framework maintainers have introduced innovative architectural solutions that allow mutation testing to be integrated into daily development cycles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Multithreading:&lt;/strong&gt; Modern PHP environments (e.g., via the &lt;code&gt;pcntl&lt;/code&gt; extension) support the asynchronous, parallel processing of mutants. Fully utilizing available CPU cores (e.g., &lt;code&gt;--threads=4&lt;/code&gt;) drastically reduces execution time.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Incremental Diff-Based Testing:&lt;/strong&gt; This represents the greatest technological breakthrough. In modern CI pipelines, Infection does not scan the entire codebase; instead, it strictly limits mutant generation to the lines of code modified in the Pull Request or Git commit (&lt;code&gt;--git-diff-lines&lt;/code&gt;). This technique reduces runtime from hours to mere seconds.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Baseline Architecture:&lt;/strong&gt; Similar to static code analyzers (PHPStan, Psalm), Infection can save existing, historical surviving mutants into a reference file (baseline). Consequently, the CI process only fails if a developer attempts to integrate &lt;em&gt;new&lt;/em&gt;, inadequately tested code into the system.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  5. Conclusion and Architectural Recommendations
&lt;/h2&gt;

&lt;p&gt;The application of mutation testing is not a universal silver bullet; its adoption requires a deliberate engineering decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critically Recommended Application Areas:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Financial technologies (FinTech), payment gateways, and transactional systems.&lt;/li&gt;
&lt;li&gt;  High-risk healthcare and government software.&lt;/li&gt;
&lt;li&gt;  Widely used Open Source libraries (e.g., the Symfony and Doctrine ecosystems actively benefit from this level of scrutiny).&lt;/li&gt;
&lt;li&gt;  Isolated testing of the Core/Domain (business) layer in software built on strict Domain-Driven Design (DDD) principles.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Contraindicated Areas:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Rapid prototypes and applications in the MVP (Minimum Viable Product) phase.&lt;/li&gt;
&lt;li&gt;  Simple CRUD (Create, Read, Update, Delete) and administrative interfaces.&lt;/li&gt;
&lt;li&gt;  Thin API endpoints where the code exclusively performs database reads without complex logical transformations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Final Verdict:&lt;/strong&gt; The application of Infection in the PHP ecosystem represents the current State of the Art in software quality. Engineering teams willing to navigate the adoption curve and manage the increased CI/CD resource requirements will achieve a degree of mathematical and logical stability that is completely unattainable with traditional coverage metrics.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bibliography and References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;[1] DeMillo, R. A., Lipton, R. J., &amp;amp; Sayward, F. G. (1978).&lt;/strong&gt; &lt;em&gt;Hints on Test Data Selection: Help for the Practicing Programmer&lt;/em&gt;. IEEE Computer, 11(4), 34-41. (The foundational work laying the theoretical groundwork for mutation testing).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;[2] Jia, Y., &amp;amp; Harman, M. (2010).&lt;/strong&gt; &lt;em&gt;An Analysis and Survey of the Development of Mutation Testing&lt;/em&gt;. IEEE Transactions on Software Engineering, 37(5), 649-678. (A comprehensive industry study on the adoption and efficacy of mutation testing).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;[3] Offutt, A. J., &amp;amp; Pan, J. (1996).&lt;/strong&gt; &lt;em&gt;Detecting Equivalent Mutants and the Feasible Path Problem&lt;/em&gt;. Proceedings of the 11th Annual Conference on Computer Assurance (COMPASS '96). (Scientific analysis of the equivalent mutants phenomenon and the challenges of detecting them discussed in this article).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;[4] Infection PHP Official Documentation. (2024).&lt;/strong&gt; &lt;em&gt;Infection - PHP Mutation Testing Framework&lt;/em&gt;. Available at: infection.github.io (Official reference for technical implementation details, Git Diff filtering, and Baseline functionality).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>testing</category>
      <category>softwareengineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>5 Angular Anti-Patterns You Need to Stop Using Immediately (and How to Fix Them in Angular 21)</title>
      <dc:creator>Szj</dc:creator>
      <pubDate>Sun, 11 Oct 2026 04:58:00 +0000</pubDate>
      <link>https://dev.to/_er2es_/5-angular-anti-patterns-you-need-to-stop-using-immediately-and-how-to-fix-them-in-angular-21-1hg6</link>
      <guid>https://dev.to/_er2es_/5-angular-anti-patterns-you-need-to-stop-using-immediately-and-how-to-fix-them-in-angular-21-1hg6</guid>
      <description>&lt;p&gt;Angular is a robust, enterprise-grade framework, but with great power comes great responsibility. Because Angular gives us so much freedom, it is incredibly easy to shoot ourselves in the foot, especially as the application starts to scale.&lt;/p&gt;

&lt;p&gt;If your app feels sluggish, or if your components are becoming an unmaintainable mess, chances are you are falling victim to a few common bad habits. &lt;/p&gt;

&lt;p&gt;Let's look at 5 of the most frequent Angular anti-patterns and how to refactor them using modern, clean code practices tailored for Angular 21.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Function Calls in Templates (The Performance Killer)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Anti-pattern:&lt;/strong&gt; Calling a component method directly in your HTML template for data binding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- [BAD]: This will execute on every single Change Detection cycle --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Total: {{ calculateTotal(cartItems) }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;[class.active]=&lt;/span&gt;&lt;span class="s"&gt;"checkIfUserIsActive(user)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it is bad:&lt;/strong&gt; Angular's Change Detection (CD) is aggressive by default. Every time anything happens (a click, a timer, an HTTP response), Angular checks the template for changes. If you bind a function in the template, Angular has to re-execute that function every single CD cycle to see if the return value changed. If &lt;code&gt;calculateTotal&lt;/code&gt; involves heavy array manipulation, your app will freeze.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Modern Solution:&lt;/strong&gt; &lt;br&gt;
Use &lt;strong&gt;Pure Pipes&lt;/strong&gt; for transformations, or better yet, use &lt;strong&gt;Angular 21 Signals&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [GOOD]: Using Angular 21 Signals&lt;/span&gt;
&lt;span class="nx"&gt;cartItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CartItem&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;

&lt;span class="c1"&gt;// computed() caches the value and ONLY recalculates if cartItems changes!&lt;/span&gt;
&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cartItems&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- [GOOD]: Just binding to the computed signal --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Total: {{ total() }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. Abandoned Subscriptions (The Memory Leak)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Anti-pattern:&lt;/strong&gt; Subscribing to an Observable but forgetting to unsubscribe when the component is destroyed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [BAD]: This subscription lives forever, even if the user navigates away.&lt;/span&gt;
&lt;span class="nf"&gt;ngOnInit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserData&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&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;&lt;strong&gt;Why it is bad:&lt;/strong&gt; When the Angular Router destroys this component, the subscription remains active in memory. This leads to memory leaks and ghost code executions in the background, causing weird UI bugs and crashing browsers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Modern Solution:&lt;/strong&gt;&lt;br&gt;
If you must use RxJS instead of Signals for a specific data stream, use the &lt;strong&gt;&lt;code&gt;takeUntilDestroyed()&lt;/code&gt;&lt;/strong&gt; operator. In modern Angular, you do not even need to implement &lt;code&gt;ngOnDestroy&lt;/code&gt; for this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [GOOD]: Modern approach&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;takeUntilDestroyed&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@angular/core/rxjs-interop&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserComponent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;takeUntilDestroyed&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="c1"&gt;// Automatically unsubscribes on destroy!&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&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;h2&gt;
  
  
  3. Nested Subscriptions (The Reactive "Callback Hell")
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Anti-pattern:&lt;/strong&gt; Subscribing to an Observable inside another Observable's &lt;code&gt;subscribe&lt;/code&gt; block.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [BAD]: Hard to read, impossible to test, and prone to race conditions.&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getProjects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;projects&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it is bad:&lt;/strong&gt; It breaks the declarative nature of RxJS. More importantly, it creates race conditions. If the user clicks fast and triggers multiple ID changes, the HTTP responses might arrive out of order, rendering the wrong data on the screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Modern Solution:&lt;/strong&gt;&lt;br&gt;
Leverage Higher-Order Mapping operators like &lt;code&gt;switchMap&lt;/code&gt;, &lt;code&gt;concatMap&lt;/code&gt;, or &lt;code&gt;mergeMap&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [GOOD]: Clean, linear, and handles race conditions safely.&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;switchMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])),&lt;/span&gt;
  &lt;span class="nf"&gt;switchMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getProjects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;organizationId&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;&lt;em&gt;(Note: &lt;code&gt;switchMap&lt;/code&gt; is perfect here because it cancels previous pending requests if a new ID is emitted).&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. "Fat" Components (The God Object)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Anti-pattern:&lt;/strong&gt; Stuffing business logic, direct HTTP calls, and complex state manipulation directly into the &lt;code&gt;.ts&lt;/code&gt; file of your component.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it is bad:&lt;/strong&gt; Your component becomes a monolith. It becomes untestable (because you have to mock 15 different dependencies) and impossible to reuse. UI components should only care about &lt;em&gt;presenting&lt;/em&gt; data and catching user events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Modern Solution:&lt;/strong&gt;&lt;br&gt;
Adopt the &lt;strong&gt;Smart / Dumb (Presentational) Component Architecture&lt;/strong&gt; using Standalone Components, and use a Facade or Service for logic.&lt;/p&gt;

&lt;p&gt;Move your HTTP logic and state mutations into a dedicated Service, keeping your component clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [GOOD]: The component just binds to the facade service.&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;standalone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CartComponent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Injecting the service using the modern inject() function&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;cartFacade&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CartFacadeService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;cartItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cartFacade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// This is a Signal exposed by the service&lt;/span&gt;

  &lt;span class="nf"&gt;onRemoveItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cartFacade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// The service handles the actual logic and API call&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;h2&gt;
  
  
  5. The Desperate &lt;code&gt;ChangeDetectorRef&lt;/code&gt; Hack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Anti-pattern:&lt;/strong&gt; Injecting &lt;code&gt;ChangeDetectorRef&lt;/code&gt; and throwing &lt;code&gt;cdr.detectChanges()&lt;/code&gt; or wrapping code in &lt;code&gt;setTimeout(() =&amp;gt; {}, 0)&lt;/code&gt; just to force the UI to update.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [BAD]: Forcing Angular to update because the data flow is broken.&lt;/span&gt;
&lt;span class="nf"&gt;updateUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;John&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cdr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detectChanges&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Please update the UI, I am begging you!&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it is bad:&lt;/strong&gt; If you find yourself using this, it means you have lost control of your state management or you are mutating data directly instead of creating new references. It is treating the symptom instead of curing the disease.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Modern Solution:&lt;/strong&gt;&lt;br&gt;
Embrace &lt;strong&gt;Zoneless Angular&lt;/strong&gt; and &lt;strong&gt;Immutability&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;Angular 21 fully supports going Zoneless (&lt;code&gt;provideZonelessChangeDetection()&lt;/code&gt;). In a Zoneless environment, the framework relies entirely on Signals and built-in reactive primitives to know when to update the DOM, eliminating the need for &lt;code&gt;Zone.js&lt;/code&gt; overhead or manual &lt;code&gt;cdr.detectChanges()&lt;/code&gt; hacks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [GOOD]: Treat data as immutable and use Signals for automatic UI updates&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;standalone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ChangeDetectionStrategy.OnPush is also recommended if you are not fully zoneless yet&lt;/span&gt;
  &lt;span class="na"&gt;changeDetection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ChangeDetectionStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OnPush&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserComponent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Jane&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;updateUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Updating the signal automatically schedules a highly optimized UI update&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;John&lt;/span&gt;&lt;span class="dl"&gt;'&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;h3&gt;
  
  
  Wrapping Up
&lt;/h3&gt;

&lt;p&gt;Angular has heavily shifted towards a reactive, Signal-driven, and Zoneless future. By dropping these 5 anti-patterns and embracing the paradigms of Angular 21, you will immediately notice improvements in your app's Lighthouse scores, memory footprint, and overall developer experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over to you:&lt;/strong&gt; Which of these anti-patterns do you see most often in legacy codebases? Are there any others you would add to this list? Let me know in the comments!&lt;/p&gt;

</description>
      <category>angular</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>programming</category>
    </item>
    <item>
      <title>PostgreSQL 19 WAIT FOR LSN from PHP: read-your-writes on a replica, and four ways it bites" published: false</title>
      <dc:creator>Szj</dc:creator>
      <pubDate>Wed, 30 Sep 2026 18:26:12 +0000</pubDate>
      <link>https://dev.to/_er2es_/postgresql-19-wait-for-lsn-from-php-read-your-writes-on-a-replica-and-four-ways-it-bites-1ogi</link>
      <guid>https://dev.to/_er2es_/postgresql-19-wait-for-lsn-from-php-read-your-writes-on-a-replica-and-four-ways-it-bites-1ogi</guid>
      <description>&lt;p&gt;You add a read replica. A user saves a form, gets redirected, and the next page shows the old data. The write went to the primary, the read went to the replica, and the replica hadn't caught up yet.&lt;/p&gt;

&lt;p&gt;PostgreSQL 19 adds a built-in answer: the &lt;code&gt;WAIT&lt;/code&gt; command. You give it the WAL position (LSN) of your write, and the replica blocks until it has replayed that far. I tried it from PHP on PostgreSQL 19 Beta 4, and found a few things worth knowing before you ship it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Note: PostgreSQL 19 was at Beta 4 when I wrote this. Check the final release notes, as details may still change.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem, measured
&lt;/h2&gt;

&lt;p&gt;Setup: one primary and one asynchronous streaming standby on the same machine, PHP 8.5.10 with PDO. After each &lt;code&gt;INSERT&lt;/code&gt; on the primary I read the row back from the replica immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5000 of 5000 reads were stale.&lt;/strong&gt; Not most of them. All of them. The read arrives about 60 µs after the commit, and the row takes roughly 300 µs to get replayed on the replica. Under write load (pgbench with 4 clients on the primary) it was 1496 of 1500.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;WAIT&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;LSN&lt;/span&gt; &lt;span class="s1"&gt;'0/0306EE20'&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TIMEOUT&lt;/span&gt; &lt;span class="s1"&gt;'50ms'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NO_THROW&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- returns one row: success | timeout | not in recovery&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flow: write on the primary, grab the current LSN, run &lt;code&gt;WAIT&lt;/code&gt; on the replica, then read from the replica.&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReadYourWrites&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="kt"&gt;PDO&lt;/span&gt; &lt;span class="nv"&gt;$primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;PDO&lt;/span&gt; &lt;span class="nv"&gt;$replica&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$timeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&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="cd"&gt;/** Call right after the write has committed on the primary (synchronous_commit = on). */&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;commitLsn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&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;primary&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT pg_current_wal_flush_lsn()'&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;fetchColumn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/** Returns the connection that is safe to read from. Never call inside an open transaction. */&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;readerFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$lsn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;PDO&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lsn&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="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;replica&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~^[0-9A-F]+/[0-9A-F]+$~'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$lsn&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Not a valid LSN'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$status&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;replica&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"WAIT FOR LSN '&lt;/span&gt;&lt;span class="nv"&gt;$lsn&lt;/span&gt;&lt;span class="s2"&gt;' WITH (TIMEOUT '&lt;/span&gt;&lt;span class="si"&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;timeoutMs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms', NO_THROW)"&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;fetchColumn&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;$status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'success'&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;replica&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;primary&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;Result on 5000 writes: &lt;strong&gt;0 stale reads&lt;/strong&gt;, with a median wait of 315 µs on an idle standby and 1.2 ms under write load. If the replica can't catch up in time, the code falls back to the primary, which is always correct.&lt;/p&gt;

&lt;p&gt;The LSN has to travel with the user between requests. Keep it in the session, and read from the replica only after &lt;code&gt;WAIT&lt;/code&gt; succeeds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Stale reads without WAIT&lt;/th&gt;
&lt;th&gt;Stale reads with WAIT&lt;/th&gt;
&lt;th&gt;WAIT median&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Idle standby&lt;/td&gt;
&lt;td&gt;5000 / 5000&lt;/td&gt;
&lt;td&gt;0 / 5000&lt;/td&gt;
&lt;td&gt;315 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write load&lt;/td&gt;
&lt;td&gt;1496 / 1500&lt;/td&gt;
&lt;td&gt;0 / 1500&lt;/td&gt;
&lt;td&gt;1.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All numbers come from a single vCPU running everything (primary, standby, PHP, pgbench), so absolute latencies are inflated and a real network adds a round trip. Treat the ratios, not the microseconds, as the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 1: you can't bind the LSN
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WAIT&lt;/code&gt; is a utility command, so PostgreSQL doesn't accept parameters in it. With PDO's default native prepared statements:&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;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$replica&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WAIT FOR LSN :lsn'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'lsn'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$lsn&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// SQLSTATE[42601]: syntax error at or near "$1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only works with &lt;code&gt;ATTR_EMULATE_PREPARES = true&lt;/code&gt;. The safe option is to validate the string with a strict regex (as in the class above) and interpolate it. An LSN is just hex, a slash and hex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: it must be the first thing in a transaction
&lt;/h2&gt;

&lt;p&gt;The docs say &lt;code&gt;WAIT&lt;/code&gt; can't run while you hold a snapshot or locks. In practice:&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;$replica&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;beginTransaction&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$replica&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT count(*) FROM t'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$replica&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"WAIT FOR LSN '&lt;/span&gt;&lt;span class="nv"&gt;$lsn&lt;/span&gt;&lt;span class="s2"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ERROR: cannot wait for a standby LSN while holding locks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same applies after the first query in a &lt;code&gt;REPEATABLE READ&lt;/code&gt; transaction (&lt;code&gt;WAIT cannot be executed while the current transaction holds a snapshot&lt;/code&gt;). A plain &lt;code&gt;SET LOCAL&lt;/code&gt; before it is fine.&lt;/p&gt;

&lt;p&gt;Two details cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An error inside a transaction aborts it (&lt;code&gt;SQLSTATE 25P02&lt;/code&gt;), so call &lt;code&gt;WAIT&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; &lt;code&gt;beginTransaction()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If the target LSN is &lt;strong&gt;already reached&lt;/strong&gt;, &lt;code&gt;WAIT&lt;/code&gt; returns immediately even after a query. The restriction only triggers when it would actually have to wait. So your tests can pass on an idle replica and fail in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also can't run inside a &lt;code&gt;DO&lt;/code&gt; block or function (&lt;code&gt;WAIT can only be executed as a top-level statement&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: the docs' LSN function can hang
&lt;/h2&gt;

&lt;p&gt;The documentation example takes the LSN with &lt;code&gt;pg_current_wal_insert_lsn()&lt;/code&gt;. In my idle test, &lt;strong&gt;5 out of 5000 waits timed out&lt;/strong&gt;. Every one of those LSNs had the same page offset: 24 bytes, which is the size of a WAL page header.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0/0322A018   0/03240018   0/03268018   0/032C8018   0/032DE018
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My explanation (an inference, not something I checked in the source): the commit record ended exactly at a WAL page boundary, and the insert pointer was reported &lt;em&gt;after&lt;/em&gt; the new page's header. The replica has replayed everything that exists, but the target sits 24 bytes past the last real record, so &lt;code&gt;WAIT&lt;/code&gt; blocks until more WAL arrives. On an idle system that can be a long time.&lt;/p&gt;

&lt;p&gt;With the default &lt;code&gt;synchronous_commit = on&lt;/code&gt;, &lt;code&gt;pg_current_wal_flush_lsn()&lt;/code&gt; does not have this problem: 0 timeouts in 5000, and it was also slightly faster. Either way, use &lt;code&gt;TIMEOUT&lt;/code&gt; and &lt;code&gt;NO_THROW&lt;/code&gt;, check the status, and fall back to the primary. &lt;strong&gt;Never ignore the returned status.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 4: synchronous_commit = off changes everything
&lt;/h2&gt;

&lt;p&gt;If your app uses asynchronous commit, the flush LSN can be &lt;em&gt;behind&lt;/em&gt; your commit record:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Primary session with &lt;code&gt;synchronous_commit = off&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;flush_lsn&lt;/code&gt;, then WAIT&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;success&lt;/code&gt; in 48 µs, but &lt;strong&gt;300 / 300 reads were stale&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;insert_lsn&lt;/code&gt;, then WAIT&lt;/td&gt;
&lt;td&gt;correct, but median wait &lt;strong&gt;201 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;WAIT&lt;/code&gt; dutifully reports success for a position the replica really did reach. It just isn't your write. The insert LSN is correct here, but you pay roughly one &lt;code&gt;wal_writer_delay&lt;/code&gt; (200 ms by default) per wait, and 8 of 300 still hit my 300 ms timeout.&lt;/p&gt;

&lt;p&gt;So: flush LSN if you use synchronous commit, insert LSN plus the fallback if you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symfony and Doctrine
&lt;/h2&gt;

&lt;p&gt;Nothing here is tied to PDO. With DBAL, run the same &lt;code&gt;WAIT&lt;/code&gt; through &lt;code&gt;executeQuery()&lt;/code&gt; on the replica connection, before opening a transaction. One natural place to capture the LSN is a listener that runs after a flush on the primary connection and stores the result in the session. I've only tested the plain PDO version above, so treat that as a design sketch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Read-after-write against an async replica is broken by default: 5000/5000 stale reads in my test.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WAIT FOR LSN&lt;/code&gt; fixes it cheaply: about 0.3 ms idle, about 1.2 ms under write load on my setup.&lt;/li&gt;
&lt;li&gt;Always pass &lt;code&gt;TIMEOUT&lt;/code&gt; and &lt;code&gt;NO_THROW&lt;/code&gt;, check the status, and fall back to the primary.&lt;/li&gt;
&lt;li&gt;Validate the LSN yourself, because you can't bind it.&lt;/li&gt;
&lt;li&gt;Call it before opening a transaction.&lt;/li&gt;
&lt;li&gt;Pick the LSN function to match your &lt;code&gt;synchronous_commit&lt;/code&gt; setting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran into this while working on search and read-heavy PostgreSQL workloads for PHP, including &lt;a href="https://github.com/er2es/fuzzphony" rel="noopener noreferrer"&gt;Fuzzphony&lt;/a&gt;. If you have a real multi-machine setup, I'd love to see your numbers in the comments.&lt;/p&gt;

</description>
      <category>php</category>
      <category>postgres</category>
    </item>
    <item>
      <title>grapheme_levenshtein() in PHP 8.5: what the docs don't tell you</title>
      <dc:creator>Szj</dc:creator>
      <pubDate>Wed, 30 Sep 2026 17:23:11 +0000</pubDate>
      <link>https://dev.to/_er2es_/graphemelevenshtein-in-php-85-what-the-docs-dont-tell-you-322n</link>
      <guid>https://dev.to/_er2es_/graphemelevenshtein-in-php-85-what-the-docs-dont-tell-you-322n</guid>
      <description>&lt;p&gt;PHP's &lt;code&gt;levenshtein()&lt;/code&gt; has always counted &lt;strong&gt;bytes&lt;/strong&gt;. For English that's invisible. For Hungarian, Polish, Czech, or anything with accents, it quietly lies:&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="nb"&gt;levenshtein&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'szőke'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'szoke'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 2, but it's one edit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ő&lt;/code&gt; is two bytes in UTF-8 (&lt;code&gt;C5 91&lt;/code&gt;), so the function sees two edits. PHP 8.5 finally adds &lt;code&gt;grapheme_levenshtein()&lt;/code&gt;, which counts user-perceived characters instead. Every article I found about it explains the syntax. None of them measured it, so I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PHP 8.5.10 (CLI, built from source), ICU 74.2, default settings, no JIT&lt;/li&gt;
&lt;li&gt;Synthetic Hungarian-style words (with &lt;code&gt;sz&lt;/code&gt;, &lt;code&gt;cs&lt;/code&gt;, &lt;code&gt;ő&lt;/code&gt;, &lt;code&gt;ű&lt;/code&gt;, ...), each compared with a copy containing one random edit&lt;/li&gt;
&lt;li&gt;20,000 pairs per length, best of 3 rounds, nanoseconds per comparison&lt;/li&gt;
&lt;li&gt;Single vCPU sandbox: absolute numbers will differ on your machine, the ratios are what matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it fixes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pair&lt;/th&gt;
&lt;th&gt;&lt;code&gt;levenshtein()&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;code points&lt;/th&gt;
&lt;th&gt;&lt;code&gt;grapheme_levenshtein()&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;szőke&lt;/code&gt; vs &lt;code&gt;szoke&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Kovács&lt;/code&gt; vs &lt;code&gt;Kovacs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ő&lt;/code&gt; vs &lt;code&gt;ö&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;é&lt;/code&gt; (NFC) vs &lt;code&gt;é&lt;/code&gt; (NFD)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&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;👩‍💻 vs 👩&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The NFC/NFD row is the real win. The same visible &lt;code&gt;é&lt;/code&gt; can be stored as one code point or two (&lt;code&gt;e&lt;/code&gt; + combining accent), depending on where the data came from. The byte version reports 3 edits between two strings that look identical. The grapheme version correctly reports 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Length&lt;/th&gt;
&lt;th&gt;&lt;code&gt;levenshtein()&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;grapheme_levenshtein()&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;with &lt;code&gt;locale='hu'&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;userland, code points&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8 chars&lt;/td&gt;
&lt;td&gt;0.22 µs&lt;/td&gt;
&lt;td&gt;3.3 µs&lt;/td&gt;
&lt;td&gt;4.0 µs&lt;/td&gt;
&lt;td&gt;3.8 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24 chars&lt;/td&gt;
&lt;td&gt;1.3 µs&lt;/td&gt;
&lt;td&gt;14.1 µs&lt;/td&gt;
&lt;td&gt;17.6 µs&lt;/td&gt;
&lt;td&gt;29.2 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64 chars&lt;/td&gt;
&lt;td&gt;6.4 µs&lt;/td&gt;
&lt;td&gt;76.9 µs&lt;/td&gt;
&lt;td&gt;98.2 µs&lt;/td&gt;
&lt;td&gt;196.1 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things stand out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It is 11-15x slower than the byte version.&lt;/strong&gt; Correctness isn't free: the function segments both strings into grapheme clusters and compares them through ICU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It beats a userland implementation&lt;/strong&gt; by about 1.2x on short strings and 2-2.6x on longer ones. If you were using a pure PHP multibyte Levenshtein, switching is a clear win.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;$locale&lt;/code&gt; parameter adds 23-28% on top.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The manual documents the complexity as O(m*n). In practice that means you should not run it over a whole table: at 24 characters, 1M rows is roughly 14 seconds of CPU per query, versus about 1.3 seconds for the byte version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two surprises
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Invisible characters can compare as equal.&lt;/strong&gt; In my tests on 8.5.10:&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;grapheme_levenshtein&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x01&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x02&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// 0&lt;/span&gt;
&lt;span class="nf"&gt;grapheme_levenshtein&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u{200B}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u{200C}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 0&lt;/span&gt;
&lt;span class="nf"&gt;grapheme_levenshtein&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x01&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different control or zero-width characters are treated as identical to each other, yet each one differs from an empty string. The equality check is a collation comparison, and ICU collation ignores some characters completely. Mid-word cases behave less predictably (&lt;code&gt;"a\u{200B}b"&lt;/code&gt; vs &lt;code&gt;"a\u{200C}b"&lt;/code&gt; gave 1, while &lt;code&gt;"a\x01b"&lt;/code&gt; vs &lt;code&gt;"a\x02b"&lt;/code&gt; gave 0). A soft hyphen (&lt;code&gt;U+00AD&lt;/code&gt;) is &lt;em&gt;not&lt;/em&gt; ignored and costs one edit. This matches a recently reported php-src issue, so check the current status before relying on either behavior.&lt;/p&gt;

&lt;p&gt;For search this is mostly harmless, but if you use the distance for deduplication or validation, strip control and format characters first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The locale parameter changed nothing in my tests.&lt;/strong&gt; I compared &lt;code&gt;hu&lt;/code&gt; against &lt;code&gt;en&lt;/code&gt; on every case above, including &lt;code&gt;cs&lt;/code&gt; vs &lt;code&gt;c&lt;/code&gt; and &lt;code&gt;sz&lt;/code&gt; vs &lt;code&gt;s&lt;/code&gt;, where you might hope for Hungarian digraph awareness. The results were identical in all cases. It only added the 23-28% cost. I'm not claiming it never matters (I can't rule out effects on other scripts or case-insensitive comparisons), but for Hungarian names it bought me nothing.&lt;/p&gt;

&lt;p&gt;Also note that the comparison is case-sensitive: &lt;code&gt;Kovács&lt;/code&gt; vs &lt;code&gt;kovács&lt;/code&gt; is 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  So how should you use it?
&lt;/h2&gt;

&lt;p&gt;Don't scan with it, re-rank with it. Let the database produce candidates cheaply and let PHP do the precise scoring on a handful of rows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL + pg_trgm: fast candidate generation&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;people&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&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="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// strip control/format characters, then lowercase&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/[\p{Cc}\p{Cf}]/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;rerank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$maxDistance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$scored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$candidates&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;grapheme_levenshtein&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nv"&gt;$maxDistance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$scored&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$d&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="nb"&gt;asort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$scored&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;$scored&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;rerank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Szoke'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Szőke'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Szűcs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Szabó'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SZOKE'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// ['SZOKE' =&amp;gt; 0, 'Szőke' =&amp;gt; 1]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-ranking 50 candidates of around 24 characters costs about 0.7 ms (50 x 14 µs), which is nothing next to the query itself. That's the sweet spot: the accuracy of a grapheme-aware distance at a cost you never notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;grapheme_levenshtein()&lt;/code&gt; over &lt;code&gt;levenshtein()&lt;/code&gt; whenever your data has accents or mixed Unicode normalization.&lt;/li&gt;
&lt;li&gt;Expect ~12x the cost of the byte version, and never run it over a full table.&lt;/li&gt;
&lt;li&gt;Normalize case and strip invisible characters yourself.&lt;/li&gt;
&lt;li&gt;Measure before you reach for &lt;code&gt;$locale&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran into all of this while building &lt;a href="https://github.com/er2es/fuzzphony" rel="noopener noreferrer"&gt;Fuzzphony&lt;/a&gt;, a PostgreSQL-native search library for PHP and Symfony. If you have your own numbers from a different machine or dataset, I'd love to see them in the comments.&lt;/p&gt;

</description>
      <category>php</category>
    </item>
    <item>
      <title>I couldn't touch the database, so I built search next to it</title>
      <dc:creator>Szj</dc:creator>
      <pubDate>Mon, 28 Sep 2026 18:40:07 +0000</pubDate>
      <link>https://dev.to/_er2es_/i-couldnt-touch-the-database-so-i-built-search-next-to-it-1pjn</link>
      <guid>https://dev.to/_er2es_/i-couldnt-touch-the-database-so-i-built-search-next-to-it-1pjn</guid>
      <description>&lt;p&gt;A production system I work on needed a search box that forgives people. They type &lt;code&gt;kavefozo&lt;/code&gt; and expect &lt;code&gt;Kávéfőző&lt;/code&gt;. They type &lt;code&gt;hedphones&lt;/code&gt; and expect headphones.&lt;/p&gt;

&lt;p&gt;The constraints were the interesting part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The schema could not change.&lt;/strong&gt; No new columns, no generated columns, no migrations on tables that weren't ours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No new infrastructure.&lt;/strong&gt; No Elasticsearch, no Meilisearch, no extra service to run, sync, back up and secure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So search had to happen inside the database that was already there. PostgreSQL turned out to have almost every piece needed. Wiring those pieces together properly was the hard part, and that wiring became an open-source library: &lt;a href="https://github.com/er2es/fuzzphony" rel="noopener noreferrer"&gt;Fuzzphony&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Fuzzphony is at &lt;strong&gt;v0.4 and still in active development&lt;/strong&gt;. The core works and is heavily tested, but the API can change before 1.0, and there are rough edges I'll be upfront about at the end.&lt;/p&gt;

&lt;p&gt;This post covers the design, the bug that taught me the most, and what I still haven't solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;LIKE '%…%'&lt;/code&gt; gets wrong
&lt;/h2&gt;

&lt;p&gt;Most search boxes start here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It misses typos (&lt;code&gt;hedphones&lt;/code&gt;), accents (&lt;code&gt;creme&lt;/code&gt; vs &lt;code&gt;crème&lt;/code&gt;) and word forms (&lt;code&gt;drills&lt;/code&gt; vs &lt;code&gt;drill&lt;/code&gt;). It can't exclude a word, it doesn't rank, and when it misses, it reads the whole table to return nothing.&lt;/p&gt;

&lt;p&gt;Postgres ships three tools for exactly these problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full-text search&lt;/strong&gt; (&lt;code&gt;tsvector&lt;/code&gt;, &lt;code&gt;tsquery&lt;/code&gt;, &lt;code&gt;ts_rank_cd&lt;/code&gt;): stemming for 28 languages, field weights A to D, relevance ranking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unaccent&lt;/code&gt;&lt;/strong&gt;: folds &lt;code&gt;Kávéfőző&lt;/code&gt; into &lt;code&gt;Kavefozo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pg_trgm&lt;/code&gt;&lt;/strong&gt;: trigram similarity, which is what lets &lt;code&gt;wireles&lt;/code&gt; match &lt;code&gt;wireless&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of them is new. The work is in making them behave together, stay in sync with your data, and be safe to point at user input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sidecar table
&lt;/h2&gt;

&lt;p&gt;Since your tables are off-limits, the index lives in its own table next to them.&lt;/p&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%2Fqyqdk348f6nz9tvmvygs.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%2Fqyqdk348f6nz9tvmvygs.PNG" alt="Where the index lives: no column is added to your tables, triggers feed a queue, a worker refreshes the sidecar table, and searches read only the sidecar" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For every index, Fuzzphony keeps a table such as &lt;code&gt;fuzzphony_products&lt;/code&gt; with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a weighted &lt;code&gt;tsvector&lt;/code&gt; for full-text search, on a GIN index,&lt;/li&gt;
&lt;li&gt;a normalised, unaccented text column for typo tolerance, on a GIN trigram index,&lt;/li&gt;
&lt;li&gt;typed filter columns on btree indexes, so &lt;code&gt;where('price', '&amp;lt;=', 20_000)&lt;/code&gt; never has to touch your table,&lt;/li&gt;
&lt;li&gt;the inputs for ranking: a boost column (popularity, say) and a recency column.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The source can be a single table or any &lt;code&gt;SELECT&lt;/code&gt;, joins included. With &lt;code&gt;schema: fuzzphony&lt;/code&gt; set, the sidecar tables, the queue and the helper functions all live in their own schema, and dropping an index is one command.&lt;/p&gt;

&lt;p&gt;To be precise about "off-limits": no column is ever added, but in the default &lt;code&gt;queue&lt;/code&gt; mode and in &lt;code&gt;trigger&lt;/code&gt; mode Fuzzphony does attach triggers to the tables it watches. That's how it sees writes from raw SQL, imports and other applications. If even a trigger is too much, the &lt;code&gt;orm&lt;/code&gt; and &lt;code&gt;manual&lt;/code&gt; modes need none.&lt;/p&gt;

&lt;p&gt;The cost of the design is obvious: there is now a second copy of the searchable data, and it has to stay in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it in sync without hurting writes
&lt;/h2&gt;

&lt;p&gt;There are four sync modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;queue&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;triggers enqueue ids, &lt;code&gt;fuzzphony:worker&lt;/code&gt; refreshes in batches&lt;/td&gt;
&lt;td&gt;most apps; writes stay fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;trigger&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;triggers refresh inside the writing transaction&lt;/td&gt;
&lt;td&gt;you need read-your-writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a Doctrine listener refreshes after &lt;code&gt;flush()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;triggers are not allowed at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;manual&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing automatic&lt;/td&gt;
&lt;td&gt;batch imports, read-only data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three details made the trigger-based modes usable on real tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Statement-level triggers with transition tables.&lt;/strong&gt; A row-level trigger on an &lt;code&gt;UPDATE brand SET …&lt;/code&gt; that touches 100 000 rows fires 100 000 times. A statement-level trigger fires once and sees every changed row through a transition table, so queuing the affected products becomes a single set-based statement. In simplified form (not the exact generated SQL):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;brand_sync&lt;/span&gt;
&lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;
&lt;span class="k"&gt;REFERENCING&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;STATEMENT&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;queue_products_of_changed_brands&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;-- inside the function, roughly:&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;sync_queue&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'products'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;brand_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Only refresh when something relevant changed.&lt;/strong&gt; An &lt;code&gt;UPDATE product SET last_viewed_at = now()&lt;/code&gt; should not reindex anything. For the index's own table, Fuzzphony checks whether a mapped column actually changed. For joined tables you opt in with &lt;code&gt;columns: ['name']&lt;/code&gt;, because a library cannot guess which of a joined table's columns matter to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;TRUNCATE&lt;/code&gt; is not a delete.&lt;/strong&gt; It fires no &lt;code&gt;DELETE&lt;/code&gt; triggers, so early versions left stale documents in the index after a &lt;code&gt;TRUNCATE&lt;/code&gt;, and not even a full reindex removed them. I found that one late. Now every watched table also gets an &lt;code&gt;AFTER TRUNCATE&lt;/code&gt; trigger, and a full reindex prunes documents whose rows no longer exist.&lt;/p&gt;

&lt;p&gt;The worker takes a batch with &lt;code&gt;DELETE … FOR UPDATE SKIP LOCKED&lt;/code&gt; and refreshes it in the same statement, so a failed refresh rolls the dequeue back and no id is lost, and several workers can run side by side. If you'd rather not run a long-lived process, &lt;code&gt;fuzzphony:worker --once&lt;/code&gt; from cron works too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens to a query
&lt;/h2&gt;

&lt;p&gt;From the application side, a search looks like this:&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;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$fuzzphony&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&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;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wireles mouse -cable'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// a typo and an exclusion&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20_000&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'in_stock'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&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;highlight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;highlights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;   &lt;span class="c1"&gt;// "&amp;lt;mark&amp;gt;Wireless&amp;lt;/mark&amp;gt; mouse"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind that call, the query goes through a few stages:&lt;/p&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%2Fv2e1fcgwms67ug91e3ng.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%2Fv2e1fcgwms67ug91e3ng.png" alt="Query pipeline: parse, exact full-text match, typo tolerance per word only if there are too few hits, relaxation if still nothing, then ranking" width="800" height="623"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few decisions in there are worth explaining.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User input never throws.&lt;/strong&gt; Unbalanced quotes, stray operators and absurdly long input are repaired and reported in &lt;code&gt;$result-&amp;gt;warnings&lt;/code&gt;, and &lt;code&gt;$result-&amp;gt;interpretedAs&lt;/code&gt; shows how the query was understood. Developer mistakes are the opposite: they fail loudly, with a hint. &lt;code&gt;Index "products" has no filter "prise". Did you mean "price"?&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact first, typo tolerance as a fallback.&lt;/strong&gt; Trigram matching is slower and noisier than full-text matching, so by default it only runs when exact matching finds fewer than &lt;code&gt;fallback_below&lt;/code&gt; hits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typo tolerance is per word.&lt;/strong&gt; The first version compared the whole query string against the fuzzy fields, so one long common word could satisfy it on its own. On the demo catalogue, &lt;code&gt;wireles mice&lt;/code&gt; returned 20 000 products (chairs, drills, kettles), of which 1 666 were mice. Now every word has to match on its own, exactly or by trigram similarity, inside the query's real AND / OR / NOT structure. The same query returns exactly the 1 666 wireless mice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An empty result gets one more try.&lt;/strong&gt; If a query of two or more words finds nothing, each word is checked on its own, the words that match nothing are dropped, and the search runs again with a warning saying which words were ignored. &lt;code&gt;wireless mouse aluminum&lt;/code&gt; returns the wireless mice instead of an empty page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ranking is explainable.&lt;/strong&gt; The score is a plain formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;relevance = text  × ts_rank_cd(weights A..D)
          + fuzzy × word_similarity(query, fuzzy fields)

score     = relevance
          + exact_bonus + prefix_bonus
          + boost   × boost column
          + recency × 2^(−age / half_life)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;min_score&lt;/code&gt; applies to relevance only, so a popularity boost can reorder relevant hits but can never pull an irrelevant one into the results. Every hit carries a score breakdown, so "why is this first?" always has an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that taught me the most
&lt;/h2&gt;

&lt;p&gt;A German test query, &lt;code&gt;Tasche für Laptop&lt;/code&gt;, did not find a product called &lt;code&gt;Laptop Tasche&lt;/code&gt;. In the French catalogue, searching for &lt;code&gt;à&lt;/code&gt; alone matched 13 products. Hungarian &lt;code&gt;és&lt;/code&gt; misbehaved the same way.&lt;/p&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%2Fpe1sehoyo9mbcw3dnw37.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%2Fpe1sehoyo9mbcw3dnw37.png" alt="The accented stop word bug: unaccent turned für into fur before the stemmer, whose stop-word list only knows für" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A text search configuration sends every token through a list of dictionaries, in order. Mine ran &lt;code&gt;unaccent&lt;/code&gt; before the Snowball stemmer. The stemmer's German stop-word list contains &lt;code&gt;für&lt;/code&gt;, but by the time the token reached it, it had become &lt;code&gt;fur&lt;/code&gt;, which is not a stop word. So &lt;code&gt;für&lt;/code&gt; was indexed as an ordinary word, and every query containing it had to match it.&lt;/p&gt;

&lt;p&gt;The fix was to drop stop words before anything else touches the token, with a stop-word dictionary built from the language's own list. In simplified form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;SEARCH&lt;/span&gt; &lt;span class="k"&gt;DICTIONARY&lt;/span&gt; &lt;span class="n"&gt;fuzzphony_german_stop&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;TEMPLATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;simple&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;STOPWORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;german&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;SEARCH&lt;/span&gt; &lt;span class="n"&gt;CONFIGURATION&lt;/span&gt; &lt;span class="n"&gt;fuzzphony_german&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;COPY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;german&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;SEARCH&lt;/span&gt; &lt;span class="n"&gt;CONFIGURATION&lt;/span&gt; &lt;span class="n"&gt;fuzzphony_german&lt;/span&gt;
    &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;MAPPING&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;asciiword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asciihword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hword_asciipart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hword_part&lt;/span&gt;
    &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;fuzzphony_german_stop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unaccent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;german_stem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;ACCEPT = false&lt;/code&gt;, the stop dictionary discards stop words and passes everything else on to the next dictionary.&lt;/p&gt;

&lt;p&gt;Two things came out of this besides the fix. &lt;code&gt;fuzzphony:doctor&lt;/code&gt; now reports a configuration that still keeps accented stop words. And the demo got a Languages page that searches small catalogues in English, German, French, Spanish and Hungarian, and shows the lexeme Postgres produced for every query word. I would have caught this bug on day one if that page had existed earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, with the caveats
&lt;/h2&gt;

&lt;p&gt;A sample run on 200 000 products, PostgreSQL 16, a small cloud VM, 20 results per query:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;ILIKE (warm)&lt;/th&gt;
&lt;th&gt;ILIKE hits&lt;/th&gt;
&lt;th&gt;Fuzzphony (warm)&lt;/th&gt;
&lt;th&gt;Fuzzphony hits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wireless&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.6 ms&lt;/td&gt;
&lt;td&gt;20, unranked&lt;/td&gt;
&lt;td&gt;11.1 ms&lt;/td&gt;
&lt;td&gt;20 of 2000+, ranked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;creme&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;251.6 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10.4 ms&lt;/td&gt;
&lt;td&gt;20 of 2000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hedphones&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;252.6 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20.7 ms&lt;/td&gt;
&lt;td&gt;20 of 2000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;drills&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;257.1 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10.6 ms&lt;/td&gt;
&lt;td&gt;20 of 2000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"noise cancelling" -headphones&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.5 ms&lt;/td&gt;
&lt;td&gt;20, wrong&lt;/td&gt;
&lt;td&gt;23.2 ms&lt;/td&gt;
&lt;td&gt;20 of 2000+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read this carefully, because a benchmark against &lt;code&gt;ILIKE&lt;/code&gt; is easy to make look good:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On plain words, &lt;code&gt;ILIKE&lt;/code&gt; is much faster. &lt;code&gt;LIMIT 20&lt;/code&gt; without an &lt;code&gt;ORDER BY&lt;/code&gt; just returns the first 20 rows the scan happens to hit. Fast, but not the best 20.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;ILIKE&lt;/code&gt; baseline has no trigram index, which is why its misses are full table scans. A &lt;code&gt;gin_trgm_ops&lt;/code&gt; index would make those rows fast. It would not make them correct: &lt;code&gt;ILIKE '%hedphones%'&lt;/code&gt; finds nothing no matter how it is indexed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ILIKE&lt;/code&gt; has no concept of exclusion, so on the last row it silently ignores &lt;code&gt;-headphones&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the numbers on your own data before believing anyone's benchmark, including this one. The benchmark script is in the repository, and CI runs it on every push.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use it
&lt;/h2&gt;

&lt;p&gt;Fuzzphony is not the right tool if you need hundreds of millions of documents or thousands of searches per second on one index, analytics-style aggregations, semantic or vector search (look at pgvector or a dedicated engine), or a database other than PostgreSQL.&lt;/p&gt;

&lt;p&gt;It fits best where the database is not yours to change (a legacy system, an ERP, tables another team owns), where you are replacing &lt;code&gt;LIKE&lt;/code&gt; in admin panels and back offices, or where the data has to stay in the database for compliance reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't solved yet
&lt;/h2&gt;

&lt;p&gt;Two things are genuinely open, and I'd like opinions on both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typo tolerance is too lenient on short words.&lt;/strong&gt; At the default similarity threshold, &lt;code&gt;mouse&lt;/code&gt; also matches &lt;code&gt;monitor&lt;/code&gt;: short words have few trigrams, so a single shared one weighs a lot. The plan is length-aware thresholds, probably with trigrams only generating candidates from a vocabulary table and an edit-distance check deciding. How do you tune &lt;code&gt;pg_trgm&lt;/code&gt; for short words?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ranking of very frequent words.&lt;/strong&gt; A GIN index can find matches but can't return them in relevance order, so for a word that matches tens of thousands of documents, Fuzzphony ranks the first &lt;code&gt;candidate_limit&lt;/code&gt; (2000 by default) that the index returns. That's not necessarily the best 2000. For most back-office searches this doesn't show, but it's the real ceiling of doing search inside Postgres, and I'd rather say so than hide it behind "approximate ordering".&lt;/p&gt;

&lt;h2&gt;
  
  
  Rough edges I'm working on before 1.0
&lt;/h2&gt;

&lt;p&gt;Writing the docs for v0.4 surfaced a few things I'd design differently, so they're on the list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trigger privileges.&lt;/strong&gt; The sync functions run with the privileges of whoever writes the row. In a shared database, every role that writes to a watched table therefore needs grants on Fuzzphony's queue, or its writes fail. Moving the enqueue step into a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function removes that coupling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A batch that always fails.&lt;/strong&gt; A refresh that fails deterministically (a document over Postgres' ~1 MB &lt;code&gt;tsvector&lt;/code&gt; limit, for example) is rolled back and retried forever, which blocks the queue behind it. It needs a dead-letter path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pruning as a default.&lt;/strong&gt; A full reindex removes documents whose rows it can't see. If the reindexing role sees fewer rows than the application (row-level security, a different &lt;code&gt;search_path&lt;/code&gt;), that removes real documents. It should stop and ask above a threshold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field scoping leaks into typo tolerance.&lt;/strong&gt; &lt;code&gt;name:sony&lt;/code&gt; can return Sony-brand products, because the fuzzy fields share one trigram column. Per-field columns are planned for 0.5.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these show up in a single-app setup with one database role, which is how most people will try it. But they're exactly what bites in the environments Fuzzphony is aimed at, so they come before new features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require fuzzphony/fuzzphony
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or run the demo, a full Symfony app on a seeded catalogue, with &lt;code&gt;ILIKE&lt;/code&gt; and Fuzzphony side by side, a ranking playground with sliders, and a configuration wizard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/er2es/fuzzphony
&lt;span class="nb"&gt;cd &lt;/span&gt;fuzzphony/demo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;    &lt;span class="c"&gt;# http://localhost:8000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It requires PHP 8.4+ and PostgreSQL 15+, and is tested on Symfony 7.4 and 8.0 against PostgreSQL 15 to 18. The current release is v0.4 and development is ongoing: breaking changes can still happen before 1.0, and each one is listed in the CHANGELOG with upgrade steps in UPGRADE.md.&lt;/p&gt;

&lt;p&gt;I built this with a lot of help from Claude Code. The design decisions are written down as ADRs in the repository, and the heavy testing (PHPStan at max level, 100% line coverage, mutation testing) is partly because of that: when you didn't type every line yourself, tests are how you know what the code actually does.&lt;/p&gt;

&lt;p&gt;Feedback is very welcome, in the comments or as a GitHub issue, especially on the two open problems above. If the library looks useful, a star on &lt;a href="https://github.com/er2es/fuzzphony" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps other people find it.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>symfony</category>
      <category>fuzzy</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
