<?xml version="1.0" encoding="UTF-8" ?>
<?xml-stylesheet href="https://jolicode.com/feed.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="fr-FR">
    <id>https://jolicode.com/blog</id>
    <link type="text/html" rel="alternate" href="https://jolicode.com/blog"/>
    <link type="application/rss+xml" rel="self" href="https://jolicode.com/feed" />

            <title>JoliCode blog - les derniers articles</title>
        <updated>2026-08-25T22:52:58+02:00</updated>    <entry>
        <id>https://jolicode.com/blog/simhash-trouver-les-pages-qui-se-ressemblent</id>
        <published>2026-08-18T09:42:00+02:00</published>
        <updated>2026-08-18T09:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/simhash-trouver-les-pages-qui-se-ressemblent"/>
        <title>SimHash : trouver les pages qui se ressemblent</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="php" />        <summary><![CDATA[Il y a quelques semaines, j&#039;ai eu besoin de détecter du contenu dupliqué dans le crawler de redirection.io. Pas du dupliqué à la virgule près, ça c&#039;est facile, mais du dupliqué « en gros, c&#039;est la même…]]></summary>
        <content type="html">
            &lt;p&gt;Il y a quelques semaines, j&#039;ai eu besoin de détecter du contenu dupliqué dans le crawler de &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://redirection.io/&quot;&gt;redirection.io&lt;/a&gt;. Pas du dupliqué à la virgule près, ça c&#039;est facile, mais du dupliqué « en gros, c&#039;est la même page ». J&#039;aurais pu utiliser &lt;a href=&quot;https://jolicode.com/blog/symfony-ai-simplifier-lanalyse-de-similarites-de-textes-et-linteraction-avec-vos-llms-favoris&quot;&gt;des embeddings avec un LLM&lt;/a&gt;, mais il me fallait quelque chose de rapide et de gratuit. J&#039;ai finalement utilisé SimHash, un algorithme qui date de 2002 et qui tient en trente lignes de PHP.&lt;/p&gt;
&lt;p&gt;Dans cet article, nous allons voir pourquoi &lt;code&gt;md5()&lt;/code&gt; ne peut pas nous aider, comment fonctionne SimHash, comment l&#039;implémenter en PHP sans aucune dépendance, et surtout comment faire tourner la comparaison en base de données quand on a des millions de pages.&lt;/p&gt;
&lt;h2&gt;Le problème&lt;/h2&gt;
&lt;p&gt;Le crawler de redirection.io parcourt un site et récupère toutes ses pages. À la fin, on veut prévenir l&#039;utilisateur : ces pages ont le même contenu.&lt;/p&gt;
&lt;p&gt;Commençons par le cas facile. Deux pages strictement identiques :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$hashA &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; md5&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($contenuA);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$hashB &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; md5&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($contenuB);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mais dans la vraie vie, les pages ne sont jamais strictement identiques. Prenez une boutique en ligne avec une fiche produit déclinée par ville :&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Toutes nos paires sont expédiées sous 24 heures depuis notre entrepôt de &lt;strong&gt;Lyon&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;!-- --&gt;
&lt;blockquote&gt;
&lt;p&gt;Toutes nos paires sont expédiées sous 24 heures depuis notre entrepôt de &lt;strong&gt;Bordeaux&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Trois cents mots identiques, un seul mot qui change. Pour un moteur de recherche, ce sont des doublons. Pour &lt;code&gt;md5()&lt;/code&gt;, ce sont deux pages qui n&#039;ont rien à voir.&lt;/p&gt;
&lt;h2&gt;Pourquoi les fonctions de hachage habituelles ne nous aident pas&lt;/h2&gt;
&lt;p&gt;Le premier réflexe serait de se dire que deux contenus proches donnent deux hash proches. Vérifions :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;echo&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; md5&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Bonjour le monde&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;), &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;echo&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; md5&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Bonjour le Monde&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;), &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;9cbfb998c9c4f8966d0df57e0065383a
1dacb65f3f3799ba5643cb3409e3aeec
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Une seule lettre change, un &lt;code&gt;m&lt;/code&gt; devenu &lt;code&gt;M&lt;/code&gt;, et les deux empreintes n&#039;ont plus rien en commun. Ce n&#039;est pas un bug, c&#039;est exactement ce qu&#039;on demande à une fonction de hachage. Ça porte un nom : l&#039;&lt;strong&gt;effet avalanche&lt;/strong&gt;. Changer un seul bit en entrée doit faire basculer, en moyenne, la moitié des bits en sortie.&lt;/p&gt;
&lt;p&gt;C&#039;est indispensable en cryptographie et pour les tables de hachage. Pour notre problème, c&#039;est l&#039;inverse de ce qu&#039;on veut.&lt;/p&gt;
&lt;p&gt;Il nous faudrait une fonction de hachage &lt;strong&gt;qui préserve la ressemblance&lt;/strong&gt; : deux textes proches doivent produire deux empreintes proches. Ça existe, ça s&#039;appelle du &lt;em&gt;locality sensitive hashing&lt;/em&gt; (hachage sensible à la localité), et SimHash en est le représentant le plus connu.&lt;/p&gt;
&lt;p&gt;L&#039;algorithme vient d&#039;un article de Moses Charikar publié en 2002. Google l&#039;a popularisé en 2007 dans un papier intitulé « Detecting Near-Duplicates for Web Crawling », où il explique comment dédupliquer 8 milliards de pages avec des empreintes de 64 bits. La méthode a donc été éprouvée.&lt;/p&gt;
&lt;h2&gt;Le principe : faire voter les morceaux du texte&lt;/h2&gt;
&lt;p&gt;Imaginez une élection avec &lt;strong&gt;64 questions&lt;/strong&gt;, chacune n&#039;admettant que deux réponses : oui ou non. Question n°0 : oui ou non ? Question n°1 : oui ou non ? Et ainsi de suite jusqu&#039;à la question n°63.&lt;/p&gt;
&lt;p&gt;Les électeurs, ce sont les &lt;strong&gt;petits morceaux du texte&lt;/strong&gt;. Chaque morceau a un avis sur les 64 questions, et cet avis vient de son propre hash : le bit n°0 de son hash est sa réponse à la question n°0, le bit n°12 sa réponse à la question n°12.&lt;/p&gt;
&lt;p&gt;On dépouille ensuite question par question. Si les « oui » l&#039;emportent, on note 1, sinon 0. On obtient 64 bits, et c&#039;est l&#039;empreinte du document.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/simhash/simhash.png&quot; data-original-width=&quot;1536&quot; data-original-height=&quot;1024&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/simhash/simhash.5182b4e2.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/simhash/simhash.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1536 / 1024)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/simhash/simhash.png&quot; alt=&quot;simhash&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Prenons maintenant un document de 300 mots, donc environ 300 électeurs, et changeons un mot. Seuls 2 ou 3 électeurs changent d&#039;avis. Sur la plupart des questions, la majorité était assez large pour que ces quelques voix ne changent rien au résultat. L&#039;empreinte reste presque la même : un ou deux bits basculent, sur les questions où le vote était serré.&lt;/p&gt;
&lt;p&gt;À l&#039;inverse, deux documents qui n&#039;ont rien à voir ont des électeurs complètement différents. Les votes n&#039;ont aucune raison de coïncider, et les deux empreintes diffèrent sur environ la moitié des bits.&lt;/p&gt;
&lt;p&gt;La question « ces deux textes se ressemblent-ils ? » devient donc « ces deux entiers de 64 bits diffèrent-ils sur peu de bits ? ». Et ça, une base de données sait le faire très vite.&lt;/p&gt;
&lt;h2&gt;Étape 1 : découper le texte en shingles&lt;/h2&gt;
&lt;p&gt;Il faut d&#039;abord fabriquer les électeurs. La solution évidente serait de prendre les mots un par un. C&#039;est une mauvaise idée.&lt;/p&gt;
&lt;p&gt;Avec des mots isolés, le document devient un sac de mots et l&#039;ordre disparaît complètement. Ces deux phrases auraient exactement la même empreinte :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;le chat mange la souris puis le chien attrape une balle rouge dans le jardin…
attrape balle chat chien dans jardin la le le le mange puis rouge souris une…
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Avec des électeurs d&#039;un seul mot, la distance entre ces deux textes est de &lt;strong&gt;0&lt;/strong&gt;. Ils sont considérés comme identiques, alors que le second n&#039;a aucun sens.&lt;/p&gt;
&lt;p&gt;La parade s&#039;appelle un &lt;strong&gt;shingle&lt;/strong&gt; (« bardeau », comme les tuiles d&#039;un toit qui se chevauchent). Au lieu de prendre les mots un par un, on prend des groupes de mots consécutifs, en avançant d&#039;un mot à chaque fois. Avec des shingles de 3 mots, la phrase &lt;code&gt;le chat mange la souris&lt;/code&gt; donne :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;le chat mange
   chat mange la
        mange la souris
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Les groupes se chevauchent, donc l&#039;ordre des mots est capturé : si on mélange les mots, tous les shingles changent. Sur le même test, la distance passe de 0 à &lt;strong&gt;36 bits sur 63&lt;/strong&gt;. On est passé de « identiques » à « rien à voir », ce qui est bien le résultat attendu.&lt;/p&gt;
&lt;p&gt;Voici le code :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Symfony\Component\String\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;u&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; shingles&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $text, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $size &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 3&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; array&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $words &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; u&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($text)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;lower&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;collapseWhitespace&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039; &#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (\&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($words) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $size) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $shingles &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    for&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($i &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $max &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($words) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $size; $i &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $max; &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$i) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $shingles[] &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; u&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039; &#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(\&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;array_slice&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($words, $i, $size))&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;toString&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $shingles;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;var_dump&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;shingles&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;le chat mange la souris&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;array(3) {
  [0] =&amp;gt; string(13) &amp;quot;le chat mange&amp;quot;
  [1] =&amp;gt; string(13) &amp;quot;chat mange la&amp;quot;
  [2] =&amp;gt; string(15) &amp;quot;mange la souris&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deux détails comptent :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;-&amp;gt;lower()&lt;/code&gt; évite que « Livraison » et « livraison » soient comptés comme deux électeurs différents ;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-&amp;gt;collapseWhitespace()&lt;/code&gt; normalise les espaces. Sans lui, un simple retour à la ligne dans le HTML suffirait à créer un shingle bidon.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Notez enfin qu&#039;un document de N mots produit N - 2 shingles avec &lt;code&gt;$size = 3&lt;/code&gt;. Chaque mot apparaît dans 3 shingles au plus, donc &lt;strong&gt;changer un mot ne modifie que 3 électeurs&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;Étape 2 : hacher chaque shingle&lt;/h2&gt;
