<?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>Forem: Jozef Môstka</title>
    <description>The latest articles on Forem by Jozef Môstka (@tito10047).</description>
    <link>https://forem.com/tito10047</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3024997%2Fb87cea6a-ffde-4026-9f9c-1b3535b8d1fe.jpg</url>
      <title>Forem: Jozef Môstka</title>
      <link>https://forem.com/tito10047</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/tito10047"/>
    <language>en</language>
    <item>
      <title>Symfony Messenger: A Great Servant, But a Terrible Master (Or How Asynchrony Cost Me Half My Beard)</title>
      <dc:creator>Jozef Môstka</dc:creator>
      <pubDate>Thu, 23 Apr 2026 11:22:07 +0000</pubDate>
      <link>https://forem.com/tito10047/symfony-messenger-a-great-servant-but-a-terrible-master-or-how-asynchrony-cost-me-half-my-beard-3k5c</link>
      <guid>https://forem.com/tito10047/symfony-messenger-a-great-servant-but-a-terrible-master-or-how-asynchrony-cost-me-half-my-beard-3k5c</guid>
      <description>&lt;p&gt;I am a long-time Symfony developer. I love the framework. I use it in countless projects—whether they are large corporate systems, smaller work tasks, or my own private pet projects. When the Symfony Messenger component was introduced years ago, I instantly fell in love with it and deployed it pretty much everywhere.&lt;/p&gt;

&lt;p&gt;Everything worked like a Swiss watch. At least, that’s what I thought... until recently.&lt;/p&gt;

&lt;p&gt;This article is a confession about how I spent three months solving two seemingly completely different, critical issues that ultimately shared one silent killer: untamed asynchrony. And also about how I tore my hair out and lost half my beard while debugging, all while my colleagues were cursing me.&lt;/p&gt;

&lt;p&gt;Case One: The Mystery of the Evening Attachment Disappearances&lt;/p&gt;

&lt;p&gt;We have a robust ticketing system. It works simply: a user creates a ticket and adds attachments. To avoid making the user wait for the upload, the attachments are processed asynchronously via Messenger, which moves them to Azure folders in the background.&lt;/p&gt;

&lt;p&gt;Three months ago, the first report came in: "Sometimes, an attachment gets lost after creating a ticket."&lt;/p&gt;

&lt;p&gt;When you looked at the data, you'd find an interesting anomaly. It mostly happened in the evening hours, around 9 PM. The cat-and-mouse game began. We spent a long time examining the code, going through the frontend JavaScript, but we found nothing. So we started adding logging. When that didn't help, we added more logs. And even more.&lt;/p&gt;

&lt;p&gt;Finally, after weeks of frustration, we found the crime scene. In our Message Handler, we had this innocent-looking piece of code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;
&lt;span class="nv"&gt;$forum&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;emRds&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Forum&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getForumId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$forum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Forum not found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s2"&gt;"id"&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getForumTicketFileId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s2"&gt;"forumId"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getForumId&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Here is the silent killer&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why was it there? A protective mechanism. If someone deleted the forum/ticket in the meantime, we didn't want the handler trying to assign files to a non-existent record and throwing exceptions (crashing). Logical, right?&lt;/p&gt;

&lt;p&gt;But what was actually happening at 9 PM?&lt;br&gt;
At 9 PM, almost nobody is working in the system. The server is completely idle and has plenty of free resources. When someone created a ticket at that time, Messenger processed the Message instantly. And by instantly, I mean so brutally fast that the handler spun up and finished before the original web request even had time to complete and commit the database transaction with the new forum!&lt;/p&gt;

&lt;p&gt;The handler was looking for the forum in the database, but it physically wasn't there yet (the transaction wasn't closed). So the code said to itself: "Ah, the forum doesn't exist, someone probably deleted it. OK, I'm discarding the attachment and exiting." By the time I figured this out, my colleagues—who had spent three months listening to client complaints about lost files—wanted to defenestrate me.&lt;/p&gt;

&lt;p&gt;Case Two: The Phantom of the Inventory Documents&lt;/p&gt;

&lt;p&gt;As if that weren't enough, I was running into another problem in parallel, in a completely different part of the system.&lt;/p&gt;

&lt;p&gt;We process inventory documents. Our CRON connects to a good old Windows SFTP server, downloads an XML file, and sends it (guess via what)—yes, via Messenger—for asynchronous processing.&lt;/p&gt;

&lt;p&gt;Once again, a bug was reported: "We are losing inventory documents!"&lt;br&gt;
I look into our monitoring system... no errors, no failed messages. Everything was flashing green.&lt;/p&gt;

&lt;p&gt;Round two of the cat-and-mouse game began. I looked for bugs in the code. I gradually added logs. Logging these kinds of asynchronous errors is pure hell. You always forget to put a log exactly where it's needed. So you add it two days later, deploy, and wait for the error to reproduce. In the meantime, you forget about the task because you have a million other things to do, and two weeks later you realize: "Oh, the code can also branch over here, I need to add a log here too."&lt;/p&gt;

&lt;p&gt;What was the resolution?&lt;br&gt;
We were connecting to a free version of a SolarWinds SFTP server. This version had one funny limitation: it allowed only one single active connection at any given time. There was no log about this on the server.&lt;/p&gt;

&lt;p&gt;My PHP script tried to connect, but if another process was already holding the connection, the SFTP silently rejected it. And what did my code do in the Handler?&lt;br&gt;
To be "defensive," before processing, I double-checked via SFTP just to be sure: "Does this XML file still exist on the server?" Since the connection failed due to the limit, the function returned that the file wasn't there.&lt;/p&gt;

&lt;p&gt;And my logic? "Ah, the file isn't there. Probably some other system or an external person deleted it. That's a legitimate use-case. I'm discarding the message, return null."&lt;/p&gt;

&lt;p&gt;BOOM. Another critical bug, another three months of stress, lost documents, and my beard half as thick. Basically, it was the exact same mental lapse as the first problem: I ignored a missing resource because I assumed a conscious user action.&lt;/p&gt;

