<?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: Aleksander Frolov</title>
    <description>The latest articles on DEV Community by Aleksander Frolov (@aleksander_frolov).</description>
    <link>https://dev.to/aleksander_frolov</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%2F4079810%2Fa40a6439-379c-4aee-a94a-5a9f6a7de24c.jpg</url>
      <title>DEV Community: Aleksander Frolov</title>
      <link>https://dev.to/aleksander_frolov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aleksander_frolov"/>
    <language>en</language>
    <item>
      <title>Tests green, queries not: what building an N+1 detector for PHPUnit taught me</title>
      <dc:creator>Aleksander Frolov</dc:creator>
      <pubDate>Mon, 05 Oct 2026 14:33:13 +0000</pubDate>
      <link>https://dev.to/aleksander_frolov/tests-green-queries-not-what-building-an-n1-detector-for-phpunit-taught-me-52d1</link>
      <guid>https://dev.to/aleksander_frolov/tests-green-queries-not-what-building-an-n1-detector-for-phpunit-taught-me-52d1</guid>
      <description>&lt;p&gt;An open-source time tracker built on Symfony has 4343 tests, and every one of them passes. Yet each time entry it saves sends three extra SELECT queries to the database: the rate of the entry itself, the rate of the project, and the rate of the activity. With 50 entries in one &lt;code&gt;flush()&lt;/code&gt;, which is exactly how many rows the bulk edit page shows, the rates alone cost 150 queries. The tests cover this code, and none of them fails: not one of them checks for problems with database queries.&lt;/p&gt;

&lt;p&gt;To solve this problem, I built &lt;a href="https://github.com/alex-frolov/query-guard" rel="noopener noreferrer"&gt;query-guard&lt;/a&gt;, a PHPUnit extension. Before it ever looked for N+1 in other people's projects, though, it managed to fool me several times, and always the same way: it showed green where it was not working at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  A ready-made package said green
&lt;/h2&gt;

&lt;p&gt;First I looked for existing solutions. The closest in spirit was a popular package: a PHPUnit trait with query counters, duplicate detection, and plan analysis, advertised as supporting Laravel and Doctrine. I installed it on my own project (Symfony, Doctrine, PostgreSQL 17) and wrote a trial N+1: five lots, each with its own tender, &lt;code&gt;findAll()&lt;/code&gt; and a read of the tender in a loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read phase, queries: 6
  1) SELECT ... FROM lots t0
  2) SELECT ... FROM tenders t0 WHERE t0.id = ?
  ...
  6) SELECT ... FROM tenders t0 WHERE t0.id = ?
duplicates found (SQL + bindings match): 0
assertQueriesAreEfficient() on a plain N+1: PASSED
assertNoLazyLoading(): PASSED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six queries instead of two, and both assertions green. The package looks for duplicates by matching SQL and values, and in an N+1 the values differ by definition. The lazy loading check returns green on Doctrine without checking anything. Plan analysis on PostgreSQL switches itself off silently and also reports green, even though it has not parsed a single query.&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%2Fuhvxzs8wgy27nalwg4ib.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%2Fuhvxzs8wgy27nalwg4ib.png" alt="Six queries of a textbook N+1 and three checks: EXPLAIN and duplicate detection pass, query shape plus place in code reveals the N+1" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then I repeated the check on MySQL, where everything in the package works, and the error flipped sign. The N+1 stayed invisible, because the plan of each individual query is flawless: &lt;code&gt;const&lt;/code&gt;, &lt;code&gt;PRIMARY&lt;/code&gt;, one row. Instead, a table of five rows got &lt;code&gt;error: Full table scan&lt;/code&gt;, and the &lt;code&gt;file:line&lt;/code&gt; in the report pointed inside the package itself.&lt;/p&gt;

&lt;p&gt;Static analysis such as phpstan-dba does not give the answer either: with an ORM, the code contains almost no "raw" queries for phpstan-dba to analyze, and the queries only appear at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  One process, one trace
&lt;/h2&gt;

&lt;p&gt;The first design came out impressive: three packages, runtime query collection with a dump to a file, a separate PHPStan rule that reads the dump and maps findings to code, plus a hash and a git revision so that the dump would not drift away from the code.&lt;/p&gt;

&lt;p&gt;Everything rested on a single reason: collection and analysis lived in different processes. If you do everything in one process, right during the test run, the call stack is alive, &lt;code&gt;file:line&lt;/code&gt; is available on the spot, and there is nothing to serialize or reconcile.&lt;/p&gt;