&lt;p&gt;Chaque shingle doit maintenant produire ses 64 réponses. On lui applique une fonction de hachage classique (ici l&#039;effet avalanche nous arrange : il garantit que deux shingles différents ont des avis indépendants), puis on lit les bits du résultat.&lt;/p&gt;
&lt;p&gt;J&#039;utilise &lt;code&gt;xxh3&lt;/code&gt;, disponible nativement depuis PHP 8.1. Ce n&#039;est pas une fonction cryptographique, mais on ne cherche pas à se défendre contre un attaquant : on veut une bonne dispersion, et surtout de la vitesse, puisqu&#039;on l&#039;appelle des centaines de fois par page.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$hash &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; unpack&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;J&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;hash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;xxh3&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;le chat mange&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;))[&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;printf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;%064b&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $hash);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Le troisième argument de &lt;code&gt;hash()&lt;/code&gt; à &lt;code&gt;true&lt;/code&gt; demande une sortie &lt;strong&gt;binaire&lt;/strong&gt; (8 octets bruts) plutôt qu&#039;hexadécimale. &lt;code&gt;unpack(&#039;J&#039;, …)&lt;/code&gt; interprète ensuite ces 8 octets comme un entier 64 bits non signé, en Big-endian.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1101000100000011111000001000010101011010001111100011111100000011
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Voilà les 64 réponses de ce shingle : oui à la question n°0 (le bit le plus à droite vaut 1), oui à la n°1, non à la n°2, etc.&lt;/p&gt;
&lt;h3&gt;Pourquoi pas  &lt;code&gt;hexdec()&lt;/code&gt; ?&lt;/h3&gt;
&lt;p&gt;Le réflexe naturel serait plutôt d&#039;écrire ceci :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$hash &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; hexdec&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;hash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;xxh3&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $shingle)); &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// ✗ NON&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Et ça ne marche pas :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;var_dump&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;hash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;xxh3&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;le chat mange&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;));       &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// string(16) &quot;d103e0855a3e3f03&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;var_dump&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;hexdec&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;d103e0855a3e3f03&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;));         &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// float(1.5061128442206372E+19)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;hexdec()&lt;/code&gt; renvoie un &lt;strong&gt;float&lt;/strong&gt; dès que la valeur dépasse &lt;code&gt;PHP_INT_MAX&lt;/code&gt;. Or un float sur 64 bits n&#039;a que 53 bits de mantisse : les 11 bits de poids faible sont perdus. Vos électeurs répondent alors n&#039;importe quoi aux 11 dernières questions, et vous passez la soirée à chercher pourquoi l&#039;algorithme marche mal.&lt;/p&gt;
&lt;p&gt;Avec &lt;code&gt;unpack(&#039;J&#039;, …)&lt;/code&gt;, on récupère un vrai &lt;code&gt;int&lt;/code&gt;. Il sera parfois négatif, PHP n&#039;ayant pas d&#039;entiers non signés, mais ce n&#039;est pas grave : c&#039;est la &lt;strong&gt;configuration des bits&lt;/strong&gt; qui nous intéresse, pas la valeur numérique. Et &lt;code&gt;($hash &amp;gt;&amp;gt; $bit) &amp;amp; 1&lt;/code&gt; lit correctement n&#039;importe quel bit, même quand le nombre est négatif.&lt;/p&gt;
&lt;h2&gt;Étape 3 : compter les votes&lt;/h2&gt;
&lt;p&gt;On tient un compteur par question, initialisé à zéro. Chaque électeur qui répond « oui » l&#039;incrémente, chaque « non » le décrémente.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$bits &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 63&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$votes &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; array_fill&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $bits, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;foreach&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($shingles &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $shingle) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $hash &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; unpack&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;J&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;hash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;xxh3&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $shingle, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;))[&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    for&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($bit &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;; $bit &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $bits; &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$bit) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; ===&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (($hash &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $bit) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x26;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            ++&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$votes[$bit];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        } &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            --&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$votes[$bit];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Le &lt;code&gt;+1 / -1&lt;/code&gt; n&#039;est pas un détail de style. Si on se contentait de compter les « oui », il faudrait ensuite comparer à la moitié du nombre d&#039;électeurs. Avec &lt;code&gt;+1 / -1&lt;/code&gt;, le seuil est simplement zéro : un compteur positif signifie que les « oui » l&#039;emportent. C&#039;est plus simple à écrire et à lire.&lt;/p&gt;
&lt;p&gt;C&#039;est aussi ici qu&#039;on pourrait &lt;strong&gt;pondérer&lt;/strong&gt; les électeurs. Rien n&#039;oblige à voter par pas de 1 : on peut donner plus de poids aux shingles rares (à la TF-IDF), ou à ceux qui apparaissent dans un &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;. C&#039;est la version pondérée de SimHash, et c&#039;est une extension naturelle de ce &lt;code&gt;++$votes[$bit]&lt;/code&gt;. Dans notre cas, le vote uniforme suffisait largement.&lt;/p&gt;
&lt;h2&gt;Étape 4 : construire l&#039;empreinte&lt;/h2&gt;
&lt;p&gt;Il ne reste qu&#039;à convertir les compteurs en bits :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$fingerprint &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($bit &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;; $bit &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $bits; &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$bit) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($votes[$bit] &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $fingerprint &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;|=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &amp;#x3C;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $bit;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;1 &amp;lt;&amp;lt; $bit&lt;/code&gt; fabrique un masque avec un seul bit à 1, à la position voulue, et le &lt;code&gt;|=&lt;/code&gt; l&#039;allume dans l&#039;empreinte. Les compteurs négatifs ou nuls laissent le bit à 0.&lt;/p&gt;
&lt;h2&gt;La classe complète&lt;/h2&gt;
&lt;p&gt;Assemblons tout ça. Voici, à quelques détails près, ce qui tourne en production chez nous. Nous n&#039;utilisons pas &lt;code&gt;symfony/string&lt;/code&gt; dans la version finale pour des raisons de performances, je m&#039;en suis servi plus haut pour la lisibilité.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;?&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;php&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;final&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;SimHash&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; compute&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $content, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $shingleSize &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 3&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $bits &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 63&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; ?int&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $words &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; preg_split&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;/&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;/&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;mb_strtolower&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;trim&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($content)), &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;PREG_SPLIT_NO_EMPTY&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (\&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($words) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $shingleSize) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            return&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; null&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $votes &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; array_fill&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $bits, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        for&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($i &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $max &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($words) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $shingleSize; $i &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $max; &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$i) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            $shingle &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; implode&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039; &#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;array_slice&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($words, $i, $shingleSize));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            $hash &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; unpack&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;J&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;hash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;xxh3&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $shingle, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;))[&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            for&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($bit &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;; $bit &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $bits; &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$bit) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;                if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; ===&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (($hash &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $bit) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x26;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;                    ++&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$votes[$bit];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                } &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;                    --&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$votes[$bit];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $fingerprint &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        for&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($bit &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;; $bit &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $bits; &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$bit) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($votes[$bit] &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                $fingerprint &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;|=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &amp;#x3C;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $bit;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $fingerprint;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Trente lignes, zéro dépendance, un seul &lt;code&gt;int&lt;/code&gt; en sortie. On peut le stocker dans une colonne &lt;code&gt;BIGINT&lt;/code&gt; et l&#039;oublier.&lt;/p&gt;
&lt;p&gt;Le &lt;code&gt;?int&lt;/code&gt; mérite un mot. Si le texte est plus court que la taille d&#039;un shingle, il n&#039;y a aucun électeur, donc pas d&#039;élection, donc pas d&#039;empreinte. On renvoie &lt;code&gt;null&lt;/code&gt; plutôt qu&#039;un &lt;code&gt;0&lt;/code&gt; qui ressemblerait à une vraie valeur et polluerait toutes nos comparaisons.&lt;/p&gt;
&lt;h2&gt;Comparer deux empreintes : la distance de Hamming&lt;/h2&gt;
&lt;p&gt;Nous savons fabriquer des empreintes. Reste à mesurer à quel point deux d&#039;entre elles se ressemblent.&lt;/p&gt;
&lt;p&gt;La mesure qui nous intéresse est simple : on compte le nombre de bits qui diffèrent. C&#039;est la &lt;strong&gt;distance de Hamming&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A     = 1 0 1 1
B     = 1 0 0 1
        ✓ ✓ ✗ ✓   →  distance = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deux opérations suffisent pour la calculer :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;un XOR (&lt;code&gt;^&lt;/code&gt;), qui met à 1 exactement les bits où les deux nombres diffèrent ;&lt;/li&gt;
&lt;li&gt;un décompte des bits à 1 du résultat, opération qu&#039;on appelle &lt;em&gt;popcount&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Trois façons de compter des bits en PHP&lt;/h3&gt;
&lt;p&gt;PHP n&#039;expose pas de &lt;code&gt;popcount&lt;/code&gt; natif, contrairement au processeur qui a une instruction dédiée. Il y a donc plusieurs options, et le classement m&#039;a surpris.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// 1. La boucle naïve : on regarde le bit de poids faible, on l&#039;ajoute au total,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;//    on décale d&#039;un cran vers la droite, et on recommence jusqu&#039;à épuisement.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; hamming&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $a, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $b)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; int&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $xor &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $a &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $b;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $distance &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    while&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; !==&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $xor) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $distance &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;+=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $xor &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x26;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $xor &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&gt;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $distance;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// 2. Kernighan : $x &amp;#x26; ($x - 1) efface le bit à 1 le plus à droite.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;//    On ne boucle donc qu&#039;autant de fois qu&#039;il y a de bits à 1.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; hammingKernighan&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $a, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $b)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; int&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $xor &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $a &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $b;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $distance &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    while&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; !==&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $xor) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $xor &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x26;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $xor &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        ++&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$distance;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $distance;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// 3. Le tricheur : on délègue tout au moteur.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; hammingSubstr&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $a, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $b)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; int&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; substr_count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;decbin&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($a &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $b), &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;1&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sur 200 000 itérations :&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Implémentation&lt;/th&gt;
&lt;th&gt;Temps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Boucle naïve&lt;/td&gt;
&lt;td&gt;0,752 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kernighan&lt;/td&gt;
&lt;td&gt;0,378 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;substr_count(decbin(...))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0,084 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Le « tricheur » gagne largement, et c&#039;est logique : les deux autres exécutent leur boucle dans la VM PHP, alors que &lt;code&gt;decbin()&lt;/code&gt; et &lt;code&gt;substr_count()&lt;/code&gt; sont du C compilé. En PHP, la boucle la plus rapide est celle qu&#039;on n&#039;écrit pas.&lt;/p&gt;
&lt;p&gt;Cela dit, dans notre code de production, cette fonction ne sert qu&#039;aux tests unitaires.&lt;/p&gt;
&lt;h2&gt;Est-ce que ça marche ?&lt;/h2&gt;
&lt;p&gt;Vérifions sur un cas réaliste. Prenons une fiche produit de 300 mots environ, et fabriquons quatre variantes :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;promo&lt;/strong&gt; : la même page, avec une phrase de bandeau promotionnel en plus (28 mots) ;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ville&lt;/strong&gt; : la même page, où « Lyon » devient « Bordeaux » (1 mot) ;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;femme&lt;/strong&gt; : la déclinaison femme du produit (4 expressions changées) ;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;autre&lt;/strong&gt; : une recette de gâteau au chocolat, qui n&#039;a rien à voir.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Voici les distances de Hamming obtenues, sur 63 bits :&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;base&lt;/th&gt;
&lt;th&gt;promo&lt;/th&gt;
&lt;th&gt;ville&lt;/th&gt;
&lt;th&gt;femme&lt;/th&gt;
&lt;th&gt;autre&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;base&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;promo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ville&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;femme&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;autre&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Le cas qui nous intéressait au départ, un seul mot de différence sur 300, donne une distance de &lt;strong&gt;2&lt;/strong&gt;. Pour rappel, &lt;code&gt;md5()&lt;/code&gt; aurait donné deux empreintes totalement étrangères l&#039;une à l&#039;autre.&lt;/p&gt;
&lt;p&gt;Les variantes plus substantielles, une phrase ajoutée ou une déclinaison produit, se situent entre 5 et 10. Elles se ressemblent, mais moins.&lt;/p&gt;
&lt;p&gt;Le document sans rapport est à 27, soit un peu moins de la moitié de 63. C&#039;est la valeur théorique attendue : deux documents indépendants ont une chance sur deux de tomber d&#039;accord sur chaque question. Sur 300 paires de textes aléatoires, la moyenne est de &lt;strong&gt;30,8&lt;/strong&gt; bits, avec des valeurs entre 20 et 45.&lt;/p&gt;
&lt;p&gt;On obtient donc une grille de lecture assez nette :&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Distance (sur 63 bits)&lt;/th&gt;
&lt;th&gt;Interprétation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Même contenu, ou différences infimes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 à 3&lt;/td&gt;
&lt;td&gt;Quasi-doublon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 à 10&lt;/td&gt;
&lt;td&gt;Documents apparentés&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~31&lt;/td&gt;
&lt;td&gt;Aucun rapport&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Bien choisir ses paramètres&lt;/h2&gt;
&lt;h3&gt;La taille des shingles&lt;/h3&gt;
&lt;p&gt;C&#039;est un arbitrage entre sensibilité et robustesse. Un shingle de 1 mot ignore complètement l&#039;ordre, nous l&#039;avons vu : deux textes aux mots mélangés donnent une distance de 0. Un shingle de 8 mots est si spécifique que la moindre reformulation fait tout basculer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3 mots&lt;/strong&gt; est la valeur qu&#039;on retrouve un peu partout dans la littérature, et c&#039;est ce que nous utilisons. Ça capture les tournures de phrase sans être hypersensible.&lt;/p&gt;
&lt;h3&gt;La longueur minimale du document&lt;/h3&gt;
&lt;p&gt;C&#039;est le paramètre qu&#039;on oublie, et c&#039;est le plus important. Reprenons l&#039;élection : à 300 votants le résultat est stable, à 5 votants il bascule dès qu&#039;une personne change d&#039;avis.&lt;/p&gt;
&lt;p&gt;Même modification, un mot changé, sur des documents de longueur croissante :&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Longueur du document&lt;/th&gt;
&lt;th&gt;Distance après un mot changé&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;20 mots&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 mots&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 mots&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200 mots&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500 mots&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 000 mots&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 000 mots&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;SimHash n&#039;est fiable que sur des textes longs.&lt;/strong&gt; Sur 20 mots, changer un mot déplace l&#039;empreinte de 8 bits, bien au-delà du seuil que nous allons fixer, alors que les deux textes sont presque identiques. Sur 2 000 mots, la même modification est complètement absorbée.&lt;/p&gt;
&lt;p&gt;C&#039;est pour ça que notre code refuse de calculer une empreinte en dessous de 20 mots. En dessous, le résultat est du bruit, et un faux positif dans un rapport SEO coûte plus cher qu&#039;une détection manquée.&lt;/p&gt;
&lt;h3&gt;Le seuil de décision&lt;/h3&gt;
&lt;p&gt;Reste à trancher : à partir de quelle distance déclare-t-on un quasi-doublon ?&lt;/p&gt;
&lt;p&gt;Nous avons retenu &lt;strong&gt;3&lt;/strong&gt;, la même valeur que dans le papier de Google. Le tableau plus haut montre pourquoi c&#039;est raisonnable : à 3 bits sur 63, on attrape « un mot a changé » (distance 2) sans attraper « c&#039;est une variante du produit » (distance 5). Et on reste très loin des 31 bits du hasard.&lt;/p&gt;
&lt;p&gt;Ce seuil dépend de vos données et de ce que vous préférez rater. Montez-le pour ratisser plus large, descendez-le si les faux positifs vous coûtent cher. C&#039;est un réglage empirique, il faut le mesurer sur votre corpus.&lt;/p&gt;
&lt;h3&gt;63 bits, pas 64&lt;/h3&gt;
&lt;p&gt;Vous avez peut-être tiqué sur le &lt;code&gt;$bits = 63&lt;/code&gt; par défaut, alors que je vous parle de 64 bits depuis le début. C&#039;est volontaire :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;printf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;%d&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &amp;#x3C;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 62&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;); &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;//  4611686018427387904&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;printf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;%d&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &amp;#x3C;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 63&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;); &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// -9223372036854775808  ← aïe&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PHP n&#039;a pas d&#039;entiers non signés. Le bit 63 est le bit de signe : l&#039;allumer rend l&#039;empreinte négative. Ça ne casse pas l&#039;algorithme en soi, le XOR et le popcount se moquent du signe, mais ça complique tout le reste de la chaîne :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;le décalage à droite &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt; propage le bit de signe ;&lt;/li&gt;
&lt;li&gt;la sérialisation JSON devient bizarre ;&lt;/li&gt;
&lt;li&gt;le stockage en base échoue si la colonne est un &lt;code&gt;UNSIGNED BIGINT&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;En se limitant aux bits 0 à 62, l&#039;empreinte reste dans &lt;code&gt;[0, PHP_INT_MAX]&lt;/code&gt;. Elle rentre sans discussion dans un &lt;code&gt;BIGINT&lt;/code&gt; signé, un &lt;code&gt;UInt64&lt;/code&gt; ClickHouse ou un &lt;code&gt;bigint&lt;/code&gt; PostgreSQL. On perd un bit sur 64, soit 1,5 % de précision, et on s&#039;épargne toute une catégorie de bugs.&lt;/p&gt;
&lt;h2&gt;Comparer à l&#039;échelle&lt;/h2&gt;
&lt;p&gt;Nous savons calculer des empreintes, mais nous n&#039;avons pas encore trouvé les doublons.&lt;/p&gt;
&lt;p&gt;Pour trouver toutes les paires de pages proches, il faut comparer toutes les paires. Avec N pages, ça fait N × (N-1) / 2 comparaisons. Sur un crawl de 1 000 pages, c&#039;est 500 000 comparaisons et PHP s&#039;en sort. Sur 100 000 pages, c&#039;est 5 milliards, et c&#039;est mort.&lt;/p&gt;
&lt;p&gt;Il n&#039;y a pas non plus d&#039;astuce d&#039;indexation évidente, parce que la distance de Hamming n&#039;est pas un ordre. Deux empreintes voisines peuvent être numériquement très éloignées : il suffit que ce soit le bit de poids fort qui diffère. Un &lt;code&gt;BETWEEN&lt;/code&gt; ou un index B-tree classique ne servent à rien.&lt;/p&gt;
&lt;p&gt;Notre solution tient en une phrase : &lt;strong&gt;on ne le fait pas en PHP&lt;/strong&gt;. On laisse la base de données s&#039;en charger. Elle sait faire du XOR et du popcount nativement, sur des colonnes entières, en parallèle, sans jamais rapatrier une ligne en mémoire PHP.&lt;/p&gt;
&lt;h3&gt;Chaque base a sa fonction&lt;/h3&gt;
&lt;p&gt;Toutes les bases sérieuses savent compter des bits, même MySQL. Il faut juste connaître le nom local de la fonction. Chacune de ces requêtes renvoie exactement les mêmes distances que notre code PHP.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MySQL / MariaDB&lt;/strong&gt; : &lt;code&gt;BIT_COUNT()&lt;/code&gt; fait le popcount, et &lt;code&gt;^&lt;/code&gt; le XOR.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;SELECT&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;BIT_COUNT&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ^ &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;AS&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; distance&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; a&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;JOIN&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; b &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;ON&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;WHERE&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; BIT_COUNT&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ^ &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 3&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt; (14 et plus) : &lt;code&gt;bit_count()&lt;/code&gt; existe, mais travaille sur des chaînes de bits, d&#039;où le cast. Attention, le XOR sur les entiers s&#039;écrit &lt;code&gt;#&lt;/code&gt; et non &lt;code&gt;^&lt;/code&gt;, qui est l&#039;exponentiation.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;SELECT&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;bit_count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; # &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)::&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;bit&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;64&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;AS&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; distance&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; a&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;JOIN&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; b &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;ON&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;WHERE&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; bit_count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; # &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)::&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;bit&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;64&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 3&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;ClickHouse&lt;/strong&gt; : &lt;code&gt;bitCount()&lt;/code&gt; et &lt;code&gt;bitXor()&lt;/code&gt;, tout en camelCase.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;SELECT&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, bitCount(bitXor(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;AS&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; distance&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; a&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;JOIN&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; b &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;ON&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;crawlId&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;crawlId&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;WHERE&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; bitCount(bitXor(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 3&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;SQLite&lt;/strong&gt; : c&#039;est le seul de la bande à n&#039;avoir aucun popcount intégré. Il faut enregistrer une fonction utilisateur depuis PHP.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$pdo&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;sqliteCreateFunction&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;hamming&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;static&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $a, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $b)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; substr_count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;decbin&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($a &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $b), &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;1&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ça fonctionne, mais on repasse par la VM PHP à chaque ligne, et on perd tout l&#039;intérêt de la manœuvre. Pour ce genre de travail, SQLite n&#039;est pas le bon outil.&lt;/p&gt;
&lt;h3&gt;Ce que nous faisons en production&lt;/h3&gt;
&lt;p&gt;Chez nous, les données de crawl vivent dans ClickHouse et la détection tourne à la fin du crawl. Le code ressemble à ça :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$sql &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &amp;#x3C;&amp;#x3C;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&#039;SQL&#039;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    SELECT DISTINCT&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; AS&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; url&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    FROM&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; crawl_urls a&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    INNER JOIN&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; crawl_urls b &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;ON&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;crawlId&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;crawlId&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    WHERE&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-3&quot;&gt;        a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;projectId&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; :projectId&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        AND&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;crawlId&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; :crawlId&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        AND&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;contentSimhash&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; IS NOT NULL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        AND&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;contentSimhash&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; IS NOT NULL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        AND&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; !=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        AND&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; bitCount(bitXor(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;contentSimhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;contentSimhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; :threshold&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    SQL&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Trois remarques sur cette requête :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;a.url != b.url&lt;/code&gt; évite qu&#039;une page soit son propre doublon. Sa distance à elle-même vaut 0, elle passerait tous les seuils du monde ;&lt;/li&gt;
&lt;li&gt;le &lt;code&gt;IS NOT NULL&lt;/code&gt; des deux côtés, c&#039;est notre &lt;code&gt;?int&lt;/code&gt; de tout à l&#039;heure qui revient. Les pages trop courtes n&#039;ont pas d&#039;empreinte, et elles ne doivent pas participer ;&lt;/li&gt;
&lt;li&gt;le &lt;code&gt;SELECT DISTINCT&lt;/code&gt; et l&#039;absence de &lt;code&gt;a.url &amp;lt; b.url&lt;/code&gt; : ici nous ne cherchons pas les paires, nous voulons &lt;strong&gt;marquer&lt;/strong&gt; les pages concernées d&#039;un drapeau &lt;code&gt;contentNearDuplicated&lt;/code&gt;. Chaque page qui a au moins un voisin proche est signalée.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Le garde-fou&lt;/h3&gt;
&lt;p&gt;Cette requête reste un produit cartésien. ClickHouse est rapide, mais N² finit toujours par gagner. Nous avons donc mis une limite très bête, mais très efficace :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($eligibleCount &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; CrawlConstants&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;MAX_URLS_FOR_NEAR_DUPLICATE_DETECTION&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-11&quot;&gt;    $this&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;logger&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;info&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Skipping near-duplicate content detection: too many eligible URLs.&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;        &#039;crawlId&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $crawl&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;id,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;        &#039;eligibleCount&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $eligibleCount,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    ]);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Au-delà de 25 000 pages, nous ne faisons pas la détection de quasi-doublons. C&#039;est un compromis assumé : cette analyse tourne sur le chemin critique de fin de crawl, et il vaut mieux une fonctionnalité absente qu&#039;un crawl qui ne se termine jamais. La détection de doublons &lt;strong&gt;exacts&lt;/strong&gt;, elle, n&#039;est qu&#039;un &lt;code&gt;GROUP BY&lt;/code&gt; : elle continue de tourner quel que soit le volume.&lt;/p&gt;
&lt;h2&gt;Aller plus loin : le principe des tiroirs&lt;/h2&gt;
&lt;p&gt;Et si 25 000 pages ne suffisaient pas ? Il existe une astuce, décrite dans le papier de Google, qui permet d&#039;indexer les recherches par distance de Hamming.&lt;/p&gt;
&lt;p&gt;Le &lt;strong&gt;principe des tiroirs&lt;/strong&gt; dit que si vous rangez 3 chaussettes dans 4 tiroirs, au moins un tiroir est forcément vide.&lt;/p&gt;
&lt;p&gt;Appliquons-le. On découpe nos 63 bits en &lt;strong&gt;4 blocs&lt;/strong&gt;. Si deux empreintes diffèrent d&#039;au plus &lt;strong&gt;3 bits&lt;/strong&gt;, alors ces 3 bits se répartissent dans au plus 3 blocs, donc &lt;strong&gt;au moins un bloc est strictement identique&lt;/strong&gt; entre les deux empreintes. Et un bloc identique, un index B-tree classique sait le trouver instantanément.&lt;/p&gt;
&lt;p&gt;On stocke donc les 4 blocs dans 4 colonnes indexées :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; TABLE&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    url&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;     text&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    simhash &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;bigint&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    b0 &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; GENERATED&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; ALWAYS&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; AS&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ((simhash &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 48&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &amp;#x26; &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;32767&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) STORED,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    b1 &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; GENERATED&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; ALWAYS&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; AS&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ((simhash &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 32&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &amp;#x26; &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;65535&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) STORED,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    b2 &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; GENERATED&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; ALWAYS&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; AS&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ((simhash &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 16&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &amp;#x26; &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;65535&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) STORED,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    b3 &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; GENERATED&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; ALWAYS&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; AS&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (simhash &amp;#x26; &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;65535&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) STORED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; INDEX&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; ON&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (b0);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; INDEX&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; ON&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (b1);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; INDEX&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; ON&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (b2);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;CREATE&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; INDEX&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; ON&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (b3);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La recherche se fait ensuite en deux temps : un &lt;strong&gt;filtrage&lt;/strong&gt; par index pour récupérer une poignée de candidats, puis une &lt;strong&gt;vérification&lt;/strong&gt; exacte sur ce petit paquet.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;SELECT&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; p&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;bit_count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; # :&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)::&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;bit&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;64&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;AS&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; distance&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;FROM&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; p&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;WHERE&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-3&quot;&gt;       p&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b0&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 48&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &amp;#x26; &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;32767&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    OR&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; p&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b1&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 32&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &amp;#x26; &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;65535&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    OR&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; p&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b2&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &gt;&gt;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 16&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &amp;#x26; &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;65535&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    OR&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; p&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;b3&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; :&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; &amp;#x26; &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;65535&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;  )&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  AND&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; bit_count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;simhash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; # :&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)::&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;bit&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;64&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 3&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Le &lt;code&gt;WHERE&lt;/code&gt; avec les &lt;code&gt;OR&lt;/code&gt; passe par les index et ne remonte qu&#039;un nombre réduit de lignes. La dernière condition, coûteuse, ne s&#039;applique plus qu&#039;à ces candidats. On est passé d&#039;un balayage complet à une recherche indexée.&lt;/p&gt;
&lt;p&gt;Un point mérite d&#039;être souligné : ce filtrage n&#039;a &lt;strong&gt;aucun faux négatif&lt;/strong&gt;. Ce n&#039;est pas une heuristique, c&#039;est une garantie mathématique. Toute paire à distance ≤ 3 partage forcément au moins un bloc. Sur 25 000 paires d&#039;empreintes générées à distance ≤ 3, les 25 000 partagent au moins un bloc identique.&lt;/p&gt;
&lt;p&gt;En contrepartie, la méthode ne marche que pour le seuil pour lequel on l&#039;a dimensionnée. Pour un seuil de 3 il faut 4 blocs, pour un seuil de 7 il en faudrait 8, avec 8 index. Le coût en stockage et en écriture grimpe vite. C&#039;est le genre d&#039;optimisation qu&#039;on met en place quand on l&#039;a mesurée nécessaire, pas avant.&lt;/p&gt;
&lt;h2&gt;Ce que SimHash ne sait pas faire&lt;/h2&gt;
&lt;p&gt;Il y a des limites, autant les connaître :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ce n&#039;est pas une mesure de similarité fine.&lt;/strong&gt; SimHash répond « proche » ou « pas proche », pas « similaire à 73 % ». La distance de Hamming approxime la similarité cosinus, mais l&#039;approximation est grossière dans les valeurs intermédiaires. Si vous avez besoin d&#039;un vrai score, il vous faut autre chose ;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ça ne comprend rien au sens.&lt;/strong&gt; Deux textes qui disent la même chose avec des mots différents auront des empreintes sans rapport. SimHash compare des suites de mots, pas des idées. Pour de la similarité sémantique, il faut regarder du côté des embeddings vectoriels, avec un autre budget ;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ce n&#039;est pas cryptographique.&lt;/strong&gt; SimHash est conçu pour que des entrées proches donnent des sorties proches, c&#039;est-à-dire l&#039;inverse des propriétés qu&#039;on attend d&#039;une fonction de hachage sécurisée. Fabriquer une collision est trivial. Ne l&#039;utilisez jamais pour de la sécurité.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Il existe enfin une alternative sérieuse : &lt;strong&gt;MinHash&lt;/strong&gt;, qui approxime la similarité de Jaccard plutôt que la similarité cosinus. Elle donne un score plus exploitable, mais elle demande de stocker plusieurs dizaines de valeurs par document, là où SimHash tient dans un seul entier. Pour poser un drapeau booléen sur des pages web, l&#039;entier unique gagne largement.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;SimHash tient en une idée : &lt;strong&gt;faire voter les morceaux d&#039;un document, bit par bit&lt;/strong&gt;. Quelques électeurs qui changent d&#039;avis ne renversent pas le scrutin, donc deux textes proches produisent deux empreintes proches. Le problème du « contenu presque identique » se ramène alors à compter des bits qui diffèrent.&lt;/p&gt;
&lt;p&gt;C&#039;est cette réduction qui rend la chose utilisable. Un document devient un &lt;code&gt;BIGINT&lt;/code&gt;. La question « ces pages se ressemblent-elles ? » devient &lt;code&gt;BIT_COUNT(a ^ b) &amp;lt;= 3&lt;/code&gt;, une expression que MySQL, PostgreSQL et ClickHouse évaluent nativement, sans jamais remonter une ligne jusqu&#039;à PHP.&lt;/p&gt;
&lt;p&gt;Chez nous, ça représente trente lignes de PHP à l&#039;écriture, une requête SQL à la lecture, et un garde-fou à 25 000 URL pour dormir tranquille. Ce n&#039;est ni sophistiqué ni parfait, mais ça fonctionne, ça se relit, et ça détecte effectivement les fiches produit qui ne diffèrent que par le nom d&#039;une ville.&lt;/p&gt;
&lt;p&gt;Un algorithme de 2002 qui fait toujours le travail. Pas mal non ?&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/quand-le-cache-de-symfony-ralentit-votre-application</id>
        <published>2026-08-12T10:42:00+02:00</published>
        <updated>2026-08-12T10:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/quand-le-cache-de-symfony-ralentit-votre-application"/>
        <title>Quand le cache de Symfony ralentit votre application...</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="symfony" />            <category term="performance" />            <category term="cache" />        <summary><![CDATA[Mettre une valeur en cache, c&#039;est toujours plus rapide, non ? Et bien, pas forcément ! Cet article partage l&#039;analyse d&#039;un problème de performance causé par la protection &amp;quot;anti-stampede&amp;quot; du composant…]]></summary>
        <content type="html">
            &lt;p&gt;Mettre une valeur en cache, c&#039;est toujours plus rapide, non ? Et bien, pas forcément ! Cet article partage l&#039;analyse d&#039;un problème de performance causé par la protection &amp;quot;anti-stampede&amp;quot; du composant Cache de Symfony, et sur la manière dont nous l&#039;avons diagnostiqué puis corrigé.&lt;/p&gt;
&lt;p&gt;Nous avons récemment corrigé un problème de lenteur sur une application Symfony en production. Le diagnostic nous a pris un certain temps, car la cause était contre-intuitive : le responsable était le composant Cache de Symfony, ou plus précisément sa protection contre le &lt;em&gt;cache stampede&lt;/em&gt;, un mécanisme que nous ne connaissions pas vraiment avant cet épisode.&lt;/p&gt;
&lt;p&gt;Comme il est peu documenté et qu&#039;il peut concerner beaucoup d&#039;applications, voici le détail du problème, la démarche de diagnostic, et le correctif que nous avons retenu.&lt;/p&gt;
&lt;h2&gt;Le symptôme : une médiathèque très lente&lt;/h2&gt;
&lt;p&gt;Sur cette application, la médiathèque de l&#039;admin est propulsée par &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://mediabundle.jolicode.com/&quot;&gt;JoliMediaBundle&lt;/a&gt;. Depuis quelque temps, son affichage était devenu très lent : à l&#039;ouverture d&#039;un dossier de médias, le premier affichage prenait entre 10 et 20 secondes de TTFB (&lt;em&gt;Time To First Byte&lt;/em&gt;), parfois davantage. Les circonstances de cette lenteur étaient assez curieuses :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;la &lt;em&gt;première&lt;/em&gt; visite d&#039;un dossier était lente, mais les visites suivantes étaient instantanées ;&lt;/li&gt;
&lt;li&gt;l&#039;environnement de préproduction, pourtant identique (même code, même configuration, même stockage), était parfaitement fluide ;&lt;/li&gt;
&lt;li&gt;sans lien apparent, d&#039;autres parties de l&#039;application souffraient de lenteurs &lt;em&gt;aléatoires&lt;/em&gt; : un même endpoint d&#039;API répondait tantôt en 100 ms, tantôt en 5 secondes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Un problème qui ne se reproduit ni en local ni en préproduction, et qui frappe au hasard : le diagnostic s&#039;annonçait laborieux 😅&lt;/p&gt;
&lt;h2&gt;Les fausses pistes&lt;/h2&gt;
&lt;p&gt;Une médiathèque lente, des fichiers sur un montage réseau : le suspect naturel, c&#039;est le stockage. C&#039;est donc par là que j&#039;ai commencé : vérification des options de montage, mesure des I/O, benchmark de lecture ou d&#039;écriture des fichiers... tout allait bien de ce côté.&lt;/p&gt;
&lt;p&gt;Au passage, ce n&#039;est d&#039;ailleurs pas si surprenant : JoliMediaBundle est conçu pour rester performant même lorsque le stockage est lent. Les variations d&#039;images sont pré-générées, les métadonnées sont mises en cache, et les pages d&#039;admin ne déclenchent pas de traitement d&#039;image à la volée. Il faut chercher ailleurs.&lt;/p&gt;
&lt;p&gt;Deuxième piste : les endpoints d&#039;API aléatoirement lents. L&#039;un d&#039;eux passe par un &lt;em&gt;transformer&lt;/em&gt; qui agrège des données coûteuses à calculer - données mises en cache applicatif pour éviter de refaire le calcul à chaque requête. Persuadés que les requêtes SQL sous-jacentes étaient le goulot d&#039;étranglement, nous avons ouvert plusieurs pull requests pour les optimiser : index, réécriture de requêtes, réduction du nombre d&#039;allers-retours...&lt;/p&gt;
&lt;p&gt;Résultat : des requêtes plus propres, mais aucune amélioration mesurable en production. L&#039;endpoint continuait de mettre parfois 5, voire 10 secondes à répondre. Quand une optimisation SQL ne change rien, c&#039;est souvent que le temps n&#039;est pas passé dans le SQL.&lt;/p&gt;
&lt;h2&gt;Profiler plutôt que supposer&lt;/h2&gt;
&lt;p&gt;Après ces deux échecs, on a fait ce que nous aurions dû faire dès le début : profiler les transactions lentes en production, plutôt que d&#039;empiler les hypothèses. Sur une trace de la médiathèque, une ligne écrasait toutes les autres :&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Symfony\Component\Cache\LockRegistry::compute&lt;/code&gt; - &lt;strong&gt;14,8 secondes de self-time&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;14,8 secondes passées non pas à calculer quoi que ce soit, mais à attendre un &lt;code&gt;flock()&lt;/code&gt;. L&#039;application ne passe pas son temps à lire des fichiers ni à exécuter des requêtes, elle attend qu&#039;un verrou posé par le composant Cache de Symfony se libère. Le cache, ce composant que l&#039;on ajoute précisément pour aller &lt;em&gt;plus vite&lt;/em&gt;, est donc d&#039;un coup devenu notre goulot d&#039;étranglement... Oups !&lt;/p&gt;
&lt;p&gt;Il est temps d&#039;aller lire son code pour comprendre ce qui se passe dans une &lt;code&gt;$cache-&amp;gt;get()&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;LockRegistry&lt;/code&gt;, la protection anti-stampede de Symfony&lt;/h2&gt;
&lt;p&gt;Avant ce debugging, je n&#039;étais pas vraiment familier de cette classe et ne savais pas précisément ce qui se passe lorsqu&#039;on écrit ce code pourtant banal :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$value &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt; $this&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;my_key&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;ItemInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $item)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; array&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $item&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;expiresAfter&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;3600&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt; $this&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;computeSomethingExpensive&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Imaginez une clé de cache très demandée qui expire. Au moment de l&#039;expiration, toutes les requêtes en cours constatent simultanément le &lt;em&gt;cache miss&lt;/em&gt;, et toutes lancent le recalcul de la valeur en parallèle. Si le calcul est coûteux (une grosse requête SQL, un appel d&#039;API externe...), des dizaines de processus exécutent alors le même calcul au même moment, et saturent la base de données ou le service distant. C&#039;est le &lt;em&gt;&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://en.wikipedia.org/wiki/Cache_stampede&quot;&gt;cache stampede&lt;/a&gt;&lt;/em&gt; (la ruée vers le cache), et c&#039;est un vrai problème.&lt;/p&gt;
&lt;p&gt;Heureusement, Symfony propose contre ce phénomène deux protections complémentaires:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;l&#039;expiration probabiliste anticipée (le paramètre &amp;quot;&lt;code&gt;$beta&lt;/code&gt;&amp;quot; de &lt;code&gt;CacheInterface::get()&lt;/code&gt;) : ce paramètre permet de moduler la probabilité qu&#039;une clé de cache soit re-calculée en avance, même si elle n&#039;est pas encore expirée. Plus une valeur approche de sa date d&#039;expiration, plus il devient probable qu&#039;une requête la recalcule &lt;em&gt;avant&lt;/em&gt; l&#039;expiration, ce qui lisse les recalculs dans le temps et évite que tous les recalculs soient groupés à heure fixe ;&lt;/li&gt;
&lt;li&gt;le &lt;code&gt;LockRegistry&lt;/code&gt; : au moment de recalculer une valeur, un verrou est posé pour que le premier arrivé calcule pendant que les autres attendent le résultat, plutôt que de tous calculer chacun de leur côté. Ainsi, si deux requêtes HTTP nécessitent le recalcul de la clé de cache &amp;quot;foo&amp;quot;, la première qui arrive pose un verrou et lance le calcul, tandis que la seconde attend que le verrou se libère pour lire la valeur recalculée. Le recalcul n&#039;est donc effectué qu&#039;une seule fois.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/cache-lock/cache-lock-flow.4221c45d.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/cache-lock/cache-lock-flow.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 714px; ; aspect-ratio: calc(714 / 835)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/cache-lock/cache-lock-flow.png&quot; alt=&quot;Le diagramme de séquence d&#039;une collision de verrous&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Le principe est sain. C&#039;est son implémentation qu&#039;il faut connaître pour comprendre notre problème.&lt;/p&gt;
&lt;h3&gt;Des verrous posés sur... les fichiers du vendor&lt;/h3&gt;
&lt;p&gt;Comment poser un verrou partagé entre tous les processus PHP d&#039;une machine (et oui... si un worker et une requête HTTP sont tous deux susceptibles de recalculer une clé de cache, il faut trouver un moyen de partager les verrous entre ces processus), sans dépendre d&#039;un service externe ? La réponse apportée par la classe &lt;code&gt;LockRegistry&lt;/code&gt; est astucieuse : en posant des &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.php.net/manual/fr/function.flock.php&quot;&gt;&lt;code&gt;flock()&lt;/code&gt;&lt;/a&gt; sur des fichiers dont on est sûr qu&#039;ils existent sur toutes les installations... les fichiers PHP du composant Cache lui-même !&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// vendor/symfony/cache/LockRegistry.php&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; static&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; array&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $files &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-3&quot;&gt;    __DIR__&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;\DIRECTORY_SEPARATOR&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Adapter&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;\DIRECTORY_SEPARATOR&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;AbstractAdapter.php&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-3&quot;&gt;    __DIR__&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;\DIRECTORY_SEPARATOR&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Adapter&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;\DIRECTORY_SEPARATOR&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;AbstractTagAwareAdapter.php&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-3&quot;&gt;    __DIR__&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;\DIRECTORY_SEPARATOR&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Adapter&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;\DIRECTORY_SEPARATOR&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;AdapterInterface.php&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // ... la liste des fichiers du dossier Adapter/ du composant, soit 24 fichiers actuellement&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;];&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Chaque clé de cache est affectée à l&#039;un de ces fichiers par un modulo sur son hash :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$key &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; self&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$files &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; abs&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;crc32&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($item&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getKey&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;())) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$files) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Il faut bien mesurer ce que cela implique :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;il n&#039;existe que 24 &amp;quot;slots&amp;quot; de verrous pour toute la machine ;&lt;/li&gt;
&lt;li&gt;ces slots sont partagés par toutes les clés de cache, tous les pools (le pool Redis de vos données métier, le pool système, ceux de vos bundles...), et tous les processus PHP de l&#039;hôte - php-fpm comme CLI ;&lt;/li&gt;
&lt;li&gt;deux clés qui n&#039;ont rien à voir l&#039;une avec l&#039;autre peuvent tomber sur le même slot, par simple collision de &lt;code&gt;crc32() % 24&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Autrement dit : quand un processus recalcule une valeur, il tient un verrou que n&#039;importe quel autre recalcul, de n&#039;importe quelle autre clé de la machine, a environ une chance sur 24 de devoir attendre. Si le calcul dure quelques millisecondes, personne ne le remarque. S&#039;il dure plusieurs secondes, tout le monde peut le payer.&lt;/p&gt;
&lt;p&gt;Ce mécanisme explique au passage l&#039;un de nos symptômes : la médiathèque était rapide en &lt;em&gt;revisite&lt;/em&gt; parce que le verrou n&#039;est pris qu&#039;au moment de recalculer une valeur. Tant que la clé est chaude dans le cache, aucun verrou n&#039;est sollicité. Seuls les &lt;em&gt;cache miss&lt;/em&gt; paient l&#039;addition.&lt;/p&gt;
&lt;h2&gt;À l&#039;assaut des coupables : des workers de calculs coûteux&lt;/h2&gt;
&lt;p&gt;Reste maintenant à comprendre qui monopolise ces verrous. Sur notre application, les serveurs frontaux ne font pas que servir du HTTP : ils font aussi tourner les workers &amp;quot;Messenger&amp;quot; - une dizaine de processus par machine, qui consomment des messages en continu. Parmi ces messages, certains déclenchent des résolutions DNS effectuées en PHP, dont les résultats sont mis en cache applicatif avec un &lt;abbr title=&quot;Time To Live&quot;&gt;TTL&lt;/abbr&gt; de 120 secondes, pour éviter de solliciter inutilement les serveurs de noms. Le traitement de certains messages peut même nécessiter plusieurs résolutions DNS, et donc plusieurs accès au cache.&lt;/p&gt;
&lt;p&gt;Faisons le calcul :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;une résolution DNS peut être lente : plusieurs requêtes en série vers plusieurs serveurs de noms, avec des timeouts qui se cumulent - le callback de cache peut durer plusieurs secondes ;&lt;/li&gt;
&lt;li&gt;un TTL de 120 secondes sur des milliers de domaines vérifiés en continu, cela signifie des recalculs permanents ;&lt;/li&gt;
&lt;li&gt;10 workers par machine qui enchaînent ces recalculs, cela signifie qu&#039;à tout instant, une bonne partie des 24 slots de &lt;code&gt;LockRegistry&lt;/code&gt; est tenue par un worker en train d&#039;attendre une réponse DNS.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pendant ce temps, côté php-fpm, une requête d&#039;admin arrive : la médiathèque doit calculer les métadonnées d&#039;un dossier froid, appelle &lt;code&gt;$cache-&amp;gt;get()&lt;/code&gt;, tombe par collision sur un slot tenu par un worker... et attend. Parfois quelques centaines de millisecondes, parfois 15 secondes. Pire encore, il peut très bien arriver que, pour un lock donné, plusieurs &amp;quot;perdants&amp;quot; s&#039;accumulent derrière le verrou, chacun attendant que le précédent libère le slot. Le TTFB de la médiathèque devient alors très variable, pouvant même parfois mener à des timeouts côté navigateur.&lt;/p&gt;
&lt;p&gt;Pour vérifier cette hypothèse, nous avons simplement arrêté les workers concernés sur les trois frontaux : la médiathèque est instantanément redevenue rapide 🎉&lt;/p&gt;
&lt;h3&gt;Oui, les workers CLI utilisent &lt;code&gt;LockRegistry&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;En lisant le code du composant, on pourrait croire que la protection anti-stampede est désactivée en CLI : la méthode &lt;code&gt;setCallbackWrapper()&lt;/code&gt; de &lt;code&gt;ContractsTrait&lt;/code&gt; &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/symfony/symfony/blob/7dbebd843d25b2f72e2f7dfbe044c632941ea924/src/Symfony/Component/Cache/Traits/ContractsTrait.php#L47-L49&quot;&gt;contient un opt-out explicite lorsque &lt;code&gt;PHP_SAPI&lt;/code&gt; vaut &lt;code&gt;cli&lt;/code&gt;&lt;/a&gt;, les processus CLI étant supposés courts et peu concurrents.&lt;/p&gt;
&lt;p&gt;Mais cet opt-out ne fonctionne que sur les branches 4.4 et 5.4. Depuis la branche 6.0, le &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/symfony/symfony/commit/3516fc6eb7680a052124cdb5f4f3f4d7078343ac&quot;&gt;passage aux propriétés typées&lt;/a&gt;) a ajouté directement dans &lt;code&gt;doGet()&lt;/code&gt; une initialisation de &lt;code&gt;$this-&amp;gt;callbackWrapper ??= LockRegistry::compute(...);&lt;/code&gt;, et lors du &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/symfony/symfony/commit/eb749ec88b7b4a70babbffd6cca00db54999f01b&quot;&gt;merge de 5.4 dans 6.0&lt;/a&gt;, cette ligne a été conservée. Depuis Symfony 6.0, le wrapper est donc systématiquement initialisé à &lt;code&gt;LockRegistry::compute()&lt;/code&gt; lors de &lt;code&gt;doGet()&lt;/code&gt;, rendant ainsi inopérant l&#039;opt-out pour le CLI. Les workers Messenger, processus CLI de longue durée, utilisent donc bien les mêmes verrous &lt;code&gt;flock()&lt;/code&gt; que php-fpm... Et c&#039;est exactement ce qui pose souci dans notre configuration.&lt;/p&gt;
&lt;h2&gt;Isoler les callbacks lents tout en conservant des locks&lt;/h2&gt;
&lt;p&gt;Évidemment, on ne va pas désactiver la protection anti-stampede de Symfony car elle est très utile. Elle fonctionne très bien pour  protéger de &lt;em&gt;race conditions&lt;/em&gt; lors de recalculs rapides. Le vrai problème, c&#039;est le mélange des genres : des callbacks qui durent plusieurs secondes ne devraient pas partager leurs verrous avec le reste de l&#039;application.&lt;/p&gt;
&lt;p&gt;L&#039;approche que nous avons choisie consiste donc à doter les callbacks lents, peu critiques, de leur propre pool de cache, avec leur propre stratégie de verrouillage. Le reste de l&#039;application continue de bénéficier de &lt;code&gt;LockRegistry&lt;/code&gt; pour des callbacks rapides.&lt;/p&gt;
&lt;h3&gt;Un pool dédié&lt;/h3&gt;
&lt;p&gt;Premier ingrédient du correctif : un pool de cache spécifique pour le résolveur DNS - même serveur Redis que le pool partagé, mais un adapter distinct :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# config/packages/cache.yaml&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;framework&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    cache&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        pools&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            dns.cache&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;                adapter&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;cache.adapter.redis&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;                provider&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;redis://%env(REDIS_HOST)%&#039;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Le service consommateur cible explicitement ce pool grâce à l&#039;attribut &lt;code&gt;#[Target]&lt;/code&gt; :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;final&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; readonly&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;DnsResolver&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; __construct&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        #[Target(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;dns.cache&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        private&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; CacheInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $cache,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        ...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    ) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Remplacer LockRegistry par un verrouillage basé sur les clés&lt;/h3&gt;
&lt;p&gt;Second ingrédient : remplacer, sur ce pool uniquement, la stratégie de &lt;code&gt;LockRegistry&lt;/code&gt; par un verrouillage &lt;strong&gt;par clé de cache&lt;/strong&gt;, en s&#039;appuyant sur le composant &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://symfony.com/doc/current/components/lock.html&quot;&gt;Lock&lt;/a&gt; et un store Redis. Deux résolutions DNS de domaines différents peuvent ainsi se calculer en parallèle sans se gêner, et surtout sans gêner personne d&#039;autre.&lt;/p&gt;
&lt;p&gt;Les adapters de cache de Symfony exposent le point d&#039;extension qu&#039;il nous faut : &lt;code&gt;AbstractAdapter::setCallbackWrapper()&lt;/code&gt;, qui permet de substituer son propre callable à &lt;code&gt;LockRegistry::compute()&lt;/code&gt;. Notre wrapper en reproduit la sémantique :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;final&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; readonly&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;DnsCacheStampedeProtection&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    private&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; const&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; int&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; RETRY_DELAY_US&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 100_000&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; __construct&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        private&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; LockFactory&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $lockFactory,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        private&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; float&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $lockTtl &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 30.0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        private&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; float&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $maxWait &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 10.0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    ) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; __invoke&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;callable&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $callback, &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;ItemInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $item, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; &amp;#x26;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$save, &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;CacheInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;&amp;#x26;&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;CacheItemPoolInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $pool, \&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Closure&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $setMetadata, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;LoggerInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $logger &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; null&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;?float&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $beta &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; null&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; mixed&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $lock &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt; $this&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;lockFactory&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;createLock&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;dns-cache:&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; .&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $item&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getKey&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(), &lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;lockTtl);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $deadline &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; microtime&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt; $this&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;maxWait;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        while&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($lock&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;acquire&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;                // nous avons gagné la course : on calcule, on sauve, on libère&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;                try&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                    $value &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $callback($item, $save);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;                    if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($save) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                        $setMetadata($item);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                        $pool&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;save&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($item&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($value));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                        $save &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; false&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;                    return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $value;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                } &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;finally&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                    $lock&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;release&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;                }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;            // quelqu&#039;un d&#039;autre calcule cette clé : on attend un peu, puis on&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;            // tente de relire la valeur qu&#039;il a sauvegardée (avec beta = 0,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;            // pour ne pas déclencher d&#039;expiration anticipée)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;            usleep&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;RETRY_DELAY_US&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;            // ... relecture du pool, et calcul sans verrou si le délai&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;            // d&#039;attente maximal est dépassé : le verrouillage ne doit&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;            // jamais empêcher d&#039;obtenir une valeur&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;L&#039;extrait ci-dessus est abrégé ; la version complète gère la relecture du pool avec &lt;code&gt;beta = 0&lt;/code&gt;, le timeout d&#039;attente et un mode dégradé : si le store de verrous est injoignable ou si l&#039;attente dépasse &lt;code&gt;$maxWait&lt;/code&gt;, on calcule sans verrou. Une protection anti-stampede qui empêcherait l&#039;application de fonctionner serait en effet un remède pire que le mal.&lt;/p&gt;
&lt;h3&gt;Une compiler pass pour installer ce wrapper&lt;/h3&gt;
&lt;p&gt;Reste à &amp;quot;brancher&amp;quot; ce wrapper sur le pool. Petit piège d&#039;intégration : en environnement de dev, le profiler de Symfony décore chaque pool d&#039;un &lt;code&gt;TraceableAdapter&lt;/code&gt;, qui n&#039;expose pas &lt;code&gt;setCallbackWrapper()&lt;/code&gt;. Un appel effectué à l&#039;exécution sur le service injecté échouerait donc en dev. La solution consiste à passer par une compiler pass, qui ajoute l&#039;appel de méthode sur la définition du service - la &lt;code&gt;CacheCollectorPass&lt;/code&gt; de Symfony sait ensuite déplacer ces appels sur l&#039;adapter interne lorsqu&#039;elle installe sa décoration :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;final&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; readonly&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;DnsCachePoolPass&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; implements&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-7&quot;&gt;CompilerPassInterface&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; process&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;ContainerBuilder&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $container)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $container&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getDefinition&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;dns.cache&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            -&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;addMethodCall&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;setCallbackWrapper&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, [&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; Reference&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;DnsCacheStampedeProtection&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::class&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)])&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        ;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Et c&#039;est tout : le reste de l&#039;application n&#039;a pas bougé d&#039;une ligne, et continue de bénéficier de &lt;code&gt;LockRegistry&lt;/code&gt; pour ses callbacks rapides. On aurait peut-être aussi pu jouer avec la priorité du décorateur, mais cette solution n&#039;a pas été explorée.&lt;/p&gt;
&lt;h3&gt;Une autre approche : agrandir le pool de verrous&lt;/h3&gt;
&lt;p&gt;Maintenant que nous avons isolé les callbacks lents, la situation est déjà bien plus saine. Cela dit, on peut quand même considérer que la limitation à seulement 24 fichiers de lock, c&#039;est finalement assez peu, surtout si ça peut suffire à provoquer des collisions entre clés de cache qui n&#039;ont rien à voir les une avec les autres.&lt;/p&gt;
&lt;p&gt;Nous avons donc choisi d&#039;agrandir le nombre des verrous disponibles, afin que, pour les pools qui emploient encore &lt;code&gt;LockRegistry&lt;/code&gt;, le risque de collision et d&#039;attente indue soit restreint.&lt;/p&gt;
&lt;p&gt;En effet, rien n&#039;oblige à se limiter aux 24 fichiers du vendor. Par exemple, on peut choisir de générer, au moment du déploiement, un dossier contenant un millier de fichiers immutables, qui seront utilisés par LockRegistry comme &amp;quot;supports&amp;quot; des appels à &lt;code&gt;flock()&lt;/code&gt; pour diluer fortement la probabilité de collision.&lt;/p&gt;
&lt;p&gt;L&#039;appel doit être fait le plus tôt possible (en tout cas avant le premier &lt;code&gt;$cache-&amp;gt;get()&lt;/code&gt;) ; &lt;code&gt;Kernel::boot()&lt;/code&gt; est un bon candidat, puisqu&#039;il couvre à la fois les entrées HTTP et les processus CLI :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// src/Kernel.php&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Symfony\Component\Cache\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;LockRegistry&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;Kernel&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; extends&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-7&quot;&gt;BaseKernel&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; boot&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;        parent&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;boot&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;        // 1000 fichiers immutables créés au déploiement&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;        LockRegistry&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;setFiles&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;glob&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt;$this&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getProjectDir&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;() &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/var/cache-locks/*.lock&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;C&#039;est simple et efficace pour réduire les collisions, mais cela ne fait que repousser le problème : tant que des callbacks de plusieurs secondes cohabitent avec le trafic web dans le même mécanisme de verrouillage, la contention finira par revenir.&lt;/p&gt;
&lt;h2&gt;En conclusion&lt;/h2&gt;
&lt;p&gt;Après déploiement du correctif, la médiathèque est redevenue fluide, y compris à la première visite d&#039;un dossier - les 10 à 22 secondes de TTFB ont disparu, et les différents endpoints d&#039;API aléatoirement lents se sont &amp;quot;assagi&amp;quot;. Sur l&#039;un d&#039;eux, qui utilise le cache dans un transformer, le premier appel &amp;quot;froid&amp;quot; est ainsi passé de 5 secondes à 50 millisecondes sans avoir besoin de toucher ni au SQL ni au code métier, mais uniquement en cessant d&#039;attendre derrière des résolutions DNS qui ne nous concernaient pas.&lt;/p&gt;
&lt;p&gt;Au final, il y a quelques bonnes leçons à tirer de cette expérience. D&#039;abord, mettre en cache, ce n&#039;est pas une opération gratuite. On a tendance à considérer &lt;code&gt;$cache-&amp;gt;get()&lt;/code&gt; comme un réflexe sans risque : &amp;quot;au pire, ça ne servira à rien, et au mieux, ça accélèrera les choses&amp;quot;. Ce n&#039;est pas tout à fait vrai :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;dès lors qu&#039;une protection &lt;em&gt;anti-stampede&lt;/em&gt; entre en jeu, chaque recalcul de valeur interagit avec un système de verrous partagé, et un callback lent peut pénaliser des parties de l&#039;application qui n&#039;ont rien à voir avec lui. Avant de mettre en cache un calcul, posez-vous la question : combien de temps dure-t-il, au pire ?&lt;/li&gt;
&lt;li&gt;et au-delà de  cette considération, nous sommes assez partisans de l&#039;approche &amp;quot;moins de code = moins de bugs&amp;quot; : si on n&#039;a pas strictement besoin de cache, autant s&#039;en passer !&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Exécuter des workers (asynchrones) sur les mêmes machines que des services web (synchrones) amplifie le problème. Même si c&#039;est une pratique économique et courante, cela pose des soucis car &lt;code&gt;LockRegistry&lt;/code&gt; raisonne par machine : des workers qui recalculent en continu des valeurs coûteuses partagent par défaut leurs 24 slots de verrous avec les requêtes web.. Et ça peut nous réserver de (mauvaises) surprises !&lt;/p&gt;
&lt;p&gt;Le profiling a bien aidé pour comprendre ce problème. L&#039;attente d&#039;un &lt;code&gt;flock()&lt;/code&gt; est invisible dans les métriques classiques : le niveau du CPU reste bas, les requêtes SQL sont rapides, les logs sont muets. En gros, on a l&#039;impression que tout va bien..! Si nous n&#039;avions pas eu sous la main une trace qui montre où le temps s&#039;écoule réellement, nous chercherions encore. Peu importe l&#039;outil utilisé pour &lt;em&gt;profiler&lt;/em&gt; - &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.blackfire.io/&quot;&gt;Blackfire&lt;/a&gt; 💛, Sentry, ou tout autre outil capable d&#039;afficher le temps passé fonction par fonction : l&#039;important est d&#039;en avoir un en production, et de le consulter avant de formuler des hypothèses.&lt;/p&gt;
&lt;p&gt;Enfin, une approche défensive consiste à toujours isoler les callbacks lents dans des pools dédiés, avec un mécanisme de lock construit sur mesure. C&#039;est la leçon la plus actionnable : si certains de vos callbacks de cache sont structurellement lents (appels réseau, calculs lourds), donnez-leur leur propre pool et une stratégie de verrouillage par clé. Quelques dizaines de lignes de code suffisent, et le reste de votre application vous dira merci!&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/l-agent-development-environment-une-nouvelle-unite-de-travail</id>
        <published>2026-08-10T10:42:00+02:00</published>
        <updated>2026-08-10T10:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/l-agent-development-environment-une-nouvelle-unite-de-travail"/>
        <title>L&#039;Agent Development Environment : une nouvelle unité de travail</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="ai" />        <summary><![CDATA[Régulièrement, quelque chose arrive et change ce que « écrire du code » veut dire. Cette fois, il se pourrait que ça ne veuille plus dire écrire du tout.
D&#039;où je viens
Ce qui suit est mon parcours, pas…]]></summary>
        <content type="html">
            &lt;p&gt;Régulièrement, quelque chose arrive et change ce que « écrire du code » veut dire. Cette fois, il se pourrait que ça ne veuille plus dire écrire du tout.&lt;/p&gt;
&lt;h2&gt;D&#039;où je viens&lt;/h2&gt;
&lt;p&gt;Ce qui suit est mon parcours, pas une histoire de PHP. D&#039;autres ont vécu ces mêmes années très différemment.&lt;/p&gt;
&lt;p&gt;Mon histoire commence en 2013, quand PHP n&#039;était pas le choix évident qu&#039;il allait devenir, donc j&#039;ai appris le C : pointeurs, gestion mémoire manuelle, segfaults. Ensuite je suis passé à l&#039;Objective-C pour du développement iPad, avec un peu de Smalltalk à côté, pour maintenir un vieux site interne.&lt;/p&gt;
&lt;p&gt;Pendant cette période, je suis tombé amoureux du web. J&#039;ai rejoint une entreprise qui faisait du PHP, un langage complètement nouveau pour moi. La stack là-bas, c&#039;était PHP 5.x, pas d&#039;autoloading, presque aucune librairie, et pas de framework. Composer et Symfony existaient déjà, on ne les utilisait simplement pas. Chaque projet démarrait de zéro, et on réinventait beaucoup de roues. C&#039;était plus dur, oui, mais c&#039;était comme ça chez nous.&lt;/p&gt;
&lt;p&gt;Puis j&#039;ai déménagé à Paris, et c&#039;est là que j&#039;ai découvert les frameworks. &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://laravel.com&quot;&gt;Laravel&lt;/a&gt; et &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://symfony.com&quot;&gt;Symfony&lt;/a&gt; faisaient tous les deux du bruit, et j&#039;ai commencé par Laravel. D&#039;un coup, une communauté entière avait déjà résolu les problèmes que je résolvais seul. Pour moi, les frameworks n&#039;étaient pas juste des outils, c&#039;était un multiplicateur.&lt;/p&gt;
&lt;p&gt;J&#039;ai fini par passer à Symfony, que j&#039;utilise et que j&#039;aime toujours aujourd&#039;hui. Tout ce dont vous avez besoin est faisable avec, et même plus.&lt;/p&gt;
&lt;p&gt;Avec le recul, un motif se répète : j&#039;ai suivi là où la technique m&#039;emmenait. Chaque époque a changé l&#039;unité de mon travail : des lignes de C, aux librairies, aux frameworks. Cet article parle de ce que je pense être l&#039;étape suivante, celle qu&#039;on est en train de vivre.&lt;/p&gt;
&lt;h2&gt;La vague des LLM&lt;/h2&gt;
&lt;p&gt;Pendant des années, les frameworks ont eu l&#039;air d&#039;être le bout de la route. Puis un nouveau type d&#039;outillage est apparu.&lt;/p&gt;
&lt;p&gt;La première vague est arrivée en 2021, avec &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/features/copilot&quot;&gt;GitHub Copilot&lt;/a&gt; : de l&#039;auto-complétion sous stéroïdes. Vous commenciez une ligne, la machine la finissait. Ça semblait magique. En 2023, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://cursor.com&quot;&gt;Cursor&lt;/a&gt; a relevé le niveau : toujours de l&#039;auto-complétion de notre code, mais avec beaucoup plus de choses intégrées, comme discuter avec sa codebase, faire des éditions inline, et écrire des blocs entiers de code depuis une simple instruction. Et depuis 2025, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.anthropic.com/claude-code&quot;&gt;Claude Code&lt;/a&gt; a pris la tête sur les agents IA, avec des modèles capables de livrer des features complètes tout seuls.&lt;/p&gt;
&lt;p&gt;La progression est claire : d&#039;abord un outil qui complétait notre code plus vite, puis un outil qui en écrivait une partie pour nous, et maintenant des agents qui construisent des features entières. L&#039;unité de travail a encore changé : des frameworks aux features.&lt;/p&gt;
&lt;p&gt;Cette vague n&#039;est pas restée entre les mains de quelques gros acteurs. La concurrence a explosé, et les modèles open-weight ont rejoint la course : &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.deepseek.com/en/&quot;&gt;DeepSeek&lt;/a&gt;, en particulier, a secoué le monde de l&#039;IA en délivrant des performances de niveau frontier pour une fraction du coût, et il était loin d&#039;être seul. Pour nous, développeurs, ça a changé la question de l&#039;accès : les modèles puissants ne sont plus enfermés chez un seul fournisseur. Vous pouvez choisir le modèle qui correspond à chaque tâche, mélanger les fournisseurs dans le même workflow, ou faire tourner des modèles plus petits sur votre propre machine avec des outils comme &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://ollama.com&quot;&gt;Ollama&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Chacune de ces vagues a redéfini une partie de notre métier. Mais à mon avis, ce n&#039;était que le lever de rideau : le vrai changement n&#039;est pas l&#039;agent en lui-même, c&#039;est l&#039;environnement dans lequel on le fait tourner.&lt;/p&gt;
&lt;h2&gt;Entrée en scène de l&#039;ADE&lt;/h2&gt;
&lt;p&gt;Le premier indice est venu de Cursor lui-même. Avec &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://cursor.com/blog/2-0&quot;&gt;Cursor 2.0&lt;/a&gt;, il s&#039;est mis à cacher les choses qu&#039;on croyait essentielles : l&#039;arborescence de fichiers, le panneau git, l&#039;éditeur au centre. Ce qui restait, c&#039;était une conversation avec un agent. Sans le nommer, c&#039;est ça un ADE : un &lt;strong&gt;Agent Development Environment&lt;/strong&gt;. Là où un IDE est construit autour de vous en train d&#039;éditer des fichiers, un ADE est construit autour de vous en train de diriger des agents, et de discuter avec eux pour construire des produits.&lt;/p&gt;
&lt;p&gt;Le nom est nouveau et pas encore très répandu, mais je l&#039;aime bien, justement parce qu&#039;il trace une ligne claire entre la façon dont on développe aujourd&#039;hui avec des IDE et ce qui arrive ensuite.&lt;/p&gt;
&lt;p&gt;Après Cursor, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://jean.build/&quot;&gt;Jean&lt;/a&gt; est allé loin dans le paradigme ADE. Le raisonnement est simple : si les agents travaillent pour vous, vous devriez pouvoir lancer plusieurs tâches en même temps. Jean embarque donc une gestion native des git worktrees : chaque tâche vit dans sa propre copie isolée du dépôt. Combiné à l&#039;intégration des pull requests et des issues &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com&quot;&gt;GitHub&lt;/a&gt;, vous pouvez prendre une issue, obtenir un worktree tout neuf, et avoir un agent qui travaille dessus en quelques secondes.&lt;/p&gt;
&lt;p&gt;Puis un collègue m&#039;a parlé d&#039;&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.onorca.dev/&quot;&gt;Orca&lt;/a&gt;. Il fait tout ce que fait Jean, avec encore plus d&#039;intégrations : &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.atlassian.com/software/jira&quot;&gt;Jira&lt;/a&gt; et &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://linear.app&quot;&gt;Linear&lt;/a&gt; sont supportés nativement, donc pendant que je travaille sur quelque chose je peux attraper une issue et créer immédiatement un worktree pour l&#039;attaquer. Chaque tâche a son propre terminal, son navigateur et son contexte, et c&#039;est du bring-your-own-subscription : Claude Code, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://openai.com/codex/&quot;&gt;Codex&lt;/a&gt;, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://opencode.ai&quot;&gt;OpenCode&lt;/a&gt; et les autres, côte à côte.&lt;/p&gt;
&lt;p&gt;Rien de la vague des agents n&#039;est perdu en route non plus. Agents, commands, skills : tout ce qu&#039;on a construit se transpose. Un ADE ne remplace pas cette boîte à outils, il se pose par-dessus, et ça ne fait que la renforcer.&lt;/p&gt;
&lt;p&gt;C&#039;est pour ça qu&#039;un ADE, ce n&#039;est pas « un IDE avec un panneau IA greffé dessus ». Les primitives sont différentes : pas des fichiers et des buffers, mais des tâches, des worktrees et des agents. L&#039;IDE partait du principe d&#039;un développeur, une copie de travail, un fil de travail. L&#039;ADE part du principe que vous en orchestrez plusieurs à la fois.&lt;/p&gt;
&lt;h2&gt;Mon setup au quotidien&lt;/h2&gt;
&lt;p&gt;Un avertissement avant de plonger : mon agent CLI principal est OpenCode, donc tous les exemples de cette section tournent autour de lui. Mais rien ici n&#039;est spécifique à OpenCode : la plupart de ces astuces se transposent directement à Claude Code, Codex, ou l&#039;agent que vous préférez.&lt;/p&gt;
&lt;h3&gt;Orca lui-même&lt;/h3&gt;
&lt;p&gt;Orca embarque beaucoup de features pensées pour vous simplifier la vie, et la première c&#039;est l&#039;intégration avec les outils de suivi d&#039;issues : GitHub issues, Jira et Linear sont tous supportés. De là, vous pouvez prendre une nouvelle issue ou consulter celles sur lesquelles vous travaillez déjà, lire tous les détails, et créer un worktree directement depuis l&#039;issue. Quand vous faites ça, le prompt par défaut est le lien de l&#039;issue (ça va beaucoup nous servir plus loin, gardez le en tête).&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/ade-presentation/orca_issues.png&quot; data-original-width=&quot;2255&quot; data-original-height=&quot;1415&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/ade-presentation/orca_issues.8de9d7d7.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/ade-presentation/orca_issues.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2255 / 1415)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/ade-presentation/orca_issues.png&quot; alt=&quot;Les issues Jira listées dans Orca&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Quand vous jonglez avec plusieurs features ou issues en même temps, chacune dans son worktree, il devient vite difficile de se souvenir de ce qu&#039;il reste à faire sur laquelle. C&#039;est pour ça qu&#039;Orca vous donne un kanban board. Chaque colonne est entièrement personnalisable ; dans mon cas j&#039;en ai quatre :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;« Todo » contient les features et les issues à traiter ;&lt;/li&gt;
&lt;li&gt;« Waiting » est pour les tâches bloquées où j&#039;ai besoin d&#039;un retour de la personne liée à l&#039;issue ;&lt;/li&gt;
&lt;li&gt;« In progress » est ce sur quoi je travaille activement ;&lt;/li&gt;
&lt;li&gt;« Draft » est pour les tâches où j&#039;ai ouvert une draft pull request sur GitHub. Comme je travaille sur beaucoup de features en même temps, je lance rarement toute la suite de tests en local, donc je laisse la CI le faire sur la draft PR et je reviens vérifier les résultats (quand il y a trop d&#039;échecs, je lance quand même les tests en local). Une fois que tout est vert, j&#039;ouvre la PR pour review et je supprime le worktree local.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/ade-presentation/orca_kanban.png&quot; data-original-width=&quot;2255&quot; data-original-height=&quot;1415&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/ade-presentation/orca_kanban.1a0474ab.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/ade-presentation/orca_kanban.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2255 / 1415)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/ade-presentation/orca_kanban.png&quot; alt=&quot;Mon kanban board dans Orca&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Dans la continuité de mon état « Draft », une autre chose que j&#039;aime bien dans Orca, c&#039;est le dock de droite. Il contient un explorateur de fichiers du projet où vous pouvez éditer les fichiers directement, comme dans un IDE. Un deuxième onglet liste toutes les sessions d&#039;agents du worktree courant : quand je suis passé à Orca, il a immédiatement retrouvé toutes mes sessions OpenCode en cours à ce moment-là, donc j&#039;ai pu reprendre sans rien perdre. Un troisième onglet couvre git : fichiers modifiés et stagés, et vous pouvez commit depuis là (ou demander à votre agent de le faire). Et le dernier onglet montre les runs &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/features/actions&quot;&gt;GitHub Actions&lt;/a&gt; de votre pull request, donc quand j&#039;ai une draft PR, je peux vérifier où en est la CI sans jamais quitter Orca.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/ade-presentation/orca_actions.png&quot; data-original-width=&quot;2255&quot; data-original-height=&quot;1415&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/ade-presentation/orca_actions.bbe44beb.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/ade-presentation/orca_actions.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2255 / 1415)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/ade-presentation/orca_actions.png&quot; alt=&quot;Le statut des GitHub Actions dans le dock de droite d&#039;Orca&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Ce sont les trois features que j&#039;utilise le plus, mais il y en a plein d&#039;autres. Les quick commands permettent de préparer des scripts complets à lancer sur un worktree : j&#039;ai une command « Install deps » qui installe tout ce qu&#039;il faut quand j&#039;ai besoin d&#039;outils comme &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://cs.symfony.com/&quot;&gt;PHP CS Fixer&lt;/a&gt; ou &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://phpstan.org&quot;&gt;PHPStan&lt;/a&gt;. Il y a aussi l&#039;intégration mobile, qui ouvre un tunnel sur le réseau local pour que vous puissiez atteindre vos agents Orca depuis votre téléphone (ajoutez un VPN et ça marche de partout ; je m&#039;en suis servi de temps en temps à la salle de sport). Et ce n&#039;est qu&#039;une fraction de ce qu&#039;Orca propose, il y a beaucoup plus à découvrir.&lt;/p&gt;
&lt;h3&gt;Mes agents&lt;/h3&gt;
&lt;p&gt;C&#039;est dans les agents que j&#039;ai le plus investi. Encore un avertissement : je travaille principalement avec Jira, donc certains agents ci-dessous sont orientés Jira, mais ils peuvent facilement être adaptés aux GitHub issues ou à Linear si besoin. Voici le détail complet de ce à quoi ressemble mon sélecteur d&#039;agents :&lt;/p&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/ade-presentation/opencode_agents.1e3a7eb5.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/ade-presentation/opencode_agents.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 552px; ; aspect-ratio: calc(552 / 267)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/ade-presentation/opencode_agents.png&quot; alt=&quot;Le sélecteur d&#039;agents dans OpenCode, avec mes agents personnalisés&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;build&lt;/code&gt; est l&#039;agent intégré qui fait les modifications. C&#039;est le seul agent de cette liste autorisé à toucher au code.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;plan&lt;/code&gt; est l&#039;autre agent intégré, utilisé simplement pour planifier.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;heavy-plan&lt;/code&gt; est le même que &lt;code&gt;plan&lt;/code&gt;, mais adossé à un modèle plus performant : si &lt;code&gt;plan&lt;/code&gt; tourne sur un modèle de classe Sonnet, celui-ci tourne sur un équivalent Opus. Je le garde pour les travaux importants ou risqués.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;jira-analyst&lt;/code&gt; prend un lien Jira, regarde tous les détails de l&#039;issue, ses parents, et les PR ouvertes à son sujet, puis résume ce qui est demandé, ce qui a déjà été fait, et ce qui pourrait être fait dans le code. C&#039;est mon point de départ numéro un, 95 % du temps.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;jira-feedback&lt;/code&gt; couvre le tour suivant : la QA a testé une de mes pull requests et a trouvé une erreur. Cet agent revérifie le ticket Jira depuis son lien, trouve la PR associée, checkout la branche en local, et essaie de comprendre le feedback et de faire de premières hypothèses.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pr-review-planner&lt;/code&gt; intervient quand une PR est ouverte en review et que quelqu&#039;un a laissé des commentaires : il les lit tous et propose comment je pourrais les traiter. Répondre à une review arrête d&#039;être une séance d&#039;archéologie et devient l&#039;exécution d&#039;une checklist.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pr-reviewer&lt;/code&gt; est pour l&#039;autre côté des reviews : quand j&#039;ai une grosse PR à review, je la review toujours moi-même, et en parallèle je lance cet agent pour qu&#039;une review IA attrape ce que j&#039;aurais, peut-être, manqué.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Vous avez peut-être remarqué un motif : à part &lt;code&gt;build&lt;/code&gt;, ce sont tous des agents d&#039;analyse. Ils lisent, résument et planifient, mais ils ne touchent pas au code. Cette contrainte est inscrite dans la définition de l&#039;agent elle-même. Voici un extrait abrégé de &lt;code&gt;jira-analyst&lt;/code&gt; :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;mode: primary&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;model: opencode-go/deepseek-v4-flash&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;temperature: 0.2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-16&quot;&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;You are a technical analyst for Jira issues. When given a Jira issue URL or&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;issue key, you analyze the ticket and deliver exactly two things: a clear&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;summary of what the issue is about, and a concrete list of actions the&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;developer must take to resolve it.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;You are strictly read-only. You NEVER modify the codebase: no edits, no file&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;creation, no refactoring, no &quot;quick fixes&quot;. Your only output is analysis and&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;explanation.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-16&quot;&gt;### Investigating the codebase&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;The action plan must be grounded in the real code, not generic advice:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; grep/glob for the classes, routes, services, config keys, or error messages&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;  mentioned in the ticket.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Use &lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt;`git log`&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt;`git blame`&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; on the affected area to find recent related&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;  changes: regressions are often introduced by an identifiable commit.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Check &lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt;`gh pr list --search &quot;&amp;#x3C;KEY&gt;&quot;`&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; for existing or past PRs referencing&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;  the ticket.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La règle read-only n&#039;est pas qu&#039;une promesse dans le prompt : le même fichier porte un bloc de permissions qui rend les éditions impossibles dès le départ.&lt;/p&gt;
&lt;h4&gt;Les permissions dans OpenCode&lt;/h4&gt;
&lt;p&gt;OpenCode résout chaque appel d&#039;outil vers une action parmi trois : &lt;code&gt;allow&lt;/code&gt; l&#039;exécute sans demander, &lt;code&gt;ask&lt;/code&gt; me demande d&#039;abord, et &lt;code&gt;deny&lt;/code&gt; le bloque purement et simplement. Ce qui compte ici, c&#039;est qu&#039;il part de valeurs par défaut permissives, la plupart des permissions sont en &lt;code&gt;allow&lt;/code&gt; d&#039;origine, donc un agent d&#039;analyse n&#039;est read-only que si vous le dites explicitement. Dans le frontmatter de l&#039;agent, ça donne ça :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;Analyze a Jira ticket and produce an action plan&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;mode&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;subagent&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;permission&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  edit&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;deny&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  webfetch&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;deny&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  bash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;    &quot;*&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;deny&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;    &quot;git *&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;allow&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;    &quot;gh *&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;allow&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;    &quot;jira *&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;allow&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;---&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La clé &lt;code&gt;permission&lt;/code&gt; est indexée par nom d&#039;outil : &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;glob&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;task&lt;/code&gt;, &lt;code&gt;skill&lt;/code&gt;, &lt;code&gt;webfetch&lt;/code&gt;, &lt;code&gt;websearch&lt;/code&gt;. Vous les passez en revue un par un et vous décidez ce que chacun a le droit de faire, et une entrée &lt;code&gt;&amp;quot;*&amp;quot;&lt;/code&gt; fixe la valeur par défaut pour tout ce que vous n&#039;avez pas nommé. Notez que &lt;code&gt;edit&lt;/code&gt; est la seule exception à la règle une clé pour un outil : elle couvre tout ce qui écrit sur le disque, donc &lt;code&gt;edit&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;, &lt;code&gt;patch&lt;/code&gt; et &lt;code&gt;multiedit&lt;/code&gt; se retrouvent tous derrière ce &lt;code&gt;deny&lt;/code&gt; unique. Le reste reste ouvert, donc l&#039;agent peut toujours lire, grep, glob et lancer les commandes dont il a besoin pour enquêter, et c&#039;est tout ce qu&#039;il fera jamais.&lt;/p&gt;
&lt;p&gt;C&#039;est dans le bloc &lt;code&gt;bash&lt;/code&gt; que ça devient intéressant. Les règles sont matchées par pattern et &lt;strong&gt;la dernière règle qui matche gagne&lt;/strong&gt;, donc le catch-all passe en premier et les règles spécifiques viennent après :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;bash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;  &quot;*&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;deny&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;           # défaut : rien ne s&#039;exécute&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;  &quot;git *&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;allow&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;      # ...sauf git&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;  &quot;git push *&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;deny&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;  # ...mais jamais push&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lisez de haut en bas, chaque ligne restreint ou rouvre celle du dessus. Inversez l&#039;ordre et le catch-all avale silencieusement tout le reste. Un dernier piège : les patterns matchent la commande parsée, arguments compris, donc &lt;code&gt;&amp;quot;git&amp;quot;&lt;/code&gt; seul ne matche qu&#039;un &lt;code&gt;git&lt;/code&gt; nu, tout ce qui a des arguments a besoin du wildcard.&lt;/p&gt;
&lt;p&gt;Ce bloc est ce qui transforme « merci de seulement analyser » en quelque chose que l&#039;agent ne peut pas contourner, même si le prompt dérive ou si le ticket essaie de l&#039;entraîner ailleurs.&lt;/p&gt;
&lt;p&gt;Et comme ces agents ne font qu&#039;analyser, ils n&#039;ont pas besoin du modèle le plus cher avec une grosse créativité : un modèle rapide et pas cher à faible température fait le travail, de manière déterministe. Si vous voulez les prompts complets, tous mes agents sont disponibles &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://gist.github.com/Korbeil/f4422dfe58a31e114cfb703edb1f5f21&quot;&gt;dans ce gist&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;Quand Orca et les agents s&#039;emboîtent&lt;/h4&gt;
&lt;p&gt;Vous vous souvenez du détail d&#039;Orca que je vous avais demandé de garder en tête : quand vous créez un worktree depuis une issue, le prompt par défaut est le lien de l&#039;issue. C&#039;est ici que ça paie. Comme &lt;code&gt;jira-analyst&lt;/code&gt; et &lt;code&gt;jira-feedback&lt;/code&gt; prennent tous les deux un lien d&#039;issue en entrée, je n&#039;ai presque rien à faire. Je sélectionne mon issue dans l&#039;intégration Jira d&#039;Orca, il ouvre un nouveau worktree, démarre mon agent, et copie le lien. Il ne me reste qu&#039;à basculer sur le bon agent et à le lancer. De « je prends ce ticket » à « un agent l&#039;analyse dans un worktree isolé », il y a deux clics et zéro copier-coller.&lt;/p&gt;
&lt;p&gt;La même astuce marche dans l&#039;autre sens, une fois que le worktree a déjà disparu. Quand une PR revient à la vie, parce que la QA a trouvé une erreur ou qu&#039;un reviewer a laissé des commentaires, je crée un nouveau worktree depuis la PR elle-même, dans l&#039;intégration GitHub d&#039;Orca cette fois. L&#039;agent s&#039;ouvre avec le lien de la PR comme prompt, je lance &lt;code&gt;jira-feedback&lt;/code&gt; ou &lt;code&gt;pr-review-planner&lt;/code&gt; dessus, et tout le reste se fait automatiquement.&lt;/p&gt;
&lt;p&gt;Pour vous donner une idée du rythme que tout ça crée : au début de la journée, je vérifie si mes draft PR en cours ont des erreurs. Si ce n&#039;est pas le cas, je prends jusqu&#039;à trois ou quatre issues Jira et je lance mon agent &lt;code&gt;jira-analyst&lt;/code&gt; sur toutes, puis je fais généralement de la code review pendant qu&#039;elles tournent. Quand un agent a fini d&#039;analyser, une grosse lecture m&#039;attend : l&#039;idée est de me concentrer d&#039;abord sur la première tâche terminée, de l&#039;envoyer en exécution, puis de prendre la deuxième pendant que la première est occupée, et ainsi de suite, jusqu&#039;à ce que je n&#039;aie plus de tâches et que je reprenne de nouvelles issues.&lt;/p&gt;
&lt;h3&gt;Mes commandes&lt;/h3&gt;
&lt;p&gt;Là où les agents portent un rôle complet, les commands sont plus proches de recettes : de petits prompts répétables que je lance à la demande. Deux des miennes sont généralistes :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/commit-and-pr&lt;/code&gt; est plutôt direct : elle génère un message de commit et une description de pull request. Si le dépôt courant a une template GitHub disponible, elle l&#039;utilise comme base pour la description de la PR.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/activity&lt;/code&gt; récupère toute mon activité Jira et GitHub sur les dernières 24 heures, pour m&#039;aider à ne rien oublier pendant mes daily stand-ups.
Les autres commands sont très liées à mes projets, et pour les comprendre il faut savoir une chose de notre workflow : une PR est ouverte, la PR est reviewée, puis elle est mise dans une milestone pour être déployée sur un environnement de qualification à des fins de test. C&#039;est comme ça que j&#039;en suis venu à ces commands :&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/github-awaiting-review&lt;/code&gt; récupère toutes les pull requests ouvertes et en attente de review, sous forme de liste prête à être copiée dans &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://slack.com&quot;&gt;Slack&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/github-milestone-triage&lt;/code&gt; récupère toutes les pull requests qui ont leur review et qui doivent être testées, et sort une jolie liste que je peux copier dans Slack aussi.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/milestone-build&lt;/code&gt; prend un lien de milestone en argument, rassemble toutes les PR de cette milestone, et crée une release branch pour qu&#039;on puisse déployer chaque changement qu&#039;elle contient. Elle gère aussi les conflits de merge en chemin.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Mes skills&lt;/h3&gt;
&lt;p&gt;Côté skills, je n&#039;en ai aucune faite maison. J&#039;utilise plutôt un catalogue : la plupart des miennes viennent de &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/MakFly/superpowers-symfony&quot;&gt;superpowers-symfony&lt;/a&gt;, une collection de skills orientées Symfony, complétée par quelques skills liées aux projets qui décrivent, par exemple, comment un message de commit ou une description de pull request doit être écrite sur chaque projet. C&#039;est aussi ce qui garde la command &lt;code&gt;commit-and-pr&lt;/code&gt; simple : la command n&#039;explique pas comment écrire quoi que ce soit, elle fait juste le travail, et les conventions viennent de ces skills. Ce n&#039;est pas grand-chose, et c&#039;est volontaire : j&#039;ai toujours eu le sentiment qu&#039;on se tourne trop souvent vers les skills, alors que ce qu&#039;on veut vraiment est une command ou un agent.&lt;/p&gt;
&lt;h3&gt;La philosophie des permissions&lt;/h3&gt;
&lt;p&gt;Vous l&#039;avez peut-être deviné en lisant le détail de mes agents : tout mon outillage IA est read-only par défaut, et &lt;code&gt;build&lt;/code&gt; est le seul agent autorisé à écrire du code. Ce n&#039;est pas un accident, c&#039;est la règle dont tout le reste découle. Les agents proposent, l&#039;humain décide. Considérez ça comme mon contrepoint à la mode des « YOLO agents », où on laisse un agent partir en roue libre avec toutes les permissions.&lt;/p&gt;
&lt;p&gt;Derrière cette règle, il y a une conviction : tout code généré avec une IA vous appartient. Le modèle l&#039;a écrit, mais c&#039;est votre nom sur le commit. Être généré par une IA n&#039;exempte pas une seule ligne de review : vous devez toujours vérifier ce qu&#039;elle fait, et comment elle le fait, exactement comme du code que vous auriez écrit vous-même.&lt;/p&gt;
&lt;p&gt;La meilleure façon de garder cette review supportable est d&#039;investir avant que le code existe. Je lis les sorties de plan en entier, à chaque fois. Je discute des détails avec l&#039;IA, je conteste les parties qui ne correspondent pas à ce que j&#039;avais en tête, et j&#039;ajuste parfois les prompts de mes agents en chemin, pour que l&#039;exécution atterrisse au plus près de ce que j&#039;ai réellement demandé.&lt;/p&gt;
&lt;p&gt;Ensuite je lis le code. Tout le code, diff par diff, de la même façon que je reviewerais la PR d&#039;un collègue : est-ce que ça fait ce que le plan disait, est-ce que ça le fait comme je l&#039;aurais fait, et est-ce qu&#039;il y a là-dedans quelque chose que je ne saurais pas expliquer dans six mois ? Investir dans le plan fait qu&#039;il reste très peu de problèmes à ce stade, mais c&#039;est bien le but. La review reste courte parce que le travail a eu lieu en amont, pas parce que je l&#039;ai bâclée.&lt;/p&gt;
&lt;h2&gt;Pourquoi ça me rend productif&lt;/h2&gt;
&lt;p&gt;Il est temps d&#039;appuyer tout ça avec quelque chose de plus concret que de l&#039;enthousiasme. Les gains ne sont pas uniformes : certains sont spectaculaires, d&#039;autres sont marginaux, et ça vaut la peine d&#039;être honnête sur qui est quoi.&lt;/p&gt;
&lt;p&gt;Le cœur de tout ça, ce sont &lt;code&gt;jira-analyst&lt;/code&gt; et &lt;code&gt;jira-feedback&lt;/code&gt;, les deux agents qui m&#039;ont fait aimer ce workflow. Là où je travaille, on a une équipe « focus » : tout le monde peut travailler sur tout, des stocks au retail en passant par la gestion client. Le changement de contexte entre les issues est énorme, et c&#039;est exactement ce que &lt;code&gt;jira-analyst&lt;/code&gt; absorbe : il m&#039;explique tout ce que j&#039;ai besoin de savoir avant de plonger dans ma tâche. Selon l&#039;issue, ça me fait gagner jusqu&#039;à une demi-journée, parfois une journée entière. C&#039;est aussi l&#039;agent dans lequel j&#039;ai le plus investi : son prompt a été amélioré itération après itération pour arriver au point actuel.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pr-review-planner&lt;/code&gt; est une bénédiction du même ordre. Traiter les retours de review me prenait beaucoup de temps, parce que chaque commentaire demande son contexte : quel code il vise, ce que le reviewer voulait dire, quelles sont les options. Maintenant je lance l&#039;agent, je lis la sortie, et j&#039;ai tout ce qu&#039;il me faut pour décider quoi faire, commentaire par commentaire.&lt;/p&gt;
&lt;p&gt;Côté commands, &lt;code&gt;github-awaiting-review&lt;/code&gt; et &lt;code&gt;github-milestone-triage&lt;/code&gt; ont remplacé une corvée : parcourir toutes les PR, vérifier qui a créé chaque issue et qui la review, construire le message Slack, l&#039;envoyer. Selon le nombre de PR ouvertes, ça pouvait me coûter jusqu&#039;à 20 minutes. Maintenant je lance la command, je reviens plus tard, et c&#039;est fait. Et les minutes ne sont même pas le vrai gain : le vrai gain, c&#039;est que je ne casse plus mon focus pour ça. Un truc aussi simple appartient à un prompt, et mon attention reste sur mes tâches. Et je vais être honnête sur &lt;code&gt;milestone-build&lt;/code&gt; : le gain de temps brut est faible, puisque avant elle je faisais simplement un &lt;code&gt;git merge&lt;/code&gt; de chaque branche dans une release branch, puis un push. Ce qu&#039;elle enlève, c&#039;est tout ce qu&#039;il y a autour des merges : je ne gère plus les conflits, ni la vérification de quelles PR doivent être dedans, tout ça est automatisé.&lt;/p&gt;
&lt;p&gt;Et puis il y a Orca lui-même. La majorité de ma routine quotidienne vit maintenant dedans : je n&#039;ouvre Jira que pour m&#039;assigner des tickets, GitHub pour review les PR des autres développeurs, et Slack pour partager les listes que mes commands préparent (et si un jour je peux faire tout ça depuis Orca, je serai content). Moins d&#039;outils veut dire moins de choses auxquelles penser, et tout va plus vite. Et la gestion des worktrees mérite un dernier mot : j&#039;ai essayé de créer les worktrees à la main, puis de demander à un agent de les créer pour moi. Que des outils comme Jean et Orca s&#039;en occupent nativement est une avancée formidable pour cette philosophie ADE.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;En regardant tout ce chemin, le motif du début tient toujours : j&#039;ai suivi là où la technique m&#039;emmenait, une fois de plus. Des lignes de C aux librairies, des librairies aux frameworks, des frameworks aux features. Sauf que cette fois, la technique n&#039;est ni un langage ni un framework : c&#039;est un collègue. Il lit les tickets, analyse le code, propose des plans ; je dirige, je review, et je décide.&lt;/p&gt;
&lt;p&gt;Et pour être honnête jusqu&#039;au bout : tout n&#039;est pas encore fluide. Les worktrees sont une bénédiction, mais il reste des trous, et le plus gros pour moi est l&#039;infrastructure locale. Faire tourner plusieurs copies du même site veut dire des conflits &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.docker.com&quot;&gt;Docker&lt;/a&gt;, donc quand je travaille dans un worktree, j&#039;évite au maximum de lancer Docker. On a travaillé sur de l&#039;outillage pour adoucir ça : nos conteneurs sont maintenant préfixés avec le nom du worktree, par exemple. Mais certaines pièces résistent : on utilise &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://traefik.io&quot;&gt;Traefik&lt;/a&gt; comme routeur HTTP, et vous ne pouvez pas en faire tourner plus d&#039;un, donc il faudra encore du travail d&#039;outillage avant que tout ça tourne bien côte à côte.&lt;/p&gt;
&lt;p&gt;Des aspérités comme celles-ci sont le signe d&#039;un écosystème jeune, pas d&#039;une mauvaise direction. Les IDE ont mis des décennies à devenir ce qu&#039;ils sont ; les ADE commencent à peine, et ils ont déjà changé mon quotidien plus que n&#039;importe quel outil depuis les frameworks. Je ne sais pas quel ADE va gagner, ni si ceux que j&#039;utilise aujourd&#039;hui seront encore là dans cinq ans. Mais je sais que je ne reviendrai pas à un environnement construit pour moi seul : les agents ont rejoint l&#039;équipe, et ils méritent un bon bureau eux aussi.&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/the-agent-development-environment-a-new-unit-of-work</id>
        <published>2026-08-10T10:42:00+02:00</published>
        <updated>2026-08-10T10:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/the-agent-development-environment-a-new-unit-of-work"/>
        <title>The Agent Development Environment: A New Unit of Work</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="ai" />        <summary><![CDATA[The Agent Development Environment: A New Unit of Work
Every few years, something comes along that changes what &amp;quot;writing code&amp;quot; means. This time, it might stop meaning writing at all.
Where we…]]></summary>
        <content type="html">
            &lt;h1&gt;The Agent Development Environment: A New Unit of Work&lt;/h1&gt;
&lt;p&gt;Every few years, something comes along that changes what &amp;quot;writing code&amp;quot; means. This time, it might stop meaning writing at all.&lt;/p&gt;
&lt;h2&gt;Where we come from&lt;/h2&gt;
&lt;p&gt;What follows is my own path, not a history of PHP. Other people lived those same years very differently.
My story starts in 2013, when PHP was not the obvious choice it would later become, so I learned C: pointers, manual memory management, segfaults. Then I moved to Objective-C for iPad development, with a bit of Smalltalk on the side, maintaining a legacy internal website.
During that time, I fell in love with the web. I joined a company doing PHP, a language that was completely new to me. The stack there was PHP 5.x, no autoloading, barely any libraries, and no framework. Composer and Symfony already existed, we just did not use them. Every project started from scratch, and we reinvented a lot of wheels. It was harder, sure, but it was just how things were for us.
Then I moved to Paris, and that is where I discovered frameworks. &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://laravel.com&quot;&gt;Laravel&lt;/a&gt; and &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://symfony.com&quot;&gt;Symfony&lt;/a&gt; were both making waves, and I started with Laravel. Suddenly, an entire community had already solved the problems I used to solve alone. For me, frameworks were not just tools; they were a multiplier.
Eventually I moved on and adopted Symfony, which I still use and love today. Everything you need can be done with it, and even more.
Looking back, one pattern holds: I followed where the tech led me. Each era changed the unit of my work: from lines of C, to libraries, to frameworks. This article is about what I think is the next step, the one we are living in right now.&lt;/p&gt;
&lt;h2&gt;The LLM wave&lt;/h2&gt;
&lt;p&gt;For years, frameworks felt like the end of that road. Then a new kind of tooling appeared.&lt;/p&gt;
&lt;p&gt;The first spark came in 2021, with &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/features/copilot&quot;&gt;GitHub Copilot&lt;/a&gt;: auto-completion on steroids. You would start a line, and the machine would finish it. It felt like magic. In 2023, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://cursor.com&quot;&gt;Cursor&lt;/a&gt; raised the bar: still auto-completing our code, but with far more built in, like chatting with your codebase, inline edits, and whole chunks of code written from a simple instruction. And since 2025, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.anthropic.com/claude-code&quot;&gt;Claude Code&lt;/a&gt; has taken the lead on AI agents, with models capable of delivering full features on their own.&lt;/p&gt;
&lt;p&gt;The progression is clear: first a tool that completed our code faster, then a tool that wrote some of it for us, and now agents that build entire features. The unit of work changed again: from frameworks to features.&lt;/p&gt;
&lt;p&gt;This wave did not stay in the hands of a few big players. Competition exploded, and open-weight models joined the race: &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.deepseek.com/en/&quot;&gt;DeepSeek&lt;/a&gt;, in particular, shook the AI world by delivering frontier-level performance at a fraction of the cost, and it was far from alone. For us developers, this changed the question of access: powerful models are no longer locked behind a single provider. You can pick the model that fits each task, mix providers in the same workflow, or run smaller models on your own hardware with tools like &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://ollama.com&quot;&gt;Ollama&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Each of these waves redefined a piece of our job. But in my opinion, this was only the opening act: the real change is not the agent itself, it is the environment we run it in.&lt;/p&gt;
&lt;h2&gt;Enter the ADE&lt;/h2&gt;
&lt;p&gt;The first hint came from Cursor itself. With &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://cursor.com/blog/2-0&quot;&gt;Cursor 2.0&lt;/a&gt;, it started hiding the things we thought were essential: the file tree, the git panel, the editor front and center. What remained was a conversation with an agent. Without naming it, that is what an ADE is: an &lt;strong&gt;Agent Development Environment&lt;/strong&gt;. Where an IDE is built around you editing files, an ADE is built around you directing agents, and chatting with them to build products.&lt;/p&gt;
&lt;p&gt;The name is new and not broadly used yet, but I like it, precisely because it draws a clear line between how we develop today with IDEs and what is coming next.&lt;/p&gt;
&lt;p&gt;After Cursor, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://jean.build/&quot;&gt;Jean&lt;/a&gt; went deep into the ADE paradigm. The reasoning is simple: if agents are busy working for you, you should be able to run several tasks at once. So Jean ships native git worktree management: each task lives in its own isolated copy of the repository. Combined with &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com&quot;&gt;GitHub&lt;/a&gt; pull request and issue integration, you can pick an issue, get a fresh worktree, and have an agent working on it in seconds.&lt;/p&gt;
&lt;p&gt;Then a coworker mentioned &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.onorca.dev/&quot;&gt;Orca&lt;/a&gt; to me. It does everything Jean does, with even more integrations: &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.atlassian.com/software/jira&quot;&gt;Jira&lt;/a&gt; and &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://linear.app&quot;&gt;Linear&lt;/a&gt; are supported natively, so while working on something I can grab an issue and immediately spin up a worktree to start on it. Each task gets its own terminal, browser, and context, and it is bring-your-own-subscription: Claude Code, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://openai.com/codex/&quot;&gt;Codex&lt;/a&gt;, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://opencode.ai&quot;&gt;OpenCode&lt;/a&gt; and others, side by side.&lt;/p&gt;
&lt;p&gt;Nothing from the agent wave is lost along the way, either. Agents, commands, skills: everything we built carries over. An ADE does not replace that toolbox, it stands on top of it, and that only makes it stronger.&lt;/p&gt;
&lt;p&gt;This is why an ADE is not &amp;quot;an IDE with an AI panel bolted on&amp;quot;. The primitives are different: not files and buffers, but tasks, worktrees, and agents. The IDE assumed one developer, one working copy, one thread of work. The ADE assumes you are orchestrating several at once.&lt;/p&gt;
&lt;h2&gt;My daily setup&lt;/h2&gt;
&lt;p&gt;One disclaimer before we dive in: my main CLI agent nowadays is OpenCode, so every example in this section is focused on it. But nothing here is OpenCode-specific: most of these tips translate directly to Claude Code, Codex, or whatever agent you prefer.&lt;/p&gt;
&lt;h3&gt;Orca itself&lt;/h3&gt;
&lt;p&gt;Orca ships with many features designed to make your life better, and the first one is issue-tracking integration: GitHub issues, Jira, and Linear are all supported. From there, you can pick a new issue or check the ones you are already working on, read every detail, and create a worktree straight from it. When you do, the default prompt is the issue link (this will help us a lot later on, stay tuned).&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/ade-presentation/orca_issues.png&quot; data-original-width=&quot;2255&quot; data-original-height=&quot;1415&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/ade-presentation/orca_issues.8de9d7d7.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/ade-presentation/orca_issues.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2255 / 1415)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/ade-presentation/orca_issues.png&quot; alt=&quot;Jira issues listed inside Orca&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;When you juggle several features or issues at once, each in its own worktree, it quickly gets hard to remember what you have to do on which one. That is why Orca gives you a kanban board. Every column is fully customizable; in my case I have four:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;quot;Todo&amp;quot; holds the features and issues to tackle;&lt;/li&gt;
&lt;li&gt;&amp;quot;Waiting&amp;quot; is for blocked tasks where I need feedback from whoever is linked to the issue;&lt;/li&gt;
&lt;li&gt;&amp;quot;In progress&amp;quot; is what I am actively working on;&lt;/li&gt;
&lt;li&gt;&amp;quot;Draft&amp;quot; is for tasks where I opened a draft pull request on GitHub. Since I work on many features at once, I rarely run the full test suite locally, so I let the CI do it on the draft PR and come back to check the results (when there are too many failures, I do run the tests locally). Once everything is green, I open the PR for review and remove the local worktree.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/ade-presentation/orca_kanban.png&quot; data-original-width=&quot;2255&quot; data-original-height=&quot;1415&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/ade-presentation/orca_kanban.1a0474ab.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/ade-presentation/orca_kanban.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2255 / 1415)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/ade-presentation/orca_kanban.png&quot; alt=&quot;My kanban board in Orca&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Following on my &amp;quot;Draft&amp;quot; state, another thing I like about Orca is the right dock. It contains a project file explorer where you can edit files directly, like you would in an IDE. A second tab lists all agent sessions for the current worktree: when I switched to Orca, it immediately found all my OpenCode sessions running at that time, so I could catch up without losing anything. A third tab covers git: modified and staged files, and you can commit from there (or ask your agent to do it). And the last tab shows the &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/features/actions&quot;&gt;GitHub Actions&lt;/a&gt; runs for your pull request, so when I have a draft PR, I can check how the CI is doing without ever leaving Orca.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/ade-presentation/orca_actions.png&quot; data-original-width=&quot;2255&quot; data-original-height=&quot;1415&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/ade-presentation/orca_actions.bbe44beb.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/ade-presentation/orca_actions.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2255 / 1415)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/ade-presentation/orca_actions.png&quot; alt=&quot;GitHub Actions status in Orca&#039;s right dock&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Those are the three features I use the most, but there are plenty of others. Quick commands let you prepare full scripts to run on a worktree: I have an &amp;quot;Install deps&amp;quot; command that installs everything required when I need tooling like &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://cs.symfony.com/&quot;&gt;PHP CS Fixer&lt;/a&gt; or &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://phpstan.org&quot;&gt;PHPStan&lt;/a&gt;. There is also the mobile integration, which opens a local network tunnel so you can reach your Orca agents from your phone (add a VPN and it works from anywhere; I have used it from time to time at the gym). And this is only a fraction of what Orca offers, there is a lot more to discover.&lt;/p&gt;
&lt;h3&gt;My agents&lt;/h3&gt;
&lt;p&gt;Agents are where I invested the most. One more disclaimer: I mainly work with Jira, so some agents below are Jira-flavored, but they can easily be swapped to GitHub issues or Linear if required. Here is the full breakdown of what my agent picker looks like:&lt;/p&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/ade-presentation/opencode_agents.1e3a7eb5.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/ade-presentation/opencode_agents.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 552px; ; aspect-ratio: calc(552 / 267)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/ade-presentation/opencode_agents.png&quot; alt=&quot;The agent picker in OpenCode, with my custom agents&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;build&lt;/code&gt; is the built-in agent that makes changes. It is the only agent in this list allowed to touch the code.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;plan&lt;/code&gt; is the other built-in one, used to simply plan things.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;heavy-plan&lt;/code&gt; is the same as &lt;code&gt;plan&lt;/code&gt;, but backed by a more performant model: if &lt;code&gt;plan&lt;/code&gt; runs on a Sonnet-class model, this one runs on an Opus equivalent. I keep it for large or risky work.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;jira-analyst&lt;/code&gt; takes a Jira link, looks at all the issue details, its parents, and the opened PRs about these issues, then sums up what is asked, what was already done, and what could be done in the code. This is my number one starting point, 95% of the time.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;jira-feedback&lt;/code&gt; covers the next round: QA tested one of my pull requests and found an error. This agent rechecks the Jira ticket from its link, finds the related PR, checks out the branch locally, and tries to understand the feedback and make first guesses.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pr-review-planner&lt;/code&gt; steps in when a PR is open for review and someone left comments: it reads them all and proposes how I could fix them. Addressing a review stops being an archeology session and becomes executing a checklist.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pr-reviewer&lt;/code&gt; is for the other side of reviews: when I have a big PR to review, I always review it myself, and in parallel I run this agent so an AI review can catch things I could, maybe, have missed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You may notice a pattern: apart from &lt;code&gt;build&lt;/code&gt;, these are all analysis agents. They read, summarize, and plan, but they do not touch the code. That constraint is baked into the agent definition itself. Here is an abridged extract of &lt;code&gt;jira-analyst&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;mode: primary&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;model: opencode-go/deepseek-v4-flash&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;temperature: 0.2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-16&quot;&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;You are a technical analyst for Jira issues. When given a Jira issue URL or&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;issue key, you analyze the ticket and deliver exactly two things: a clear&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;summary of what the issue is about, and a concrete list of actions the&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;developer must take to resolve it.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;You are strictly read-only. You NEVER modify the codebase: no edits, no file&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;creation, no refactoring, no &quot;quick fixes&quot;. Your only output is analysis and&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;explanation.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-16&quot;&gt;### Investigating the codebase&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;The action plan must be grounded in the real code, not generic advice:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; grep/glob for the classes, routes, services, config keys, or error messages&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;  mentioned in the ticket.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Use &lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt;`git log`&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt;`git blame`&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; on the affected area to find recent related&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;  changes: regressions are often introduced by an identifiable commit.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Check &lt;/span&gt;&lt;span class=&quot;syntax-11&quot;&gt;`gh pr list --search &quot;&amp;#x3C;KEY&gt;&quot;`&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; for existing or past PRs referencing&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;  the ticket.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The read-only rule is not just a promise in the prompt: the same file carries a permission block that makes edits impossible in the first place.&lt;/p&gt;
&lt;h4&gt;Permissions in OpenCode&lt;/h4&gt;
&lt;p&gt;OpenCode resolves every tool call to one of three actions: &lt;code&gt;allow&lt;/code&gt; runs it without asking, &lt;code&gt;ask&lt;/code&gt; prompts me first, and &lt;code&gt;deny&lt;/code&gt; blocks it outright. What matters here is that it starts from permissive defaults, most permissions are &lt;code&gt;allow&lt;/code&gt; out of the box, so an analysis agent is only read-only if you say so explicitly. In the agent frontmatter, that looks like this:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;Analyze a Jira ticket and produce an action plan&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;mode&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;subagent&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;permission&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  edit&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;deny&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  webfetch&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;deny&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  bash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;    &quot;*&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;deny&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;    &quot;git *&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;allow&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;    &quot;gh *&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;allow&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;    &quot;jira *&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;allow&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;---&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;permission&lt;/code&gt; key is keyed by tool name: &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;glob&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;task&lt;/code&gt;, &lt;code&gt;skill&lt;/code&gt;, &lt;code&gt;webfetch&lt;/code&gt;, &lt;code&gt;websearch&lt;/code&gt;. You go through them one by one and decide what each is allowed to do, and a &lt;code&gt;&amp;quot;*&amp;quot;&lt;/code&gt; entry sets the default for everything you did not name. Note that &lt;code&gt;edit&lt;/code&gt; is the one exception to the one-key-one-tool rule: it covers everything that writes to disk, so &lt;code&gt;edit&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;, &lt;code&gt;patch&lt;/code&gt; and &lt;code&gt;multiedit&lt;/code&gt; all sit behind that single &lt;code&gt;deny&lt;/code&gt;. The rest stay open, so the agent can still read, grep, glob and run the commands it needs to investigate, and that is all it will ever do.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;bash&lt;/code&gt; block is where it gets interesting. Rules are matched by pattern and &lt;strong&gt;the last matching rule wins&lt;/strong&gt;, so the catch-all goes first and the specific rules come after it:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;bash&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;  &quot;*&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;deny&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;           # default: nothing runs&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;  &quot;git *&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;allow&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;      # ...except git&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;  &quot;git push *&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;deny&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;  # ...but never push&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read top to bottom, each line narrows or reopens the one above. Flip the order and the catch-all silently swallows everything else. One last gotcha: patterns match the parsed command, arguments included, so &lt;code&gt;&amp;quot;git&amp;quot;&lt;/code&gt; alone only matches a bare &lt;code&gt;git&lt;/code&gt;, anything with arguments needs the wildcard.&lt;/p&gt;
&lt;p&gt;That block is what turns &amp;quot;please only analyze&amp;quot; into something the agent cannot get around, even if the prompt drifts or the ticket tries to talk it into something else.&lt;/p&gt;
&lt;p&gt;And since these agents only analyze, they do not need the most expensive model with high creativity: a cheap, fast model at low temperature does the job, deterministically. If you want the full prompts, all my agents are available &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://gist.github.com/Korbeil/f4422dfe58a31e114cfb703edb1f5f21&quot;&gt;in this gist&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;When Orca and the agents click together&lt;/h4&gt;
&lt;p&gt;Remember the Orca detail I told you to keep in mind: when you create a worktree from an issue, the default prompt is the issue link. This is where it pays off. Since &lt;code&gt;jira-analyst&lt;/code&gt; and &lt;code&gt;jira-feedback&lt;/code&gt; both take an issue link as their input, I have almost nothing to do. I select my issue in the Jira integration of Orca, it opens a new worktree, starts my agent, and copies the link. All that is left for me is to switch to the right agent and run it. From &amp;quot;I will take this ticket&amp;quot; to &amp;quot;an agent is analyzing it in an isolated worktree&amp;quot;, there are a couple of clicks and zero copy-paste.&lt;/p&gt;
&lt;p&gt;The same trick works from the other side, once the worktree is already gone. When a PR comes back to life, because QA found an error or a reviewer left comments, I create a new worktree from the PR itself, in the GitHub integration of Orca this time. The agent opens with the PR link as its prompt, I run &lt;code&gt;jira-feedback&lt;/code&gt; or &lt;code&gt;pr-review-planner&lt;/code&gt; on it, and everything else is done automatically.&lt;/p&gt;
&lt;p&gt;To give you an idea of the rhythm this all creates: at the start of the day, I check whether my current draft PRs have any errors. If they do not, I take up to three or four Jira issues and start my &lt;code&gt;jira-analyst&lt;/code&gt; agent on all of them, then usually do some code review while they run. When an agent is done analyzing, a big read awaits me: the idea is to focus the most on the first task to finish, send it off to execution, then take the second one while the first is busy, and so on, until I have no more tasks to do and pick up new issues.&lt;/p&gt;
&lt;h3&gt;My commands&lt;/h3&gt;
&lt;p&gt;Where agents carry a full role, commands are closer to recipes: small, repeatable prompts I run on demand. Two of mine are general purpose:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/commit-and-pr&lt;/code&gt; is kind of straightforward: it generates a commit message and a pull request description. If the current repository has a GitHub template available, it uses it as the base for the PR description.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/activity&lt;/code&gt; fetches all my activity from Jira and GitHub over the last 24 hours, to help me not forget stuff during my daily stand-ups.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The other commands are heavily related to my projects, and to understand them you need to know one thing about our workflow: a PR is opened, the PR is reviewed, then it is put in a milestone so it gets deployed to a qualification environment for testing purposes. That is how I came up with these commands:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/github-awaiting-review&lt;/code&gt; fetches all pull requests that are open and waiting for a review, as a list ready for me to copy into &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://slack.com&quot;&gt;Slack&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/github-milestone-triage&lt;/code&gt; fetches all pull requests that have their review and need to be tested, and outputs a nice list I can copy into Slack too.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/milestone-build&lt;/code&gt; takes a milestone link as argument, gathers all the PRs from that milestone, and creates a release branch so we can deploy every change in it. It also handles the merge conflicts along the way.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;My skills&lt;/h3&gt;
&lt;p&gt;For skills, I have no custom-made ones. I use a catalogue instead: most of mine come from &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/MakFly/superpowers-symfony&quot;&gt;superpowers-symfony&lt;/a&gt;, a collection of Symfony-focused skills, completed by a few project-related ones describing, for example, how a commit message or a pull request description should be written on each project. This is also what keeps the &lt;code&gt;commit-and-pr&lt;/code&gt; command simple: the command does not explain how to write anything, it just does the work, and the conventions come from these skills. That is not much, and it is on purpose: I always felt we reach for skills too often, when what we really want is a command or an agent.&lt;/p&gt;
&lt;h3&gt;The permission philosophy&lt;/h3&gt;
&lt;p&gt;You may have guessed it from the agent breakdown: all my AI tooling is read-only by default, and &lt;code&gt;build&lt;/code&gt; is the only agent allowed to write code. That is not an accident, it is the rule everything else follows from. Agents propose, the human decides. Consider it my counterpoint to the &amp;quot;YOLO agents&amp;quot; trend of letting an agent run wild with every permission granted.&lt;/p&gt;
&lt;p&gt;Behind that rule sits a conviction: all code generated with any AI is owned by you. The model wrote it, but your name is on the commit. Being AI-generated does not exempt a single line from review: you still have to check what it does, and how it does it, exactly like code you would have written yourself.&lt;/p&gt;
&lt;p&gt;The best way to keep that review bearable is to invest before the code exists. I read the plan outputs entirely, every time. I discuss the details with the AI, push back on the parts that do not match what I had in mind, and sometimes adjust my agents&#039; prompts along the way, so the execution lands as close as possible to what I actually asked for.
Then I read the code. All of it, diff by diff, the same way I would review a colleague&#039;s PR: does it do what the plan said, does it do it the way I would have, and is there anything in there I would not be able to explain in six months? Investing in the plan means very few issues are left at that point, but that is the whole point. The review stays short because the work happened upstream, not because I skipped it.&lt;/p&gt;
&lt;h2&gt;Why this makes me productive&lt;/h2&gt;
&lt;p&gt;Time to back all of this with something more concrete than enthusiasm. The gains are not uniform: some are spectacular, some are marginal, and it is worth being honest about which is which.&lt;/p&gt;
&lt;p&gt;The core of it all is &lt;code&gt;jira-analyst&lt;/code&gt; and &lt;code&gt;jira-feedback&lt;/code&gt;, the two agents that made me love this workflow. Where I work, we have a &amp;quot;focus&amp;quot; team: everyone can work on anything, from stocks to retail to customer management. The context switch between issues is huge, and that is exactly what &lt;code&gt;jira-analyst&lt;/code&gt; absorbs: it explains everything I need to know before I dive in. Depending on the issue, that saves me up to half a day, sometimes a full day. It is also the agent I have invested the most in: its prompt was improved iteration after iteration to get to the current point.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pr-review-planner&lt;/code&gt; is a blessing of the same kind. Review feedback used to take me a lot of time, because each comment demands its context: what code it points at, what the reviewer meant, what the options are. Now I run the agent, read the output, and I have everything I need to decide what to do, comment by comment.&lt;/p&gt;
&lt;p&gt;On the command side, &lt;code&gt;github-awaiting-review&lt;/code&gt; and &lt;code&gt;github-milestone-triage&lt;/code&gt; replaced a chore: going through all the PRs, checking who created each issue and who is reviewing it, building the Slack message, sending it. Depending on how many PRs are open, that could cost me up to 20 minutes. Now I run the command, come back later, and it is done. And the minutes are not even the real win: the real win is that I no longer break my focus for it. Stuff that simple belongs to a prompt, and my attention stays on my tasks. And I will be honest about &lt;code&gt;milestone-build&lt;/code&gt;: the raw time saving is small, since before it I would simply &lt;code&gt;git merge&lt;/code&gt; each branch into a release branch and push. What it removes is everything around the merges: I no longer handle the conflicts, nor check which PRs should be in, all of that is automated.&lt;/p&gt;
&lt;p&gt;Then there is Orca itself. Most of my daily routine now lives in it: I only open Jira to assign tickets to myself, GitHub to review other developers&#039; PRs, and Slack to share the lists my commands prepare (and if one day I can do all of that from Orca, I will be happy). Fewer tools means less stuff to think about, and everything goes faster. And worktree management deserves a final word: I tried creating worktrees manually, then asking an agent to create them for me. Having tools like Jean and Orca handle it natively is an amazing step forward for this ADE philosophy.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Looking back at the whole journey, the pattern from the beginning still holds: I followed where the tech led me, one more time. From lines of C to libraries, from libraries to frameworks, from frameworks to features. Except this time, the tech is not a language or a framework: it is a coworker. It reads the tickets, analyzes the code, proposes plans; I direct, review, and decide.&lt;/p&gt;
&lt;p&gt;And to be honest until the end: not everything is smooth yet. Worktrees are a blessing, but there are still holes, and the biggest one for me is local infrastructure. Running several copies of the same website means &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.docker.com&quot;&gt;Docker&lt;/a&gt; conflicts, so when I work in a worktree, I avoid running Docker as much as possible. We worked on tooling to soften this: our containers are now prefixed with the worktree name, for example. But some pieces resist: we use &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://traefik.io&quot;&gt;Traefik&lt;/a&gt; as our HTTP router, and you cannot run more than one of these, so it will take more work on tooling before all of this runs well side by side.&lt;/p&gt;
&lt;p&gt;Rough edges like these are the sign of a young ecosystem, not of a wrong direction. IDEs took decades to become what they are; ADEs are barely getting started, and they already changed my daily work more than any tool since frameworks. I do not know which ADE will win, or if the ones I use today will still be here in five years. But I know I am not going back to an environment built for me alone: agents have joined the team now, and they deserve a good office too.&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/writing-the-php-virtual-machine-in-rust-with-a-lot-of-help-from-ai</id>
        <published>2026-07-29T15:42:00+02:00</published>
        <updated>2026-07-29T15:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/writing-the-php-virtual-machine-in-rust-with-a-lot-of-help-from-ai"/>
        <title>Writing the PHP Virtual Machine in Rust (with a lot of help from AI)</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="php" />            <category term="compilation" />            <category term="ia" />            <category term="rust" />        <summary><![CDATA[A few years ago, if someone had told me to rewrite the PHP engine in Rust, I would probably have laughed 😆. Not because it is technically impossible, but because such a project traditionally requires…]]></summary>
        <content type="html">
            &lt;p&gt;A few years ago, if someone had told me to rewrite the PHP engine in Rust, I would probably have laughed 😆. Not because it is technically impossible, but because such a project traditionally requires years of reading C code, reverse engineering decades of optimizations, and an intimidating amount of perseverance.&lt;/p&gt;
&lt;p&gt;Today, Large Language Models have changed how we approach ambitious engineering projects. They do not magically produce production-ready software, but they dramatically reduce the cost of understanding unfamiliar codebases, exploring implementation strategies, and validating ideas.&lt;/p&gt;
&lt;p&gt;Projects like &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/oven-sh/bun/pull/30412&quot;&gt;Bun&lt;/a&gt; and the &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/react/react/pull/36173&quot;&gt;React Compiler&lt;/a&gt; show that revisiting mature ecosystems has become more realistic, not because AI replaces engineers, but because it multiplies their capacity.&lt;/p&gt;
&lt;p&gt;This is the story of building a new PHP Virtual Machine in Rust, what worked, what failed, and why the goal was never to create yet another PHP interpreter.&lt;/p&gt;
&lt;h2&gt;The first prototype: surprisingly easy and terribly slow&lt;/h2&gt;
&lt;p&gt;Like many AI-assisted projects, the first version began with an intentionally naive approach. Without studying the PHP source code in depth, we asked the model to generate a VM from its own knowledge, then guided it with examples and corrections whenever something failed.&lt;/p&gt;
&lt;p&gt;Before long, we had a functional prototype capable of executing a meaningful subset of PHP, but it was incredibly slow. That was hardly surprising: the generated implementation favored correctness over performance.&lt;/p&gt;
&lt;p&gt;Furthermore it generated a &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://en.wikipedia.org/wiki/Stack_machine&quot;&gt;stack vm&lt;/a&gt; where &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.npopov.com/2017/04/14/PHP-7-Virtual-machine.html&quot;&gt;PHP is a registry vm&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/php-vm-rust/Stack-VM-VS-Register-VM.png&quot; data-original-width=&quot;1536&quot; data-original-height=&quot;1024&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/php-vm-rust/Stack-VM-VS-Register-VM.df4599cd.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/php-vm-rust/Stack-VM-VS-Register-VM.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1536 / 1024)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/php-vm-rust/Stack-VM-VS-Register-VM.png&quot; alt=&quot;StackVMvsRegisterVm&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;I believe that this difference would have been very painful at some point. Those kinds of differences can make some behavior don’t work the same way, like destruction orders of values, or timing of errors emission. Given the current PHP ecosystem, I&#039;m pretty sure that some existing libraries rely on those orders. Since the goal is to mimic a real migration of PHP, I believe that we should respect that.&lt;/p&gt;
&lt;p&gt;The prototype (done in one day) only proved that the idea was viable, but it was clearly not the final architecture.&lt;/p&gt;
&lt;h2&gt;Copying PHP exactly was not the goal&lt;/h2&gt;
&lt;p&gt;At that point, one option was to follow the path Bun took with its rewrite and reproduce the original implementation as closely as possible. This has obvious advantages: PHP&#039;s engine contains more than twenty years of knowledge and optimization from hundreds of contributors, and reproducing its architecture often means reproducing its performance.&lt;/p&gt;
&lt;p&gt;Such rewrite generally breaks the target language way of doing things, look at &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/oven-sh/bun/pull/30412&quot;&gt;the Bun rewrite pull request&lt;/a&gt;. It is full of &lt;code&gt;unsafe&lt;/code&gt;, which, in my opinion, completely defeats the purpose of using Rust and makes this PR a complete nonsense.&lt;/p&gt;
&lt;p&gt;The purpose of the project was not simply to rewrite Zend Engine in another language, but to understand how PHP works and explore what its VM might look like if designed from the beginning around Rust&#039;s philosophy.&lt;/p&gt;
&lt;p&gt;That meant embracing ownership, avoiding global mutable state, minimizing unsafe code, and taking advantage of the language rather than constantly fighting it. Otherwise, there would be little point in choosing Rust.&lt;/p&gt;
&lt;h2&gt;Learning from PHP instead of copying it&lt;/h2&gt;
&lt;p&gt;Not copying PHP does not mean ignoring it. A significant part of the project involved reading Zend Engine code, asking AI why particular mechanisms existed, tracing historical decisions, and discussing alternative implementations.&lt;/p&gt;
&lt;p&gt;I learned a lot on how PHP works, why it works that way, and how to implement similar behavior in Rust.&lt;/p&gt;
&lt;p&gt;Those conversations often revealed that an optimization reflected older CPU behavior, that strange-looking code avoided an allocation on a critical path, or that Rust offered a more natural implementation. Just as often, they showed that PHP had already found the best solution years ago.&lt;/p&gt;
&lt;p&gt;The objective gradually shifted from rewriting PHP to understanding why it became what it is today.&lt;/p&gt;
&lt;h2&gt;Building the VM I always wanted&lt;/h2&gt;
&lt;p&gt;Studying the engine also gave me an opportunity to experiment with ideas I had wanted to see in PHP for years.&lt;/p&gt;
&lt;h3&gt;Respecting ownership and avoiding global state&lt;/h3&gt;
&lt;p&gt;One of the first constraints was to follow the Rust way of doing things: avoid global mutable state. Instead, the VM is designed to be a self-contained object that can be instantiated multiple times, each with its own state.&lt;/p&gt;
&lt;p&gt;This makes the architecture easier to reason about while allowing multiple independent VMs to run within the same thread. It also simplifies testing and embedding, and fits much more naturally with Rust&#039;s programming model. A global mutable state in Rust is problematic since it either forces a &lt;code&gt;RefCell&lt;/code&gt; around it to make it safe (slower) or does unsafe code (a nogo).&lt;/p&gt;
&lt;h3&gt;A fork model for requests&lt;/h3&gt;
&lt;p&gt;Instead of rebuilding a fresh runtime for every request, the VM can be paused and cloned using copy-on-write semantics. The clone executes the request and is then discarded, while the untouched original is immediately ready to serve as the base for the next one.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/php-vm-rust/RequestMode.png&quot; data-original-width=&quot;1668&quot; data-original-height=&quot;943&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/php-vm-rust/RequestMode.484ce18e.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/php-vm-rust/RequestMode.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1668 / 943)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/php-vm-rust/RequestMode.png&quot; alt=&quot;Fork Mode&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;In the code it works like this:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;?&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;php&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;require&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;vendor/autoload.php&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// initialize a preloaded state&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$kernel &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; new&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Symfony\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Kernel&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;prod&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$kernel&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;boot&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// cache some data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$data &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; do_some_expensive_work&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// pause and wait for a request&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;rphp_request_pause&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// execution will start from here for every request, with the preloaded state and cached data available&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$request &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; Request&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;createFromGlobals&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$kernel&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;handle&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($request);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$newData &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; something&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// newData / request / static cache created after the pause will be dropped at the end of the request, but $data will remain available for the next one&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This mode sits between the classic PHP model, where each request starts from scratch, and a long-running process, like FrankenPHP worker mode, where state is preserved across requests. It allows the VM to avoid the repeated reconstruction of application state while still preserving the isolation between requests.&lt;/p&gt;
&lt;h2&gt;AI as a discussion partner&lt;/h2&gt;
&lt;p&gt;The most unexpected part of the project was not code generation, but the depth of the technical discussions. We could explore why PHP laid out an object in a particular way, whether an optimization existed because of CPU caches, or whether Rust&#039;s type system could eliminate a runtime check.&lt;/p&gt;
&lt;p&gt;I also learned a lot about how a compiler works, how to design a VM, and the inside of the Zend Engine.&lt;/p&gt;
&lt;p&gt;The AI was rarely right on its first attempt, but it was almost always useful. It became less a code generator than a technical sparring partner, able to summarize thousands of lines of C, suggest alternative designs, and help turn questions into experiments.&lt;/p&gt;
&lt;p&gt;But it still writes sloppy code, even after passing a lot of times trying to fix some structures, and telling the AI to follow specific rules. The current code is far from perfect.&lt;/p&gt;
&lt;p&gt;In the end, the project was not written by AI; it was accelerated by it. That may be the most important change these tools bring to ambitious software projects.&lt;/p&gt;
&lt;p&gt;For people that are afraid that AI will code everything for them, I can tell you that it is not the case. If you do not understand the code and the underlying concepts, you will fail miserably. AI is a tool, a partner, not a replacement for knowledge and experience. But it is also dangerous, the number of times I believe it has done something amazing, only to find out that it was wrong, is countless. It is a tool that can help you, but it can also mislead you. You need to be careful and always verify the results.&lt;/p&gt;
&lt;h2&gt;Why Rust? Why not [insert your own language here]?&lt;/h2&gt;
&lt;p&gt;This experimentation could have been done in C (and just make a fork on PHP), or we could have used Zig, or even &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/ircmaxell/PHPPHP&quot;&gt;PHP&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There is mainly 2 reasons that I have chosen this :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I know the language well, meaning I don&#039;t need to think about language when I read code, I only think about logic which saves a lot of time reviewing it.&lt;/li&gt;
&lt;li&gt;I do think that Rust is actually the best language to work with AI, simply because it has so many constraints: which can be painful as a developer, but makes it safer when it’s AI writing it. AI is not perfect, and I don’t think it will ever be one day, simply because perfection is a moving state, and it is subjective. Also making errors is part of the process of learning. So in my point of view AI needs deterministic tools to be good, and the more deterministic constraints the more it’s powerful, since AI can have automatic feedback on those constraints and can adapt what it does. It also adds a lot of confidence on what is built for the end user.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Where the project stands today&lt;/h2&gt;
&lt;p&gt;You can see the project on GitHub at &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/rphp&quot;&gt;https://github.com/jolicode/rphp&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Although there is still a long way to go, roughly 80% of the basic compatibility tests now pass. Many features and edge cases remain, but the VM can already execute a significant amount of real-world PHP code.&lt;/p&gt;
&lt;p&gt;For pure PHP code without external or native function calls, it is currently between 5 and 15 times slower than PHP. This is unsurprising given Zend Engine&#039;s two decades of profiling and optimization, but raw interpreter speed is only part of the picture.&lt;/p&gt;
&lt;p&gt;However, fork mode is around 30 times faster than the VM&#039;s classic execution mode on the same application. This is the most encouraging result so far because it suggests that interpreter performance is not the only available lever.
On the Symfony Demo application, avoiding the repeated reconstruction of application state is enough to make the VM faster than PHP / FrankendPHP in classic mode despite its slower interpreter.&lt;/p&gt;
&lt;p&gt;If we can progressively close the gap in pure code execution while preserving the fork model&#039;s architectural advantages, the result could be a genuinely fast engine for real-world applications.&lt;/p&gt;
&lt;p&gt;It was written in about one month, with daily adjustment / reading / refocus on what was important. There was also a lot of autonomous work, where the AI was iterating over tests fixing, closing the gap between the current implementation and the expected / existing behavior. For this occasion I used a Max 20x Claude plan (about 200€, only take it for a month) and mostly used Opus 4.8 / Sonnet 4.6 and even Fable when it was available at some points. We could feel a difference between those 3 models, but I could feel, given how we constantly adjust it, that any of those 3 models would have rendered the same results.&lt;/p&gt;
&lt;h2&gt;What&#039;s next?&lt;/h2&gt;
&lt;p&gt;I&#039;m not sure what to do next. The project has already been a success in terms of learning and experimentation, but it is still far from being a production-ready PHP engine. I may continue to improve the VM, or I may start a new project with the knowledge gained from this one.&lt;/p&gt;
&lt;p&gt;Some of those ideas may turn out to be mistakes, while others may prove surprisingly effective. Either way, the journey has already been worth it.&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker</id>
        <published>2026-07-23T09:41:00+02:00</published>
        <updated>2026-07-23T09:41:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker"/>
        <title>Détecter les régressions visuelles dans la CI avec Playwright et Docker</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="test" />            <category term="ci" />            <category term="e2e" />        <summary><![CDATA[Mise à jour 25/08/2026 : ajout d&#039;une explication quand il est préférable d&#039;utiliser du CSS custom au lieu d&#039;appliquer un masque Playwright.
Sur un gros site public, le front bouge tout le temps : une…]]></summary>
        <content type="html">
            &lt;p&gt;&lt;strong&gt;Mise à jour 25/08/2026&lt;/strong&gt; : ajout d&#039;une explication quand il est préférable d&#039;utiliser du CSS custom au lieu d&#039;appliquer un masque Playwright.&lt;/p&gt;