&lt;p&gt;The Common Denominator and the Solution&lt;/p&gt;

&lt;p&gt;Both problems were of the exact same nature. In both cases, I created a graceful fallback for a situation I thought was a user-caused edge-case (deleting a record). I never dreamed that one problem was caused by excessive server speed and the other by the technical limits of a 1-connection SFTP.&lt;/p&gt;

&lt;p&gt;How did I solve it? Very simply.&lt;/p&gt;

&lt;p&gt;In both cases, I just turned off asynchrony and let them process synchronously.&lt;/p&gt;

&lt;p&gt;For the first problem with the attachments, it could have been solved using a DelayStamp (so the message waits a bit for the DB transaction to complete), but we needed the attachment to be available in the system immediately after the page refresh. Synchronous processing solved that perfectly.&lt;/p&gt;

&lt;p&gt;The second case with the SFTP couldn't be solved any other way. Since the server allowed only one connection, parallelization and asynchrony were a bad idea from the very beginning.&lt;/p&gt;

&lt;p&gt;Final Takeaways&lt;/p&gt;

&lt;p&gt;Symfony Messenger is a truly great servant. It can incredibly speed up application response times and tuck heavy logic away into the background. But it is a damn fast and powerful tool.&lt;/p&gt;

&lt;p&gt;If you hand it code that silently "swallows" Not Found states without accounting for Race conditions or the limits of external infrastructure, get ready to spend long nights staring at empty logs.&lt;/p&gt;

&lt;p&gt;And if you ever meet me and notice I'm missing a chunk of my beard... at least now you know why. Blame it on asynchrony.&lt;/p&gt;

&lt;p&gt;Do you have similar horror stories with asynchronous processing? Share them in the comments so I know I'm not alone in this!&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Symfony Asset Mapper: How to Finally Test JavaScript Properly Without the Pain</title>
      <dc:creator>Jozef Môstka</dc:creator>
      <pubDate>Mon, 20 Apr 2026 14:26:32 +0000</pubDate>
      <link>https://forem.com/tito10047/symfony-asset-mapper-how-to-finally-test-javascript-properly-without-the-pain-290f</link>
      <guid>https://forem.com/tito10047/symfony-asset-mapper-how-to-finally-test-javascript-properly-without-the-pain-290f</guid>
      <description>&lt;p&gt;You know the drill. Symfony Asset Mapper is a great tool. You've gotten rid of Webpack, npm install, and complex build processes. Everything is fast, clean, and modern. And then comes the fateful question: &lt;strong&gt;"And how do we actually test this?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then comes the reality check. Asset Mapper works on the principle of &lt;code&gt;importmap.php&lt;/code&gt;, which your Node.js (and thus most test runners) has no clue about. You try to run a test and you get: &lt;code&gt;ERR_MODULE_NOT_FOUND&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Many people just wave it off and say that testing Asset Mapper is simply impossible, or you have to switch back to a complex frontend stack. But what if I told you there's an elegant solution that bridges both worlds?&lt;/p&gt;

&lt;h3&gt;
  
  
  The "Aha!" moment: Symlinks as a bridge
&lt;/h3&gt;

&lt;p&gt;My idea was simple: Node.js expects libraries in the &lt;code&gt;node_modules&lt;/code&gt; folder. Symfony has them in &lt;code&gt;assets/vendor/&lt;/code&gt; or in &lt;code&gt;vendor/&lt;/code&gt; (for Stimulus bundles). So why not force Node.js to see what Symfony sees, without having to duplicate anything or "hack" the imports?&lt;/p&gt;

&lt;p&gt;The solution is a small PHP script that reads your import map and creates a symlink structure in &lt;code&gt;node_modules&lt;/code&gt;. Node.js will think everything is installed, while in reality, it will be reading the same files your browser uses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The PHP script that does the magic
&lt;/h3&gt;

&lt;p&gt;Here is a simplified version of our "linker" script. It uses the Symfony &lt;code&gt;Filesystem&lt;/code&gt; component for safe file manipulation.&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;// bin/setup-js-tests.php&lt;/span&gt;
&lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/../vendor/autoload.php'&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;Symfony\Component\Filesystem\Filesystem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$projectRoot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__DIR__&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$importmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;require&lt;/span&gt; &lt;span class="nv"&gt;$projectRoot&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/importmap.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Filesystem&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$nodeModules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$projectRoot&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/node_modules'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$fs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nodeModules&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nv"&gt;$fs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nodeModules&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$importmap&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$targetDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$nodeModules&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$sourcePath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'path'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; 
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$projectRoot&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;ltrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'path'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'./'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$projectRoot&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/assets/vendor/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$fs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sourcePath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$fs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$targetDir&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="nv"&gt;$fs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$targetDir&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="nb"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sourcePath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$fs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;symlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sourcePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$targetDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// If it's a single file, we turn it into a package with index.js&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$fs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$targetDir&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nv"&gt;$fs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$targetDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$fs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;symlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sourcePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$targetDir&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/index.js'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$fs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dumpFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$targetDir&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/package.json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'module'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'main'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'index.js'&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;echo&lt;/span&gt; &lt;span class="s2"&gt;"Imports for JS tests have been successfully linked!&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Node.js Configuration
&lt;/h3&gt;

