<?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>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>
