<?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: OtezVikentiy</title>
    <description>The latest articles on DEV Community by OtezVikentiy (@otezvikentiy).</description>
    <link>https://dev.to/otezvikentiy</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%2F4046100%2Fb03a824c-f541-4d76-93b9-ee52f7c2fe05.png</url>
      <title>DEV Community: OtezVikentiy</title>
      <link>https://dev.to/otezvikentiy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/otezvikentiy"/>
    <language>en</language>
    <item>
      <title>Build a JSON-RPC 2.0 API in Symfony in 15 minutes: from composer require to OpenAPI</title>
      <dc:creator>OtezVikentiy</dc:creator>
      <pubDate>Tue, 11 Aug 2026 06:35:02 +0000</pubDate>
      <link>https://dev.to/otezvikentiy/build-a-json-rpc-20-api-in-symfony-in-15-minutes-from-composer-require-to-openapi-5bpk</link>
      <guid>https://dev.to/otezvikentiy/build-a-json-rpc-20-api-in-symfony-in-15-minutes-from-composer-require-to-openapi-5bpk</guid>
      <description>&lt;p&gt;REST works great while your API describes resources. But as soon as the domain becomes verb-shaped - &lt;code&gt;recalculateInvoice&lt;/code&gt;, &lt;code&gt;mergeAccounts&lt;/code&gt;, &lt;code&gt;assignTask&lt;/code&gt; - you end up bending verbs into nouns and arguing about which HTTP method cancels an order. JSON-RPC 2.0 cuts through all of that: every call is just &lt;code&gt;method&lt;/code&gt; + &lt;code&gt;params&lt;/code&gt;, one endpoint, a spec that fits on two pages, and batching out of the box.&lt;/p&gt;

&lt;p&gt;In this article we will build a working JSON-RPC 2.0 API on Symfony: a task tracker with DTO validation, batch requests and generated OpenAPI documentation. There is surprisingly little code to write: methods are declared with attributes, validation is derived from property types, and Swagger is generated by a console command.&lt;/p&gt;

&lt;p&gt;Everything below lives as a ready-to-run project on GitHub: &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-demo" rel="noopener noreferrer"&gt;symfony-jsonrpc-api-demo&lt;/a&gt; - clone it and poke it with curl while you read. We will use the &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle" rel="noopener noreferrer"&gt;otezvikentiy/json-rpc-api&lt;/a&gt; bundle (PHP 8.2-8.5, Symfony 6.4/7/8; this article uses PHP 8.4 and Symfony 7.4).&lt;/p&gt;

&lt;p&gt;Full disclosure: I am the author of the bundle. It has been running in production for three years - internal fintech tooling, an HRM system - nothing glamorous load-wise, but the correctness, logging and audit requirements were real, and they shaped most of what you will see below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer create-project symfony/skeleton:&lt;span class="s2"&gt;"7.4.*"&lt;/span&gt; tasks-api
&lt;span class="nb"&gt;cd &lt;/span&gt;tasks-api
composer require otezvikentiy/json-rpc-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Flex has contrib recipes enabled, the bundle registers itself. If not, it is two lines by hand:&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;// config/bundles.php&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="nc"&gt;OV\JsonRPCAPIBundle\OVJsonRPCAPIBundle&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'all'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&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;Wire up the route and a minimal config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/routes/ov_json_rpc_api.yaml&lt;/span&gt;
&lt;span class="na"&gt;ov_json_rpc_api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;@OVJsonRPCAPIBundle/config/routes/routes.yaml'&lt;/span&gt;
&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/ov_json_rpc_api.yaml&lt;/span&gt;
&lt;span class="na"&gt;ov_json_rpc_api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;access_control_allow_origin_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bundle registers a single route, &lt;code&gt;/api/v{version}&lt;/code&gt; - every request goes through it. Note the CORS list format: these are full origins, &lt;code&gt;scheme://host[:port]&lt;/code&gt;, exactly as the browser sends them in the &lt;code&gt;Origin&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;Check that it is alive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-S&lt;/span&gt; localhost:8000 &lt;span class="nt"&gt;-t&lt;/span&gt; public
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8000/api/v1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","method":"test","params":{},"id":1}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;-32601&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Method not found."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A well-formed JSON-RPC "method not found" error means the transport works. Now let's add methods.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first method: createTask
&lt;/h2&gt;

&lt;p&gt;A method in this bundle is three classes: a Request (what comes in), a Response (what goes out) and the method itself carrying the &lt;code&gt;#[JsonRPCAPI]&lt;/code&gt; attribute. No YAML manifests, no base controllers to extend.&lt;/p&gt;