&lt;p&gt;That is how the tool became a PHPUnit extension. In the PHPUnit 10+ event system one test gives one trace, and N+1 can only be seen in a trace. There is a limitation as well: the tool sees only what the tests cover.&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%2Fg6bat2p0ig9mp6fxdo53.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%2Fg6bat2p0ig9mp6fxdo53.png" alt="PHPUnit events: setUp() queries are kept aside, the trace opens on Test\Prepared, and in strict mode PHPUnit prints OK while the process exits with 1" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first thing to decide was where to open the trace. If it opens at the very start of test preparation, &lt;code&gt;setUp()&lt;/code&gt; ends up inside it, and a factory that creates 50 entities in a loop produces 50 identical INSERTs from one place, a perfect false positive. So the trace opens on the &lt;code&gt;Test\Prepared&lt;/code&gt; event, after &lt;code&gt;setUp()&lt;/code&gt;, and fixture queries are collected separately.&lt;/p&gt;

&lt;p&gt;Then came &lt;code&gt;strict&lt;/code&gt; mode. I wanted a finding to fail the test. The PHPUnit 10–13 event system lets an extension neither mark a test as failed nor change the exit code. The only thing that worked was &lt;code&gt;register_shutdown_function&lt;/code&gt;, which runs after PHPUnit has finished. As a result PHPUnit prints &lt;code&gt;OK&lt;/code&gt; while the process exits with 1. CI understands that; a human stares at the output in confusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  EXPLAIN on an empty database
&lt;/h2&gt;

&lt;p&gt;The hardest thing to accept was that &lt;code&gt;EXPLAIN&lt;/code&gt; on a test database is useless. A test database holds three fixture rows. With three rows the optimizer honestly picks a full scan because it is cheaper, and the plan tells you nothing about production.&lt;/p&gt;

&lt;p&gt;So the rules had to be split into two tiers. The first tier does not depend on data volume and works right away: N+1, duplicates, queries in a loop, a missing &lt;code&gt;LIMIT&lt;/code&gt;, a query budget per test. The second tier reads plans (&lt;code&gt;table-scan&lt;/code&gt;, &lt;code&gt;filesort&lt;/code&gt;, &lt;code&gt;temporary-table&lt;/code&gt;, &lt;code&gt;no-possible-index&lt;/code&gt;); it is off by default and turns on together with a pointer to a database that has real volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The N+1 rule
&lt;/h2&gt;

&lt;p&gt;The rule itself is simple: one query shape with the values stripped out, one place in the code, different values, at least three repeats, and reads only. Batched lookups with &lt;code&gt;IN (?, ?, ?)&lt;/code&gt; do not count, since that is exactly how N+1 gets fixed. For Doctrine, a lazy collection being initialized leaves a &lt;code&gt;PersistentCollection&lt;/code&gt; in the stack, so such a finding gets the &lt;code&gt;error&lt;/code&gt; level and names the association.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query-guard
  tests traced: 404, queries: 25736 (in setUp: 0)

  findings: 207

  * [error] n-plus-one — App\Tests\Controller\EntryControllerTest::testExport
    App\Entity\Entry::$tags — lazy-loaded association, 10 queries
    src/Entity/Entry.php:418

  * [warning] n-plus-one — App\Tests\Controller\EntryControllerTest::testSaveRates
    50 queries of the same shape from one place, different values: SELECT ...
    src/Repository/EntryRepository.php:810
      from src/Pricing/RateService.php:96 App\Repository\EntryRepository::findRates
      from src/Controller/EntryController.php:212 App\Pricing\RateService::calculate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Class names and paths replaced.) A legacy project needs a baseline, otherwise the first install produces hundreds of findings and the tool gets removed the same day. On the time tracker, 75 hits across 43 tests collapsed into 24 signatures, and the rerun came out clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven projects that were not mine
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The CMS: I blamed the wrong code
&lt;/h3&gt;

&lt;p&gt;There was one confirmed lazy load: the collection of file versions on a media item. The report showed only the place the query came from, an entity getter called from everywhere, so I filled in the gap myself and wrote in the issue that the N+1 happens when the API reads a list of media.&lt;/p&gt;

&lt;p&gt;The maintainer objected, and with good reason: on read, the needed file version is fetched by the same query through a JOIN. I reran the tests, saving the full stack of every query. Every lazy load happened on save, not on read: a PATCH request that adds or removes media on a contact creates an activity log event for each item, and each event loads the file versions with a query of its own.&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%2F4tuk09jvw8t76uey7yt8.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%2F4tuk09jvw8t76uey7yt8.png" alt="My guess in the issue is crossed out; in fact a contact PATCH creates an activity-log event per media item, and each loads file versions" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That mistake is why the summary now has &lt;code&gt;from&lt;/code&gt; lines: the chain of callers leading up to the query.&lt;/p&gt;