&lt;p&gt;Now we need to tell Node.js how to run the tests. We'll use a standard &lt;code&gt;package.json&lt;/code&gt;, but with a small improvement: we'll use the &lt;code&gt;pretest&lt;/code&gt; hook, which automatically runs our PHP script before every test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"private"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --test tests/js/*.test.mjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test:watch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --watch --test tests/js/*.test.mjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pretest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"php bin/setup-js-tests.php"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Methodology: Why this way?
&lt;/h3&gt;

&lt;p&gt;For testing, we chose the &lt;strong&gt;native Node.js test runner&lt;/strong&gt; (available since version 20). Why?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Zero configuration:&lt;/strong&gt; No need to install Jest, Vitest, or anything similar.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Speed:&lt;/strong&gt; It starts instantly.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Reality-based:&lt;/strong&gt; No complex import mocking. You are testing the exact same files that run in production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Thanks to symlinks, we can write clean imports in our tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;myFunction&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../assets/js/my-function.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Midi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tonejs/midi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// This now works!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach allows us to follow &lt;strong&gt;TDD (Test Driven Development)&lt;/strong&gt; on the frontend while maintaining the simplicity of Asset Mapper. If you add a new library via &lt;code&gt;importmap:require&lt;/code&gt;, just run &lt;code&gt;npm test&lt;/code&gt; and everything will be automatically re-linked.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;Testing JavaScript in Symfony Asset Mapper is not impossible. All it takes is a small bridge in the form of a PHP script, and you can leverage the power of a modern Node.js environment without having to leave the comfort zone of your PHP framework.&lt;/p&gt;

&lt;h3&gt;
  
  
  Save time with AssetMapperTestBundle
&lt;/h3&gt;

&lt;p&gt;If you don’t want to maintain the script yourself, I’ve packaged this logic into a small, lightweight Symfony bundle. It handles the symlinking, directory mapping, and single-file package generation automatically.&lt;/p&gt;

&lt;p&gt;Check it out on GitHub: &lt;a href="https://github.com/tito10047/asset-mapper-test-bundle" rel="noopener noreferrer"&gt;tito10047/asset-mapper-test-bundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can have your JS tests running in seconds with a single command:&lt;br&gt;
&lt;code&gt;npm test&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Give it a try in your project and say goodbye to "missing module" errors. Your code (and your mental health) will thank you. 🍻&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>How I turned hundreds of thousands of "dumb" SVG icons into a semantic search engine in 7 languages under 20ms (using LLM and Meilisearch)</title>
      <dc:creator>Jozef Môstka</dc:creator>
      <pubDate>Tue, 07 Apr 2026 06:54:11 +0000</pubDate>
      <link>https://forem.com/tito10047/how-i-turned-hundreds-of-thousands-of-dumb-svg-icons-into-a-semantic-search-engine-in-7-languages-3kal</link>
      <guid>https://forem.com/tito10047/how-i-turned-hundreds-of-thousands-of-dumb-svg-icons-into-a-semantic-search-engine-in-7-languages-3kal</guid>
      <description>&lt;p&gt;Every frontend and full-stack developer knows this pain: You're building a UI, you need an icon for "settings", and you type &lt;code&gt;settings&lt;/code&gt; into the library's search bar. The result? &lt;em&gt;0 results.&lt;/em&gt; Why? Because the library author named that icon &lt;code&gt;heroicons-outline-cog&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Searching for icons without semantics is like looking for a life partner and the search engine offers you an e-shop with a lifetime warranty on refrigerators.&lt;/p&gt;

&lt;p&gt;It frustrated me so much that I decided to build ycon.cc ? a tool that aggregates hundreds of open-source libraries and actually &lt;em&gt;understands&lt;/em&gt; what you're looking for. In this article, I'll show you the technical background of how I enriched a massive icon dataset with semantics using AI and how I forced the whole thing to run under 20 milliseconds thanks to Meilisearch.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fho8ooaa05h7qd10ms8at.png" alt="bad search vs good search" width="800" height="436"&gt;
&lt;/h2&gt;

&lt;h2&gt;
  
  
  1. The Problem: Great data, zero context
&lt;/h2&gt;

&lt;p&gt;When designing the architecture, I didn't want to reinvent the wheel and write my own scrapers for every icon library (Tabler, Heroicons, Material Design, etc.). Instead, I took advantage of the amazing open-source project &lt;strong&gt;&lt;code&gt;iconify/json&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you don't know it, it's a gigantic collection of validated, cleaned, and unified open-source icons in a single standardized JSON format. Suddenly, I had nearly 327,000 icons at my disposal without the effort of parsing SVG files.&lt;/p&gt;

&lt;p&gt;The structure Iconify provided was clean and functional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tabler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"icons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"car"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;g fill=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; ... /&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, there was one huge catch. This data is built for rendering, not for searching. For a classic search engine, it's a nightmare. If a user searches for the word "vozidlo" or "auto" in Slovak (or Spanish), the system fails. Tagging such a large number of icons manually would take me about three lifetimes.&lt;/p&gt;

&lt;p&gt;This is where AI enters the scene.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. AI Magic: Semantic enrichment
&lt;/h2&gt;

&lt;p&gt;I decided to use GPT-5-nano to breathe semantic life into each icon. The task was clear: look at the icon's name and generate the most accurate synonyms in English.&lt;/p&gt;

&lt;p&gt;Here is the prompt that, after many iterations, worked best:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;**System / Context:**
    You are an expert UX Copywriter and Linguist specializing in search engine optimization for UI icons. Your goal is to generate highly relevant search keywords (synonyms, related actions, and concepts) for a given list of UI icons.
**Instructions:**
    1. I will provide you with a JSON array of icons. Each icon has an `id`, a `clean_name`, and a `category`.
    2. For each icon, generate a maximum of 6 highly relevant English keywords (`en`) .
    3. Think about **what users would type into a search bar** to find this icon. Include both the physical object (e.g., "magnifying glass") and the associated action/concept (e.g., "search", "find", "zoom").
    4. Do NOT include the original `clean_name` in the tags (I already have it).
    5. Keep keywords short (1-2 words max per keyword). All lowercase.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To save time and money, I didn't send icons to the API one by one, but in batches of 25. This entire process for the full dataset cost me roughly $10 and ran in the background for about 6 hours.&lt;/p&gt;

&lt;p&gt;The resulting JSON document, which I saved and prepared for indexing, suddenly looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tabler:car"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"car"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"vehicle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"transport"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"drive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"machine"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"svg_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;svg ... /&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We had the data. Now it needed to be searchable quickly.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzveqnml9buictpj3x3kx.png" alt="meilisearch upgrade" width="800" height="436"&gt;
&lt;/h2&gt;

&lt;h2&gt;
  
  
  3. Speed: Meilisearch and why I stored SVG in it
&lt;/h2&gt;

&lt;p&gt;As the search engine, I chose &lt;strong&gt;Meilisearch&lt;/strong&gt; (written in Rust). It's built exactly for "typo-tolerance" and lightning-fast responses.&lt;/p&gt;

&lt;p&gt;Originally, I pulled the SVG codes of the icons directly from the SQL database when rendering the grid. However, this turned out to be a bottleneck ? with 100 icons per page, it meant either 100 small SELECTs or one large join, which took hundreds of milliseconds with hundreds of thousands of records.&lt;/p&gt;

&lt;p&gt;I therefore decided on a radical step: &lt;strong&gt;Store the SVG code (in the &lt;code&gt;body&lt;/code&gt; attribute) directly in Meilisearch.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While this eliminated the SQL database from the search process, I ran into a new problem: &lt;strong&gt;Over-fetching&lt;/strong&gt;. Meilisearch, by default, returns &lt;strong&gt;all&lt;/strong&gt; attributes in the response. With a pagination of 100 icons, Meilisearch was sending me not only 100 SVG strings but also thousands of generated synonyms (6 words � 7 languages � 100 icons). PHP had to download this gigantic JSON over the network and deserialize it, which again drove latency up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The solution? A surgical cut via &lt;code&gt;attributesToRetrieve&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Synonyms (keywords) only serve for Meilisearch to find the icon. The frontend doesn't need to see them! In &lt;code&gt;IconSearchService&lt;/code&gt;, I modified the search parameters as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$searchParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'hitsPerPage'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'page'&lt;/span&gt;                 &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'attributesToSearchOn'&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;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'clean_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"keywords.&lt;/span&gt;&lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'attributesToRetrieve'&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;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'width'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'height'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// We pull only what we need!&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$index&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&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="nv"&gt;$searchParams&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because I'm pulling the &lt;code&gt;body&lt;/code&gt; (SVG code) directly from Meilisearch, I don't need any database query for each icon. At the same time, I excluded the &lt;code&gt;keywords&lt;/code&gt; fields, which would unnecessarily bloat the transferred data.&lt;/p&gt;

&lt;p&gt;The result? Response time dropped to a stable &lt;strong&gt;15-20 milliseconds&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Multilingualism for free: When even "cheap" AI is expensive
&lt;/h2&gt;

&lt;p&gt;Once I had the basic English dataset ready, I thought of another improvement: Why limit it to English? I wanted ycon.cc to be a global tool and support the most well-known world languages.&lt;/p&gt;

&lt;p&gt;When calculating the costs for the OpenAI API, I realized that translating hundreds of thousands of icons into 6 more languages would burn money unnecessarily given the massive number of requests. So I started looking for a way to solve it locally, "in my living room".&lt;/p&gt;

&lt;p&gt;I chose &lt;strong&gt;LibreTranslate&lt;/strong&gt; ? an open-source translation engine that I ran in Docker directly on my computer. No API keys, no monthly limits, no fees for every token.&lt;/p&gt;

&lt;p&gt;To ensure translations were as accurate as possible, I didn't use isolated words in &lt;code&gt;LibreTranslateService&lt;/code&gt;, but joined them into small units using &lt;code&gt;implode(', ', $keywords)&lt;/code&gt;. This gives the translator the necessary context, and the results are much more natural than if I translated each word individually.&lt;/p&gt;

&lt;p&gt;I built the entire process on asynchronous processing via Symfony Messenger. The &lt;code&gt;TranslateIconGroupHandler&lt;/code&gt; handler gradually took batches of icons and translated them into all activated languages.&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;// TranslateIconGroupHandler.php - The heart of translations&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;TranslateIconGroupMessage&lt;/span&gt; &lt;span class="nv"&gt;$message&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="c1"&gt;// ... loading locales from DB ...&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$localeMap&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$localeId&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$localeCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Idempotency: if we already have the translation, we skip&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;translationExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;iconGroupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$localeId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Contextual keyword translation (joined by comma)&lt;/span&gt;
        &lt;span class="nv"&gt;$translated&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;libreTranslate&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;translateContextual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$localeCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$translation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IconGroupTranslation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$translation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTranslatedKeywords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$translated&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;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$translation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&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;As a result, I had support for &lt;strong&gt;7 languages in the system completely for free&lt;/strong&gt;. The search engine thus understands not only the term "car", but also "auto", "vehicle", or "coche".&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Developer Experience: Copy-pasting is gone too
&lt;/h2&gt;

&lt;p&gt;Now that we have perfect and fast searching filled with data from &lt;code&gt;iconify&lt;/code&gt;, the next step followed. Developers hate manually converting SVG files into components.&lt;/p&gt;

&lt;p&gt;In ycon.cc, I therefore implemented the Strategy design pattern, which immediately transforms SVG code (with support for Iconify standards) into the format the developer currently needs (Tailwind classes, Vue/React components, or Symfony UX).&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="na"&gt;#[AutoconfigureTag('app.icon_code_generator')]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SymfonyUxGenerator&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;IconCodeGeneratorInterface&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Icon&lt;/span&gt; &lt;span class="nv"&gt;$icon&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;$alias&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="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$icon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getIconSet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPrefix&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$originalName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$icon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$iconName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%s:%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$originalName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$renderName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$alias&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$iconName&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;$alias&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;yield&lt;/span&gt; &lt;span class="s2"&gt;"Import Icon"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;code&amp;gt;php bin/console ux:icon:import %s --as=%s&amp;lt;/code&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$iconName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$alias&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="s2"&gt;"Import Icon"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;code&amp;gt;php bin/console ux:icon:import %s&amp;lt;/code&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$iconName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="s2"&gt;"Twig Component"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;code&amp;gt;"&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;htmlspecialchars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;twig:ux:icon name="%s" /&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$renderName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;/code&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="s2"&gt;"Render Icon"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;code&amp;gt;{{ ux_icon(\'%s\') }}&amp;lt;/code&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$renderName&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;Just pick a framework and with one click, you have the code ready in your clipboard.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8qsflua0iky8x0vc5zss.png" alt="ycon.cc" width="800" height="396"&gt;
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Transforming a gigantic repository like &lt;code&gt;iconify/json&lt;/code&gt; into a fully semantic search tool was exactly the technical adventure why I love programming. The combination of LLM for data-enrichment and Meilisearch for lightning-fast querying is a combo I can only recommend.&lt;/p&gt;

&lt;p&gt;If you are currently building a website or application and are tired of remembering exact technical names for icons, &lt;strong&gt;I have launched a beta version at &lt;a href="https://ycon.cc/" rel="noopener noreferrer"&gt;ycon.cc&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Try entering a context into the search (e.g., "mute sound" or "add to cart") and let me know in the comments if it found what you expected. I greatly appreciate every piece of feedback (even critical).&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>symfony</category>
      <category>react</category>
      <category>ux</category>
    </item>
    <item>
      <title>Symfony UX: Single Directory Components (SDC) - The Path to Cleaner Architecture Without Tailwind</title>
      <dc:creator>Jozef Môstka</dc:creator>
      <pubDate>Wed, 28 Jan 2026 04:39:00 +0000</pubDate>
      <link>https://forem.com/tito10047/symfony-ux-single-directory-components-sdc-the-path-to-cleaner-architecture-without-tailwind-2bmn</link>
      <guid>https://forem.com/tito10047/symfony-ux-single-directory-components-sdc-the-path-to-cleaner-architecture-without-tailwind-2bmn</guid>
      <description>&lt;p&gt;Frontend development in Symfony has taken a huge leap forward in recent years. Thanks to &lt;strong&gt;Symfony UX&lt;/strong&gt; and &lt;strong&gt;Twig Components&lt;/strong&gt;, we've gained the ability to write reusable components much like in React or Vue. However, the more components you have, the more you encounter one annoying problem: &lt;strong&gt;files are scattered across the entire project.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A PHP class in &lt;code&gt;src/Components&lt;/code&gt;, a Twig template in &lt;code&gt;templates/components&lt;/code&gt;, CSS in &lt;code&gt;assets/styles&lt;/code&gt;, and JavaScript somewhere else entirely. When you want to change the color of a button, you're looking at a trip through four different directories.&lt;/p&gt;

&lt;p&gt;Some time ago, Hugo Alliaume published a great article &lt;a href="https://hugo.alliau.me/blog/posts/a-better-architecture-for-your-symfony-ux-twig-components" rel="noopener noreferrer"&gt;A Better Architecture for Your Symfony UX Twig Components&lt;/a&gt;, where he proposed the concept of &lt;strong&gt;Single Directory Components (SDC)&lt;/strong&gt;. The idea is simple: let's have everything in one folder.&lt;/p&gt;

&lt;p&gt;Today, we'll show you how to take this concept to the next level, completely get rid of manual configuration, and maybe even Tailwind.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Single Directory Components (SDC)?
&lt;/h2&gt;

&lt;p&gt;If you've ever worked with Angular or Vue, you know that feeling of order. An &lt;code&gt;Alert&lt;/code&gt; component is a single folder containing everything essential.&lt;/p&gt;

&lt;p&gt;In the SDC approach, it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src_component/
└── UI/
    └── Button/
        ├── Button.php           # Logic
        ├── Button.html.twig     # Template
        ├── Button.css           # Styles
        └── Button_controller.js # Interactions (Stimulus)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Main Advantages:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Maintainability:&lt;/strong&gt; When you no longer need a component, you just delete one folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer Experience:&lt;/strong&gt; No context switching between distant folders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation:&lt;/strong&gt; Styles and scripts belong only to that one component.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Is Tailwind Passé? The Power of Native CSS and Variables
&lt;/h2&gt;

&lt;p&gt;Lately, there's more and more talk about how, with the arrival of modern CSS features (like CSS variables, nesting, &lt;code&gt;@layer&lt;/code&gt;), the need for utility frameworks like Tailwind is decreasing. Articles like &lt;a href="https://medium.com/@all.technology.stories/efc42ac3f83b" rel="noopener noreferrer"&gt;The Power of CSS Variables&lt;/a&gt; remind us that pure CSS is stronger today than ever before.&lt;/p&gt;

&lt;p&gt;The SDC approach plays right into this trend. When you have a CSS file right next to your PHP class, you don't need to write 20 Tailwind classes in your HTML. You can define clean, semantic CSS that uses CSS variables linked directly to PHP logic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Our Solution: UX SDC Bundle
&lt;/h2&gt;

&lt;p&gt;Hugo's article showed how to set up SDC manually. However, it requires changes to &lt;code&gt;composer.json&lt;/code&gt;, Twig configuration, AssetMapper, and Stimulus.&lt;/p&gt;

&lt;p&gt;Our bundle &lt;code&gt;tito10047/ux-sdc&lt;/code&gt; does it for you. It's a &lt;strong&gt;Zero Configuration&lt;/strong&gt; solution. Just install the bundle and mark the class with an attribute.&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="na"&gt;#[AsSdcComponent('UI:Button')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The bundle automatically finds Button.html.twig, Button.css, and Button_controller.js&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The "Magic" Inside (Without Performance Loss)
&lt;/h3&gt;

&lt;p&gt;The bundle solves technical challenges that you would have to patch yourself with a manual approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Asset Orchestration:&lt;/strong&gt; CSS and JS files are injected into the page &lt;strong&gt;only when&lt;/strong&gt; the component is actually rendered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No More "Phantom" Controllers:&lt;/strong&gt; You don't have to create an empty Stimulus controller just so AssetMapper knows about your CSS. The bundle handles it for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP Preload &amp;amp; FOUC:&lt;/strong&gt; We automatically generate &lt;code&gt;Link&lt;/code&gt; headers for HTTP preload. The browser starts downloading CSS even before it starts parsing HTML. This eliminates the annoying flash of unstyled content (FOUC).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production Performance:&lt;/strong&gt; Thanks to a compiler pass, no disk scanning happens in production. Everything is pre-prepared in the cache.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Real-world Example: Project "Formalitka"
&lt;/h2&gt;

&lt;p&gt;What does it look like in the real world? Take a look at the project &lt;a href="https://formalitka.mostka.sk" rel="noopener noreferrer"&gt;formalitka.mostka.sk&lt;/a&gt;. The entire UI kit is built on SDC without a single line of Tailwind.&lt;/p&gt;

&lt;p&gt;Let's take their &lt;code&gt;Button&lt;/code&gt; component. The PHP class defines properties like &lt;code&gt;color&lt;/code&gt; or &lt;code&gt;size&lt;/code&gt;. The CSS then processes these properties using variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Button.css */&lt;/span&gt;
&lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;components&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;.button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-on-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--transition&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="err"&gt;&amp;amp;.button--secondary&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
            &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-secondary&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="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thanks to SDC, this complex component (containing seal animations, sounds, and special effects) is contained within one directory and is easy to maintain.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Get Started?