&lt;p&gt;The Request describes the parameters. The rule is simple: constructor parameters are required, properties with setters are optional:&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;// src/RPC/V1/CreateTask/CreateTaskRequest.php&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\RPC\V1\CreateTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateTaskRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$assigneeEmail&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="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;string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getTitle&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;title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setTitle&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;$title&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getAssigneeEmail&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;assigneeEmail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setAssigneeEmail&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;$assigneeEmail&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;assigneeEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$assigneeEmail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Response is a plain class; whatever the class makes public - a public getter or a public property - ends up in the JSON. Promoted constructor properties keep it short:&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;// src/RPC/V1/CreateTask/CreateTaskResponse.php&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\RPC\V1\CreateTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateTaskResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$success&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;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&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;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$status&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;readonly&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$assigneeEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the method itself - an ordinary autowired service:&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;// src/RPC/V1/CreateTaskMethod.php&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\RPC\V1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\RPC\V1\CreateTask\CreateTaskRequest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\RPC\V1\CreateTask\CreateTaskResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Task\TaskStorage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;OV\JsonRPCAPIBundle\Core\Annotation\JsonRPCAPI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;OV\JsonRPCAPIBundle\Core\ApiMethodInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonRPCAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;methodName&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'createTask'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Create a task'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tasks'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateTaskMethod&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ApiMethodInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;TaskStorage&lt;/span&gt; &lt;span class="nv"&gt;$storage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;CreateTaskRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;CreateTaskResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$task&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;storage&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAssigneeEmail&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CreateTaskResponse&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="nv"&gt;$task&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="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;assigneeEmail&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TaskStorage&lt;/code&gt; here is a trivial JSON-file store (about 80 lines in the demo repo; in a real project a Doctrine repository takes its place - the method contract does not change). The API version is derived from the namespace: &lt;code&gt;App\RPC\V1&lt;/code&gt; -&amp;gt; &lt;code&gt;/api/v1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Call it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8000/api/v1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","method":"createTask","params":{"title":"Try the demo","assigneeEmail":"alice@example.com"},"id":1}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"success"&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Try the demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"todo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"assigneeEmail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"alice@example.com"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Validation you don't have to write
&lt;/h2&gt;

&lt;p&gt;The best part: the bundle builds the validator set itself, from the PHP types of the Request class. &lt;code&gt;private string $title&lt;/code&gt; in the constructor means "required, string". &lt;code&gt;?string $assigneeEmail&lt;/code&gt; with a setter means "optional, string or null". Since version 5.0 the type comparison is strict - no silent coercion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8000/api/v1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","method":"createTask","params":{"title":123},"id":4}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;-32602&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Invalid params. Additional info: [title] - This value should be of type string."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"123"&lt;/code&gt; will not quietly become a number, &lt;code&gt;1&lt;/code&gt; will not become &lt;code&gt;true&lt;/code&gt; - the client gets an immediate &lt;code&gt;-32602 Invalid params&lt;/code&gt; with a readable explanation. Your request contract is literally the PHP types of your DTO.&lt;/p&gt;

&lt;h2&gt;
  
  
  Domain errors: JRPCException
&lt;/h2&gt;

&lt;p&gt;For errors like "task not found" the bundle ships &lt;code&gt;JRPCException&lt;/code&gt; - a thrown exception becomes a proper JSON-RPC error object:&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;// src/RPC/V1/GetTaskMethod.php (excerpt)&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;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;GetTaskRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;GetTaskResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$task&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;storage&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&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;$task&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JRPCException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Task not found.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;JRPCException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SERVER_ERROR&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetTaskResponse&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="nv"&gt;$task&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="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;assigneeEmail&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;-32000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Task not found."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spec-defined codes (&lt;code&gt;-32700&lt;/code&gt;...&lt;code&gt;-32603&lt;/code&gt;) and the server range &lt;code&gt;[-32099, -32000]&lt;/code&gt; are validated by the exception itself - you cannot accidentally invent an invalid code. Everything else - any unexpected &lt;code&gt;Throwable&lt;/code&gt; - reaches the client as a generic &lt;code&gt;-32603 Internal error&lt;/code&gt;, while the full stack trace goes to the log only. No leaking file paths or class names to the outside world.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batches: N calls, one HTTP request
&lt;/h2&gt;