&lt;p&gt;Sur un gros site public, le front bouge tout le temps : une migration Tailwind par-ci, un composant React par-là, un bloc CMS qui change de gabarit. Et comme toujours avec le CSS, la modification d’une classe qui semblait anodine peut très bien décaler un bloc trois pages plus loin, sans que personne ne s’en rende compte avant la mise en production.&lt;/p&gt;
&lt;p&gt;Sur un de nos projets, nous avions déjà des tests Behat pour le fonctionnel, et des tests PHPUnit pour le métier. Mais aucun de ces tests ne disait « la page d’accueil ne ressemble plus à la page d’accueil ». C’est exactement ce que font les tests de non-régression visuelle, et &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://playwright.dev/&quot;&gt;Playwright&lt;/a&gt; le fait très bien nativement.&lt;/p&gt;
&lt;p&gt;Dans cet article, nous allons voir la stack que nous avons mise en place sur ce projet : les tests eux-mêmes, les tasks Castor pour les piloter, le passage par Docker pour avoir un rendu stable entre les machines de l’équipe, et enfin comment nous postons les images de diff directement dans un commentaire de la pull request.&lt;/p&gt;
&lt;h2&gt;Le principe : &lt;code&gt;toHaveScreenshot()&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Playwright, la solution que nous utilisons déjà pour nos tests &lt;abbr title=&quot;End to End&quot;&gt;E2E&lt;/abbr&gt;, propose une assertion faite pour ça : &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://playwright.dev/docs/test-snapshots&quot;&gt;&lt;code&gt;toHaveScreenshot()&lt;/code&gt;&lt;/a&gt;. Elle prend une capture de la page, la compare avec l’image de référence commitée dans le dépôt, et échoue si les deux diffèrent trop. En l&#039;occurrence, Playwright vérifie si le nombre de pixels différents entre les 2 images est inférieur à un seuil configuré.&lt;/p&gt;
&lt;p&gt;Notre fichier &lt;code&gt;application/e2e/screenshots.spec.ts&lt;/code&gt; couvre les pages structurantes du site : la home, la page de résultats de recherche, une page de détail, etc. Plusieurs pages, autant d&#039;images de référence, et de quoi attraper l’immense majorité des régressions CSS.&lt;/p&gt;
&lt;p&gt;Un test ressemble à ça :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;test&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;homepage screenshot&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;async&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ({ &lt;/span&gt;&lt;span class=&quot;syntax-12&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; }) &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;=&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    await&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; page.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;goto&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(homeUrl);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // The search form is a React component that mounts client-side;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // wait for it so the layout below does not shift.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    await&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; page.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;locator&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;#tab-search&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;waitFor&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ state: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;visible&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    await&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; stabilize&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(page);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    await&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; expect&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(page).&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;toHaveScreenshot&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;homepage.png&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        fullPage: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        animations: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;disabled&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        maxDiffPixels: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;        // The header image is picked at random server-side, so mask it.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        mask: [page.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getByTestId&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;homepage-header-image&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Écrire le test en lui-même est donc assez trivial. Toute la difficulté de l’exercice est ailleurs : il faut que la page soit &lt;strong&gt;déterministe&lt;/strong&gt;. Un test visuel qui échoue une fois sur trois ne sert à rien, car au bout de deux semaines toute l’équipe relance le job sans même regarder. Nous avons donc passé pas mal de temps, non pas à écrire les tests, mais à supprimer une par une toutes les sources de variation.&lt;/p&gt;
&lt;h3&gt;Attendre que la page soit vraiment stable&lt;/h3&gt;
&lt;p&gt;Le piège classique : la capture est prise pendant que la page finit de se construire. Images en lazy loading, blocs asynchrones, polices web qui provoquent un reflow au moment où elles arrivent… La capture est techniquement valide, mais elle ne correspond à rien de reproductible.&lt;/p&gt;
&lt;p&gt;D’où ce petit helper, appelé dans tous les tests :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// Wait for the page to be visually stable before taking a full-page screenshot:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// network idle (lazy images / async blocks) + web fonts loaded (avoids reflow).&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;async&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; stabilize&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-12&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;Promise&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    await&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; page.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;waitForLoadState&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;networkidle&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    await&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; page.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;evaluate&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(() &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;=&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; document.fonts.ready);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Et quand ça ne suffit pas, on attend explicitement l’élément qui pose problème. Sur la home, c’est le formulaire de recherche (un composant React monté côté client) ; sur une autre page, c’est un Swiper qui se réorganise &lt;em&gt;après&lt;/em&gt; le &lt;code&gt;networkidle&lt;/code&gt; :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;await&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; page.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;locator&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;.js-swiper-expertises.c-swiperinitialized&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;first&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;waitFor&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ state: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;visible&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; });&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La classe &lt;code&gt;c-swiperinitialized&lt;/code&gt; n’est ajoutée qu’une fois le carrousel initialisé : c’est donc un bon signal pour savoir que le rendu final est atteint.&lt;/p&gt;
&lt;h3&gt;Masquer ce qui est volontairement aléatoire&lt;/h3&gt;
&lt;p&gt;Certaines zones ne seront jamais stables, et c’est normal : c’est le produit qui le veut. L’image d’en-tête de la home est tirée au sort côté serveur. Sur la page d&#039;un point de vente, le bloc FAQ s’affiche aléatoirement, et les horaires d’ouverture mettent en avant le jour courant… qui change tous les jours.&lt;/p&gt;
&lt;p&gt;Plutôt que de chercher à contourner le problème, on peut simplement demander à Playwright de masquer ces zones : elles seront recouvertes d’un aplat avant la comparaison.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/ci-playwright/playwright-elements-masques.png&quot; data-original-width=&quot;1280&quot; data-original-height=&quot;1026&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/ci-playwright/playwright-elements-masques.a2d770bf.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/ci-playwright/playwright-elements-masques.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1280 / 1026)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/ci-playwright/playwright-elements-masques.png&quot; alt=&quot;Les éléments masqués par Playwright&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Pour parvenir à cela, il faut lister les éléments à masquer directement dans la config de Playwright :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;mask: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    page.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getByTestId&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;faq-block&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;), &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// display randomly&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    page.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getByTestId&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;store-timetable&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;), &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// current day open by default (changes daily)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;],&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nous utilisons ici des &lt;code&gt;data-testid&lt;/code&gt; plutôt que des classes CSS : cela donne un point d’accroche stable, qui ne bougera pas à la prochaine refonte du style.&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--note&quot;&gt;
    &lt;p class=&quot;c-alert__title&quot;&gt;
                    &lt;span class=&quot;c-icon c-icon--monospace&quot;&gt;
                &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; aria-hidden=&quot;true&quot; class=&quot;c-icon__svg&quot; focusable=&quot;false&quot; viewBox=&quot;0 0 70 71&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M35 .9c19.3 0 35 15.7 35 35s-15.7 35-35 35-35-15.7-35-35S15.7.9 35 .9m0 5c-16.552 0-30 13.449-30 30s13.448 30 30 30c16.552.103 30-13.448 30-30 0-16.551-13.448-30-30-30m0 24.9c1.7 0 3 1.3 3 3v15.3c0 1.7-1.3 3-3 3s-3-1.3-3-3V33.8c0-1.7 1.3-3 3-3m0-11c.8 0 1.6.3 2.3.9.6.5.9 1.3.9 2.1 0 .2-.1.4-.1.6-.1.2-.1.4-.2.6s-.2.3-.3.5-.3.4-.4.5c-1.1 1.1-3.1 1.1-4.2 0-.2-.2-.3-.3-.4-.5s-.2-.3-.3-.5-.2-.4-.2-.6c-.1-.2-.1-.4-.1-.6 0-.8.3-1.6.9-2.1.5-.6 1.3-.9 2.1-.9&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Info&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Parfois, appliquer un masque sur un élément de cette manière peut également masquer d&#039;autres éléments. Vous pourriez alors aussi choisir de masquer vous-même certains éléments, en &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://playwright.dev/docs/test-snapshots#stylepath&quot;&gt;incluant une feuille de style dédiée aux tests&lt;/a&gt; et qui ferait un &lt;code&gt;display: none !important; visibility: hidden !important;&lt;/code&gt; sur les éléments ciblés par exemple.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;Fixer le viewport et tolérer une poignée de pixels&lt;/h3&gt;