&lt;/h2&gt;

&lt;p&gt;Installation is lightning fast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require tito10047/ux-sdc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the bundle and in &lt;code&gt;config/packages/ux_sdc.yaml&lt;/code&gt;, set where your components reside:&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="na"&gt;ux_sdc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ux_components_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%kernel.project_dir%/src_component'&lt;/span&gt;
    &lt;span class="na"&gt;component_namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;App\Component'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And add the asset placeholder to your &lt;code&gt;base.html.twig&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;render_component_assets&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. From this moment on, just create directories and write code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance Under the Microscope: Proof Instead of Promises
&lt;/h2&gt;

&lt;p&gt;When automation and "magic" come into play, the question often arises: &lt;em&gt;What about performance?&lt;/em&gt; We put the SDC approach to a stress test with 500 unique components. Here are the key findings from our benchmarks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Classic Approach&lt;/th&gt;
&lt;th&gt;SDC Approach&lt;/th&gt;
&lt;th&gt;Difference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Warmup (Prod)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;583.1 ms&lt;/td&gt;
&lt;td&gt;586.2 ms&lt;/td&gt;
&lt;td&gt;+3.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Render (Prod Runtime)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;26.5 ms&lt;/td&gt;
&lt;td&gt;31.6 ms&lt;/td&gt;
&lt;td&gt;+5.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Render (Dev Runtime)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;26.5 ms&lt;/td&gt;
&lt;td&gt;88.4 ms&lt;/td&gt;
&lt;td&gt;+61.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What does this mean in practice?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;In production, the overhead is almost zero.&lt;/strong&gt; Thanks to the compiler pass, no disk scanning happens at runtime. Rendering a single component has an overhead of only about &lt;strong&gt;8.8µs&lt;/strong&gt;, a negligible price for fully automated asset and Stimulus management.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Dev mode is optimized for DX.&lt;/strong&gt; Instead of having to clear the cache every time a file changes, the bundle in dev mode uses &lt;strong&gt;runtime autodiscovery&lt;/strong&gt;. It scans the disk only for those components that are currently being rendered. This means that if you add a new CSS file or change a Twig template, you see the changes immediately. Thanks to internal metadata caching within a single request, repeated rendering of the same component remains very fast.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Memory Footprint:&lt;/strong&gt; With 500 components, the bundle consumes about 8MB more memory during container compilation, which is perfectly fine for modern applications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can find the full report in our &lt;a href="https://github.com/tito10047/ux-twig-component-asset/blob/main/benchmark.md" rel="noopener noreferrer"&gt;benchmark.md&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Future and Your Feedback (Version 0.0.1)
&lt;/h2&gt;