&lt;p&gt;This is a protocol feature rather than a bundle feature, but here it works out of the box - send an array of requests instead of a single object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8000/api/v1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'[{"jsonrpc":"2.0","method":"createTask","params":{"title":"One"},"id":1},
       {"jsonrpc":"2.0","method":"createTask","params":{"title":"Two"},"id":2},
       {"jsonrpc":"2.0","method":"listTasks","params":{},"id":3}]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is an array of three results, matched by &lt;code&gt;id&lt;/code&gt;. Where a REST client makes N round-trips (and the frontend shows N spinners), this is one request. The maximum batch size is capped by config (&lt;code&gt;max_batch_size&lt;/code&gt;), so a million-call batch DoS does not get through.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenAPI from the same source of truth
&lt;/h2&gt;

&lt;p&gt;The method attributes and DTO types are the single source of truth - and the OpenAPI 3.1 document is generated from them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/packages/ov_json_rpc_api.yaml (add this)&lt;/span&gt;
&lt;span class="na"&gt;ov_json_rpc_api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="na"&gt;swagger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;api_v1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;api_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
            &lt;span class="na"&gt;base_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;http://localhost:8000'&lt;/span&gt;
            &lt;span class="na"&gt;info&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;Tasks&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;JSON-RPC&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;API'&lt;/span&gt;
                &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Task&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tracker&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;built&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;JSON-RPC&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2.0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;OV_JSON_RPC_API_SWAGGER_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public/openapi/

php bin/console ov:swagger:generate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is &lt;code&gt;public/openapi/api_v1.yaml&lt;/code&gt; with schemas for every Request/Response - ready for Swagger UI, Postman or client SDK generation. The docs cannot drift away from the code, because they are made from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else is in the demo
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-demo" rel="noopener noreferrer"&gt;demo repo&lt;/a&gt; takes the same application further, and you can see the rest working there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the full CRUD&lt;/strong&gt; - &lt;code&gt;getTask&lt;/code&gt;, &lt;code&gt;listTasks&lt;/code&gt; with a status filter, &lt;code&gt;updateTask&lt;/code&gt;, &lt;code&gt;deleteTask&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API versioning&lt;/strong&gt;: a paginated &lt;code&gt;listTasks&lt;/code&gt; v2 lives at &lt;code&gt;/api/v2&lt;/code&gt; without touching v1 - it is just a second namespace, &lt;code&gt;App\RPC\V2&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sane default limits&lt;/strong&gt;: body size, JSON depth, batch size - all configurable, all covered by tests;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;request logging with masking&lt;/strong&gt; of sensitive fields by regex patterns (29 built-in patterns: password, token, card_number, ...);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;functional tests&lt;/strong&gt; through a regular &lt;code&gt;WebTestCase&lt;/code&gt; - 26 tests over the real endpoint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those deserves its own write-up - tell me in the comments which one to start with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The bundle: &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle" rel="noopener noreferrer"&gt;github.com/OtezVikentiy/symfony-jsonrpc-api-bundle&lt;/a&gt; - 768 tests, 99% coverage, a mutation-testing gate in CI, semver with an explicit BC policy.&lt;/li&gt;
&lt;li&gt;The demo: &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-demo" rel="noopener noreferrer"&gt;github.com/OtezVikentiy/symfony-jsonrpc-api-demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Questions and ideas: &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle/discussions" rel="noopener noreferrer"&gt;Discussions&lt;/a&gt;; confirmed bugs: Issues.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback is welcome - including the harsh kind. This project has learned a lot from it.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>My idle ClickHouse was merging 11 million rows every 30 seconds</title>
      <dc:creator>OtezVikentiy</dc:creator>
      <pubDate>Fri, 24 Jul 2026 21:12:14 +0000</pubDate>
      <link>https://dev.to/otezvikentiy/my-idle-clickhouse-was-merging-11-million-rows-every-30-seconds-2d4i</link>
      <guid>https://dev.to/otezvikentiy/my-idle-clickhouse-was-merging-11-million-rows-every-30-seconds-2d4i</guid>
      <description>&lt;p&gt;I run a small self-hosted observability tool on the cheapest VPS I could find on purpose: &lt;strong&gt;2 cores, 2 GB RAM, 20 GB SATA SSD&lt;/strong&gt;. It ingests errors, traces and metrics from two low-traffic sites of mine. The stack is three containers — a Go app, PostgreSQL, and ClickHouse.&lt;/p&gt;

&lt;p&gt;One evening &lt;code&gt;docker stats&lt;/code&gt; showed ClickHouse sitting on &lt;strong&gt;880 MB of its 1 GB limit&lt;/strong&gt; and the box swapping, with basically zero events coming in. So I went looking for where the memory and disk had gone. The answer turned out to be a good lesson in how a database can spend almost all of its I/O talking to itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  543 KB of my data, 579 MB of ClickHouse talking about ClickHouse
&lt;/h2&gt;

