<?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%2Fd4ebb7d3-41ec-4626-ad19-bbc09d386b73.jpg</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>How to upload a file over JSON-RPC, when JSON has no type for a file</title>
      <dc:creator>OtezVikentiy</dc:creator>
      <pubDate>Wed, 19 Aug 2026 06:40:00 +0000</pubDate>
      <link>https://dev.to/otezvikentiy/how-to-upload-a-file-over-json-rpc-when-json-has-no-type-for-a-file-5g76</link>
      <guid>https://dev.to/otezvikentiy/how-to-upload-a-file-over-json-rpc-when-json-has-no-type-for-a-file-5g76</guid>
      <description>&lt;p&gt;JSON has no representation for a file. Strings, numbers, arrays, objects - that is the whole list. So every JSON-RPC API eventually runs into the same question: how do you accept a file upload - a photo, a scan, a PDF - when the protocol itself cannot carry binary data?&lt;/p&gt;

&lt;p&gt;The usual answer is: you don't. The file goes to a separate, ordinary controller that reads &lt;code&gt;$request-&amp;gt;files&lt;/code&gt;, and the JSON-RPC layer handles everything else next to it. And now you have exactly the ad hoc endpoint sprawl that JSON-RPC was supposed to remove.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle" rel="noopener noreferrer"&gt;otezvikentiy/json-rpc-api&lt;/a&gt; 5.2 there is a different answer. And more interesting than the feature is how it came about: I did not write it - an external contributor did. But first things first.&lt;/p&gt;

&lt;p&gt;Full disclosure: I am the author of the bundle, and I have maintained it alone for almost three years. That is exactly why a release whose headline feature was written by someone else feels like a different kind of event to me.&lt;/p&gt;

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

&lt;p&gt;Straight from &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle/issues/8" rel="noopener noreferrer"&gt;issue #8&lt;/a&gt;: two services exchange scanned images plus structured metadata (tenant, station, session) on the same call - something like &lt;code&gt;captures.create(tenantId, stationId, image)&lt;/code&gt;. Today &lt;code&gt;image&lt;/code&gt; cannot be expressed as a parameter of a JSON-RPC method, so that call has to live outside the bundle as a separate multipart controller.&lt;/p&gt;

&lt;p&gt;What you want is for the method to simply declare a parameter of type &lt;code&gt;UploadedFile&lt;/code&gt; and get the file, like any other parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solution: multipart as a transport adapter
&lt;/h2&gt;

&lt;p&gt;The key idea is to leave the core untouched. A &lt;code&gt;multipart/form-data&lt;/code&gt; request is normalized into the very same JSON-RPC envelope an ordinary request produces, only with &lt;code&gt;UploadedFile&lt;/code&gt; objects already sitting inside &lt;code&gt;params&lt;/code&gt;. Everything below the transport - hydration, batching, validation - stays completely unaware of multipart, exactly the way it is unaware that a GET request's payload came from a query string.&lt;/p&gt;