&lt;h3&gt;
  
  
  The time tracker: where 150 queries come from
&lt;/h3&gt;

&lt;p&gt;The full suite, 4343 tests and 38,103 queries: 1182 hits in 84 places, 9 of them with confirmed lazy loading. Three made it down to production code, and I filed an issue for each. The largest counters came from a fixture, so I measured the production bulk save path separately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entries in one &lt;code&gt;flush()&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Queries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exactly 4N + 5: one INSERT and three rate SELECTs per entry. Schematically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// onFlush subscriber&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;$scheduledEntries&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rateService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;calculate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// three separate SELECTs inside&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The online store on Pest: green and empty
&lt;/h3&gt;

&lt;p&gt;A Laravel project on Pest with 1477 tests. The run was green, but no report file appeared, and query-guard printed not a single line. I put &lt;code&gt;file_put_contents&lt;/code&gt; on the first line of the extension's &lt;code&gt;bootstrap()&lt;/code&gt;. The file appeared, so the extension was loading, but it quit on the very next line. Pest always adds &lt;code&gt;--no-output&lt;/code&gt; to the PHPUnit arguments, because it prints the output itself through Collision, and I had an early return on &lt;code&gt;$configuration-&amp;gt;noOutput()&lt;/code&gt;. For PHPUnit that flag means "the user asked for silence"; for Pest it means "PHPUnit is not the one printing".&lt;/p&gt;

&lt;p&gt;The same run exposed a second silent failure: under &lt;code&gt;pest --parallel&lt;/code&gt; every ParaTest worker wrote its report to the same file, and on a sample of 34 tests the report kept 7.&lt;/p&gt;

&lt;h3&gt;
  
  
  The music server: 1611 warnings for 1612 tests
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OK, but there were issues!
Tests: 1612, Assertions: 11785, PHPUnit Warnings: 1611.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel empties the container in &lt;code&gt;tearDown()&lt;/code&gt;, but until the next test's &lt;code&gt;setUp()&lt;/code&gt; the facade keeps pointing at the previous test's &lt;code&gt;DatabaseManager&lt;/code&gt;. The adapter asked &lt;code&gt;is_callable([$manager, 'listen'])&lt;/code&gt; and got &lt;code&gt;true&lt;/code&gt;, because the manager has &lt;code&gt;__call&lt;/code&gt;. The call went into an empty container. Rechecking the CRM on Pest showed the same exceptions there all along: Pest prints a single &lt;code&gt;WARN&lt;/code&gt; line with no counter, and among 42 tests I missed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why silence is worse than a crash
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkrt595sheowbrnwsekkz.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%2Fkrt595sheowbrnwsekkz.png" alt="Four silent failures: the Eloquent stub, Pest and --no-output, ParaTest and a shared report file, Laravel exceptions between tests" width="800" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A tool that crashes gets fixed the same day. A tool that stays silent gets removed a month later with "it never found anything useful", and nobody ever learns that it simply was not working.&lt;/p&gt;

&lt;p&gt;So the package's main rule is this: a green report and "we did not look" must not look the same. The summary tells you when no ORM was found or interception did not start, when not a single query arrived, when a plan rule cannot work on this platform, and when a third of the findings point into &lt;code&gt;vendor/&lt;/code&gt; or at fixtures.&lt;/p&gt;

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

&lt;p&gt;Requirements: PHP 8.2+, PHPUnit 10.5–13, Doctrine ORM 2–3 with DBAL 3–4 or Laravel 11+. The cost per query inside a test is about 0.006 ms and roughly 0.5 MB of memory per thousand queries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="nt"&gt;--dev&lt;/span&gt; alex-frolov/query-guard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;phpunit.xml&lt;/code&gt; wiring and the Doctrine setup are in the &lt;a href="https://github.com/alex-frolov/query-guard" rel="noopener noreferrer"&gt;README&lt;/a&gt;. Look past the color of the run and read the summary: how many tests were traced, how many queries, and which three places top the list.&lt;/p&gt;