&lt;p&gt;Deux derniers réglages, dans &lt;code&gt;playwright.config.ts&lt;/code&gt; et dans les tests :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;use: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    ignoreHTTPSErrors: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    /* Fixed viewport to keep screenshot dimensions deterministic across machines. */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    viewport: { width: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1280&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, height: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;900&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    trace: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;on-first-retry&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;},&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Un viewport fixe garantit que les dimensions de la capture ne dépendent pas de la machine. Quant au &lt;code&gt;maxDiffPixels&lt;/code&gt; (50 sur la plupart des pages, 100 sur la home qui est plus chargée), il laisse passer les micro-variations d’antialiasing sans laisser passer un vrai décalage de bloc. C’est un curseur à régler : trop bas, les tests deviennent flaky ; trop haut, on rate des régressions. Ces valeurs se sont stabilisées à l’usage.&lt;/p&gt;
&lt;h2&gt;Un rendu stable grâce à Docker&lt;/h2&gt;
&lt;p&gt;Il reste malgré tout une source de variation, et c’est probablement la plus importante : une capture d’écran n’est pas seulement le résultat de votre HTML et de votre CSS, c’est aussi le résultat du moteur de rendu de la machine qui a pris la capture. Rendu des polices, antialiasing, sous-pixels : macOS et Linux ne produisent tout simplement pas les mêmes pixels.&lt;/p&gt;
&lt;p&gt;Playwright en est d’ailleurs conscient, puisqu’il suffixe les images de référence par plateforme. Voici le contenu de notre dossier de snapshots :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;application/e2e/screenshots.spec.ts-snapshots/
├── detail-page-chromium-linux.png
├── homepage-chromium-linux.png
├── list-page-chromium-linux.png
├── pro-homepage-chromium-linux.png
├── store-homepage-chromium-linux.png
└── store-page-chromium-linux.png
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notez bien le suffixe &lt;code&gt;-chromium-linux&lt;/code&gt;. Or, notre équipe est mixte : certains développent sur macOS, d’autres sur Linux. Si chacun lance Playwright sur son hôte, il faut soit commiter deux jeux d’images (&lt;code&gt;-darwin&lt;/code&gt; et &lt;code&gt;-linux&lt;/code&gt;) et les maintenir en double, soit accepter que les collègues sur Mac échouent systématiquement sur des tests pourtant verts en CI. Aucune des deux options n’est satisfaisante.&lt;/p&gt;
&lt;p&gt;Heureusement, la solution est celle que nous appliquons déjà à tout le reste sur ce projet : &lt;strong&gt;tout tourne dans Docker&lt;/strong&gt;. Les navigateurs Playwright sont installés dans un conteneur dédié à tout le tooling du projet (Composer, nodejs, etc.), jamais sur l’hôte, et les tests sont exécutés dedans. Ainsi, que ce soit les développeurs sous Mac ou Linux, ou bien depuis le runner de CI, c&#039;est toujours le même environnement de rendu qui est exécuté. Les images de référence sont générées une fois, dans le conteneur sous Linux, et valent pour tout le monde.&lt;/p&gt;
&lt;p&gt;Quant aux binaires des navigateurs, ils atterrissent dans le cache par défaut de Playwright (&lt;code&gt;$HOME/.cache/ms-playwright&lt;/code&gt;). Sur notre projet, ce dossier est un volume monté : ils survivent ainsi aux reconstructions de conteneur et ne sont pas re-téléchargés à chaque lancement.&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--note&quot;&gt;
    &lt;p class=&quot;c-alert__title&quot;&gt;
                    &lt;span class=&quot;c-icon c-icon--monospace&quot;&gt;
                &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; aria-hidden=&quot;true&quot; class=&quot;c-icon__svg&quot; focusable=&quot;false&quot; viewBox=&quot;0 0 70 71&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M35 .9c19.3 0 35 15.7 35 35s-15.7 35-35 35-35-15.7-35-35S15.7.9 35 .9m0 5c-16.552 0-30 13.449-30 30s13.448 30 30 30c16.552.103 30-13.448 30-30 0-16.551-13.448-30-30-30m0 24.9c1.7 0 3 1.3 3 3v15.3c0 1.7-1.3 3-3 3s-3-1.3-3-3V33.8c0-1.7 1.3-3 3-3m0-11c.8 0 1.6.3 2.3.9.6.5.9 1.3.9 2.1 0 .2-.1.4-.1.6-.1.2-.1.4-.2.6s-.2.3-.3.5-.3.4-.4.5c-1.1 1.1-3.1 1.1-4.2 0-.2-.2-.3-.3-.4-.5s-.2-.3-.3-.5-.2-.4-.2-.6c-.1-.2-.1-.4-.1-.6 0-.8.3-1.6.9-2.1.5-.6 1.3-.9 2.1-.9&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Info&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Sur ce projet, nos tests ne tournent volontairement que sur Chrome. Donc nous évitons de télécharger Firefox et WebKit pour rien. Pour cela, on ajoute la config suivante dans le fichier &lt;code&gt;playwright.config.ts&lt;/code&gt; :&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;projects: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            name: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;chromium&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            use: { &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;devices[&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Desktop Chrome&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;] },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    ],&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Puis on demande à Playwright d&#039;installer uniquement Chromium :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;yarn&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; playwright&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; install&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; chromium&#039;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Grâce au cache natif et à l&#039;utilisation d&#039;un seul navigateur, nous gagnons une quinzaine de secondes de temps d&#039;exécution du job E2E dans notre CI.&lt;/p&gt;