&lt;p&gt;First thing I checked: how much data had my app actually stored versus how much ClickHouse had stored about &lt;em&gt;itself&lt;/em&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My application database: &lt;strong&gt;543 KB, 16k rows&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;system&lt;/code&gt; database: &lt;strong&gt;579 MB, 46.3M rows&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roughly a thousand to one. Disk was &lt;strong&gt;12 GB used out of 20&lt;/strong&gt; — on a tool that had recorded half a megabyte of real telemetry.&lt;/p&gt;

&lt;p&gt;The culprit was ClickHouse's own system logs, several of which &lt;strong&gt;have no TTL by default&lt;/strong&gt; and therefore grow forever:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;trace_log&lt;/code&gt; — 404 MB, 26M rows (the query profiler writes here; it's on by default, sampling once per second)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;asynchronous_metric_log&lt;/code&gt; — 16.6M rows&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;text_log&lt;/code&gt; — 132 MB&lt;/li&gt;
&lt;li&gt;plus &lt;code&gt;query_log&lt;/code&gt;, &lt;code&gt;latency_log&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only &lt;code&gt;metric_log&lt;/code&gt;, &lt;code&gt;processors_profile_log&lt;/code&gt; and &lt;code&gt;part_log&lt;/code&gt; ship with a TTL. Everything else just accumulates.&lt;/p&gt;

&lt;p&gt;Then I looked at the insert rate over 30 seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;trace_log&lt;/code&gt; — 227 rows/s&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;asynchronous_metric_log&lt;/code&gt; — 157 rows/s&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;text_log&lt;/code&gt; — 44 rows/s&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;my application — about 5 rows/s&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;98.8% of all inserts were ClickHouse narrating its own internals.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that's expensive beyond disk
&lt;/h2&gt;

&lt;p&gt;Here's the number that made me stop. Over the same 30 seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rows &lt;strong&gt;inserted&lt;/strong&gt;: 16,222&lt;/li&gt;
&lt;li&gt;rows &lt;strong&gt;merged&lt;/strong&gt;: 11,007,643&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a &lt;strong&gt;1 : 678&lt;/strong&gt; ratio. For every row written, the engine rewrote 678 already-sitting rows.&lt;/p&gt;

&lt;p&gt;The mechanics: MergeTree drops every insert into its own data part, then merges parts into bigger ones so reads stay fast. When the table is small this is cheap. But when a table holds 26M rows and the inserts are tiny and constant, each successive merge drags along more and more already-written data. In the limit, the engine spends most of its I/O shuffling old rows around, not storing new ones. That was my "60% disk busy, idle app" mystery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hypothesis that was wrong
&lt;/h2&gt;

&lt;p&gt;I was sure I had it: the bloated &lt;code&gt;trace_log&lt;/code&gt; drives the merges, the merges burn CPU, and the profiler samples the merge threads too — a nice closed loop. Test: measure CPU, &lt;code&gt;TRUNCATE TABLE system.trace_log&lt;/code&gt;, measure again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CPU didn't drop.&lt;/strong&gt; ~69% before, ~81% after.&lt;/p&gt;

&lt;p&gt;Honest caveat: my "after" window opened 60 seconds after deleting 400 MB, and part cleanup itself loads the machine, so some of that rise could be the TRUNCATE. But the point stood — one table didn't explain it.&lt;/p&gt;

&lt;p&gt;So I disabled the heavy logs entirely and re-measured. Merges collapsed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;merged rows / 30s: &lt;strong&gt;11,007,643 → 5,727&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;inserted rows / 30s: &lt;strong&gt;16,222 → 35&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;disk: &lt;strong&gt;2.7 GB freed&lt;/strong&gt; out of 20&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And CPU… barely moved: 69% → 61%.&lt;/p&gt;

&lt;p&gt;The conclusion I had to accept: the system logs were a &lt;strong&gt;real disk and I/O problem&lt;/strong&gt;, but they were &lt;strong&gt;not the CPU cause&lt;/strong&gt;. Two separate symptoms I'd carelessly glued into one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap I set for myself
&lt;/h2&gt;

&lt;p&gt;While writing this up I'd claimed ClickHouse was "eating half the machine." It wasn't, and the mistake is a common one: &lt;strong&gt;&lt;code&gt;docker stats&lt;/code&gt; reports CPU as a percentage of one core, not the whole machine.&lt;/strong&gt; 60% in &lt;code&gt;docker stats&lt;/code&gt; on a 2-core box is ~30% of the box. &lt;code&gt;ps&lt;/code&gt; on the host confirmed it: 51.4% across two cores. If you debug container load, always cross-check against the host — it's easy to double your own problem on paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moved the needle
&lt;/h2&gt;