&lt;p&gt;The full story, with plan parsing on MySQL and PostgreSQL and the JSON report for CI: &lt;a href="https://frolov.guru/en/writing/query-guard-n-plus-one/" rel="noopener noreferrer"&gt;frolov.guru/en/writing/query-guard-n-plus-one&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Aleksander Frolov, a senior/staff backend engineer building highload PHP systems. I write about architecture and performance on &lt;a href="https://frolov.guru/en/" rel="noopener noreferrer"&gt;frolov.guru&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>testing</category>
      <category>performance</category>
      <category>laravel</category>
    </item>
    <item>
      <title>One CAST made a view 13x slower in PostgreSQL. So I benchmarked MySQL 8.4 against PostgreSQL 17</title>
      <dc:creator>Aleksander Frolov</dc:creator>
      <pubDate>Mon, 14 Sep 2026 18:59:09 +0000</pubDate>
      <link>https://dev.to/aleksander_frolov/one-cast-made-a-view-13x-slower-in-postgresql-so-i-benchmarked-mysql-84-against-postgresql-17-11en</link>
      <guid>https://dev.to/aleksander_frolov/one-cast-made-a-view-13x-slower-in-postgresql-so-i-benchmarked-mysql-84-against-postgresql-17-11en</guid>
      <description>&lt;p&gt;At one interview I was asked about VIEWs. I answered honestly: in real projects I had barely run into them; for aggregates it is safer to keep a separate table. One of the interviewers said, “You understand nothing about VIEWs,” and everyone laughed.&lt;/p&gt;

&lt;p&gt;A lot of time has passed and the number of VIEWs in my code never grew, but the question stayed with me: what if things have changed? New engines have shipped. So I brought up MySQL 8.4.11 and PostgreSQL 17.11, loaded byte-for-byte identical data into both — a million orders, two million line items, 780 thousand payments — and ran the main scenarios one after another.&lt;/p&gt;

&lt;p&gt;The test rig is public and reproducible: &lt;a href="https://github.com/alex-frolov/mysql-postgresql-view-test" rel="noopener noreferrer"&gt;github.com/alex-frolov/mysql-postgresql-view-test&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here is what came out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;I measure server-side time: the root &lt;code&gt;actual time&lt;/code&gt; from &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; in MySQL, &lt;code&gt;Execution Time&lt;/code&gt; in PostgreSQL. Two warm-ups, seven measurements, take the median. Comparing absolute milliseconds across machines makes no sense; only ratios between identical queries matter. The query window is the same everywhere — June, merchant with ID 42.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple view costs nothing
&lt;/h2&gt;

&lt;p&gt;A wrapper over &lt;code&gt;orders&lt;/code&gt; with no aggregation runs at one and a half to two milliseconds in both engines, the spread stays within noise, and the plans match the direct query. MySQL folds the definition into the query (the MERGE algorithm), PostgreSQL expands it through the rewrite rule before the planner even sees it. The optimizer simply does not notice that you queried a view.&lt;/p&gt;

&lt;p&gt;A reusable filter and a stable read contract are free. Boring — and boring is the best case here.&lt;/p&gt;

&lt;h2&gt;
  
  
  One CAST, thirteen times more expensive
&lt;/h2&gt;

&lt;p&gt;An aggregating daily-revenue view:&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;VIEW&lt;/span&gt; &lt;span class="n"&gt;v_merchant_daily&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;orders_cnt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query on top is exactly what anyone would write — filter by merchant, filter by &lt;code&gt;day&lt;/code&gt;, order by &lt;code&gt;day&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;PostgreSQL returns it in 24.5 ms. The same result fetched directly from the table takes 1.85 ms. A thirteenfold difference out of nowhere.&lt;/p&gt;

&lt;p&gt;My first explanation was the standard one: “the aggregate computes before the filter, the engine groups the whole table.” I opened the plan and found out I was wrong. No full-table aggregation anywhere: both predicates get pushed below the grouping, exactly 469 rows are aggregated — the same as in the direct query.&lt;/p&gt;

&lt;p&gt;The difference sits in another line: &lt;code&gt;Rows Removed by Filter: 9531&lt;/code&gt;. The direct query uses the &lt;code&gt;(merchant_id, created_at)&lt;/code&gt; index on both columns. Through the view, the date condition arrives as an expression — &lt;code&gt;CAST(created_at AS DATE) &amp;gt;= '2026-06-01'&lt;/code&gt; — and an expression cannot serve as a range bound on the index. Only the merchant condition survives down to the index: all ten thousand of that merchant's orders get pulled out of the heap from scattered pages, and there the filter throws away 9531 of them. That comes to 8971 buffer accesses against 440.&lt;/p&gt;

&lt;p&gt;MySQL spends 4.85 ms against 2.16 ms in the same spot — a little over two times. It evaluates the CAST right inside the index (index condition pushdown), cutting candidate rows off before they ever leave the table.&lt;/p&gt;