&lt;h2&gt;Piloter les tests avec Castor&lt;/h2&gt;
&lt;p&gt;Comme souvent quand nos projets nécessitent de lancer des commandes, nous avons mis en place une &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://castor.jolicode.com/&quot;&gt;task Castor&lt;/a&gt; pour simplifier la DX. Le but est que personne n’ait jamais besoin de savoir dans quel conteneur, ni avec quelles variables d’environnement, Playwright doit tourner :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;#[AsTask(description: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;E2E Playwright Tests&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; e2e&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;?string&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $filter &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; null&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $updateScreenshots &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; false&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;    io&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;section&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Running E2E Playwright Tests...&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // Browsers are downloaded in the default cache directory ($HOME/.cache/ms-playwright), which&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // is a mounted volume: the download only happens once. Only chromium is needed by the tests.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;    io&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Installing Playwright browsers...&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;    docker_compose_run&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;yarn playwright install chromium&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $command &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;yarn playwright test&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // Optionally filtered to a single spec&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; !==&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $filter) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $command &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.=&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039; &#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; .&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; escapeshellarg&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($filter);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($updateScreenshots) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;        io&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Running tests and updating screenshots...&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $command &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.=&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039; --update-snapshots&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;    docker_compose_run&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($command);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pour résumer, cette task va :&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;s’assurer que le navigateur utilisé par les tests est installé ;&lt;/li&gt;
&lt;li&gt;lancer les tests, éventuellement filtrés sur un seul fichier de spec ;&lt;/li&gt;
&lt;li&gt;et, si on le lui demande, régénérer les images de référence plutôt que de les comparer.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Le workflow au quotidien tient alors en trois commandes :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# Lancer les comparaisons&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;castor&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; qa:e2e&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --filter&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; screen&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# En cas d’échec : ouvrir les images de diff (les différences ressortent en rouge)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;castor&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; qa:e2e-diff&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# Si les différences sont légitimes (modification CSS, ajout de contenu…) :&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# régénérer les images de référence&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;castor&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; qa:e2e&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --update-screenshots&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La task &lt;code&gt;qa:e2e-diff&lt;/code&gt; ne fait pas grand-chose, mais elle évite d’avoir à fouiller dans &lt;code&gt;test-results/&lt;/code&gt; pour trouver le bon PNG :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;#[AsTask(description: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Open the diff images of the last failing screenshots&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; e2e_diff&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $diffs &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; glob&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(\&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;dirname&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;__DIR__&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/application/test-results/*/*-diff.png&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;?:&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ([] &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;===&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $diffs) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;        io&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;success&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;No screenshot diff found. All screenshots match.&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    foreach&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($diffs &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $diff) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;        io&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;writeln&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Opening &#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; .&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; basename&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($diff));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;        open&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($diff);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notez que Playwright n’écrit ces fichiers &lt;code&gt;-diff.png&lt;/code&gt; que pour les captures ayant réellement échoué. Il n’y a donc rien à filtrer : ce qui se trouve dans le dossier est exactement ce qui est cassé.&lt;/p&gt;
&lt;h2&gt;Poster les diffs dans la pull request&lt;/h2&gt;
&lt;p&gt;Tout ceci fonctionne très bien en local. En CI, en revanche, l’expérience était nettement moins agréable : un job rouge, un message « expected 50 pixels, got 3400 », et il fallait ensuite aller récupérer les images à la main pour comprendre ce qui avait changé.&lt;/p&gt;
&lt;p&gt;Nous avons donc ajouté une étape supplémentaire à la CI : quand un screenshot échoue, les images sont postées dans un commentaire de la pull request, avec l’attendu, l’obtenu et le diff côte à côte dans un tableau Markdown. La personne qui relit voit le problème directement en ouvrant la PR, sans avoir à cliquer sur « Détails ».&lt;/p&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/ci-playwright/playwright-commentaire-pr.cd616993.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/ci-playwright/playwright-commentaire-pr.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 911px; ; aspect-ratio: calc(911 / 1111)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/ci-playwright/playwright-commentaire-pr.png&quot; alt=&quot;Le commentaire posté en cas de régression visuelle&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Le job GitHub Actions reste très simple, puisque toute la logique est déportée dans des tasks Castor :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;- &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;E2E Playwright Tests&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  run&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;castor qa:e2e&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;- &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;Report E2E screenshot failures on the PR&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;${{ failure() &amp;#x26;&amp;#x26; github.event_name == &#039;pull_request&#039; }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  run&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;castor qa:e2e-report-failures&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  env&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;      GITHUB_TOKEN&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;      E2E_PR_NUMBER&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;${{ github.event.pull_request.number }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;      E2E_RUN_ID&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;${{ github.run_id }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;      GITHUB_REPOSITORY&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;${{ github.repository }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;      GITHUB_SERVER_URL&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;${{ github.server_url }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;- &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;Clear E2E screenshot report on the PR&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;${{ success() &amp;#x26;&amp;#x26; github.event_name == &#039;pull_request&#039; }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  run&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;castor qa:e2e-clear-report&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;  env&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;      GITHUB_TOKEN&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;      E2E_PR_NUMBER&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;${{ github.event.pull_request.number }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;      GITHUB_REPOSITORY&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;${{ github.repository }}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;c-alert c-alert--note&quot;&gt;
    &lt;p class=&quot;c-alert__title&quot;&gt;
                    &lt;span class=&quot;c-icon c-icon--monospace&quot;&gt;
                &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; aria-hidden=&quot;true&quot; class=&quot;c-icon__svg&quot; focusable=&quot;false&quot; viewBox=&quot;0 0 70 71&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M35 .9c19.3 0 35 15.7 35 35s-15.7 35-35 35-35-15.7-35-35S15.7.9 35 .9m0 5c-16.552 0-30 13.449-30 30s13.448 30 30 30c16.552.103 30-13.448 30-30 0-16.551-13.448-30-30-30m0 24.9c1.7 0 3 1.3 3 3v15.3c0 1.7-1.3 3-3 3s-3-1.3-3-3V33.8c0-1.7 1.3-3 3-3m0-11c.8 0 1.6.3 2.3.9.6.5.9 1.3.9 2.1 0 .2-.1.4-.1.6-.1.2-.1.4-.2.6s-.2.3-.3.5-.3.4-.4.5c-1.1 1.1-3.1 1.1-4.2 0-.2-.2-.3-.3-.4-.5s-.2-.3-.3-.5-.2-.4-.2-.6c-.1-.2-.1-.4-.1-.6 0-.8.3-1.6.9-2.1.5-.6 1.3-.9 2.1-.9&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Info&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Mettre la logique dans une task Castor plutôt que dans le YAML de GitHub Actions a un avantage non négligeable : on peut la lancer en local avec une option &lt;code&gt;--dry-run&lt;/code&gt; qui construit et affiche le commentaire sans rien envoyer. Débugger un rendu Markdown sans avoir à pousser un commit pour chaque essai, c’est appréciable.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Il reste une contrainte à contourner : on ne peut pas afficher une image dans un commentaire GitHub sans que celle-ci soit accessible via une URL publique. Nous hébergeons donc les images sur notre préproduction, en réutilisant l’accès SSH dont on se sert déjà pour le déploiement :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$remoteUser &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;deploy&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$remoteHost &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;preprod-web01&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$remoteDir &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/var/www/sites/www.example.com/current/web/images/_e2e&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// Careful: pick a host that is NOT behind a Basic auth, or GitHub cannot fetch the images.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$publicBaseUrl &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;https://static-preprod.example.com/images/_e2e&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$retentionDays &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 30&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Castor fournit justement tout ce qu’il faut pour piloter une machine distante, avec &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://castor.jolicode.com/docs/going-further/helpers/ssh&quot;&gt;&lt;code&gt;ssh_run()&lt;/code&gt;, &lt;code&gt;ssh_upload()&lt;/code&gt; et &lt;code&gt;ssh_download()&lt;/code&gt;&lt;/a&gt;. Une fois les images rangées dans un dossier de staging local, l’envoi tient en deux appels :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// Upload the whole staging directory. ssh_upload() always runs scp with &quot;-r&quot;, and copying a&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// source directory onto a non-existing destination creates it: remove any leftover from a&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// previous attempt on the same run first, otherwise scp would nest it inside itself.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$remoteRunDir &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $remoteDir &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; .&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $runKey;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;ssh_run&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;rm -rf %s &amp;#x26;&amp;#x26; mkdir -p %s&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;escapeshellarg&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($remoteRunDir), &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;escapeshellarg&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($remoteDir)),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    host: $remoteHost,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    user: $remoteUser,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;ssh_upload&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($staging, $remoteRunDir, host: $remoteHost, user: $remoteUser);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;c-alert c-alert--note&quot;&gt;
    &lt;p class=&quot;c-alert__title&quot;&gt;
                    &lt;span class=&quot;c-icon c-icon--monospace&quot;&gt;
                &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; aria-hidden=&quot;true&quot; class=&quot;c-icon__svg&quot; focusable=&quot;false&quot; viewBox=&quot;0 0 70 71&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M35 .9c19.3 0 35 15.7 35 35s-15.7 35-35 35-35-15.7-35-35S15.7.9 35 .9m0 5c-16.552 0-30 13.449-30 30s13.448 30 30 30c16.552.103 30-13.448 30-30 0-16.551-13.448-30-30-30m0 24.9c1.7 0 3 1.3 3 3v15.3c0 1.7-1.3 3-3 3s-3-1.3-3-3V33.8c0-1.7 1.3-3 3-3m0-11c.8 0 1.6.3 2.3.9.6.5.9 1.3.9 2.1 0 .2-.1.4-.1.6-.1.2-.1.4-.2.6s-.2.3-.3.5-.3.4-.4.5c-1.1 1.1-3.1 1.1-4.2 0-.2-.2-.3-.3-.4-.5s-.2-.3-.3-.5-.2-.4-.2-.6c-.1-.2-.1-.4-.1-.6 0-.8.3-1.6.9-2.1.5-.6 1.3-.9 2.1-.9&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Info&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Nous utilisons des runners GitHub qui sont self-hostés et tournent sur une machine située sur l&#039;infra du client et qui a donc accès à l&#039;instance de préproduction. Dans la plupart des situations, ce n&#039;est pas le cas, il vous faudra donc trouver comment rendre accessible publiquement ces images (hébergement S3-like, service dédié, etc).&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Le commentaire est ensuite construit à la main en Markdown :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$body &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.=&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &quot;## ❌ Régression visuelle E2E&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$body &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;Des screenshots ont changé sur ce run ([logs](%s)).&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $runUrl);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;foreach&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($screenshots &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $screenshot) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $base &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;%s/%s/%s/%s&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $publicBaseUrl, $runKey, $screenshot[&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;testDir&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;], $screenshot[&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;name&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;]);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $body &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;### `%s`&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $screenshot[&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;name&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;]);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $body &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.=&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &quot;| Attendu | Obtenu | Diff |&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;| --- | --- | --- |&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $body &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;            &quot;| %s | %s | %s |&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-3&quot;&gt;            null&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; !==&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $screenshot[&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;expected&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;] &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;![attendu](%s-expected.png)&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $base) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;—&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-3&quot;&gt;            null&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; !==&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $screenshot[&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;actual&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;] &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;![obtenu](%s-actual.png)&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $base) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;—&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;![diff](%s-diff.png)&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $base),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Quelques détails supplémentaires méritent d’être mentionnés.&lt;/p&gt;
&lt;h3&gt;Un commentaire « sticky » plutôt qu’un nouveau à chaque run&lt;/h3&gt;
&lt;p&gt;Une PR avec plusieurs allers-retours finirait vite avec une dizaine de commentaires de robot. Pour éviter ça, nous plaçons un marqueur HTML invisible en tête du corps du message :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// Hidden marker used to identify the sticky E2E screenshots report comment on a PR.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; E2E_REPORT_MARKER&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;&amp;#x3C;!-- e2e-screenshots-report --&gt;&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Il suffit ensuite de parcourir les commentaires de la PR à la recherche de ce marqueur :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; E2E_GITHUB_API_BASE&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;https://api.github.com&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; e2e_find_report_comment&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;array&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $options, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $repo, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $pr)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; ?int&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $page &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    do&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $listUrl &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;%s/repos/%s/issues/%s/comments?per_page=100&amp;#x26;page=%d&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;E2E_GITHUB_API_BASE&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $repo, $pr, $page);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $comments &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; http_request&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;GET&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $listUrl, $options)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;toArray&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        foreach&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($comments &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $comment) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;str_contains&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($comment[&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;body&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;] &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;??&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;E2E_REPORT_MARKER&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;                return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $comment[&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;id&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        ++&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$page;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    } &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;while&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; ===&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($comments));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; null&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Les &lt;code&gt;$options&lt;/code&gt; sont communes à tous les appels, et regroupent l’authentification et les en-têtes attendus par l’API :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; e2e_github_options&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $token)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; array&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;        &#039;auth_bearer&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $token,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;        &#039;headers&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;            &#039;Accept&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;application/vnd.github+json&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;            &#039;X-GitHub-Api-Version&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;2022-11-28&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        ],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    ];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Et selon qu’on a trouvé un commentaire existant ou non, on fait un &lt;code&gt;PATCH&lt;/code&gt; sur celui-ci ou un &lt;code&gt;POST&lt;/code&gt; d’un nouveau :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$options &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; e2e_github_options&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($token);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$existingId &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; e2e_find_report_comment&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($options, $repo, $pr);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; !==&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $existingId) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $url &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;%s/repos/%s/issues/comments/%d&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;E2E_GITHUB_API_BASE&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $repo, $existingId);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;    http_request&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;PATCH&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $url, [&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$options, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;json&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;body&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $body]])&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getContent&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;    io&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;success&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(\&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Updated PR #%s comment with %d screenshot(s).&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $pr, \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($screenshots)));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;} &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $url &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;%s/repos/%s/issues/%s/comments&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;E2E_GITHUB_API_BASE&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $repo, $pr);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;    http_request&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;POST&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $url, [&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$options, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;json&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;body&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $body]])&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getContent&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;    io&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;success&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(\&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Posted a comment on PR #%s with %d screenshot(s).&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $pr, \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($screenshots)));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La même fonction de recherche est réutilisée par la task &lt;code&gt;qa:e2e-clear-report&lt;/code&gt; : quand les tests repassent au vert, on retrouve le commentaire par son marqueur et on le supprime (&lt;code&gt;DELETE&lt;/code&gt;), pour que la PR ne garde pas la trace d’un problème déjà corrigé.&lt;/p&gt;
&lt;h3&gt;Dédoublonner les retries&lt;/h3&gt;
&lt;p&gt;En CI, Playwright réessaie deux fois (&lt;code&gt;retries: process.env.CI ? 2 : 0&lt;/code&gt;) et écrit les résultats de chaque tentative dans des dossiers frères &lt;code&gt;&amp;lt;test&amp;gt;-retryN&lt;/code&gt;. Sans traitement, la même régression apparaîtrait donc trois fois dans le commentaire. Nous regroupons les images par test et par capture, en ne gardant que la tentative la plus élevée :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// Retries live in sibling &quot;&amp;#x3C;test&gt;-retryN&quot; directories; strip the suffix for a stable key.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$testDir &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; basename&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($dir);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$retry &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;preg_match&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;/&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;(.&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;)-retry(&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;\d&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;/&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $testDir, $matches)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $testDir &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $matches[&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $retry &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;) $matches[&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$key &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $testDir &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; .&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $name;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;isset&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($screenshots[$key]) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $screenshots[$key][&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;retry&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;] &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $retry) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    continue&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Purger les vieilles images&lt;/h3&gt;
&lt;p&gt;Enfin, comme nous poussons des images sur un serveur partagé à chaque échec, il faut éviter que le dossier ne grossisse indéfiniment. Un &lt;code&gt;find&lt;/code&gt; sur les dossiers de run trop anciens fait l’affaire. Notez le &lt;code&gt;allowFailure&lt;/code&gt; : le ménage ne doit pas faire échouer le rapport s’il se passe mal.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// Prune old run directories so the shared folder does not grow forever.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;ssh_run&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;sprintf&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;        &#039;find %s -mindepth 1 -maxdepth 1 -type d -mtime +%d -exec rm -rf {} +&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;        escapeshellarg&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($remoteDir),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $retentionDays,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    ),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    host: $remoteHost,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    user: $remoteUser,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    allowFailure: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Au bout du compte, toute la logique pour poster les régressions visuelles en commentaire dans GitHub représente environ 200 lignes de PHP, mais cette amélioration nous fait gagner du confort au quotidien : on passe d’un « le job E2E est rouge » à un « ah oui, le footer a pris 4px » sans quitter la page de la PR.&lt;/p&gt;
&lt;h2&gt;Le cas des fixtures aléatoires&lt;/h2&gt;
&lt;p&gt;Il reste une dernière source de variation, et c’est celle qui nous a demandé le plus de tâtonnements.&lt;/p&gt;
&lt;p&gt;Nos fixtures utilisent &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/nelmio/alice&quot;&gt;nelmio/alice&lt;/a&gt;, et donc Faker, ce qui implique de l’aléatoire : des titres, des prix, des descriptions, des noms générés à la volée. Or une page de détail ne peut évidemment pas produire une capture stable si le prix affiché change à chaque chargement des fixtures.&lt;/p&gt;
&lt;p&gt;Nous avions déjà fait en sorte d&#039;éviter les soucis d&#039;aléatoire dès le début du projet car les tests Behat avaient le même besoin d&#039;avoir des contenus stables dans le temps, et la solution tient en une ligne de configuration :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# application/config/packages/nelmio_alice.yaml&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;when@dev&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x26;&lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;dev&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    nelmio_alice&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        locale&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;fr_FR&#039;&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt; # Default locale for the Faker Generator&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;        seed&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;42&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;when@test&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;dev&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Avec un seed fixe, Faker devient déterministe : les mêmes fixtures rechargées produisent exactement les mêmes données, et donc exactement les mêmes pixels. Problème réglé, en apparence.&lt;/p&gt;
&lt;p&gt;Sauf que le déterminisme d’un générateur pseudo-aléatoire est positionnel. Le seed garantit une séquence de valeurs, mais pas l’affectation d’une valeur donnée à un objet donné. Si vous ajoutez une entité au milieu d’un fichier de fixtures, par exemple pour un test Behat qui n’a rien à voir avec le visuel, vous consommez un tirage supplémentaire, et tout ce qui vient après décale d’un cran dans la séquence. Les prix changent, les titres changent, et les captures d’écran deviennent rouges à cause d’un test fonctionnel sans aucun rapport.&lt;/p&gt;
&lt;p&gt;Le symptôme est assez déroutant la première fois qu’on le rencontre : la PR ne touche pas une ligne de CSS, et pourtant les tests visuels échouent.&lt;/p&gt;
&lt;p&gt;La règle que nous nous sommes donnée est simple : &lt;strong&gt;quand une capture change à cause d’un décalage de fixtures, on change la fixture impactée puis on régénère l’image&lt;/strong&gt;. Concrètement, on identifie la donnée qui a bougé sur la page (un prix, un titre, un nom) et on lui donne une valeur en dur au lieu de la laisser à Faker :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# Avant : la valeur dépend de la position du tirage dans la séquence.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;&amp;#x3C;sentence()&gt;&#039;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;price&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;&amp;#x3C;numberBetween(100000, 900000)&gt;&#039;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# Après : la valeur est figée, et ne bougera plus jamais.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Une valeur figée pour les tests de screenshots&#039;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;price&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;245000&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Le commentaire de PR est précieux pour ça : il montre immédiatement &lt;em&gt;quelle&lt;/em&gt; donnée a changé. Constater qu’un prix est passé de 245 000 € à 312 000 € prend deux secondes, là où le déduire d’un compteur de pixels est impossible.&lt;/p&gt;
&lt;p&gt;L’intérêt de procéder ainsi, c’est que la correction est définitive et que l’effort est réparti dans le temps. Nous ne figeons pas toutes les fixtures d’un coup - ce serait un gros chantier, et une bonne partie n’apparaît de toute façon dans aucune capture. Nous figeons uniquement celles qui nous ont réellement posé problème, au moment où elles nous le posent. Au fil des PR, les données visibles sur les pages sous test deviennent progressivement déterministes, et ce type d’échec se raréfie de lui-même.&lt;/p&gt;
&lt;h2&gt;En résumé&lt;/h2&gt;
&lt;p&gt;Grâce à Playwright, Docker et Castor, nous avons pu mettre en place une détection des régressions visuelles qui reste simple à l’usage :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;toHaveScreenshot()&lt;/code&gt; fait tout le travail de comparaison, sans outil externe ni service tiers à payer ;&lt;/li&gt;
&lt;li&gt;le passage par Docker garantit un rendu identique sur les postes de l’équipe et en CI, quel que soit l’OS ;&lt;/li&gt;
&lt;li&gt;les tasks Castor masquent la plomberie et installent leurs dépendances toutes seules ;&lt;/li&gt;
&lt;li&gt;le déterminisme est obtenu par un ensemble de petits réglages : viewport fixe, attente explicite de stabilisation, masques sur les zones volontairement aléatoires et seed sur les fixtures ;&lt;/li&gt;
&lt;li&gt;les diffs postés directement dans la pull request rendent chaque échec compréhensible en un coup d’œil.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Quelques captures d’écran seulement, et quelques centaines de lignes de configuration : ce n’est évidemment pas une couverture exhaustive du site, et ce n’est pas le but. Mais depuis leur mise en place, les régressions de mise en page sur les pages structurantes sont détectées avant la mise en production, et non plus après.&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/joliday-2026-avec-du-malt-des-aiguilles-de-la-3d-et-des-copeaux</id>
        <published>2026-07-21T15:42:00+02:00</published>
        <updated>2026-07-21T15:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/joliday-2026-avec-du-malt-des-aiguilles-de-la-3d-et-des-copeaux"/>
        <title>JoliDay 2026 avec du malt, des aiguilles, de la 3D et des copeaux</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="JoliCode" />            <category term="joliday" />        <summary><![CDATA[Comme tous les ans, nous avons profité d’un jour férié (en application de la journée de solidarité avec les personnes âgées) pour nous retrouver tous ensemble et faire des projets ni Web ni Mobile, mais…]]></summary>
        <content type="html">
            &lt;p&gt;Comme tous les ans, nous avons profité d’un jour férié (en application de la journée de solidarité avec les personnes âgées) pour nous retrouver tous ensemble et faire des projets ni Web ni Mobile, mais toujours de qualité 👌. Nous avons un gros faible pour le DIY et retrouvons la même gratification à résoudre des problèmes dans « le monde réel » que dans nos projets du quotidien !&lt;/p&gt;
&lt;p&gt;Dans cet article, nous vous présentons les différents projets réalisés en une journée par les JoliCodeuses et JoliCodeurs ! Cette année a été particulièrement riche en projets qui améliorent nos bureaux 😯&lt;/p&gt;
&lt;h2&gt;Conception et réalisation de tables basses&lt;/h2&gt;
&lt;p&gt;Après avoir cassé le plateau en verre de la table basse pour la cinquième fois 💥, nous avons fini par nous rendre à l’évidence : mieux vaut éviter les meubles fragiles dans les locaux (surtout après l’apéro).&lt;/p&gt;
&lt;p&gt;Nous avons alors réfléchi à comment remplacer le plateau par quelque chose de plus résistant, mais toujours esthétique, et comme on adore mettre les mains dans le cambouis, nous avons décidé de le faire nous-même.&lt;/p&gt;
&lt;p&gt;En lieu et place de cambouis, c’est dans la sciure que Greg, Suzanne, Ariel, Han et Caro ont décidé de se plonger, en réalisant deux plateaux de table en bois. C’était l’occasion ou jamais de manier scie cloche, affleureuse, défonceuse et autres outils bruyants aux noms exotiques.&lt;/p&gt;
&lt;p&gt;Nos pieds de table actuels sont des &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.lego.com/fr-fr/product/8-stud-desk-drawer-red-5006142&quot;&gt;boîtes de Lego en forme de Lego géants&lt;/a&gt; (non, cet article n’est pas sponsorisé). Nous avons donc imaginé des plateaux avec des trous à la bonne taille pour que les tenons (c’est le terme consacré) de nos boîtes de Lego viennent s’insérer précisément dans le bois, maintenant les plateaux en place tout en dépassant un peu pour leur donner un style unique.&lt;/p&gt;
&lt;p&gt;Après la découpe de notre grande planche et le perçage des trous, un joli arrondi autour des trous et des bords des plateaux avec une fraise quart-de-rond et un peu de polissage à la main (le tout en plein soleil sur la terrasse 🥵), il était temps de d’appliquer un petit coup de peinture pour fignoler.
Notre équipe a terminé la journée de la sciure plein les cheveux mais très heureuse du travail accompli et déjà pleine d’idées d’améliorations pour le prochain JoliDay.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joliday/table-basse.jpg&quot; data-original-width=&quot;2370&quot; data-original-height=&quot;1639&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joliday/table-basse.6a5dd163.webp&quot; /&gt;&lt;source type=&quot;image/jpeg&quot; srcset=&quot;/media/cache/content/2026/joliday/table-basse.jpg&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2370 / 1639)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joliday/table-basse.jpg&quot; alt=&quot;Table basse&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h2&gt;De la couture, de la peinture et de la broderie pour embellir nos locaux&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://jolicode.com/blog/du-tissu-des-cartes-a-jouer-un-minitel-et-du-bois-cetait-joliday-2024#de-la-couture-et-de-la-broderie&quot;&gt;Une fois n’est pas coutume&lt;/a&gt;, nous avons troqué nos ordinateurs contre des aiguilles et des machines à coudre pour un atelier couture 🪡.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://jolicode.com/blog/des-insectes-des-lego-un-chateau-et-de-la-peinture-cetait-joliday-2025#est-un-chateau-bien-habille&quot;&gt;Les jolies lettres brodées&lt;/a&gt;, enfin terminées, ont été découpées et cousues sur le rideau de notre château. Celui-ci arbore désormais fièrement, dans un style médiéval, les lettres “JC” et “PO” en clin d’oeil aux structures occupant les locaux.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joliday/chateau.jpeg&quot; data-original-width=&quot;3024&quot; data-original-height=&quot;2268&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joliday/chateau.2d59ba2f.webp&quot; /&gt;&lt;source type=&quot;image/jpeg&quot; srcset=&quot;/media/cache/content/2026/joliday/chateau.jpeg&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(3024 / 2268)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joliday/chateau.jpeg&quot; alt=&quot;&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Cet atelier attire de plus en plus de petits curieux. Ainsi Paul a pu confectionner une housse de coussin sur les conseils avisés de Lucie. Lucie et Han y ont ensuite brodé de petits motifs. Et voilà de beaux coussins pour nos locaux !&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joliday/coussins.jpeg&quot; data-original-width=&quot;4032&quot; data-original-height=&quot;3024&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joliday/coussins.120fc883.webp&quot; /&gt;&lt;source type=&quot;image/jpeg&quot; srcset=&quot;/media/cache/content/2026/joliday/coussins.jpeg&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(4032 / 3024)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joliday/coussins.jpeg&quot; alt=&quot;Un coussin avec le texte bisous et de petits coeurs et un autre avec une abeille et des fleurs&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Enfin, pour égayer nos événements internes, nous avons décidé de confectionner une nappe sur mesure. Lucie et Sophie se sont chargées de la découpe du tissu aux bonnes dimensions, puis de la couture. Pour la décoration, l’idée de la broder a vite été abandonnée, au risque d’y passer les dix prochains JoliDay…&lt;/p&gt;
&lt;p&gt;Armées de pinceaux et de peinture textile, Sophie, Laureen, Aga et Katia ont laissé libre cours à leurs créations. Il reste encore quelques motifs à ajouter afin que la nappe puisse trouver sa place lors de nos soirées.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joliday/nappe.jpg&quot; data-original-width=&quot;3468&quot; data-original-height=&quot;3635&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joliday/nappe.95e21759.webp&quot; /&gt;&lt;source type=&quot;image/jpeg&quot; srcset=&quot;/media/cache/content/2026/joliday/nappe.jpg&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(3468 / 3635)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joliday/nappe.jpg&quot; alt=&quot;Nappe&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h2&gt;Un établi tout bien rangé&lt;/h2&gt;
&lt;p&gt;Comme vous vous en doutez, tous ces travaux manuels nécessitent des outils.
Et à JoliCode, depuis longtemps, il y a un établi qui regroupe plusieurs spécialités. Nous avons des outils pour le travail du bois, l&#039;impression 3D, une grosse partie électronique, et tout un tas de petites choses inclassables aussi (poignées de porte, plomberie, paillettes et pots de peinture par exemple).&lt;/p&gt;
&lt;p&gt;Le plan était simple : tout vider, tout trier, tout nettoyer, et enfin réorganiser. Car avec le temps, nous en avons accumulé des choses !&lt;/p&gt;
&lt;p&gt;D’une part, nous voulions que tout le monde s’y retrouve, mais aussi que ce soit Joli (code).&lt;/p&gt;
&lt;p&gt;Des tiroirs, il y en a sur cet établi : tout vider et nettoyer, ça nous a pris la matinée. Ensuite, le plus difficile : réorganiser (et nommer les choses). Un tiroir pour la visserie, un pour la plomberie, un pour l’électricité, l’électronique, enfin, vous avez compris. Et malheureusement, nous n’avons pas pu échapper au tiroir &amp;quot;divers&amp;quot;.&lt;/p&gt;
&lt;p&gt;Puis, après avoir fixé au mur une plaque SKÅDIS de nos amis suédois, nous avons pu rendre disponible ce qui est le plus souvent utilisé : cutter, WD40, scotch, tournevis divers et bien sûr le kit de réparation iFixit 💛&lt;/p&gt;
&lt;p&gt;Visuellement, pour que ça claque, nous avons mis notre logo LED et des petites plantes, bien sûr !&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joliday/avant-apres.jpg&quot; data-original-width=&quot;1532&quot; data-original-height=&quot;649&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joliday/avant-apres.7e536786.webp&quot; /&gt;&lt;source type=&quot;image/jpeg&quot; srcset=&quot;/media/cache/content/2026/joliday/avant-apres.jpg&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1532 / 649)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joliday/avant-apres.jpg&quot; alt=&quot;établi avant et après&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Petit jeu bonus : sauras-tu retrouver l’éléphant qui se cache sur l’établi ?&lt;/p&gt;
&lt;p&gt;Enfin saviez-vous qu&#039;il était possible de retourner les clés allen qui servent à monter les meubles à IKEA ? Nous en avons retrouvé des centaines !&lt;/p&gt;
&lt;h2&gt;3 types de bières pour cette cuvée 2026&lt;/h2&gt;
&lt;p&gt;Depuis la première édition du JoliDay, nous brassons notre propre bière. En sirop ou en tout grain, version simple, avec du houblon maison ou une touche de miel… nous avons tout testé !&lt;/p&gt;
&lt;p&gt;Pour cette année, nous avons choisi de concocter trois petits brassins différents : une blonde classique, une IPA et une blanche.&lt;/p&gt;
&lt;p&gt;Comme tous les ans, nous avons ressorti la marmite, le thermomètre et le serpentin et avons fait chauffer et refroidir nos moûts, embaumant les locaux d&#039;une bonne odeur de céréales.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joliday/jolibeers.png&quot; data-original-width=&quot;1000&quot; data-original-height=&quot;1258&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joliday/jolibeers.95b34890.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joliday/jolibeers.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1000 / 1258)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joliday/jolibeers.png&quot; alt=&quot;Prototype d&#039;étiquettes&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Bientôt la mise en bouteille ! 🍻&lt;/p&gt;
&lt;h2&gt;Des bureaux en 3D et des micro-voitures : notre JoliKart maison&lt;/h2&gt;
&lt;p&gt;Grande amatrice de jeux vidéo, notre équipe a décidé de délaisser le Web le temps d’une journée pour se frotter au développement sur &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://godotengine.org/fr/&quot;&gt;Godot&lt;/a&gt;. L’objectif ? Créer un jeu de course multijoueur façon &lt;em&gt;Mario Kart&lt;/em&gt;, au beau milieu de nos bureaux.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joliday/joliday-2026-jolikart-map.png&quot; data-original-width=&quot;1600&quot; data-original-height=&quot;1165&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joliday/joliday-2026-jolikart-map.7561dd7f.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joliday/joliday-2026-jolikart-map.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1600 / 1165)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joliday/joliday-2026-jolikart-map.png&quot; alt=&quot;Carte 3D des bureaux sur Godot - JoliKart&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Pour planter le décor, nous avons commencé par scanner l’open space en 3D à l’aide de l’application &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://poly.cam/fr-fr&quot;&gt;Polycam&lt;/a&gt; et du capteur &lt;abbr title=&quot;Light Detection And Ranging&quot;&gt;LiDAR&lt;/abbr&gt; de l’iPhone (une technologie de détection laser qui permet de mesurer les distances et de modéliser des volumes réels). Une fois cette base récupérée, nous nous sommes divisés en deux binômes : le premier sur Blender pour nettoyer l’export 3D Polycam et ajouter des « props », ces éléments de décor (plantes vertes, bornes d’arcade et logo JoliCode) ; et le second sur le moteur Godot Engine pour coder la physique et le gameplay.&lt;/p&gt;
&lt;p&gt;En fin de journée, le pari était à moitié réussi : nous avions une voiture contrôlable qui slalomait entre les pieds de chaises, gérait les collisions avec le mobilier et validait des checkpoints !&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joliday/joliday-2026-jolikart-kart.png&quot; data-original-width=&quot;2164&quot; data-original-height=&quot;1478&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joliday/joliday-2026-jolikart-kart.c28ffeeb.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joliday/joliday-2026-jolikart-kart.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2164 / 1478)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joliday/joliday-2026-jolikart-kart.png&quot; alt=&quot;Le JoliKart en action&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Nous avons perdu pas mal de temps l’après-midi sur la resynchronisation de la carte 3D entre Blender et Godot. Ne sachant pas trop dans quel logiciel il valait mieux l’éditer, chaque import avait tendance à faire sauter nos collisions et nos modifications de scripts. Après coup, on nous a conseillé de concevoir uniquement les volumes de base dans Blender, puis de gérer les caméras, les lumières et les décors directement dans Godot.&lt;/p&gt;
&lt;p&gt;Mais notre plus gros blocage est venu d&#039;un problème d&#039;échelle. La carte et le modèle de la voiture étant initialement importés en taille réelle (1:1), la voiture s’est retrouvée géante dans un environnement bien trop petit pour elle. Nous avons donc tenté de réduire sa taille pour obtenir un effet à la &lt;em&gt;Micro Machines&lt;/em&gt;. Et c’est là que le moteur physique de Godot a montré ses limites : la gestion de nombres aussi petits a provoqué des erreurs d’arrondi, rendant le véhicule totalement incontrôlable. À l’inverse, nous avons essayé d’agrandir l’échelle globale de la scène, mais ce sont alors les éclairages qui ne fonctionnaient plus. C’était le dilemme d’avant notre démo : soit une physique stable mais une voiture géante, soit un format miniature au prix d’une scène aux lumières chaotiques.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joliday/joliday-2026-jolikart-logo.png&quot; data-original-width=&quot;1600&quot; data-original-height=&quot;988&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joliday/joliday-2026-jolikart-logo.06c1b461.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joliday/joliday-2026-jolikart-logo.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1600 / 988)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joliday/joliday-2026-jolikart-logo.png&quot; alt=&quot;Logo JoliCode modélisé en 3D - JoliKart&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Peu importe, nous nous sommes bien amusés à découvrir Godot en développant ce mini jeu. Et pour la prochaine fois, nous savons déjà ce qu’il reste à faire : réparer les lumières, améliorer l’interface et enfin ajouter des bonus thématiques (goûter, raclette party ou downtime réseau général) ainsi que le fameux mode multijoueur.&lt;/p&gt;
&lt;h2&gt;Pour conclure&lt;/h2&gt;
&lt;p&gt;Cette journée fait partie des nombreux side-projects que l’équipe poursuit tout au long de l’année, et cette édition a été une nouvelle fois riche en apprentissages.
Envie de jouer avec nous ? &lt;a href=&quot;https://jolicode.com/jobs&quot;&gt;Changez de boulot !&lt;/a&gt;&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/jolimediasyliusbundle-a-new-bridge-for-your-sylius-projects</id>
        <published>2026-07-20T14:42:00+02:00</published>
        <updated>2026-07-20T14:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/jolimediasyliusbundle-a-new-bridge-for-your-sylius-projects"/>
        <title>JoliMediaSyliusBundle, a New Bridge for Your Sylius Projects</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="php" />            <category term="symfony" />            <category term="sylius" />        <summary><![CDATA[Does the excellent Sylius E-commerce framework even need an introduction?
If Sylius has established itself in the e-commerce ecosystem, it&#039;s largely thanks to its ability to adapt to a wide variety of…]]></summary>
        <content type="html">
            &lt;p&gt;Does the excellent &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://sylius.com/&quot;&gt;Sylius&lt;/a&gt; E-commerce framework even need an introduction?&lt;/p&gt;
&lt;p&gt;If Sylius has established itself in the e-commerce ecosystem, it&#039;s largely thanks to its ability to adapt to a wide variety of business needs without imposing a rigid architecture. Its extension system allows for gradual feature evolution while preserving the framework&#039;s core mechanisms.&lt;/p&gt;
&lt;p&gt;Late last year, we launched &lt;a href=&quot;https://jolicode.com/blog/jolimediabundle-un-nouveau-bundle-de-medias-pour-vos-projets-symfony&quot;&gt;JoliMediaBundle&lt;/a&gt;, a Symfony bundle dedicated to managing media libraries.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/sonata-grid-view.png&quot; data-original-width=&quot;1203&quot; data-original-height=&quot;891&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/sonata-grid-view.1cfceef2.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/sonata-grid-view.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1203 / 891)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/sonata-grid-view.png&quot; alt=&quot;The SonataAdmin bridge&quot; /&gt;&lt;/picture&gt;
&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/easyadmin-grid-view.png&quot; data-original-width=&quot;1204&quot; data-original-height=&quot;890&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/easyadmin-grid-view.18c21cd6.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/easyadmin-grid-view.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1204 / 890)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/easyadmin-grid-view.png&quot; alt=&quot;The EasyAdmin bridge&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;It already came with two bridges for SonataAdmin and EasyAdmin.&lt;/p&gt;
&lt;h2&gt;The Bridge&#039;s Origin&lt;/h2&gt;
&lt;p&gt;Having recently joined JoliCode as a Sylius expert, I quickly started discussions around integrating the MediaBundle into this ecosystem. That&#039;s how the idea for this new bridge was born, aiming to seamlessly connect media management with Sylius.&lt;/p&gt;
&lt;h2&gt;Media Management in Sylius&lt;/h2&gt;
&lt;p&gt;Before introducing this new bridge, it&#039;s useful to take stock of how media management works in Sylius today.&lt;/p&gt;
&lt;p&gt;By default, Sylius provides a simple but effective system for associating images with the main catalog resources, such as products or taxons. This management relies on image entities directly linked to resources, with some basic metadata (type, position, etc.).&lt;/p&gt;
&lt;p&gt;This approach perfectly meets the typical needs of an e-commerce site: illustrating a product, displaying category visuals, or managing simple galleries.&lt;/p&gt;
&lt;p&gt;However, certain limitations emerge as needs become more cross-cutting. Specifically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the difficulty of easily reusing the same media in multiple places without duplication;&lt;/li&gt;
&lt;li&gt;the lack of centralized file organization (folders, tags, search…);&lt;/li&gt;
&lt;li&gt;scattered media management, specific to each resource.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In practice, each entity embeds its own files, which works well at small scale but quickly becomes cumbersome when media volume increases or multiple teams are involved.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/product-images-before.png&quot; data-original-width=&quot;1684&quot; data-original-height=&quot;761&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/product-images-before.84a3c34d.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/product-images-before.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1684 / 761)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-images-before.png&quot; alt=&quot;Current product image management&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h2&gt;The Sylius and JoliMediaBundle Bridge&lt;/h2&gt;
&lt;p&gt;It is precisely this observation that motivated the reflection around a more centralized and reusable media management within the Sylius ecosystem, leveraging the JoliMediaBundle developed at JoliCode.
To address these limitations, we developed the Sylius bridge for this bundle.
The goal is not to modify how Sylius works or to rewrite its media management, but to add a clean integration layer based on its extension mechanisms.&lt;/p&gt;
&lt;p&gt;In a Sylius project, media are used in product images, taxon images, and administrator avatars. These uses are well integrated into the native model but remain isolated from one another. The bridge enhances this by introducing a centralized media library.&lt;/p&gt;
&lt;p&gt;It is also designed to be compatible with the broader Sylius Stack, not just an e-commerce context. It can thus be integrated into Sylius back-offices used as application bases, where media management is a cross-cutting need across multiple functional domains. This allows using the same centralized media library approach in more general projects built with Sylius.&lt;/p&gt;
&lt;p&gt;Concretely, the approach relies on simple and targeted extensions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;extending Sylius entities when necessary;&lt;/li&gt;
&lt;li&gt;replacing back-office form fields to use the JoliMediaBundle media selector;&lt;/li&gt;
&lt;li&gt;progressive integration via the extension points provided by Sylius.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This strategy preserves current business usage while introducing a more coherent and reusable media management. Each image (product, taxon, administrator) keeps its role, but now fits within a common media library logic.&lt;/p&gt;
&lt;p&gt;The integration remains deliberately unobtrusive: the bridge acts as an overlay that plugs into the Sylius ecosystem without modifying its foundations.&lt;/p&gt;
&lt;p&gt;It is this progressive approach that makes adoption possible in an existing project, without data model overhaul or functional disruption.&lt;/p&gt;
&lt;p&gt;The question then becomes more concrete: how does this bridge technically fit into Sylius, and what mechanisms are used to cleanly connect the media library to the existing model?&lt;/p&gt;
&lt;h2&gt;Simple Integration into Sylius&lt;/h2&gt;
&lt;p&gt;Once you have &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://mediabundle.jolicode.com/getting-started/installation/&quot;&gt;installed the Media bundle&lt;/a&gt;, you need to enable the Sylius bridge:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// filepath: config/bundles.php&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // ...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    JoliCode\MediaBundle\Bridge\Sylius\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;JoliMediaSyliusBundle&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::class&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;all&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; true&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;];&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, you enable the routes for the back-office:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# filepath: config/routes/joli_media.yaml&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;_joli_media_sylius&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    resource&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;@JoliMediaSyliusBundle/src/Admin/Controller/&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    prefix&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;/admin/media&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And finally, you import the package configuration:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# filepath: config/packages/joli_media_sylius.yaml&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;imports&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    - { &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;resource&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;@JoliMediaSyliusBundle/config/app.php&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The idea is to leverage the classic extension mechanisms of Symfony and Sylius to remain as non-intrusive as possible.&lt;/p&gt;
&lt;h3&gt;A Reusable Trait for Media&lt;/h3&gt;
&lt;p&gt;The first building block consists of using a trait that adds media management to any business entity:&lt;/p&gt;
&lt;p&gt;You can associate a media with a Sylius Doctrine entity while keeping a simple domain logic. This will synchronize the existing &lt;code&gt;path&lt;/code&gt; field in Sylius.&lt;/p&gt;
&lt;h3&gt;Extending Sylius Entities&lt;/h3&gt;
&lt;p&gt;This approach integrates naturally with existing entities. For example, for product images:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;namespace App\Entity\Product;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;use Doctrine\ORM\Mapping as ORM;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;+use JoliCode\MediaBundle\Bridge\Sylius\Doctrine\ORM\EntityWithMediaImageTrait;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;use Sylius\Component\Core\Model\ProductImage as BaseProductImage;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;#[ORM\Entity]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;#[ORM\Table(name: &#039;sylius_product_image&#039;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;class ProductImage extends BaseProductImage&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;   +use EntityWithMediaImageTrait;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This way we extend a Sylius entity without modifying its core, only adding the ability to handle a media via the bundle.&lt;/p&gt;
&lt;h3&gt;Back-office Integration&lt;/h3&gt;
&lt;p&gt;Finally, on the administration side, the integration happens through a Sylius form extension. The native file field is replaced by a dedicated component from the JoliMediaBundle:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;ProductImageTypeExtension&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; extends&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-7&quot;&gt;AbstractTypeExtension&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;   public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; buildForm&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;FormBuilderInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $builder, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;array&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $options)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;   {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;       $builder&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;add&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;file&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;MediaChoiceType&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::class&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;           &#039;property_path&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;media&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;       ]);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;   }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;   public&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; static&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; getExtendedTypes&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; iterable&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;   {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;       yield&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; ProductImageType&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::class&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;   }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This allows plugging the media library directly into the Sylius administration interface without breaking existing forms.&lt;/p&gt;
&lt;p&gt;These form extensions are directly provided by the bridge, you just need to declare them in Symfony:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# config/services.yaml&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;   JoliCode\MediaBundle\Bridge\Sylius\Admin\Form\Extension\AvatarImageTypeExtension&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;null&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;   JoliCode\MediaBundle\Bridge\Sylius\Admin\Form\Extension\ProductImageTypeExtension&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;null&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;   JoliCode\MediaBundle\Bridge\Sylius\Admin\Form\Extension\TaxonImageTypeExtension&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;null&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/product-images-after.png&quot; data-original-width=&quot;1717&quot; data-original-height=&quot;832&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/product-images-after.a0213f32.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/product-images-after.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1717 / 832)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-images-after.png&quot; alt=&quot;Product image management with the media bundle&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;File&lt;/code&gt; input is replaced by the one from the Media bundle.&lt;/p&gt;
&lt;p&gt;For the more observant, you may notice that we modified the template to remove the preview provided natively by Sylius.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/media-library-list-view.png&quot; data-original-width=&quot;1650&quot; data-original-height=&quot;1253&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/media-library-list-view.dc768947.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/media-library-list-view.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1650 / 1253)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/media-library-list-view.png&quot; alt=&quot;The media library (list-view)&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;The Media Library is the standout feature of the Media bundle. It allows you to view and organize your file hierarchy.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/media-library-grid-view.png&quot; data-original-width=&quot;1693&quot; data-original-height=&quot;939&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/media-library-grid-view.98293dc0.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/media-library-grid-view.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1693 / 939)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/media-library-grid-view.png&quot; alt=&quot;The media library (grid view)&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;A &amp;quot;Grid&amp;quot; view is also available to display images in a larger format.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/media-details.png&quot; data-original-width=&quot;1711&quot; data-original-height=&quot;1295&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/media-details.2d396fee.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/media-details.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1711 / 1295)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/media-details.png&quot; alt=&quot;Media details&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;A media details page provides additional information as well as integration options.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/variations.png&quot; data-original-width=&quot;1704&quot; data-original-height=&quot;1063&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/variations.6c243f5a.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/variations.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1704 / 1063)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/variations.png&quot; alt=&quot;Media variations&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;A &amp;quot;Variations&amp;quot; tab is available to view the different variants of your media, with their respective sizes, formats, and dimensions.&lt;/p&gt;
&lt;p&gt;This makes it possible to use the JoliMediaBundle compression system instead of the native Sylius system (which uses LiipImagine).&lt;/p&gt;
&lt;p&gt;Let&#039;s replace the images in the product list:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;?&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;php&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;namespace&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;App\Grid\Mutator&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Sylius\Bundle\AdminBundle\Grid\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;ProductGridInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Sylius\Bundle\GridBundle\Builder\Field\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;TwigField&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Sylius\Component\Grid\Builder\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;GridBuilderInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Sylius\Component\Grid\Attribute\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;AsGridMutator&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Sylius\Component\Grid\Mutator\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;GridMutatorInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;#[AsGridMutator(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    grid: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;sylius_admin_product&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // or&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    grid: &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;ProductGridInterface&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;NAME&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt; // constant added in Sylius 2.3&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;ReplaceImageFromProductGridMutator&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; implements&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-7&quot;&gt;GridMutatorInterface&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; __invoke&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;GridBuilderInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $gridBuilder)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $gridBuilder&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            -&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;withFields&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;                TwigField&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;image&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, template: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;admin/product/grid/field/image.html.twig&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            )&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        ;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This way we replace the image field using our own Twig template.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;&amp;#x3C;!-- templates/admin/product/grid/field/image.html.twig -&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;{% &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;@JoliMediaSylius/admin/shared/helper/product_image.html.twig&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; import&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; image&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt; %}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;&amp;#x3C;div class=&quot;thumbnail-box-image&quot;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;   {{ image(&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;) }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;&amp;#x3C;/div&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/product-images.png&quot; data-original-width=&quot;1716&quot; data-original-height=&quot;1051&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/product-images.e1601445.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/product-images.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1716 / 1051)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-images.png&quot; alt=&quot;Product images&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;The result looks identical at first glance, but you can see that the image has been processed by the JoliMediaBundle upon closer inspection:&lt;/p&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/product-image-inspection.18928b6d.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/product-image-inspection.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 382px; ; aspect-ratio: calc(382 / 134)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-image-inspection.png&quot; alt=&quot;Product image with inspector&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h3&gt;Usage in the Shop&lt;/h3&gt;
&lt;p&gt;Since Sylius&#039;s core mechanism is not modified, images continue to work as before on the front end.
They can therefore be processed directly by LiipImagine by default, without requiring any specific adaptation. In a first step, it is not mandatory to go further than this administration-side integration.
It is then possible to gradually improve &lt;a href=&quot;https://jolicode.com/blog/jolimediabundle-un-nouveau-bundle-de-medias-pour-vos-projets-symfony#revenons-a-nos-moutons-pourquoi-un-nouveau-bundle-de-gestion-de-medias-pour-symfony&quot;&gt;image rendering quality&lt;/a&gt; by making the necessary adjustments on the front end, as described in &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://mediabundle.jolicode.com/bridges/sylius/#shop&quot;&gt;the bundle documentation&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Reorganizing Your Media Library&lt;/h3&gt;
&lt;p&gt;A change propagation mechanism in your entities allows you to freely move or rename your media without breaking existing references in your application.
This approach not only makes it easier to search for and identify media in the back-office, but also facilitates the gradual reorganization of the media library over time, whether for restructuring a file hierarchy, standardizing file names, or grouping certain media by functional domain.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Sylius already provides a solid foundation for media management in an e-commerce context. The JoliMediaBundle brings a more cross-cutting and structured vision of file management. The bridge between the two does not seek to oppose these approaches, but to make them coexist cleanly.
In practice, this combination allows preserving the simplicity of the Sylius model while introducing a centralized, reusable media library better suited to projects that grow or become more complex.
It is also a way to extend Sylius&#039;s very philosophy: remain extensible, without imposing rigidity, while leaving the freedom to adapt the architecture to the project&#039;s actual needs.&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/jolimediasyliusbundle-un-nouveau-bridge-pour-vos-projets-sylius</id>
        <published>2026-07-20T14:42:00+02:00</published>
        <updated>2026-07-20T14:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/jolimediasyliusbundle-un-nouveau-bridge-pour-vos-projets-sylius"/>
        <title>JoliMediaSyliusBundle, un nouveau bridge pour vos projets Sylius</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="php" />            <category term="symfony" />            <category term="sylius" />        <summary><![CDATA[Est-il encore nécessaire de présenter l’excellent framework E-commerce Sylius ?
Si Sylius s’est imposé dans l’écosystème e-commerce, c’est notamment grâce à sa capacité à s’adapter à des besoins métier…]]></summary>
        <content type="html">
            &lt;p&gt;Est-il encore nécessaire de présenter l’excellent framework E-commerce &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://sylius.com/&quot;&gt;Sylius&lt;/a&gt; ?&lt;/p&gt;