&lt;p&gt;Disabling the heavy logs, plus tuning for a small machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;asynchronous_metrics_update_period_s&lt;/code&gt;: 1 → 60&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;metric_log&lt;/code&gt;: collect 1s → 30s, flush 7.5s → 60s, 3-day TTL&lt;/li&gt;
&lt;li&gt;background pools: &lt;code&gt;background_schedule_pool_size&lt;/code&gt; &lt;strong&gt;128 → 8&lt;/strong&gt; (the default targets many-core servers), &lt;code&gt;background_pool_size&lt;/code&gt; 4, the rest at 2&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mark_cache_size&lt;/code&gt;: &lt;strong&gt;5 GiB → 256 MiB&lt;/strong&gt;. Yes — the default mark cache is five times the whole container's memory limit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result, averaged over 15 samples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Container memory&lt;/td&gt;
&lt;td&gt;842–920 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;256 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host memory used&lt;/td&gt;
&lt;td&gt;1043 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;779 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load average&lt;/td&gt;
&lt;td&gt;1.15&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.79&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk used&lt;/td&gt;
&lt;td&gt;12 GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.2 GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The big win is memory: −66%. ClickHouse had been living at 85–90% of its cgroup limit and would OOM on any spike; now it sits at ~25% with real headroom. The mark cache is most of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake that took prod down
&lt;/h2&gt;

&lt;p&gt;Worth telling, because it's the one that stings. I applied the first tuning config &lt;strong&gt;straight to the live server without testing it locally&lt;/strong&gt;. ClickHouse runs a sanity check at startup: &lt;code&gt;number_of_free_entries_in_pool_to_execute_mutation&lt;/code&gt; (default 20) must not exceed &lt;code&gt;background_pool_size × background_merges_mutations_concurrency_ratio&lt;/code&gt;. I'd set &lt;code&gt;background_pool_size=4&lt;/code&gt;, the product was 8, the check failed → &lt;code&gt;BAD_ARGUMENTS&lt;/code&gt; → the server refused to start. Monitoring was down for a few minutes.&lt;/p&gt;

&lt;p&gt;The bug wasn't the number. It was the order of operations. The fix — which is also how I recovered — is to boot a throwaway container of the same version with the config locally, confirm it starts, &lt;em&gt;then&lt;/em&gt; ship. It takes thirty seconds, which is less than a prod rollback. The rollback, thankfully, was clean: a &lt;code&gt;.bak&lt;/code&gt; next to the file, restored in 8 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't tune your defaults down to the minimum
&lt;/h2&gt;

&lt;p&gt;When I brought these settings into the project repo, someone reasonably asked: won't they hurt a 10-core / 10 GB server? Yes, they would. I checked all eleven settings; only three are universal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;random_page_cost=1.1&lt;/code&gt; and &lt;code&gt;effective_io_concurrency=200&lt;/code&gt; — those are facts about SSDs, not about machine size&lt;/li&gt;
&lt;li&gt;a TTL on the system logs — unbounded growth is bad on any hardware&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other eight actively hurt a big box: &lt;code&gt;background_pool_size=4&lt;/code&gt; caps merge parallelism and invites "too many parts", a shrunken &lt;code&gt;mark_cache_size&lt;/code&gt; adds disk reads, &lt;code&gt;shared_buffers=64MB&lt;/code&gt; is absurd at 10 GB, and so on. So the config ended up in two layers — a base compose file with only the universal settings, and an opt-in overlay for constrained machines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.small.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I took away: &lt;strong&gt;if a setting is proportional to resources, it doesn't belong in your default config.&lt;/strong&gt; Defaults should only hold what's true on 2 cores and on 20.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;None of this is really about my project — it's about stock database defaults. ClickHouse out of the box assumes it has many cores, plenty of RAM, and that nobody's counting disk. When that isn't true, you can hand back a couple of gigabytes of disk and two-thirds of your memory with one config file. Just test it locally first.&lt;/p&gt;

&lt;p&gt;The tool I was measuring is &lt;a href="https://github.com/OtezVikentiy/gotcha" rel="noopener noreferrer"&gt;github.com/OtezVikentiy/gotcha&lt;/a&gt; — Go, self-hosted, speaks the Sentry SDK protocol and OTLP. Everything above is reproducible: the configs are in the repo and the box is described up top.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>devops</category>
      <category>database</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