&lt;p&gt;A subquery with the same text took 26.9 ms, a CTE took 24.6 ms. The problem is not &lt;code&gt;CREATE VIEW&lt;/code&gt;; it is the semantics. Any construct with the same grouping behaves the same way.&lt;/p&gt;

&lt;p&gt;The takeaway I keep: the view exposes a column called &lt;code&gt;day&lt;/code&gt; while the index lives on &lt;code&gt;created_at&lt;/code&gt;. It hands you an interface that looks like a table but cannot reach the index — silently. Ever since, whenever I see &lt;code&gt;CREATE VIEW ... GROUP BY&lt;/code&gt; in a pull request, I ask which columns it exposes and whether indexes exist underneath them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cascade that beat the handwritten query
&lt;/h2&gt;

&lt;p&gt;Three views stacked: a daily aggregate, a sum on top, a join with the reference table at the very top. Every level recomputes on every access. The obvious expectation: the deeper the stack, the worse.&lt;/p&gt;

&lt;p&gt;PostgreSQL confirmed it: 832 ms against 243 ms for the single-pass query.&lt;/p&gt;

&lt;p&gt;MySQL refuted it: the cascade took 1436 ms while the direct query took 2432 ms. The stack of three views was almost twice as fast as the handwritten single-pass report.&lt;/p&gt;

&lt;p&gt;The reason is the shape of the join, not the depth. The direct query joins orders with the reference table before grouping — a nested loop doing 750000 point lookups by primary key. In the cascade the join moves to the top, where after two aggregations only 75 rows remain out of 750 thousand.&lt;/p&gt;

&lt;p&gt;PostgreSQL's cascade loses parallelism, and its intermediate 48 thousand groups do not fit into the default 4 MB &lt;code&gt;work_mem&lt;/code&gt;: 30 MB spilled to disk per execution. Across nine runs the &lt;code&gt;temp_bytes&lt;/code&gt; counter grew by 278 MB — for a report that returns ten rows.&lt;/p&gt;

&lt;p&gt;Whether a cascade is expensive is a question of join shape and memory settings, not depth. You cannot predict it; you can only test it, and at production volumes.&lt;/p&gt;

&lt;h2&gt;
  
  
  ORDER BY inside a view is a time bomb
&lt;/h2&gt;

&lt;p&gt;A codebase classic: &lt;code&gt;CREATE VIEW ... ORDER BY created_at DESC&lt;/code&gt; — “so it’s definitely sorted”. Today both engines return sorted data instantly, reading the index backwards. Tomorrow the optimizer recalculates statistics, picks a different access path, and code that silently relied on ordering starts returning a random ten.&lt;/p&gt;

&lt;p&gt;The SQL standard does not guarantee row order for a view, and the MySQL documentation says so in plain text. Such a bug does not crash, does not log anything, and surfaces six months later as a user complaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where is the cache?
&lt;/h2&gt;

&lt;p&gt;Reading a materialized view with a unique index takes 0.24 ms against 24.5 ms for the live aggregate — a hundred times faster. Tempting to credit caching. But neither engine has a result cache: MySQL removed the Query Cache in 8.0, PostgreSQL never had one. Thirty repetitions of the same query produce a flat series — every run computes again.&lt;/p&gt;

&lt;p&gt;A materialized view is faster not because it caches, but because there is nothing left to compute. The price sits elsewhere: REFRESH on a million orders takes 1146 ms and is always full — no incremental refresh in PostgreSQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What reading does to writing
&lt;/h2&gt;

&lt;p&gt;Fifteen-second insert windows, batches of eight hundred rows. With two background readers hammering the aggregating view, write throughput dropped by 7% in MySQL and by 16% in PostgreSQL. Replacing the live view with a summary table maintained incrementally by the writer removed the drop entirely.&lt;/p&gt;

&lt;p&gt;A view itself does not get in the way of writes. A constant reader of a heavy aggregate does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that matters: scale
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Orders&lt;/th&gt;
&lt;th&gt;MySQL, view/direct&lt;/th&gt;
&lt;th&gt;PostgreSQL, view/direct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;1.6×&lt;/td&gt;
&lt;td&gt;2.7×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;2.3×&lt;/td&gt;
&lt;td&gt;10.7×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000,000&lt;/td&gt;
&lt;td&gt;2.5×&lt;/td&gt;
&lt;td&gt;50.4×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MySQL holds steady across the whole range — index condition pushdown works at any volume. The PostgreSQL gap grows with the data. And a summary table maintained by the writer does not depend on volume at all: 0.02–0.20 ms from ten thousand to ten million orders — thousands of times faster than a live view at the top end.&lt;/p&gt;