&lt;p&gt;This bundle is currently in version &lt;strong&gt;0.0.1&lt;/strong&gt;. It's an early stage where we're looking for the right path. I wrote this article not only to share our solution but mainly to get feedback from you, the community.&lt;/p&gt;

&lt;p&gt;We're interested in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What do you think about the proposed API?&lt;/li&gt;
&lt;li&gt;Is there anything missing that you would expect in an SDC approach?&lt;/li&gt;
&lt;li&gt;Do you see any pitfalls in this approach that we've overlooked?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We want this bundle to be built on solid foundations and real developer needs from the first stable version. Every GitHub issue, discussion, or comment moves us forward.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Single Directory Components combined with modern CSS are changing the way we think about Symfony UX. We're getting rid of unnecessary boilerplate code, cleaning up our project structure, and increasing our performance as developers.&lt;/p&gt;

&lt;p&gt;Try the &lt;a href="https://github.com/tito10047/ux-twig-component-asset" rel="noopener noreferrer"&gt;UX SDC Bundle&lt;/a&gt; and say goodbye to scattered files. The future of Symfony UX is in order and clarity.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Ultimate Image Solution for Symfony - stop writing srcset manually</title>
      <dc:creator>Jozef Môstka</dc:creator>
      <pubDate>Fri, 09 Jan 2026 12:09:12 +0000</pubDate>
      <link>https://forem.com/tito10047/stop-writing-srcset-manually-the-ultimate-image-solution-for-symfony-59cn</link>
      <guid>https://forem.com/tito10047/stop-writing-srcset-manually-the-ultimate-image-solution-for-symfony-59cn</guid>
      <description>&lt;h2&gt;
  
  
  Solving the Image Problem in Symfony: Meet PGI
