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