&lt;p&gt;Up to tens of thousands of rows, choosing between a live view and a summary table is a matter of taste. Closer to a million it becomes a matter of architecture. Past a million there is no choice left.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A simple view really consumes nothing; both engines look straight through it.&lt;/li&gt;
&lt;li&gt;An aggregating view in the hot path is a bad approach: its cost grows faster than the data.&lt;/li&gt;
&lt;li&gt;Check which columns a view exposes and whether indexes sit underneath.&lt;/li&gt;
&lt;li&gt;ORDER BY inside a view breeds silent bugs instead of convenience.&lt;/li&gt;
&lt;li&gt;A materialized view pays off only where reads greatly outnumber writes and data lag upsets nobody.&lt;/li&gt;
&lt;li&gt;A summary table with incremental maintenance is orders of magnitude cheaper and volume-independent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full walkthrough with query plans, memory measurements and the write-load scenarios: &lt;a href="https://frolov.guru/en/writing/mysql-postgresql-view-test/" rel="noopener noreferrer"&gt;frolov.guru/en/writing/mysql-postgresql-view-test&lt;/a&gt;. The rig reproduces from &lt;a href="https://github.com/alex-frolov/mysql-postgresql-view-test" rel="noopener noreferrer"&gt;github.com/alex-frolov/mysql-postgresql-view-test&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The list of niches I named at that interview has not changed by a single item. What changed is this: behind every item there is now a query plan instead of a habit.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Aleksander Frolov, a senior/staff backend engineer building highload PHP systems (Symfony, payments, auctions). I write about architecture and performance on &lt;a href="https://frolov.guru/en/" rel="noopener noreferrer"&gt;frolov.guru&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>sql</category>
    </item>
    <item>
      <title>A modern API reference for Symfony with Scalar</title>
      <dc:creator>Aleksander Frolov</dc:creator>
      <pubDate>Sun, 30 Aug 2026 07:21:55 +0000</pubDate>
      <link>https://dev.to/aleksander_frolov/a-modern-api-reference-for-symfony-with-scalar-1dh5</link>
      <guid>https://dev.to/aleksander_frolov/a-modern-api-reference-for-symfony-with-scalar-1dh5</guid>
      <description>&lt;p&gt;Symfony had no official integration with Scalar — the open-source API Reference renderer that makes Swagger UI look its age. Laravel got one back in 2024: &lt;code&gt;scalar/laravel&lt;/code&gt; has &lt;strong&gt;231,862 installs&lt;/strong&gt; on Packagist. Symfony got nothing — not a single package on Packagist, and zero in Scalar's official list of 30+ integrations (Express, FastAPI, NestJS, Spring Boot, Laravel — but no Symfony).&lt;/p&gt;

&lt;p&gt;I work with Symfony daily, so I closed the gap: the &lt;code&gt;alex-frolov/scalar-symfony&lt;/code&gt; bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bundle does
&lt;/h2&gt;

&lt;p&gt;It renders Scalar API Reference from &lt;strong&gt;any&lt;/strong&gt; OpenAPI document. One route, zero coupling to how the spec was generated: a static &lt;code&gt;openapi.yaml&lt;/code&gt;, swagger-php, NelmioApiDocBundle, or API Platform all work — because the bundle never parses or proxies the document; the page loads it client-side.&lt;/p&gt;

&lt;p&gt;Install and configure in two files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require alex-frolov/scalar-symfony
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/packages/scalar_symfony.yaml&lt;/span&gt;
&lt;span class="na"&gt;scalar_symfony&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/openapi.yaml'&lt;/span&gt;          &lt;span class="c1"&gt;# your OpenAPI document (required)&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/scalar'&lt;/span&gt;               &lt;span class="c1"&gt;# route (default: /scalar)&lt;/span&gt;
    &lt;span class="na"&gt;cdn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.65.1'&lt;/span&gt;

    &lt;span class="na"&gt;configuration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;default'&lt;/span&gt;
        &lt;span class="na"&gt;metaData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;API&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Reference'&lt;/span&gt;

    &lt;span class="na"&gt;scalar_options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;               &lt;span class="c1"&gt;# any Scalar option, passed through as is&lt;/span&gt;
        &lt;span class="na"&gt;darkMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;modern'&lt;/span&gt;

    &lt;span class="na"&gt;access_control&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public&lt;/span&gt;              &lt;span class="c1"&gt;# or 'attribute' + security attribute&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it — the reference lives at &lt;code&gt;/scalar&lt;/code&gt;. The config is serialized XSS-safely (JSON_HEX_TAG/APOS/AMP/QUOT), so even a malicious &lt;code&gt;title&lt;/code&gt; can't break out of the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation that works as a client