&lt;/h2&gt;

&lt;p&gt;Images are the heaviest part of the modern web. They are responsible for slow page loads, frustrating Cumulative Layout Shift (CLS), and a nightmare developer experience when trying to implement truly responsive designs. If you’ve ever wrestled with complex &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; tags or manually calculated &lt;code&gt;srcset&lt;/code&gt; values for different breakpoints, you know the pain.&lt;/p&gt;

&lt;p&gt;Enter &lt;strong&gt;PGI&lt;/strong&gt; (Progressive Image Bundle)—the new standard for image handling in Symfony 6.4 and newer versions.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Hook: Why Core Web Vitals Changed Everything
&lt;/h3&gt;

&lt;p&gt;Google’s Core Web Vitals—specifically Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS)—have transformed how we build websites. It’s no longer enough to just "show an image." You need to show it fast, and you must ensure the layout doesn’t jump when the image finally appears.&lt;/p&gt;

&lt;p&gt;For many Symfony developers, achieving a perfect 100/100 PageSpeed score while maintaining a clean codebase has felt like a trade-off. You either spend hours writing custom responsive logic or you settle for "good enough" performance. PGI was born to eliminate that trade-off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code Showcase: From Messy to Masterful
&lt;/h3&gt;

&lt;p&gt;Let’s look at the difference. A standard responsive image in Twig often looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'images/hero.jpg'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; 
     &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'images/hero_sm.jpg'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="s"&gt; 600w, &lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'images/hero_lg.jpg'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="s"&gt; 1200w"&lt;/span&gt;
     &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 600px) 100vw, 50vw"&lt;/span&gt;
     &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"675"&lt;/span&gt;
     &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;
     &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Hero image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that’s just for &lt;em&gt;one&lt;/em&gt; aspect ratio. With PGI, you get a clean, Tailwind-inspired syntax:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;twig:pgi:Image&lt;/span&gt; 
    &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"images/hero.jpg"&lt;/span&gt; 
    &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"sm:12@square md:6@landscape"&lt;/span&gt;
    &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Responsive hero image"&lt;/span&gt; 
    &lt;span class="na"&gt;preload&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Real-world Case: just6words.com