&lt;p&gt;Si Sylius s’est imposé dans l’écosystème e-commerce, c’est notamment grâce à sa capacité à s’adapter à des besoins métier très variés sans imposer une architecture rigide. Son système d’extensions permet de faire évoluer progressivement les fonctionnalités tout en conservant les mécanismes du cœur du framework.&lt;/p&gt;
&lt;p&gt;Fin d&#039;année dernière, nous avons lancé le &lt;a href=&quot;https://jolicode.com/blog/jolimediabundle-un-nouveau-bundle-de-medias-pour-vos-projets-symfony&quot;&gt;JoliMediaBundle&lt;/a&gt;, un bundle Symfony dédié à la gestion de bibliothèques de medias.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/sonata-grid-view.png&quot; data-original-width=&quot;1203&quot; data-original-height=&quot;891&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/sonata-grid-view.1cfceef2.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/sonata-grid-view.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1203 / 891)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/sonata-grid-view.png&quot; alt=&quot;Le bridge SonataAdmin&quot; /&gt;&lt;/picture&gt;
&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/easyadmin-grid-view.png&quot; data-original-width=&quot;1204&quot; data-original-height=&quot;890&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/easyadmin-grid-view.18c21cd6.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/easyadmin-grid-view.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1204 / 890)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/easyadmin-grid-view.png&quot; alt=&quot;Le bridge EasyAdmin&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Il était déjà accompagné de deux bridges pour SonataAdmin et EasyAdmin.&lt;/p&gt;
&lt;h2&gt;La Genèse du bridge&lt;/h2&gt;
&lt;p&gt;Récemment arrivé chez JoliCode et expert Sylius, j’ai rapidement engagé des discussions autour de l’intégration du MediaBundle dans cet écosystème. C’est ainsi qu’est née l’idée de ce nouveau bridge, visant à connecter harmonieusement la gestion des médias avec Sylius.&lt;/p&gt;
&lt;h2&gt;La gestion des médias dans Sylius&lt;/h2&gt;
&lt;p&gt;Avant d’introduire ce nouveau bridge, il est utile de faire un état des lieux de la gestion des médias dans Sylius aujourd’hui.&lt;/p&gt;
&lt;p&gt;Par défaut, Sylius propose un système simple mais efficace pour associer des images aux principales ressources du catalogue, comme les produits ou les taxons. Cette gestion repose sur des entités d’images directement liées aux ressources, avec quelques métadonnées basiques (type, position, etc.).&lt;/p&gt;
&lt;p&gt;Cette approche répond parfaitement aux besoins classiques d’un site e-commerce : illustrer un produit, afficher des visuels de catégories, ou encore gérer des galeries simples.&lt;/p&gt;
&lt;p&gt;En revanche, certaines limites apparaissent dès que les besoins deviennent plus transverses. Notamment :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;la difficulté à réutiliser facilement un même média à plusieurs endroits sans duplication ;&lt;/li&gt;
&lt;li&gt;l’absence d’une organisation centralisée des fichiers (dossiers, tags, recherche…) ;&lt;/li&gt;
&lt;li&gt;une gestion éclatée des médias, propre à chaque ressource.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;En pratique, chaque entité embarque ses propres fichiers, ce qui fonctionne bien à petite échelle mais devient rapidement contraignant dès que le volume de médias augmente ou que plusieurs équipes interviennent.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/product-images-before.png&quot; data-original-width=&quot;1684&quot; data-original-height=&quot;761&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/product-images-before.84a3c34d.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/product-images-before.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1684 / 761)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-images-before.png&quot; alt=&quot;La gestion actuelle des images de produits&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h2&gt;Le bridge Sylius et JoliMediaBundle&lt;/h2&gt;
&lt;p&gt;C’est précisément ce constat qui a motivé la réflexion autour d’une gestion des médias plus centralisée et réutilisable au sein de l’écosystème Sylius, en s’appuyant sur le JoliMediaBundle développé chez JoliCode.
Pour répondre à ces limites, nous avons développé le bridge Sylius pour ce bundle.
L’objectif n’est pas de modifier le fonctionnement de Sylius ni de réécrire sa gestion des médias, mais d’y ajouter une couche d’intégration propre, basée sur ses mécanismes d’extension.&lt;/p&gt;
&lt;p&gt;Dans un projet Sylius, les médias sont utilisés dans les images produit, les images de taxons et les avatars administrateurs. Ces usages sont bien intégrés au modèle natif mais restent isolés les uns des autres. Le bridge vient enrichir ce fonctionnement en introduisant une médiathèque centralisée.&lt;/p&gt;
&lt;p&gt;Il est également pensé pour être compatible avec la Sylius Stack au sens large, et pas uniquement avec un contexte e-commerce. Il peut ainsi s’intégrer dans des back-offices Sylius utilisés comme base d’application, où la gestion de médias est un besoin transverse à plusieurs domaines fonctionnels. Cela permet d’utiliser la même approche de médiathèque centralisée dans des projets plus généraux construits avec Sylius.&lt;/p&gt;
&lt;p&gt;Concrètement, l’approche repose sur des extensions simples et ciblées :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;extension des entités Sylius concernées lorsque cela est nécessaire ;&lt;/li&gt;
&lt;li&gt;remplacement des champs de formulaire dans le back-office pour utiliser le sélecteur de médias du JoliMediaBundle ;&lt;/li&gt;
&lt;li&gt;intégration progressive via les points d’extension fournis par Sylius.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cette stratégie permet de conserver les usages métier actuels tout en introduisant une gestion plus cohérente et réutilisable des médias. Chaque image (produit, taxon, administrateur) garde son rôle, mais s’inscrit désormais dans une logique commune de médiathèque.&lt;/p&gt;
&lt;p&gt;L’intégration reste volontairement discrète : le bridge agit comme une surcouche qui s’insère dans l’écosystème Sylius sans en modifier les fondations.&lt;/p&gt;
&lt;p&gt;C’est cette approche progressive qui rend l’adoption possible dans un projet existant, sans refonte du modèle de données ni rupture fonctionnelle.&lt;/p&gt;
&lt;p&gt;La question devient alors plus concrète : comment ce bridge s’insère-t-il techniquement dans Sylius, et quels sont les mécanismes utilisés pour relier proprement la médiathèque au modèle existant ?&lt;/p&gt;
&lt;h2&gt;Une intégration simple dans Sylius&lt;/h2&gt;
&lt;p&gt;Lorsque vous avez &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://mediabundle.jolicode.com/getting-started/installation/&quot;&gt;installé le Media bundle&lt;/a&gt;, il faut activer le bridge Sylius :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// filepath: config/bundles.php&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // ...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    JoliCode\MediaBundle\Bridge\Sylius\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;JoliMediaSyliusBundle&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::class&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;all&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; true&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;];&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ensuite, on active les routes pour le back-office :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# filepath: config/routes/joli_media.yaml&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;_joli_media_sylius&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    resource&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;@JoliMediaSyliusBundle/src/Admin/Controller/&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    prefix&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;/admin/media&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Et enfin, on importe la configuration du package :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# filepath: config/packages/joli_media_sylius.yaml&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;imports&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    - { &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;resource&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;@JoliMediaSyliusBundle/config/app.php&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;L’idée est de s’appuyer sur les mécanismes d’extension classiques de Symfony et Sylius, afin de rester le moins intrusif possible.&lt;/p&gt;
&lt;h3&gt;Un trait réutilisable pour les médias&lt;/h3&gt;
&lt;p&gt;La première brique consiste à utiliser un trait permettant d’ajouter une gestion de média à n’importe quelle entité métier :&lt;/p&gt;
&lt;p&gt;Vous pouvez associer un média à une entité Doctrine Sylius, tout en conservant une logique simple côté domaine. Cela synchronisera le champ &lt;code&gt;path&lt;/code&gt; existant dans Sylius.&lt;/p&gt;
&lt;h3&gt;Extension des entités Sylius&lt;/h3&gt;
&lt;p&gt;Cette approche s’intègre naturellement aux entités existantes. Par exemple, pour les images produit :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;namespace App\Entity\Product;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;use Doctrine\ORM\Mapping as ORM;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;+use JoliCode\MediaBundle\Bridge\Sylius\Doctrine\ORM\EntityWithMediaImageTrait;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;use Sylius\Component\Core\Model\ProductImage as BaseProductImage;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;#[ORM\Entity]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;#[ORM\Table(name: &#039;sylius_product_image&#039;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;class ProductImage extends BaseProductImage&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;   +use EntityWithMediaImageTrait;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On étend ainsi une entité Sylius sans en modifier le cœur, en ajoutant uniquement la capacité de manipuler un média via le bundle.&lt;/p&gt;
&lt;h3&gt;Intégration dans le back-office&lt;/h3&gt;
&lt;p&gt;Enfin, côté administration, l’intégration passe par une extension de formulaire Sylius. Le champ fichier natif est remplacé par un composant dédié au JoliMediaBundle :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;ProductImageTypeExtension&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; extends&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-7&quot;&gt;AbstractTypeExtension&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;   public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; buildForm&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;FormBuilderInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $builder, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;array&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $options)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;   {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;       $builder&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;add&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;file&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;MediaChoiceType&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::class&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;           &#039;property_path&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;media&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;       ]);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;   }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;   public&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; static&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; getExtendedTypes&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; iterable&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;   {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;       yield&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; ProductImageType&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::class&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;   }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cela permet de brancher directement la médiathèque dans l’interface d’administration Sylius, sans casser les formulaires existants.&lt;/p&gt;
&lt;p&gt;Ces Forms extensions sont directement fournies par le bridge, il vous suffit de les déclarer dans Symfony:&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# config/services.yaml&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;   JoliCode\MediaBundle\Bridge\Sylius\Admin\Form\Extension\AvatarImageTypeExtension&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;null&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;   JoliCode\MediaBundle\Bridge\Sylius\Admin\Form\Extension\ProductImageTypeExtension&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;null&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;   JoliCode\MediaBundle\Bridge\Sylius\Admin\Form\Extension\TaxonImageTypeExtension&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;null&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/product-images-after.png&quot; data-original-width=&quot;1717&quot; data-original-height=&quot;832&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/product-images-after.a0213f32.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/product-images-after.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;aspect-ratio: calc(1717 / 832)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-images-after.png&quot; alt=&quot;La gestion des images de produits avec le media bundle&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Le &lt;code&gt;File&lt;/code&gt; input est remplacé par celui du Media bundle.&lt;/p&gt;
&lt;p&gt;Pour les plus observateurs, vous pouvez remarquer que nous avons modifié le template pour retirer l’aperçu fourni par Sylius nativement.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/media-library-list-view.png&quot; data-original-width=&quot;1650&quot; data-original-height=&quot;1253&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/media-library-list-view.dc768947.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/media-library-list-view.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1650 / 1253)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/media-library-list-view.png&quot; alt=&quot;La médiathèque (list-view)&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;La Médiathèque est le point fort du Media bundle. Elle permet de visualiser mais également d’organiser votre arborescence.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/media-library-grid-view.png&quot; data-original-width=&quot;1693&quot; data-original-height=&quot;939&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/media-library-grid-view.98293dc0.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/media-library-grid-view.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1693 / 939)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/media-library-grid-view.png&quot; alt=&quot;La médiathèque (grid view)&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Une vue « Grid » est également disponible pour afficher les images en plus grand format.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/media-details.png&quot; data-original-width=&quot;1711&quot; data-original-height=&quot;1295&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/media-details.2d396fee.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/media-details.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1711 / 1295)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/media-details.png&quot; alt=&quot;Détails du media&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Une page détails du media permet d’obtenir davantage d’informations ainsi que les options d’intégration.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/variations.png&quot; data-original-width=&quot;1704&quot; data-original-height=&quot;1063&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/variations.6c243f5a.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/variations.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1704 / 1063)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/variations.png&quot; alt=&quot;Variations du media&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Un onglet « Variations » est disponible afin de consulter les différentes variantes de vos médias, avec leurs tailles, formats et dimensions respectifs.&lt;/p&gt;
&lt;p&gt;Il est ainsi possible d’utiliser le système de compression du JoliMediaBundle au lieu du système natif de Sylius (utilisant LiipImagine).&lt;/p&gt;
&lt;p&gt;Remplaçons les images dans la liste des produits :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;?&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;php&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;namespace&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;App\Grid\Mutator&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Sylius\Bundle\AdminBundle\Grid\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;ProductGridInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Sylius\Bundle\GridBundle\Builder\Field\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;TwigField&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Sylius\Component\Grid\Builder\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;GridBuilderInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Sylius\Component\Grid\Attribute\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;AsGridMutator&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Sylius\Component\Grid\Mutator\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;GridMutatorInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;#[AsGridMutator(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    grid: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;sylius_admin_product&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // ou&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    grid: &lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;ProductGridInterface&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;NAME&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt; // constante ajoutée sur Sylius 2.3&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;ReplaceImageFromProductGridMutator&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; implements&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-7&quot;&gt;GridMutatorInterface&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; __invoke&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;GridBuilderInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $gridBuilder)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $gridBuilder&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;            -&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;withFields&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;                TwigField&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;image&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, template: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;admin/product/grid/field/image.html.twig&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            )&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        ;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ainsi on remplace le field image en utilisant notre propre template Twig.&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;&amp;#x3C;!-- templates/admin/product/grid/field/image.html.twig -&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;{% &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;@JoliMediaSylius/admin/shared/helper/product_image.html.twig&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; import&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; image&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt; %}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;&amp;#x3C;div class=&quot;thumbnail-box-image&quot;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;   {{ image(&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;) }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;&amp;#x3C;/div&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/joli-media-sylius-bundle/product-images.png&quot; data-original-width=&quot;1716&quot; data-original-height=&quot;1051&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/product-images.e1601445.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/product-images.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1716 / 1051)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-images.png&quot; alt=&quot;Images de produits&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Le résultat est identique en apparence, mais on peut voir que l’image a été traitée par le JoliMediaBundle en y regardant de plus près :&lt;/p&gt;
&lt;p&gt;&lt;picture&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/joli-media-sylius-bundle/product-image-inspection.18928b6d.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/joli-media-sylius-bundle/product-image-inspection.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 382px; ; aspect-ratio: calc(382 / 134)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-image-inspection.png&quot; alt=&quot;Image du produit avec l’inspecteur&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h3&gt;Utilisation dans le shop&lt;/h3&gt;
&lt;p&gt;Le mécanisme cœur de Sylius n’étant pas modifié, les images continuent de fonctionner comme auparavant côté front.
Elles peuvent donc être traitées directement par LiipImagine par défaut, sans nécessiter d’adaptation spécifique. Il n’est pas obligatoire, dans un premier temps, d’aller plus loin que cette intégration côté administration.
Il est ensuite possible d’améliorer progressivement &lt;a href=&quot;https://jolicode.com/blog/jolimediabundle-un-nouveau-bundle-de-medias-pour-vos-projets-symfony#revenons-a-nos-moutons-pourquoi-un-nouveau-bundle-de-gestion-de-medias-pour-symfony&quot;&gt;la qualité du rendu des images&lt;/a&gt; en effectuant les ajustements nécessaires côté front, comme décrit dans &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://mediabundle.jolicode.com/bridges/sylius/#shop&quot;&gt;la documentation du bundle&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Réorganiser votre médiathèque&lt;/h3&gt;
&lt;p&gt;Un mécanisme de propagation des changements dans vos entités permet de déplacer ou renommer vos médias librement, sans casser les références existantes dans votre application.
Cette approche facilite non seulement la recherche et l’identification des médias dans le back-office, mais également la réorganisation progressive de la médiathèque au fil du temps, que ce soit pour restructurer une arborescence, harmoniser des noms de fichiers ou regrouper certains médias par domaine fonctionnel.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Sylius propose déjà une base solide pour la gestion des médias dans un contexte e-commerce. Le JoliMediaBundle apporte une vision plus transversale et structurée de la gestion des fichiers. Le bridge entre les deux ne cherche pas à opposer ces approches, mais à les faire coexister proprement.
En pratique, cette combinaison permet de conserver la simplicité du modèle Sylius tout en introduisant une médiathèque centralisée, réutilisable et plus adaptée à des projets qui grandissent ou se complexifient.
C’est aussi une manière de prolonger la philosophie même de Sylius : rester extensible, sans imposer de rigidité, tout en laissant la liberté d’adapter l’architecture aux besoins réels du projet.&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/accelerer-votre-ci-mettre-en-cache-l-etat-de-la-base-de-donnees</id>
        <published>2026-07-17T10:41:00+02:00</published>
        <updated>2026-07-17T10:41:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/accelerer-votre-ci-mettre-en-cache-l-etat-de-la-base-de-donnees"/>
        <title>Accélérer votre CI : mettre en cache l’état de la base de données</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="sql" />            <category term="ci" />            <category term="fixtures" />        <summary><![CDATA[Il y a quelques années, j’écrivais Accélérer votre intégration continue. L’article passait en revue tout un tas de techniques pour rendre une CI plus rapide : cache Composer, cache Yarn, layers Docker,…]]></summary>
        <content type="html">
            &lt;p&gt;Il y a quelques années, j’écrivais &lt;a href=&quot;https://jolicode.com/blog/accelerer-votre-integration-continue&quot;&gt;Accélérer votre intégration continue&lt;/a&gt;. L’article passait en revue tout un tas de techniques pour rendre une CI plus rapide : cache Composer, cache Yarn, layers Docker, parallélisation, &lt;code&gt;tmpDir&lt;/code&gt; de PHPStan… et, tout en bas de la liste, une idée un peu à part : plutôt que de rejouer les fixtures à chaque build, charger un dump SQL pré-généré.&lt;/p&gt;