&lt;/h2&gt;

&lt;p&gt;The bundle serves the API reference of the Tender Platform (Symfony 8.1, highload auction API, OpenAPI 3.1 spec). Through Test Request I'm hitting &lt;code&gt;POST /auth/register&lt;/code&gt;, filling the JSON body, and pressing Send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP/&lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Created&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;794&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ms)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"company_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0c7702c6-9667-4ea3-8caa-df4990522ee7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"86a40d70-5ef9-44fd-882b-8f703e10df7e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verification_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real UUIDs, real latency, real backend: the docs page doubles as the tool you test the API with. That's what Swagger UI never delivered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality bar
&lt;/h2&gt;

&lt;p&gt;The first commit was small; the release survived a review against Symfony's official bundle best practices and external hardening passes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Functional tests:&lt;/strong&gt; 16 tests / 46 assertions — routes 200/403/404, config validation errors, XSS-escaping, real SecurityBundle integration;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static analysis:&lt;/strong&gt; PHPStan level max, 0 errors;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI matrix:&lt;/strong&gt; PHP 8.2/8.3/8.5 × Symfony 6.4/7.2/7.4/8.0, including &lt;code&gt;--prefer-lowest&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config hardening:&lt;/strong&gt; &lt;code&gt;attribute&lt;/code&gt; mode without Symfony Security fails &lt;code&gt;cache:clear&lt;/code&gt; at compile time with a clear message;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security docs:&lt;/strong&gt; SRI (SHA-384), CSP/nonce guidance, self-hosting recipe.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The CI war story: a token that didn't fit
&lt;/h2&gt;

&lt;p&gt;Half the GitHub Actions jobs failed with &lt;code&gt;Your github oauth token for github.com contains invalid characters&lt;/code&gt;. &lt;code&gt;setup-php&lt;/code&gt; writes the Actions &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; (prefixed &lt;code&gt;ghs_&lt;/code&gt;) into composer's global &lt;code&gt;auth.json&lt;/code&gt;, and Composer 2.8 only accepts &lt;code&gt;ghp_&lt;/code&gt;/&lt;code&gt;gho_&lt;/code&gt;/&lt;code&gt;github_pat_&lt;/code&gt;. The fix: delete &lt;code&gt;auth.json&lt;/code&gt; on the runner before &lt;code&gt;composer validate --no-check-publish&lt;/code&gt;, keep the token for dependency installation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it official
&lt;/h2&gt;

&lt;p&gt;I opened a proposal in the Scalar organization — &lt;strong&gt;Discussion #9920: "Proposal: official Symfony integration (scalar/symfony)"&lt;/strong&gt; — &lt;a href="https://github.com/scalar/scalar/discussions/9920" rel="noopener noreferrer"&gt;https://github.com/scalar/scalar/discussions/9920&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're a Symfony developer who wants modern API docs the way Laravel has them, a reaction on the discussion helps signal maintainer attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start tomorrow:&lt;/strong&gt; &lt;code&gt;composer require alex-frolov/scalar-symfony&lt;/code&gt; → point &lt;code&gt;scalar_symfony.url&lt;/code&gt; at any OpenAPI document → import routes and open &lt;code&gt;/scalar&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Aleksander Frolov — Senior/Staff PHP engineer, Symfony/Laravel, highload. &lt;a href="https://frolov.guru" rel="noopener noreferrer"&gt;frolov.guru&lt;/a&gt; · &lt;a href="https://github.com/alex-frolov/scalar-symfony" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>architecture</category>
      <category>openapi</category>
    </item>
    <item>
      <title>Idempotency and Retry in a Payment Core: Operations That Can't Be Duplicated and Can't Be Forgotten</title>
      <dc:creator>Aleksander Frolov</dc:creator>
      <pubDate>Sun, 16 Aug 2026 09:16:29 +0000</pubDate>
      <link>https://dev.to/aleksander_frolov/idempotency-and-retry-in-a-payment-core-operations-that-cant-be-duplicated-and-cant-be-forgotten-5ff7</link>
      <guid>https://dev.to/aleksander_frolov/idempotency-and-retry-in-a-payment-core-operations-that-cant-be-duplicated-and-cant-be-forgotten-5ff7</guid>
      <description>&lt;h1&gt;
  
  
  Idempotency and Retry in a Payment Core: Operations That Can't Be Duplicated and Can't Be Forgotten