&lt;p&gt;The wire format: one text part named &lt;code&gt;jsonrpc&lt;/code&gt; carries the full JSON-RPC envelope as a string (all scalar parameters live inside it), and every other part is a file, its part name being the parameter name.&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;-X&lt;/span&gt; POST http://localhost/api/v1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'jsonrpc={"jsonrpc":"2.0","method":"captures.create","params":{"tenantId":"t-1"},"id":1}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'image=@scan.png'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The method declares the parameter as an ordinary DTO property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\File\UploadedFile&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;Request&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;$tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?UploadedFile&lt;/span&gt; &lt;span class="nv"&gt;$image&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;getTenantId&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;tenantId&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;setTenantId&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;$tenantId&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;tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&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;getImage&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?UploadedFile&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;image&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;setImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?UploadedFile&lt;/span&gt; &lt;span class="nv"&gt;$image&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;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$image&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 reads it in the handler as a real &lt;code&gt;UploadedFile&lt;/code&gt; - with &lt;code&gt;move()&lt;/code&gt;, &lt;code&gt;getClientOriginalName()&lt;/code&gt;, the whole Symfony surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three decisions worth explaining
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scalars do not become form fields.&lt;/strong&gt; The temptation was to spread every parameter across separate form fields. But a form field is a string, and then &lt;code&gt;"42"&lt;/code&gt; and &lt;code&gt;42&lt;/code&gt; become indistinguishable again - the untyped-transport ambiguity the bundle deliberately tolerates only for GET, where a query string leaves no choice. POST has types, and losing them is not worth it. So scalars stay in the JSON envelope, and only files travel as form parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is switched on twice.&lt;/strong&gt; One switch is &lt;code&gt;multipart.enabled&lt;/code&gt; for the application, the other is &lt;code&gt;acceptsMultipart: true&lt;/code&gt; on the method's attribute. This is not belt-and-suspenders: the &lt;code&gt;Content-Type&lt;/code&gt; is checked before any method is known, so the global switch alone cannot say anything about a particular method. And turning the transport on for the application while silently opening it to every method already written - none of which expected it - is the wrong default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation is Symfony's own, not hand-rolled.&lt;/strong&gt; A declared &lt;code&gt;UploadedFile&lt;/code&gt; compiles to &lt;code&gt;Assert\Type&lt;/code&gt; followed by &lt;code&gt;Assert\File&lt;/code&gt;, through the same machinery that produces &lt;code&gt;Assert\Type('int')&lt;/code&gt; for an &lt;code&gt;int&lt;/code&gt; field. The size limit (&lt;code&gt;multipart.max_file_bytes&lt;/code&gt;, in Symfony's own notation such as &lt;code&gt;'10Mi'&lt;/code&gt;) is enforced by &lt;code&gt;Assert\File&lt;/code&gt;, and it brings the handling of all eight PHP upload error codes with it. A failed upload (&lt;code&gt;upload_max_filesize&lt;/code&gt; exceeded, a partial transfer, no temp directory) comes back as &lt;code&gt;-32602&lt;/code&gt; naming the field, rather than as an unusable &lt;code&gt;UploadedFile&lt;/code&gt; reaching the method.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest about security
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;multipart/form-data&lt;/code&gt; is a CORS "simple request", exactly like form-encoded. And the mandatory &lt;code&gt;Content-Type: application/json&lt;/code&gt; introduced in 5.0 was precisely what closed that CSRF vector. So enabling multipart reopens it - but for the methods that declare &lt;code&gt;acceptsMultipart: true&lt;/code&gt;, and only those.&lt;/p&gt;

&lt;p&gt;The bundle does not pretend it solved this for you. The docs warn loudly: before switching it on, make sure at least one of these holds for the affected methods - authentication does not travel in cookies (a header token is not CORS-safelisted), or the session cookie is &lt;code&gt;SameSite=Lax/Strict&lt;/code&gt;, or the method checks a CSRF token. The two-level opt-in limits the blast radius; the rest is the application's decision, not the bundle's.&lt;/p&gt;

&lt;p&gt;The first-version limits are stated plainly too: batch stays JSON-only, files at the top level of &lt;code&gt;params&lt;/code&gt; only, POST only.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it came about - and why that matters more than the feature
&lt;/h2&gt;

&lt;p&gt;I did not write this code. It started with &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle/issues/8" rel="noopener noreferrer"&gt;issue #8&lt;/a&gt;: &lt;a href="https://github.com/tacman" rel="noopener noreferrer"&gt;tacman&lt;/a&gt; described the problem, proposed two shapes (a minimal one and one modeled on the GraphQL multipart spec), mapped out honestly where it would touch hydration, and asked for direction before writing anything. We settled the shape in the comments. A few days later came &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle/pull/9" rel="noopener noreferrer"&gt;PR #9&lt;/a&gt;: seven commits, a green CI across the whole matrix including the coverage and mutation-testing gates, plus a branch on the demo application so the feature could be run rather than only read.&lt;/p&gt;

&lt;p&gt;In a couple of places his solution was better than the sketch in the issue - in particular compiling &lt;code&gt;Assert\File&lt;/code&gt; from the config, which I had not planned. The review took one pass: run his branch locally (822 tests, Infection MSI above the gate, PHPStan and cs-fixer clean), read the whole diff, and leave a few non-blocking notes. Merging was not scary.&lt;/p&gt;

&lt;p&gt;For a maintainer who carried the project alone for three years, a first serious external PR - a careful one, with tests and a demo - is worth more than any number of stars. It is the first sign that something living is forming around the project, and probably the best outcome one could hope for when opening the source. Thank you, tacman.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require otezvikentiy/json-rpc-api:^5.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The full release: &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle/releases/tag/5.2-stable" rel="noopener noreferrer"&gt;5.2 on GitHub&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Feature docs (wire format, config, the error catalogue, and the patterns for cases outside its scope - base64 for small payloads, a two-step upload for large ones): &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle/blob/master/docs/multipart.md" rel="noopener noreferrer"&gt;docs/multipart.md&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The demo project where it all works together: &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-demo" rel="noopener noreferrer"&gt;symfony-jsonrpc-api-demo&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&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;. Questions and ideas in &lt;a href="https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle/discussions" rel="noopener noreferrer"&gt;Discussions&lt;/a&gt;, bugs in Issues. As this story shows, a good issue sometimes turns into a feature - feedback of any kind is welcome.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>api</category>
      <category>opensource</category>
    </item>
    <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>