&lt;p&gt;L’idée est séduisante, mais sous sa forme la plus simple (un dump statique, versionné, régénéré à la main) elle a un défaut rédhibitoire : il faut penser à le régénérer dès qu’une migration ou une fixture change. En pratique, un tel dump finit toujours par diverger de la réalité. Soit on oublie de le mettre à jour et les tests tournent sur des données périmées, soit on le régénère « au cas où » à chaque fois, et on perd tout le bénéfice.&lt;/p&gt;
&lt;p&gt;J’ai eu l’occasion de mettre tout ça en place sur un projet client, une application Symfony dont la CI commençait à traîner en longueur. C’est ce contexte réel qui sert de fil rouge à cet article : nous allons voir comment transformer cette astuce en un vrai cache, automatique, adressé par son contenu, et qui s’invalide tout seul.&lt;/p&gt;
&lt;p&gt;Petites précision avant de commencer :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Sur ce projet, toute l’automatisation (installation, fixtures, build, tests…) passe par des tâches &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://castor.jolicode.com&quot;&gt;Castor&lt;/a&gt;. Les extraits de code de cet article sont donc des tâches Castor, écrites en PHP, que la CI appelle comme n’importe quelle commande.&lt;/li&gt;
&lt;li&gt;Nous utilisons des runners Github Action self-hosted sur un même serveur. Nous pouvons donc jouer avec le cache en partageant directement des dossiers entre les jobs. Mais il reste possible de faire la même chose avec les runners cloud fournis par Github en jouant avec les &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/actions/cache&quot;&gt;actions de cache natives&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Le coût qu’on veut éviter&lt;/h2&gt;
&lt;p&gt;Préparer la base de données de test de notre application n’est pas anodin. À chaque build, il faut :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Créer le schéma de la base principale en rejouant les migrations Doctrine (&lt;strong&gt;118 migrations, 542 requêtes SQL, ~10 s&lt;/strong&gt; rien que pour ça) ;&lt;/li&gt;
&lt;li&gt;Provisionner une seconde base, « géographique » : nos entités reproduisent le schéma d’un référentiel fourni par un prestataire tiers, mais on n’importe pas sa base complète, bien trop volumineuse. On fait donc un &lt;code&gt;doctrine:schema:create&lt;/code&gt;, puis on charge nos propres fixtures pour cette base, dont un jeu de fichiers SQL d’environ &lt;strong&gt;57 Mo&lt;/strong&gt; ;&lt;/li&gt;
&lt;li&gt;Charger les fixtures métier (avec Alice) ;&lt;/li&gt;
&lt;li&gt;Générer les localisations à partir de ces données géo, puis recalculer les entités qui en dépendent ;&lt;/li&gt;
&lt;li&gt;Réindexer le tout dans Elasticsearch.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;c-alert c-alert--note&quot;&gt;
    &lt;p class=&quot;c-alert__title&quot;&gt;
                    &lt;span class=&quot;c-icon c-icon--monospace&quot;&gt;
                &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; aria-hidden=&quot;true&quot; class=&quot;c-icon__svg&quot; focusable=&quot;false&quot; viewBox=&quot;0 0 70 71&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M35 .9c19.3 0 35 15.7 35 35s-15.7 35-35 35-35-15.7-35-35S15.7.9 35 .9m0 5c-16.552 0-30 13.449-30 30s13.448 30 30 30c16.552.103 30-13.448 30-30 0-16.551-13.448-30-30-30m0 24.9c1.7 0 3 1.3 3 3v15.3c0 1.7-1.3 3-3 3s-3-1.3-3-3V33.8c0-1.7 1.3-3 3-3m0-11c.8 0 1.6.3 2.3.9.6.5.9 1.3.9 2.1 0 .2-.1.4-.1.6-.1.2-.1.4-.2.6s-.2.3-.3.5-.3.4-.4.5c-1.1 1.1-3.1 1.1-4.2 0-.2-.2-.3-.3-.4-.5s-.2-.3-.3-.5-.2-.4-.2-.6c-.1-.2-.1-.4-.1-.6 0-.8.3-1.6.9-2.1.5-.6 1.3-.9 2.1-.9&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Info&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Une autre solution pour rendre nos migrations plus rapides serait de les fusionner. Nous en avions déjà parlé dans un &lt;a href=&quot;https://jolicode.com/blog/a-new-way-to-squash-your-doctrine-migrations&quot;&gt;précédent article&lt;/a&gt;.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Bout à bout, la seule construction de la base tourne autour de &lt;strong&gt;50 secondes&lt;/strong&gt;. Et comme chaque suite de tests tourne dans son propre job (PHPUnit, Behat, e2e…), on ne veut surtout pas payer ce coût plusieurs fois. Dans notre CI, un unique job &lt;code&gt;prepare-application&lt;/code&gt; construit l’application et la base une fois, et tous les autres jobs en repartent.&lt;/p&gt;