&lt;/h4&gt;

&lt;p&gt;To see PGI in action, let's look at how it handles a single image across different devices using an example from &lt;a href="https://just6words.com/" rel="noopener noreferrer"&gt;just6words.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Instead of writing multiple &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags or complex &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; logic, you define everything in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;twig:pgi:Image&lt;/span&gt; 
    &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"realcase.jpg"&lt;/span&gt; 
    &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"sm:12@square md:6@landscape"&lt;/span&gt; 
    &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Responsive real-world example"&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h5&gt;
  
  
  What happens on different devices?
&lt;/h5&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Mobile (sm):&lt;/strong&gt; The browser loads a &lt;strong&gt;Square (1:1)&lt;/strong&gt; crop. It takes the full width of the container.
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fld739iug4pu86tst6czu.jpg" alt="Mobile (sm) - Square" width="640" height="640"&gt; &lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Tablet/Desktop (md+):&lt;/strong&gt; The browser automatically switches to a &lt;strong&gt;Landscape (16:9)&lt;/strong&gt; crop. The image now occupies only half of the grid (6/12 columns).
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiqgeuz4l2fuq9lmu1af3.jpg" alt="Tablet/Desktop (md) - Landscape" width="384" height="216"&gt; &lt;/li&gt;
&lt;/ol&gt;

&lt;h5&gt;
  
  
  Rendered HTML (Clean &amp;amp; Semantic)
&lt;/h5&gt;

&lt;p&gt;PGI generates a single, optimized block of HTML that handles all the heavy lifting, including the &lt;strong&gt;Blur Placeholder&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"progressive-image-container"&lt;/span&gt; 
     &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"--img-width-sm: 640px; --img-aspect-sm: 1; --img-width-md: 384px; --img-aspect-md: 1.7777777777778;"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;canvas&lt;/span&gt; &lt;span class="na"&gt;data-tito10047--progressive-image-bundle--progressive-image-target=&lt;/span&gt;&lt;span class="s"&gt;"placeholder"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/canvas&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/realcase.jpg"&lt;/span&gt; 
         &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"/media/cache/640x640/realcase.jpg 640w, /media/cache/384x216/realcase.jpg 384w"&lt;/span&gt; 
         &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(min-width: 768px) 384px, (min-width: 640px) 640px, 100vw"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  What's happening here?
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sm:12@square&lt;/code&gt;&lt;/strong&gt;: Full width on small screens, automatically cropped to 1:1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;md:6@landscape&lt;/code&gt;&lt;/strong&gt;: Half width (6/12 columns) from medium breakpoint, automatically cropped to 16:9.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;xl:[430x370]&lt;/code&gt;&lt;/strong&gt;: Need a very specific size for a custom layout? PGI supports arbitrary values directly in the &lt;code&gt;sizes&lt;/code&gt; attribute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;preload&lt;/code&gt;&lt;/strong&gt;: PGI automatically injects a &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; into your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, optimizing your LCP score instantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  The "Progressive" in PGI: Blur-up Experience
&lt;/h4&gt;

&lt;p&gt;One of the most satisfying features for users is the built-in &lt;strong&gt;Blurhash&lt;/strong&gt; support. Instead of showing a blank space or a generic spinner, PGI renders a beautiful, ultra-lightweight blurred version of the image immediately.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57enz2jygzke7hgt5oyr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57enz2jygzke7hgt5oyr.png" alt="Blured image" width="758" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This "blur-up" technique significantly improves &lt;strong&gt;perceived performance&lt;/strong&gt;. Users see the layout and the context of the image instantly, even on slow connections, while the high-resolution version loads in the background. Once ready, the high-res image smoothly fades in, replacing the placeholder.&lt;/p&gt;

&lt;h3&gt;
  
  
  Technical Deep Dive: Zero CLS and CSS Magic
&lt;/h3&gt;

&lt;p&gt;How does PGI prevent layout shift? It’s not just about adding &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; attributes. PGI leverages modern CSS &lt;code&gt;aspect-ratio&lt;/code&gt; and CSS variables.&lt;/p&gt;

&lt;p&gt;When the component renders, it calculates the aspect ratio based on your &lt;code&gt;sizes&lt;/code&gt; definition. It then wraps the image in a container that reserves the exact space needed. No more content jumping as images load—even if the image is lazy-loaded or slow to arrive.&lt;/p&gt;