&lt;/h1&gt;

&lt;p&gt;Build operations that can't be duplicated and can't be forgotten — that's the contract for a payment core.&lt;/p&gt;

&lt;p&gt;On the core I built for ROSSTRAFFY (20,000+ successful transactions/day, 99.99% uptime), the most dangerous failure class wasn't crashes. It was retries.&lt;/p&gt;

&lt;p&gt;The network doesn't guarantee delivery. A request can be processed, but the response lost — the client retries, and the server can't tell "didn't arrive" from "processed, but response lost". If a retry means "run the request again", you get double charges. If it means "resume the same operation", you get exactly one outcome per key.&lt;/p&gt;

&lt;p&gt;The contract: every payment happens exactly once — not 0, not 2 times. Zero means a lost payment and a missed discount window; two means double charges, refunds, disputes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approach: the payment core as a state machine
&lt;/h2&gt;

&lt;p&gt;A payment operation is not a "request" that either ran or didn't. It's a path with state: created, processing, completed, rejected, in reconciliation. Every transition must be idempotent — applying the same transition twice must not change the result.&lt;/p&gt;

&lt;p&gt;Idempotency lives at three levels, and skipping any one lets duplicates through:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;API level&lt;/strong&gt; — one idempotency key per business operation, generated once, surviving all retries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handler level&lt;/strong&gt; — the operation is a state machine; every transition checks the current state before acting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage level&lt;/strong&gt; — unique constraints as the last line of defense.&lt;/li&gt;
&lt;/ol&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%2Fp45vdvnrxd166rexpj84.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%2Fp45vdvnrxd166rexpj84.png" alt="Operation state machine: every transition is idempotent" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operation = state machine&lt;/strong&gt;: every transition idempotent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One idempotency key per business operation&lt;/strong&gt;, generated once, surviving all retries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency at three levels&lt;/strong&gt;: API, handler, storage (unique constraints as the last line of defense).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment = two operations&lt;/strong&gt;: hold (block funds) → capture (charge after the external system confirms), each with its own key and retry rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync leg&lt;/strong&gt;: bounded retries, exponential backoff, timeouts, jitter, dead-letter queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async leg (ГИС ГМП)&lt;/strong&gt;: queue + workers, load balancing across 5+ acquirers (Strategy pattern), failover on degradation → 99.8% acceptance reliability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recurring payments stay on the same acquirer&lt;/strong&gt; (confirmed token); switching only for new payments and emergencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt;: counters + latency per stage in Grafana, alerts on anomalies. "More retries, fewer successful operations" = retry is masking the problem, not fixing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliations as the second safety net&lt;/strong&gt; (ClickHouse: daily financial reports in 3–5 minutes instead of hours).&lt;/li&gt;
&lt;/ul&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%2Fup22vcxn1kfiqqaf0cu3.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%2Fup22vcxn1kfiqqaf0cu3.png" alt="Hold and capture: exactly-once contract" width="799" height="340"&gt;&lt;/a&gt;&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%2Fyy535uy1lyhr1wjhrcnt.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%2Fyy535uy1lyhr1wjhrcnt.png" alt="Async leg: queue, balancing, payment inspector, reconciliations" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation and results
&lt;/h2&gt;

&lt;p&gt;We validated with A/B tests behind feature flags on real traffic. Effects were fractions of a percent of conversion — only real payment statistics could see them; synthetic tests couldn't.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Erroneous and fraudulent operations&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−25%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment conversion&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+~10%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median transaction processing time&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−15%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;Idempotency isn't a nice-to-have in payments. It's the contract that makes retries safe at scale — and what turns "the user paid twice" into "the user paid once, and we can prove it".&lt;/p&gt;

&lt;p&gt;The full article with diagrams and code (EN): &lt;a href="https://frolov.guru/en/writing/idempotency-retry/" rel="noopener noreferrer"&gt;https://frolov.guru/en/writing/idempotency-retry/&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;About the author: Alexander Frolov — Senior/Staff Backend Engineer (PHP, highload) and Team Lead, 18+ years: payment cores, multi-tenant platforms at federal scale. Articles on highload PHP and architecture: &lt;a href="https://frolov.guru" rel="noopener noreferrer"&gt;frolov.guru&lt;/a&gt;. If you're fighting timeouts, duplicates or lost payments — that's an idempotency problem, and it's fixable. DM or &lt;a href="mailto:aleksander@frolov.guru"&gt;aleksander@frolov.guru&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>performance</category>
      <category>php</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