&lt;p&gt;Mais même une seule fois par build, c’est déjà trop. La grande majorité des pull requests ne touche ni aux migrations, ni aux fixtures, ni aux modèles. Reconstruire la base à l’identique à chaque push, c’est du gâchis.&lt;/p&gt;
&lt;h2&gt;L’idée : un snapshot adressé par son contenu&lt;/h2&gt;
&lt;p&gt;Le raisonnement est simple. L’état final de la base est &lt;strong&gt;déterministe&lt;/strong&gt; : à migrations, fixtures et code de chargement identiques, on obtient exactement la même base. Si on sait résumer « tout ce qui détermine la base » en une empreinte, alors on peut :&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;calculer cette empreinte au début de la préparation ;&lt;/li&gt;
&lt;li&gt;si un dump correspondant existe déjà, le restaurer et s’arrêter là ;&lt;/li&gt;
&lt;li&gt;sinon, tout reconstruire comme avant, puis sauvegarder le dump sous cette empreinte pour la prochaine fois.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;C’est le principe du cache adressé par le contenu (&lt;em&gt;content-addressed&lt;/em&gt;), exactement comme Docker le fait avec ses layers. Mais toute la difficulté tient dans une seule question : comment savoir si le dump en cache est encore valable ?&lt;/p&gt;
&lt;h2&gt;La clé de cache, le cœur du système&lt;/h2&gt;
&lt;p&gt;C’est la partie la plus intéressante. La clé est un hash SHA-256 du &lt;strong&gt;contenu&lt;/strong&gt; de tout ce qui influence les données. Voici la fonction qui la calcule :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; fixtures_snapshot_key&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; string&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $root &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; PathHelper&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getRoot&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $hash &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; hash_init&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;sha256&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;    hash_update&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($hash, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;SNAPSHOT_VERSION&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);       &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// bust manuel global&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;    hash_update&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($hash, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Y-m-d&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;));          &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// bucket journalier&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // Les dossiers dont le contenu change le jeu de données&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $dirs &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $root &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/application/migrations&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $root &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/application/fixtures&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $root &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/application/src/Model&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $root &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/application/src/Location&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $root &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/application/src/Command/Debug&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        $root &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/application/src/Command/Location&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    ];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    foreach&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;finder&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;in&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($dirs)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;sortByName&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;() &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $file) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;        hash_update&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($hash, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;substr&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($file&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getPathname&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(), \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;strlen&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($root) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)); &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// le chemin…&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;        hash_update_file&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($hash, $file&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getPathname&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()); &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// …et le contenu&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // La config Doctrine et le lock des dépendances comptent aussi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    $extraFiles &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; finder&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;in&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($root &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/application/config/packages&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;doctrine*.yaml&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;sortByName&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    foreach&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; ($extraFiles &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $file) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;        hash_update&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($hash, &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;substr&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($file&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getPathname&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(), \&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;strlen&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($root) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;        hash_update_file&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($hash, $file&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;getPathname&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;());&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-9&quot;&gt;    hash_update_file&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($hash, $root &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;/application/composer.lock&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt; hash_final&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($hash);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Trois décisions méritent qu’on s’y arrête.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On hashe le contenu, pas un timestamp ni un hash de commit.&lt;/strong&gt; C’est ce qui rend le cache &lt;em&gt;correct&lt;/em&gt;. Le jour où quelqu’un ajoute une migration ou modifie une fixture, le contenu change, donc la clé change, donc on reconstruit, automatiquement, sans que personne n’ait à y penser. À l’inverse, une pull request qui ne touche qu’un template ou une feuille de style retombe sur la même clé et restaure le dump en quelques secondes. Cerise sur le gâteau : ça marche aussi avec des modifications non commitées, puisqu’on lit les fichiers directement sur le disque.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On inclut le chemin des fichiers dans le hash&lt;/strong&gt;, pas seulement leur contenu. Sans ça, renommer ou déplacer un fichier sans en changer le contenu passerait inaperçu.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Un bucket journalier&lt;/strong&gt; (&lt;code&gt;date(&#039;Y-m-d&#039;)&lt;/code&gt;) entre dans la clé. Beaucoup de pages et de requêtes dépendent d’une notion de récence : « les articles publiés ces 7 derniers jours », « les annonces qui expirent bientôt »… Les fixtures qui les alimentent sont donc datées relativement à aujourd’hui. Si on figeait le même dump indéfiniment, ces dates vieilliraient : au bout de quelques jours, une entité « publiée il y a 2 jours » se retrouverait datée d’il y a une semaine, sortirait du périmètre testé, et casserait un test qui vérifie un affichage « récent ». En intégrant la date du jour dans la clé, on force au minimum une reconstruction quotidienne, ce qui garde ces données fraîches sans surcoût notable.&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--note&quot;&gt;
    &lt;p class=&quot;c-alert__title&quot;&gt;
                    &lt;span class=&quot;c-icon c-icon--monospace&quot;&gt;
                &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; aria-hidden=&quot;true&quot; class=&quot;c-icon__svg&quot; focusable=&quot;false&quot; viewBox=&quot;0 0 70 71&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M35 .9c19.3 0 35 15.7 35 35s-15.7 35-35 35-35-15.7-35-35S15.7.9 35 .9m0 5c-16.552 0-30 13.449-30 30s13.448 30 30 30c16.552.103 30-13.448 30-30 0-16.551-13.448-30-30-30m0 24.9c1.7 0 3 1.3 3 3v15.3c0 1.7-1.3 3-3 3s-3-1.3-3-3V33.8c0-1.7 1.3-3 3-3m0-11c.8 0 1.6.3 2.3.9.6.5.9 1.3.9 2.1 0 .2-.1.4-.1.6-.1.2-.1.4-.2.6s-.2.3-.3.5-.3.4-.4.5c-1.1 1.1-3.1 1.1-4.2 0-.2-.2-.3-.3-.4-.5s-.2-.3-.3-.5-.2-.4-.2-.6c-.1-.2-.1-.4-.1-.6 0-.8.3-1.6.9-2.1.5-.6 1.3-.9 2.1-.9&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Info&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Ce cache introduit un piège subtil sur les dates de fixtures. Comme le dump est généré une fois puis rejoué pendant un maximum de 24 h, le « maintenant » vu par les fixtures est celui de la &lt;em&gt;génération&lt;/em&gt;, pas celui du test. Une fenêtre large ne pose aucun problème : une entité « publiée il y a 2 jours » le restera, à quelques heures près, pour tous les tests qui repartent du dump. Mais une fenêtre serrée devient un piège : une fixture « expire dans 1 heure » ou « créée il y a 5 minutes » aura déjà franchi son seuil au moment où un test restaure un dump vieux de trois heures. Pour ces cas-là, mieux vaut des marges larges, ou une donnée créée à la volée dans le test plutôt que dans les fixtures partagées.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Enfin, une constante &lt;code&gt;SNAPSHOT_VERSION&lt;/code&gt; permet d’invalider &lt;em&gt;tous&lt;/em&gt; les snapshots d’un coup, à la main, si on modifie la logique de génération elle-même. La ceinture et les bretelles.&lt;/p&gt;
&lt;h2&gt;Restaurer ou reconstruire&lt;/h2&gt;
&lt;p&gt;Maintenant que la clé est calculée, le reste est mécanique. Le dump vit dans un dossier persistant, partagé entre les jobs et les builds successifs du runner (le même volume qui sert déjà de cache Composer/Yarn) :&lt;/p&gt;
&lt;pre class=&quot;syntax-0&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$snapshotFile &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$HOME&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;/fixtures-snapshots/{&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$key&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;}.sql.gz&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; (&lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;file_exists&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;($snapshotFile)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;    // Hit : on restaure et on repart&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;    run&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;gunzip -c {&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$snapshotFile&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;} | mariadb -h mysql -u root -p***&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// Miss : toute la reconstruction habituelle…&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;// …puis on sauvegarde pour la prochaine fois :&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;mariadb-dump --single-transaction --databases geo_fixtures app_fixtures \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;     | gzip &gt; {&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$snapshotFile&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;}.tmp.$$ &amp;#x26;&amp;#x26; mv {&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$snapshotFile&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;}.tmp.$$ {&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;$snapshotFile&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;}&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deux remarques sur ce bout de code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Redis et Elasticsearch sont exclus du dump&lt;/strong&gt;. Redis et Elasticsearch sont des datastores à part. J&#039;ai préféré gardé leur fonctionnement actuel, c&#039;est à dire que nous continuons à les recharger systématiquement, aussi bien sur un hit que sur un miss. Ce n’est pas gênant : ces deux étapes ne représentent que quelques secondes dans la CI. Le gros du temps à gagner était ailleurs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;L’écriture du dump est atomique&lt;/strong&gt;, et ce détail-là fait toute la différence entre un cache qui marche et un cache qui vous cause plus de soucis qu’autre chose. On écrit dans un fichier temporaire unique (&lt;code&gt;.tmp.$$&lt;/code&gt;, avec le PID du process), puis on le renomme (&lt;code&gt;mv&lt;/code&gt;). Sur nos runners self-hosted partagés, deux pull requests peuvent très bien construire la même clé au même moment, ou un build peut être annulé en plein &lt;code&gt;mariadb-dump&lt;/code&gt;. Sans cette précaution, un autre build restaurerait un dump tronqué et échouerait de façon aléatoire, le pire type de bug de CI. Le &lt;code&gt;mv&lt;/code&gt; étant atomique sur un même système de fichiers, un fichier final n’existe que s’il est complet.&lt;/p&gt;
&lt;h2&gt;Le résultat&lt;/h2&gt;
&lt;p&gt;Voici les chiffres, mesurés sur deux runs réels de notre CI (même machine, avant et après) :&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Étape&lt;/th&gt;
&lt;th&gt;Sans cache (reconstruction)&lt;/th&gt;
&lt;th&gt;Avec cache (restauration)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Provisionnement de la base&lt;/td&gt;
&lt;td&gt;~53 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~6 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tâche &lt;code&gt;fixtures&lt;/code&gt; complète (base + Redis + indexation ES)&lt;/td&gt;
&lt;td&gt;~79 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~14 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Le provisionnement de la base (la partie qui reconstruisait les schémas, chargeait les fixtures géo et rejouait les fixtures métier) tombe de ~53 s à ~6 s : un &lt;code&gt;gunzip&lt;/code&gt; piped dans &lt;code&gt;mariadb&lt;/code&gt;, et c’est tout. Le reste de la tâche (rechargement Redis, réindexation Elasticsearch) tourne dans les deux cas, d’où les ~14 s résiduelles.&lt;/p&gt;
&lt;p&gt;Pour la grande majorité des pull requests, celles qui ne touchent pas au modèle de données, la préparation de la base passe donc d’une minute à quelques secondes. Et le jour où l’on touche vraiment aux migrations ou aux fixtures, le cache se reconstruit tout seul, sans qu’on ait à y penser, parce que sa clé a changé.&lt;/p&gt;
&lt;p&gt;Si vous ne deviez retenir qu’une chose, ce serait celle-ci : ce qui fait la valeur de ce cache, ce n’est ni la compression ni le &lt;code&gt;mariadb-dump&lt;/code&gt;, c’est la &lt;strong&gt;clé&lt;/strong&gt;. Un cache n’est utile que s’il est à la fois agressif (il évite un maximum de travail) et correct (il ne sert jamais de données périmées). En dérivant la clé du contenu exact qui produit la base, on obtient les deux d’un coup, et plus personne n’a à se demander « faut-il régénérer le dump ? ». La réponse est dans le hash.&lt;/p&gt;

        </content>
    </entry></feed>