&lt;h4&gt;
  
  
  Powering the Engine: LiipImagine Integration
&lt;/h4&gt;

&lt;p&gt;PGI doesn't reinvent the wheel for image processing. Instead, it stands on the shoulders of a giant: &lt;strong&gt;LiipImagineBundle&lt;/strong&gt;. By default, PGI can delegate the heavy lifting of resizing and filtering to LiipImagine, allowing you to leverage its full ecosystem.&lt;/p&gt;

&lt;p&gt;One of the most powerful features of this synergy is the &lt;strong&gt;automatic conversion to modern formats like WebP&lt;/strong&gt;. With just a few lines of configuration, you can ensure that every image served by PGI is not only perfectly sized but also perfectly compressed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example Configuration:&lt;/strong&gt;&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/liip_imagine.yaml&lt;/span&gt;
&lt;span class="na"&gt;liip_imagine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;default_filter_set_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;webp&lt;/span&gt;
    &lt;span class="na"&gt;webp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="c1"&gt;# config/packages/progressive_image.yaml&lt;/span&gt;
&lt;span class="na"&gt;progressive_image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;75&lt;/span&gt;
        &lt;span class="na"&gt;post_processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;cwebp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;q&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;30&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By using the &lt;code&gt;liip_imagine&lt;/code&gt; decorator, PGI automatically routes image requests through LiipImagine's filter system. This means you can use all your existing Liip filters while benefiting from PGI's superior Twig syntax and Zero CLS features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Under the Hood: The Configuration That Makes It Possible
&lt;/h3&gt;

&lt;p&gt;One of the most praised aspects of PGI is its flexibility. While it works out-of-the-box, the real power lies in its configuration. Let’s break down the most important parts of &lt;code&gt;progressive_image.yaml&lt;/code&gt;:&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/progressive_image.yaml&lt;/span&gt;
&lt;span class="na"&gt;progressive_image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Responsive Strategy&lt;/span&gt;
    &lt;span class="na"&gt;responsive_strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;grid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;framework&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tailwind&lt;/span&gt; &lt;span class="c1"&gt;# or bootstrap, or custom&lt;/span&gt;
        &lt;span class="na"&gt;ratios&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;landscape&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;16/9"&lt;/span&gt;
            &lt;span class="na"&gt;square&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1/1"&lt;/span&gt;
            &lt;span class="na"&gt;hero&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;21/9"&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Resolvers (Where are your images?)&lt;/span&gt;
    &lt;span class="na"&gt;resolvers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;public_files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filesystem"&lt;/span&gt;
            &lt;span class="na"&gt;roots&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;%kernel.project_dir%/public'&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;assets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;asset_mapper"&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Transparent HTML Caching&lt;/span&gt;
    &lt;span class="na"&gt;image_cache_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;image_cache_service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache.app"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  1. Responsive Strategy
&lt;/h4&gt;

&lt;p&gt;This is the brain of PGI. By telling the bundle you use &lt;strong&gt;Tailwind&lt;/strong&gt; or &lt;strong&gt;Bootstrap&lt;/strong&gt;, it automatically knows the container widths for every breakpoint. When you say &lt;code&gt;md:6&lt;/code&gt;, PGI looks up the &lt;code&gt;md&lt;/code&gt; container width, divides it by 2 (6/12 columns), and generates the exact image size needed.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Resolvers: File Freedom
&lt;/h4&gt;

&lt;p&gt;Whether you keep your images in the &lt;code&gt;public/&lt;/code&gt; folder or use the modern &lt;strong&gt;Symfony AssetMapper&lt;/strong&gt;, PGI can find them. You can even define a &lt;code&gt;chain&lt;/code&gt; resolver to look in multiple places. This is a lifesaver for projects transitioning to newer Symfony features.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Performance First: HTML Caching
&lt;/h4&gt;

&lt;p&gt;Generating Blurhash and reading metadata requires CPU power. PGI solves this with &lt;strong&gt;transparent HTML caching&lt;/strong&gt;. Once a component is rendered, the final HTML is stored in your cache. The next time someone visits the page, PGI serves the cached HTML instantly, skipping all PHP logic.&lt;/p&gt;

&lt;h4&gt;
  
  
  Point of Interest (PoI) Cropping
&lt;/h4&gt;

&lt;p&gt;One of the coolest features is "Smart Cropping." Instead of blindly cropping from the center, you can define a Point of Interest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;twig:pgi:Image&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"team.jpg"&lt;/span&gt; &lt;span class="na"&gt;pointInterest=&lt;/span&gt;&lt;span class="s"&gt;"75x25"&lt;/span&gt; &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"md:6@square"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures that the most important part of the image (like a person's face) stays in the frame, regardless of whether it's being cropped to a square, portrait, or landscape ratio.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why PGI Belongs in Your Next Project
&lt;/h3&gt;

&lt;p&gt;PGI isn't just a wrapper; it's a complete ecosystem for Symfony:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero Configuration:&lt;/strong&gt; Install and it just works with Bootstrap or Tailwind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic Generation:&lt;/strong&gt; It generates all required sizes on the fly and caches them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LiipImagine Integration:&lt;/strong&gt; It plays nice with existing tools if you need custom filters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparent Caching:&lt;/strong&gt; It can cache the resulting HTML to avoid re-calculating metadata on every request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building a modern Symfony application and care about SEO, UX, and your own sanity as a developer, PGI is the missing piece of the puzzle. It’s already powering high-performance sites like &lt;a href="https://just6words.com/" rel="noopener noreferrer"&gt;just6words.com&lt;/a&gt;, helping them achieve near-perfect PageSpeed scores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ready to boost your PageSpeed?&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://github.com/tito10047/progressive-image-bundle" rel="noopener noreferrer"&gt;Check out PGI on GitHub&lt;/a&gt; and join the movement toward a faster, more stable web.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>webdev</category>
      <category>images</category>
    </item>
  </channel>
</rss>
