<?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/" xmlns:reactions="https://jolicode.com/xmlns/reactions/1.0" 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" />

        <script src="https://jolicode.com/build/xslt-polyfill.min.js" xmlns="http://www.w3.org/1999/xhtml"></script>

            <title>JoliCode blog - les derniers articles</title>
        <updated>2026-10-09T23:45:21+02:00</updated>    <entry>
        <id>https://jolicode.com/blog/dotai-2026-de-l-illusion-d-autonomie-a-la-realite-du-terrain-reprendre-le-controle-sur-nos-agents</id>
        <published>2026-09-25T15:42:00+02:00</published>
        <updated>2026-09-25T15:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/dotai-2026-de-l-illusion-d-autonomie-a-la-realite-du-terrain-reprendre-le-controle-sur-nos-agents"/>
        <title>dotAI 2026 : De l’illusion d’autonomie à la réalité du terrain, reprendre le contrôle sur nos agents</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="ia" />            <category term="ai" />            <reactions:summary total="4">                <reactions:reaction emoji="❤️" shortname="heart" count="3"/>                <reactions:reaction emoji="🚀" shortname="rocket" count="1"/>            </reactions:summary>
        <summary><![CDATA[
Nous étions à la dotAI 2026. Et comme il est de coutume, voici notre retour sur ce qu’on a aimé, ce qui s&#039;y est dit et ce qu’on y a appris. Bonne lecture !
L’année écoulée a été marquée par l’essor des…]]></summary>
        <content type="html">
            &lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/dot-ai/dotai_2026_intro.png&quot; data-original-width=&quot;1681&quot; data-original-height=&quot;936&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/dot-ai/dotai_2026_intro.a13ea522.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/dot-ai/dotai_2026_intro.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1681 / 936)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/dot-ai/dotai_2026_intro.png&quot; alt=&quot;intro&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Nous étions à la &lt;strong&gt;dotAI 2026&lt;/strong&gt;. Et comme il est de coutume, voici notre retour sur ce qu’on a aimé, ce qui s&#039;y est dit et ce qu’on y a appris. Bonne lecture !&lt;/p&gt;
&lt;p&gt;L’année écoulée a été marquée par l’essor des architectures multi-agents et des &lt;em&gt;harnesses&lt;/em&gt;, ces surcouches qui entourent un &lt;abbr title=&quot;Large Language Model&quot;&gt;LLM&lt;/abbr&gt; pour le transformer en travailleur autonome. Peu à peu, nous délaissons nos &lt;abbr title=&quot;Integrated Development Environment&quot;&gt;IDE&lt;/abbr&gt; au profit des &lt;abbr title=&quot;Agentic Development Environment&quot;&gt;ADE&lt;/abbr&gt;, et passons désormais l&#039;essentiel de notre temps à planifier et à relire du code plutôt qu&#039;à en écrire.&lt;/p&gt;
&lt;p&gt;Cette évolution rapide transforme nos métiers au quotidien, et c&#039;était précisément l&#039;enjeu de cette édition : choix d&#039;architecture, mise en place de garde-fous, anatomie des modèles, souveraineté ainsi que cas d’usage concrets. Retour sur les grandes idées qui ont rythmé la journée.&lt;/p&gt;
&lt;h2&gt;L&#039;IA dans le workflow dev : Reprendre le contrôle sur la boucle de revue&lt;/h2&gt;
&lt;p&gt;L&#039;arrivée massive des agents dans le quotidien des équipes produit un volume inédit de code et de Pull Requests, déplaçant le véritable goulot d&#039;étranglement de la production de code vers la capacité humaine de relecture. &lt;strong&gt;Guillaume Vernade&lt;/strong&gt; (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://deepmind.google/&quot;&gt;Google DeepMind&lt;/a&gt;) et &lt;strong&gt;Greg Qualls&lt;/strong&gt; comparent ce phénomène au comportement d&#039;un stagiaire junior hyperactif ou d&#039;un cerveau &lt;abbr title=&quot;Trouble Déficit de l&#039;Attention avec ou sans Hyperactivité&quot;&gt;TDAH&lt;/abbr&gt;. Les agents manquent intrinsèquement de mémoire de travail à long terme, saturent leur fenêtre de contexte et interrompent en permanence les développeurs pour valider des micro-décisions ou faire relire du code non testé qui échoue aux CI.&lt;/p&gt;
&lt;p&gt;Face à cette saturation, la tentation de se réfugier dans des &amp;quot;usines logicielles autonomes&amp;quot; (&lt;em&gt;dark software factories&lt;/em&gt;, l&#039;idée d&#039;une chaîne où l&#039;IA fait tout, toute seule) ou des spécifications purement formelles est une impasse (c&#039;est l&#039;idée d&#039;écrire un cahier des charges ultra-exhaustif pour contrôler l&#039;IA au mot près). Comme l&#039;explique &lt;strong&gt;Stanislas Polu&lt;/strong&gt; (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://dust.tt/&quot;&gt;Dust&lt;/a&gt;), rédiger et maintenir des spécifications formelles exhaustives devient aussi complexe que d&#039;écrire le code lui-même. Pire encore, lorsqu&#039;un bug survient en production à 4 heures du matin, personne ne peut réparer un système construit comme une boîte noire que plus aucun humain ne comprend. Le code reste le meilleur niveau d&#039;abstraction pour maintenir la compréhension d&#039;une équipe. Pour reprendre le contrôle, la solution réside dans le &lt;em&gt;&lt;strong&gt;loop engineering&lt;/strong&gt;&lt;/em&gt; : externaliser les fonctions exécutives de l&#039;agent via des listes de tâches externes persistant hors du contexte, imposer des budgets d&#039;exécution stricts, mettre en silo l&#039;architecture applicative pour isoler l&#039;impact des PRs et exiger l&#039;exécution de tests automatisés complets par l&#039;agent avant toute relecture humaine.&lt;/p&gt;
&lt;p&gt;Lorsque ces garde-fous sont en place, les gains de productivité deviennent massifs : chez &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.anthropic.com/&quot;&gt;Anthropic&lt;/a&gt;, &lt;strong&gt;Ian Massingham&lt;/strong&gt; indique que Claude rédige désormais plus de 80 % du code mergé avec une productivité multipliée par 8. Cependant, atteindre cette maturité nécessite d&#039;adopter un &lt;em&gt;&lt;strong&gt;reaper mindset&lt;/strong&gt;&lt;/em&gt; : la capacité d&#039;une équipe à supprimer sans regret ses anciens outils, &lt;abbr title=&quot;Retrieval Augmented Generation&quot;&gt;RAG&lt;/abbr&gt; complexe ou briques de prompt engineering dès qu&#039;une nouvelle génération de modèles les rend obsolètes.&lt;/p&gt;
&lt;h2&gt;Sous le capot : interprétabilité, espace latent, post-training et optimisation&lt;/h2&gt;
&lt;p&gt;Pour comprendre ce qui se passe dans la tête d&#039;une IA, &lt;strong&gt;David Louapre&lt;/strong&gt; (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://huggingface.co/&quot;&gt;Hugging Face&lt;/a&gt; / &lt;em&gt;&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.youtube.com/@ScienceEtonnante&quot;&gt;Science Étonnante&lt;/a&gt;&lt;/em&gt;) et &lt;strong&gt;Aygalic Jara&lt;/strong&gt; nous font visiter son &amp;quot;cerveau&amp;quot; vectoriel. L&#039;interprétabilité mécanistique cherche à repérer quel concept s&#039;allume dans les réseaux de neurones. En modifiant les vecteurs d&#039;activation internes (le &lt;em&gt;steering&lt;/em&gt;), &lt;strong&gt;David Louapre&lt;/strong&gt; a montré en direct &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.youtube.com/watch?v=G85qi0_17YE&quot;&gt;(voir le replay de son talk)&lt;/a&gt; comment forcer un modèle à devenir obsédé par la Tour Eiffel ! C&#039;est une technique calquée sur la fameuse expérience du &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.anthropic.com/news/golden-gate-claude&quot;&gt;Golden Gate Claude d&#039;Anthropic&lt;/a&gt;, que vous pouvez d&#039;ailleurs reproduire chez vous via des librairies d&#039;exploration comme &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/TransformerLensOrg/TransformerLens&quot;&gt;TransformerLens&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;De son côté, &lt;strong&gt;Aygalic Jara&lt;/strong&gt; &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.youtube.com/watch?v=dFsws_PfGUk&quot;&gt;(replay de sa présentation ici)&lt;/a&gt; a rendu la détection d&#039;hallucinations beaucoup moins abstraite. Au lieu d&#039;analyser le texte final généré par l&#039;IA, son approche consiste à mesurer la &amp;quot;température d&#039;incertitude&amp;quot; directement dans les couches cachées du modèle (l&#039;espace latent) avant même la fin de la phrase. Une méthode qui fait d&#039;ailleurs écho aux récentes avancées majeures sur la &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.nature.com/articles/s41586-024-07421-0&quot;&gt;détection des hallucinations par entropie sémantique (Nature, 2024)&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/dot-ai/dotai_2026_2_aygalic_jara_llm_hallucinations.jpg&quot; data-original-width=&quot;4096&quot; data-original-height=&quot;2302&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/dot-ai/dotai_2026_2_aygalic_jara_llm_hallucinations.e4bb2d2c.webp&quot; /&gt;&lt;source type=&quot;image/jpeg&quot; srcset=&quot;/media/cache/content/2026/dot-ai/dotai_2026_2_aygalic_jara_llm_hallucinations.jpg&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(4096 / 2302)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/dot-ai/dotai_2026_2_aygalic_jara_llm_hallucinations.jpg&quot; alt=&quot;halucinations&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Lorsque le prompt ne suffit plus, il faut modifier les poids du modèle par le &lt;strong&gt;Post-Training&lt;/strong&gt;. &lt;strong&gt;Ziv Ilan&lt;/strong&gt; (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.nvidia.com/fr-fr/solutions/ai/&quot;&gt;Nvidia&lt;/a&gt;) détaille la combinaison du &lt;em&gt;Supervised Fine-Tuning&lt;/em&gt; (SFT) sur des jeux de données et du &lt;em&gt;Reinforcement Learning&lt;/em&gt; (RL) où l&#039;IA apprend par récompense dans des environnements de test (compilateurs, exécution de code). Mais pour les données structurées, les LLMs classiques échouent car ils lisent les tables mot à mot comme un roman. &lt;strong&gt;Kevin Scaman&lt;/strong&gt; illustre ce problème avec la prédiction de la &lt;strong&gt;résiliation client (&lt;em&gt;churn&lt;/em&gt;)&lt;/strong&gt; : face à un tableau de milliers de colonnes désordonnées, un LLM s&#039;emmêle les pinceaux, alors qu&#039;un Large Tabular Model (LTM) regarde la table dans sa totalité (lignes et colonnes) pour extraire instantanément les signaux statistiques.
💡 &lt;strong&gt;Ce qu&#039;il faut en retenir &amp;amp; Pour aller plus loin :&lt;/strong&gt;
&lt;strong&gt;Le Post-Training en pratique :&lt;/strong&gt; Si vous souhaitez comprendre comment aligner un modèle avec du RL et du SFT, la documentation de la librairie TRL (Transformer Reinforcement Learning) est un excellent point de départ.
&lt;strong&gt;Arrêtez d&#039;utiliser des LLMs pour vos fichiers Excel/CSV :&lt;/strong&gt; Les LLMs sont faits pour le langage naturel. Pour les données tabulaires, les LTMs sont la nouvelle norme.
&lt;strong&gt;Testez par vous-même :&lt;/strong&gt; Le dépôt GitHub de TabPFN (PriorLabs) propose des notebooks prêts à l&#039;emploi pour tester la classification sur des données tabulaires en quelques lignes de code Python.&lt;/p&gt;
&lt;p&gt;Au niveau du moteur d&#039;exécution, &lt;strong&gt;Xuan-Son Nguyen&lt;/strong&gt; présente les optimisations du projet open source &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/ggerganov/llama.cpp&quot;&gt;llama.cpp&lt;/a&gt;. Pour éliminer la lenteur du &amp;quot;token par token&amp;quot;, il s&#039;appuie sur le &lt;em&gt;speculative decoding&lt;/em&gt;. Initiée et popularisée notamment par DeepSeek avec des approches comme &lt;strong&gt;&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/z-lab/dflash&quot;&gt;DFlash&lt;/a&gt;&lt;/strong&gt;. Cette technique consiste à faire prédire plusieurs tokens futurs en parallèle par un petit modèle rapide avant de les valider en un seul passage, ce qui permet de plus que doubler la vitesse d&#039;inférence en local.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/dot-ai/dotai_2026_2_xuan_son_nguyen_llama.jpg&quot; data-original-width=&quot;6144&quot; data-original-height=&quot;4080&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/dot-ai/dotai_2026_2_xuan_son_nguyen_llama.99eb0fdd.webp&quot; /&gt;&lt;source type=&quot;image/jpeg&quot; srcset=&quot;/media/cache/content/2026/dot-ai/dotai_2026_2_xuan_son_nguyen_llama.jpg&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(6144 / 4080)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/dot-ai/dotai_2026_2_xuan_son_nguyen_llama.jpg&quot; alt=&quot;DFlash&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h2&gt;Souveraineté, on-device et sobriété : Où et comment faire tourner ses agents ?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Joffrey Thomas&lt;/strong&gt; et &lt;strong&gt;Henry Lagarde&lt;/strong&gt; (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://mistral.ai/fr/&quot;&gt;Mistral AI&lt;/a&gt;) rappellent les risques stratégiques des API propriétaires SaaS : fuites de données internes, coûts cachés à chaque boucle d&#039;agent et ruptures de production dès qu&#039;un fournisseur modifie son API. L&#039;écart entre modèles ouverts et propriétaires s&#039;étant réduit à quelques mois, ils plaident pour le déploiement de stacks souveraines et auto-hébergées. Ils rappellent également un principe de sobriété essentiel : &lt;strong&gt;toutes les tâches ne nécessitent pas un agent LLM&lt;/strong&gt;. Utiliser de l&#039;OCR traditionnel ou de l&#039;algorithmique classique pour extraire du texte est souvent 100 fois plus rapide, sobre et économique que de lancer un agent gourmand en tokens.&lt;/p&gt;
&lt;p&gt;Cette quête de sobriété se retrouve sur les appareils mobiles et objets connectés avec &lt;strong&gt;Christian Keller&lt;/strong&gt; (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.meta.ai/&quot;&gt;Meta&lt;/a&gt;). Un VLM (&lt;em&gt;Vision-Language Model&lt;/em&gt;) est un modèle d&#039;IA capable de comprendre et de raisonner simultanément sur du texte et sur des images. Sur un smartphone ou des lunettes connectées, la contrainte principale n&#039;est pas seulement la mémoire, mais la batterie. Via le framework open source &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://pytorch.org/executorch/&quot;&gt;ExecuTorch&lt;/a&gt;, Christian Keller prône une architecture en pyramide : faire tourner de tout petits modèles de perception ultra-spécialisés (détection de contours, reconnaissance vocale) directement sur l&#039;appareil, et ne faire appel au cloud ou à un gros LLM généraliste que si la tâche l&#039;exige vraiment.&lt;/p&gt;
&lt;p&gt;Faire tourner des modèles géants sur internet, c&#039;est comme essayer de faire tenir un énorme dictionnaire dans la mémoire d&#039;un petit serveur local. Pour y arriver sans faire ramer le réseau, &lt;strong&gt;Leo Arsenin&lt;/strong&gt; (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.cloudflare.com/solutions/ai/&quot;&gt;Cloudflare&lt;/a&gt;) explique qu&#039;on compresse le dictionnaire (la quantification du cache &lt;abbr title=&quot;Key Value&quot;&gt;KV&lt;/abbr&gt;) pour qu&#039;il prenne deux fois moins de place sans perdre ses informations importantes. Et surtout, quand un utilisateur pose plusieurs questions d&#039;affilée, le réseau s&#039;assure de le renvoyer exactement vers le même serveur GPU qui se souvient déjà du début de la discussion, au lieu de tout réexpliquer à un nouveau serveur à chaque fois.&lt;/p&gt;
&lt;h2&gt;Au-delà du Chatbot : Nouvelles interfaces et cas d&#039;usage métiers&lt;/h2&gt;
&lt;p&gt;Pour mesurer l&#039;impact de l&#039;IA sur des domaines critiques, &lt;strong&gt;Hélène Philippe&lt;/strong&gt; présente le cas de la radiologie médicale. L&#039;évaluation de l&#039;évolution des tumeurs entre deux scanners nécessite une précision chirurgicale que les VLMs généralistes ne peuvent pas offrir en raison du risque d&#039;hallucinations cliniques. Son équipe a conçu un pipeline d&#039;agents autonomes pilotant des outils de mesure spécialisés, atteignant 67 % de rappel sur le benchmark &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://arxiv.org/abs/2609.01470&quot;&gt;RADMatch&lt;/a&gt;. Cela montre que dans les métiers complexes, l&#039;IA ne remplace pas l&#039;expertise mais orchestre des outils métiers sous un contrôle strict.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/dot-ai/dotai_2026_4_helene_philippe_radiological_agi_2.jpg&quot; data-original-width=&quot;4080&quot; data-original-height=&quot;2295&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/dot-ai/dotai_2026_4_helene_philippe_radiological_agi_2.13ea4aaa.webp&quot; /&gt;&lt;source type=&quot;image/jpeg&quot; srcset=&quot;/media/cache/content/2026/dot-ai/dotai_2026_4_helene_philippe_radiological_agi_2.jpg&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(4080 / 2295)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/dot-ai/dotai_2026_4_helene_philippe_radiological_agi_2.jpg&quot; alt=&quot;radiologie&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Côté web et développement, &lt;strong&gt;Patrick Brosset&lt;/strong&gt; (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.microsoft.com&quot;&gt;Microsoft&lt;/a&gt;) s&#039;attaque à la fragilité du scraping web. Aujourd&#039;hui, lorsqu&#039;un agent navigue sur un site avec des outils comme &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://playwright.dev/&quot;&gt;Playwright&lt;/a&gt;, il clique à l&#039;aveugle et consomme énormément de tokens pour comprendre le DOM. Le nouveau standard W3C &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/webmachinelearning/webmcp&quot;&gt;WebMCP&lt;/a&gt; permet aux développeurs d&#039;exposer directement des fonctions JavaScript exécutables par l&#039;agent. En quelques lignes de code côté client, le site devient nativement accessible aux agents :&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;// Exemple d&#039;exposition d&#039;un outil WebMCP en JS&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;document.modelContext.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;registerTool&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;  name: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;commander_pizza&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;  description: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;Commande directement une pizza pour l&#039;utilisateur&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;  parameters: { type: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;object&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, properties: { taille: { type: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;string&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-8&quot;&gt;  execute&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;params&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 class=&quot;syntax-4&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; await&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; api.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;order&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(params); }&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;Cette approche divise par deux la consommation de tokens, élimine l&#039;instabilité des sélecteurs CSS et accélère drastiquement l&#039;exécution.&lt;/p&gt;
&lt;p&gt;Enfin, comment tester un assistant virtuel comme &lt;em&gt;Dr. Doctolib&lt;/em&gt; avant de le mettre entre les mains de vrais patients ? &lt;strong&gt;Gaëtan Brison&lt;/strong&gt; (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.doctolib.fr/&quot;&gt;Doctolib&lt;/a&gt;) explique que poser des questions isolées ne suffit pas. Pour valider le système, Doctolib crée de faux patients virtuels (des simulateurs) dotés de profils psychologiques et d&#039;historiques médicaux spécifiques. Ces patients virtuels discutent sur plusieurs sessions avec l&#039;IA pour vérifier si elle se souvient bien des allergies mentionnées trois jours plus tôt, si elle réagit correctement en cas d&#039;urgence et si elle maintient une sécurité médicale absolue à 100 % dans le temps.&lt;/p&gt;
&lt;h2&gt;Accompagner la révolution IA dans les agences tech&lt;/h2&gt;
&lt;p&gt;Au-delà des conférences, l&#039;accélération de l&#039;IA pose une question centrale à toutes les structures tech : comment accompagner cette transformation sans subir la vague ? L&#039;IA n&#039;est plus un sujet de R&amp;amp;D distant, mais une réalité qui réinvente les métiers du développement, du design et du conseil.&lt;/p&gt;
&lt;p&gt;Face à ce changement brutal, la clé réside dans l&#039;accompagnement des équipes. Cela implique de fournir des moyens techniques (crédits de jetons, accès aux modèles), d&#039;aménager du temps effectif dédié à la veille et aux tests, et de renforcer les échanges collectifs (pair-programming augmenté, ateliers pratiques). Il s&#039;agit également d&#039;instaurer une gouvernance transparente avec les clients : définir des chartes d&#039;usage claires sur la confidentialité et s&#039;aligner dès le départ sur le &lt;em&gt;comment&lt;/em&gt; et le &lt;em&gt;pourquoi&lt;/em&gt; l&#039;IA est intégrée aux projets.&lt;/p&gt;
&lt;p&gt;Pour prolonger ces réflexions et suivre l&#039;évolution des pratiques agentiques, la conférence en ligne &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://dev.events/ON/ai&quot;&gt;All Day AI&lt;/a&gt; se tiendra le &lt;strong&gt;22 octobre 2026&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Enfin même si l’IA solutionne beaucoup de problèmes, rappelez-vous que &lt;strong&gt;toutes les tâches ne nécessitent pas un agent LLM&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/dot-ai/dotai_2026_outro.png&quot; data-original-width=&quot;1681&quot; data-original-height=&quot;936&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/dot-ai/dotai_2026_outro.30e79292.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/dot-ai/dotai_2026_outro.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1681 / 936)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/dot-ai/dotai_2026_outro.png&quot; alt=&quot;outro&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/d-un-ade-a-un-orchestrator-la-construction-de-pablo</id>
        <published>2026-09-25T11:42:00+02:00</published>
        <updated>2026-09-25T11:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/d-un-ade-a-un-orchestrator-la-construction-de-pablo"/>
        <title>D&#039;un ADE à un orchestrator : la construction de PABLO</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="ia" />            <category term="ai" />
        <summary><![CDATA[Mes agents sont devenus plus rapides. Mon cerveau, non.
Où nous en étions
Dans mon article précédent, je décrivais mon environnement de travail : un Agent Development Environment, non pas un IDE avec…]]></summary>
        <content type="html">
            &lt;p&gt;&lt;em&gt;Mes agents sont devenus plus rapides. Mon cerveau, non.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Où nous en étions&lt;/h2&gt;
&lt;p&gt;Dans &lt;a href=&quot;https://jolicode.com/blog/the-agent-development-environment-a-new-unit-of-work&quot;&gt;mon article précédent&lt;/a&gt;, je décrivais mon environnement de travail : un Agent Development Environment, non pas un IDE avec un panneau de conversation greffé, mais un outil construit autour de tâches, de worktrees et d&#039;agents. Je prends une issue, l&#039;ADE ouvre un worktree isolé avec un agent déjà en cours d&#039;exécution, et je dirige cet agent au lieu de taper du code. Par-dessus se trouvent les agents que j&#039;ai affinés pendant des mois, tous en lecture seule par construction ; &lt;code&gt;build&lt;/code&gt; est le seul autorisé à modifier le code.&lt;/p&gt;
&lt;p&gt;Les gains de ces agents se concentrent en un point : la compréhension. À mon travail, nous n&#039;avons pas d&#039;équipes spécialisées, donc n&#039;importe qui peut se voir confier n&#039;importe quelle tâche : un bug de synchronisation de stock le matin, une fonctionnalité dans une partie du code que je n&#039;ai jamais ouvert l&#039;après-midi, le prochain ticket dans l’application retail. Chaque tâche pourrait être un domaine différent avec ses propres règles métier, et je bascule entre elles trois ou quatre fois par jour. L&#039;agent &lt;code&gt;task-analyst&lt;/code&gt; lit le ticket, ses parents et le code associé, puis me livre l&#039;ensemble en une seule lecture.&lt;/p&gt;
&lt;p&gt;Comprendre un ticket est une chose, mais une fonctionnalité n&#039;est pas terminée quand la branche est poussée. Entre le code et la production se trouve un second métier fait d&#039;attente et de vérifications : le statut de la CI, les reviews, la QA. Tout cela vit dans GitHub et Jira, en dehors de mes worktrees. J&#039;avais automatisé la réflexion et gardé l&#039;attente.&lt;/p&gt;
&lt;p&gt;Cet article porte sur l&#039;outil que j&#039;ai construit pour combler ce fossé : PABLO, une application Symfony. En son cœur se trouve une machine à états, quelque chose que les développeurs PHP livrent depuis une décennie sous un nom moins à la mode que celui qu&#039;utilise l&#039;écosystème IA aujourd&#039;hui.&lt;/p&gt;
&lt;h2&gt;Les corvées que personne n&#039;a automatisées&lt;/h2&gt;
&lt;p&gt;Voici à quoi ressemblait réellement mon matin : avant d&#039;écrire la moindre ligne, je devais traiter une checklist mentale sur 5 ou 6 pull requests ouvertes et deux ou trois projets.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;La CI est-elle verte ? L&#039;ADE n&#039;affiche le statut que pour la PR du worktree courant ; les autres vivent dans des onglets de navigateur.&lt;/li&gt;
&lt;li&gt;Quelqu&#039;un les a-t-il reviewées, et est-il temps de redemander sans devenir la personne qui demande toutes les 90 minutes ?&lt;/li&gt;
&lt;li&gt;La QA les a-t-elle planifiées, ou cassées ?&lt;/li&gt;
&lt;li&gt;Du feedback est-il arrivé sur le ticket ? Le trouver, recréer un worktree pour une branche supprimée la semaine dernière.&lt;/li&gt;
&lt;li&gt;La CI est-elle rouge ? Alors tout ce que les agents font pour moi est inutile tant que je ne relance pas les choses moi-même : remarquer l&#039;échec, créer un worktree, choisir l&#039;agent, le lancer, attendre.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rien de tout cela n&#039;est difficile, et c&#039;est exactement le problème : chaque point coûte un changement de contexte, la chose que j&#039;avais passée un an à éliminer. Ce ne sont jamais les minutes qui faisaient mal ; ce qui faisait mal, c&#039;était l&#039;entrelacement : vérifier la CI, revenir à une tâche, se rappeler que personne n&#039;a reviewé, revenir encore.&lt;/p&gt;
&lt;p&gt;Mes agents avaient éliminé le bruit, rendant le travail facile à comprendre. Mais le coût du suivi de toutes ces tâches restait le mien. Une douzaine de pièces mobiles à travers trois outils, et dans ce système, quelque chose doit poller. C&#039;était moi.&lt;/p&gt;
&lt;h2&gt;PABLO&lt;/h2&gt;
&lt;p&gt;Alors j&#039;ai fait : PABLO, pour &lt;em&gt;Personal Assistant for Boring Logic &amp;amp; Operations&lt;/em&gt;. L&#039;acronyme est venu après le nom, mais « boring » (ennuyeux) est le mot honnête dedans. Rien de ce que fait PABLO n&#039;est malin : il regarde les pull requests, lit les dates, les compare avec 10 minutes auparavant, et conclut que quelque chose a changé (ou pas).&lt;/p&gt;
&lt;p&gt;L&#039;ADE reste l&#039;endroit où je travaille, et PABLO reste un niveau au-dessus, répondant en boucle à une seule question : étant donné l&#039;état de cette pull request, y a-t-il quelque chose qu&#039;un agent devrait faire, et si oui, lequel ? Quand la réponse est oui, PABLO lance automatiquement cet agent via le binaire de l&#039;ADE, dans le bon worktree, exactement comme je l&#039;aurais fait.&lt;/p&gt;
&lt;p&gt;Sous le capot se trouve une application console Symfony avec trois points d&#039;entrée :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;un binaire &lt;code&gt;pablo&lt;/code&gt; ;&lt;/li&gt;
&lt;li&gt;la console Symfony ;&lt;/li&gt;
&lt;li&gt;un petit dashboard.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Il y a une exception à ce tableau : &lt;code&gt;/pablo-commit-and-pr&lt;/code&gt; est une commande OpenCode, pas une commande PABLO. L&#039;agent écrit le message de commit et la description de la pull request (la seule partie du flux où un agent bat du simple code), puis rend la tâche à PABLO, qui déplace la tâche vers l&#039;état &lt;code&gt;draft&lt;/code&gt; et prend le relais.&lt;/p&gt;
&lt;p&gt;Et enfin, un scheduler en arrière-plan (systemd sur Linux ou launchd sur macOS) met à jour les informations sur les pull requests toutes les cinq minutes.&lt;/p&gt;
&lt;p&gt;Cette séparation compte plus qu&#039;il n&#039;y paraît. PABLO ne demande jamais qu&#039;à lancer un agent dans un worktree et à être prévenu quand le run se termine, donc tout ce que PABLO sait de l&#039;ADE tient derrière une seule interface : &lt;code&gt;AgentLauncherInterface&lt;/code&gt;. L&#039;interface rend l&#039;ADE remplaçable : si un meilleur arrive le mois prochain, je réécris une classe. Le remplacement a déjà eu lieu une fois : je travaillais avec &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.onorca.dev/&quot;&gt;Orca&lt;/a&gt; avant, et je travaille avec &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://openchamber.dev/&quot;&gt;OpenChamber&lt;/a&gt; aujourd&#039;hui. Un couplage va plus loin : quand l&#039;ADE refuse un worktree, la porte de secours est un &lt;code&gt;opencode run&lt;/code&gt; headless. PABLO ne dépend vraiment que d&#039;OpenCode.&lt;/p&gt;
&lt;p&gt;Pourquoi PHP pour un daemon en arrière-plan en 2026 ? C&#039;est le langage dans lequel je pense le plus vite, et le seul utilisateur, c&#039;est moi.&lt;/p&gt;
&lt;p&gt;Une dernière décision : PABLO ne stocke aucun token et ne parle à aucune API. Tout passe par des CLIs déjà authentifiés sur ma machine (&lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;acli&lt;/code&gt;, &lt;code&gt;linear&lt;/code&gt;, &lt;code&gt;orca&lt;/code&gt;, &lt;code&gt;openchamber&lt;/code&gt;), donc il n&#039;y a rien à faire tourner ou à fuiter. Quand quelque chose manque, &lt;code&gt;pablo system:doctor&lt;/code&gt; le dit, avant qu&#039;un cron n&#039;échoue silencieusement à 3 heures du matin.&lt;/p&gt;
&lt;p&gt;L&#039;intelligence a toujours été dans les agents. Ce qui manquait, c&#039;était quelque chose d&#039;assez bête pour tourner toutes les cinq minutes pour toujours sans s&#039;ennuyer.&lt;/p&gt;
&lt;h2&gt;Graph engineering, ou : une machine à états avec un meilleur nom&lt;/h2&gt;
&lt;p&gt;Si vous avez lu sur les agents IA récemment, vous avez vu le terme « graph engineering » : modéliser le travail en nœuds et en arêtes au lieu d&#039;un seul énorme prompt. Enlevez le vocabulaire à la mode, et ce n&#039;est qu&#039;une machine à états. Parce que le développement logiciel est brouillon, la machine à états de PABLO doit être flexible. Plusieurs états doivent facilement retomber sur &lt;code&gt;draft&lt;/code&gt;, et une commande manuelle peut extraire une tâche d&#039;un état à tout moment. Les frameworks de machines à états lourds exigent de déclarer à l&#039;avance chaque transition autorisée et bloquent tout le reste. C&#039;est trop rigide pour mon workflow, alors j&#039;ai sauté les frameworks et construit un moteur simple : des états, des transitions, et une règle souple sur ce qui est autorisé depuis où.&lt;/p&gt;
&lt;p&gt;Dans la plupart des frameworks d&#039;agents, les nœuds sont des étapes de raisonnement (plan, search, summarize, critique), et le graphe vit à l&#039;intérieur de la tâche. Dans PABLO, les nœuds sont les états qu&#039;une pull request traverse dans une vraie équipe : drafted, CI en échec, attente de review, changes requested, attente de QA. Le graphe n&#039;est pas l&#039;agent : c&#039;est le processus dans lequel je travaille déjà. C&#039;est ce qui garde le graphe stable après la prochaine sortie de modèle, les prompts et les modèles vivant à l&#039;intérieur des nœuds.&lt;/p&gt;
&lt;p&gt;Donc la machine à états est une table. Littéralement une table, dans une classe :&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;State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;Draft&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value &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;emoji&#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;📝&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;label&#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;draft&#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;onEnter&#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-5&quot;&gt;self&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 class=&quot;syntax-1&quot;&gt;&#039;enterDraft&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;], &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;polled&#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-5&quot;&gt;State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;CiRed&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value &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;emoji&#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;🔴&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;label&#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;ci-red&#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;onEnter&#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-5&quot;&gt;self&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 class=&quot;syntax-1&quot;&gt;&#039;enterCiRed&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;], &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;polled&#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;/code&gt;&lt;/pre&gt;
&lt;p&gt;Chaque ligne de la table déclare comment afficher l&#039;état, ce qui s&#039;exécute à l&#039;entrée, et si le poller le surveille. À côté de la première table, une seconde dit ce que chaque état peut vérifier :&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;public&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; const&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; POLL_CHECKS&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-5&quot;&gt;    State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;Draft&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value         &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;checkCiRed&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;checkCiGreen&#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;    State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;CiRed&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value         &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;checkCiGreen&#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;    State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;ReadyToReview&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value &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;checkCiRed&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;checkReviews&#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;    State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;WaitingReview&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value &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;checkCiRed&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;checkReviews&#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;    State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;NeedsTesting&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value  &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;checkFailureSignal&#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;C&#039;est tout le moteur. Ajouter une étape est une ligne et une méthode, pas un refactoring. Le handler on-enter unique est partagé par le poller, les commandes et les overrides manuels, donc les transitions ne divergent jamais. Même idée pour les trackers : une interface de provider, et la machine à états n&#039;a aucune idée de quel tracker elle parle.&lt;/p&gt;
&lt;h2&gt;Les états, et ce qui les réveille&lt;/h2&gt;
&lt;p&gt;Il y a neuf états. Pas de &lt;code&gt;todo&lt;/code&gt;, pas d&#039;&lt;code&gt;init&lt;/code&gt;, exprès : une issue que je n&#039;ai pas commencée n&#039;a pas de tâche et apparaît simplement dans la liste de ce qui m&#039;est assigné. Entrer dans &lt;code&gt;in-progress&lt;/code&gt; crée la tâche, la branche, le worktree et le premier run d&#039;agent ; un merge ferme la tâche depuis n&#039;importe où et supprime le worktree.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;État&lt;/th&gt;
&lt;th&gt;Entré par&lt;/th&gt;
&lt;th&gt;Ce qui se passe à l&#039;entrée&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🔨 &lt;code&gt;in-progress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pablo task:start &amp;lt;issue-url&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;crée la branche et le worktree, lance &lt;code&gt;task-analyst&lt;/code&gt; une fois, plus le script de démarrage du projet s&#039;il en a un&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥱 &lt;code&gt;waiting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pablo task:waiting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rien, et le polling s&#039;arrête. Revenir en arrière restaure l&#039;état précédent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📝 &lt;code&gt;draft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/pablo-commit-and-pr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;commit, push, draft pull request ouverte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔴 &lt;code&gt;ci-red&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;poller : la CI échoue&lt;/td&gt;
&lt;td&gt;lance &lt;code&gt;ci-analyst&lt;/code&gt; sur les checks en échec et leurs logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;👀 &lt;code&gt;ready-to-review&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;poller : la CI est verte&lt;/td&gt;
&lt;td&gt;marque la pull request prête sur GitHub, puis bascule immédiatement vers l&#039;état suivant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;👀 &lt;code&gt;waiting-review&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;atteint depuis le précédent&lt;/td&gt;
&lt;td&gt;rien, c&#039;est ici qu&#039;une tâche attend des humains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🧪 &lt;code&gt;needs-testing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;poller : une review approbatrice est arrivée&lt;/td&gt;
&lt;td&gt;enregistre le timestamp qui devient la baseline QA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔁 &lt;code&gt;request-changes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;poller : changes requested&lt;/td&gt;
&lt;td&gt;repasse la pull request en draft, lance &lt;code&gt;pr-feedback&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🚨 &lt;code&gt;testing-failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;poller : le signal d&#039;échec QA s&#039;est déclenché&lt;/td&gt;
&lt;td&gt;repasse la pull request en draft, lance &lt;code&gt;task-feedback&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;La forme de la chose, en graphe :&lt;/p&gt;
&lt;p&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://jolicode.com//media/cache/content/2026/pablo-orchestrator/mermaid-diagram-2026-09-21-100259.png&quot; alt=&quot;Graph Mermaid&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Regardez les flèches, pas les boîtes : chaque flèche vers &lt;code&gt;draft&lt;/code&gt; est une commande que j&#039;ai tapée ; chaque autre flèche est le poller. Je décide quand le travail quitte mes mains ; la machine gère le reste.&lt;/p&gt;
&lt;p&gt;Trois détails ont demandé bien plus d&#039;itérations que prévu.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Le premier : ce qui compte comme une review.&lt;/strong&gt; Mes propres reviews sont exclues : répondre à un commentaire sur ma propre pull request crée une review à mon nom, ce qui me renverrait sinon en draft juste pour avoir répondu à une question. Les reviews de bots sont ignorées sauf whitelist. Seules les reviews postérieures au dernier événement timeline &lt;code&gt;ready_for_review&lt;/code&gt; de GitHub comptent (un vrai timestamp, contrairement au verdict du summary), donc une approbation périmée ne peut pas fuiter dans ce tour. La dernière review par reviewer gagne, et tout ce qui n&#039;est pas une approbation bat toutes les approbations : si quelqu&#039;un a pris la peine d&#039;écrire, je lis le feedback avant que la QA ne le fasse.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Le deuxième : exactement un seul chemin de retour vers &lt;code&gt;draft&lt;/code&gt;, et c&#039;est une commande.&lt;/strong&gt; Pas de détection de push : un &lt;code&gt;git push&lt;/code&gt; brut ne déplace rien. Cela semble restrictif jusqu&#039;à ce qu&#039;on considère que je pousse des dizaines de fois par jour, souvent juste pour lancer la CI. Une commande explicite est un signal. Un push est du bruit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Le troisième : ce que j&#039;ai délibérément choisi de ne pas traiter comme des événements.&lt;/strong&gt; CI pending ne déclenche rien. CI rouge dans &lt;code&gt;needs-testing&lt;/code&gt; ne déclenche rien non plus : un build cassé apparaîtra au moment du merge, et interrompre la QA n&#039;aide personne. La moitié de la conception d&#039;une machine à états, c&#039;est choisir ce qui n&#039;est pas un événement, et cette moitié ne reçoit presque aucune attention.&lt;/p&gt;
&lt;p&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://jolicode.com//media/cache/content/2026/pablo-orchestrator/pablo-dashboard.png&quot; alt=&quot;Dashboard web PABLO&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Ce que « vert » veut réellement dire&lt;/h2&gt;
&lt;p&gt;Deux de ces transitions pendent à une question : la CI est-elle verte ? Cela ressemble à un fait qu&#039;on vérifie. Ça n&#039;en est pas un.&lt;/p&gt;
&lt;p&gt;Ma première version : rouge en échec, vert quand tout passe, en attente sinon. Cette règle fonctionnait partout sauf sur le projet où j&#039;avais le plus besoin de PABLO.&lt;/p&gt;
&lt;p&gt;Ce projet tourne sous CircleCI, qui publie ses deployment gates sur GitHub comme status contexts : &lt;code&gt;deploy-code-approval-ppr&lt;/code&gt;, &lt;code&gt;deploy-code-approval-qlf&lt;/code&gt;. Ce sont des gates manuelles sur chaque pipeline, y compris les branches de feature où personne ne clique dessus (nous ne déployons pas en qualification à chaque commit). Elles restent à &lt;code&gt;PENDING&lt;/code&gt; pour toujours, ou arrivent en &lt;code&gt;ACTION_REQUIRED&lt;/code&gt;, que ma règle comptait comme échec, donc « La CI est-elle verte ? » n&#039;était jamais oui.&lt;/p&gt;
&lt;p&gt;Le correctif est une ligne de configuration dans PABLO :&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;ci&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;  ignore_checks&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: [&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;approval&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;Tout check dont le nom, le workflow ou le status context contient cette sous-chaîne est retiré de la question entièrement : il ne peut ni rendre le verdict rouge, ni le retenir en attente pour toujours. Deux gates, un mot.&lt;/p&gt;
&lt;p&gt;Cette unique clé de configuration m&#039;a forcé à réaliser quelque chose : une CI verte est une règle, pas un fait. Ce que GitHub appelle un « CI check » est en réalité un tas bruyant de tests unitaires, de gates de déploiement manuelles et de bots bavards. Comme aucun outil ne peut deviner magiquement lesquels comptent réellement pour une branche donnée, PABLO pousse cette décision vers le fichier YAML du projet. Pour garder le moteur simple, j&#039;ai ajouté deux autres règles brutales : un dépôt avec zéro check est considéré comme vert, et le matching par sous-chaîne reste volontairement bête.&lt;/p&gt;
&lt;p&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://jolicode.com//media/cache/content/2026/pablo-orchestrator/github-approval-gates.png&quot; alt=&quot;Gates d&#039;approbation GitHub&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;La seule exception à mon propre dogme&lt;/h2&gt;
&lt;p&gt;Dans mon article précédent, j&#039;ai fait un point sur le fait que mes agents sont en lecture seule, pas par instruction mais par construction : le frontmatter (voir mon article précédent pour comprendre ce qu&#039;est un frontmatter) refuse &lt;code&gt;edit&lt;/code&gt; et &lt;code&gt;write&lt;/code&gt;. Les quatre analystes de PABLO suivent la même règle : modèle pas cher et rapide, interdit en écriture.&lt;/p&gt;
&lt;p&gt;Mais il y a cinq agents. Le cinquième agent est &lt;code&gt;rebase-conflict-resolver&lt;/code&gt;, le seul endroit où j&#039;ai cassé ma propre règle exprès. Toutes les douze heures, un job de sync rebase chaque worktree de mes tâche sur la branche primaire. En cas de conflit, git abandonne et réinitialise le worktree, et un agent est lancé pour refaire le rebase et résoudre chaque conflit. Son frontmatter montre le coût : &lt;code&gt;edit: allow&lt;/code&gt;, &lt;code&gt;write: allow&lt;/code&gt;, &lt;code&gt;git push*: allow&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Pourquoi le laisser entrer ? La sync est un dry run par défaut. Une clé de configuration transforme le rapport en action : &lt;code&gt;auto_apply: true&lt;/code&gt;, posé seulement là où je suis sur que j’ai le moins de risques possibles.&lt;/p&gt;
&lt;p&gt;Son rayon d&#039;action est borné comme tout le reste. Il peut écrire du code et pousser, mais toute commande agissant sur le processus plutôt que sur le code (merging, approving, commenting) est refusée. Il peut réécrire ma branche ; il ne peut ni l&#039;approuver, ni la merger, ni parler en mon nom. La permission dangereuse n&#039;a jamais été &lt;code&gt;edit&lt;/code&gt; ; c&#039;était agir en tant que moi devant d&#039;autres personnes. Deux garde-fous de plus : une branche sale (fichiers non commités restants) n&#039;est jamais rebasée, et quand l&#039;agent prend le relais d&#039;un rebase, PABLO affiche une commande claire et copiable pour voir la session de l&#039;agent dans OpenCode.&lt;/p&gt;
&lt;p&gt;Git fait le travail de sécurité restant. Le push est toujours &lt;code&gt;--force-with-lease&lt;/code&gt;, jamais un &lt;code&gt;--force&lt;/code&gt; nu. Si j&#039;ai poussé moi-même pendant que le cron tournait, la vérification de lease échoue, le worktree revient en arrière, et le job réessaie douze heures plus tard. Mon push gagne toujours. Quand un conflit nécessite vraiment un humain, l&#039;agent affiche &lt;code&gt;PABLO_CONFLICT_UNRESOLVABLE&lt;/code&gt; et ne pousse rien.&lt;/p&gt;
&lt;p&gt;La partie honnête : cet agent peut faire des dégâts, contrairement aux quatre autres. J&#039;accepte ce risque parce que tout ce qu&#039;il écrit atterrit dans une pull request que je review avant le merge. C&#039;est mon propre code. En échange, je n&#039;ai pas eu de pull request en conflit depuis un moment.&lt;/p&gt;
&lt;p&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://jolicode.com//media/cache/content/2026/pablo-orchestrator/pablo-rebase.png&quot; alt=&quot;Reporting rebase web PABLO&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Tout ne mérite pas un LLM&lt;/h2&gt;
&lt;p&gt;Dans mon article précédent, je décrivais deux de mes commandes d&#039;agent préférées : des prompts quotidiens qui appelaient le CLI &lt;code&gt;gh&lt;/code&gt;, lisaient mes pull requests ouvertes, et utilisaient un LLM pour écrire un message Slack demandant à l&#039;équipe des reviews et de la QA. Ça ressemblait à de la magie à l&#039;époque. Aucune des deux n&#039;a survécu. Elles sont maintenant simplement &lt;code&gt;pablo show:prs&lt;/code&gt; : un script PHP lisant depuis le cache du poller, affichant exactement le même markdown prêt pour Slack avec une file de review, un séparateur, et une file de QA. Aucun LLM impliqué.&lt;/p&gt;
&lt;p&gt;Le même instinct a façonné le dashboard web, rendu depuis le cache du poller, et cela mène à quelque chose à quoi je ne m&#039;attendais pas. Un outil construit pour orchestrer des agents IA s&#039;avère être des timestamps, des file locks, du CLI parsé et une table de transitions autorisées. L&#039;intelligence est dans cinq fichiers markdown ; tout le reste est de la plomberie sans éclat qui décide quand ouvrir le robinet.&lt;/p&gt;
&lt;h2&gt;Ce qui a réellement changé&lt;/h2&gt;
&lt;p&gt;La différence la plus claire : je n&#039;ai pas vérifié moins, j&#039;ai arrêté de vérifier moi-même.&lt;/p&gt;
&lt;p&gt;Plus de tour de mes pull requests pour des pipelines rouges, plus de chasse aux reviews. Une liste, &lt;code&gt;pablo tasks&lt;/code&gt; ou le dashboard web, me dit ce qui m&#039;attend ; tout le reste est le tour de quelqu&#039;un d&#039;autre.&lt;/p&gt;
&lt;p&gt;Le cas de la CI à lui seul a valu la construction de PABLO. La CI passe rouge à onze heures du soir, le poller le remarque en moins de dix minutes et lance &lt;code&gt;ci-analyst&lt;/code&gt; dans le bon worktree ; au matin, le diagnostic attend. L&#039;analyse était déjà automatisée par mes agents ; ce qui a changé, c&#039;est que le fait de remarquer et l&#039;attente sont automatisés aussi.&lt;/p&gt;
&lt;p&gt;Une tâche complète se passe maintenant ainsi : &lt;code&gt;pablo task:start&lt;/code&gt; sur un lien Jira, travailler avec l&#039;agent, &lt;code&gt;/pablo-commit-and-pr&lt;/code&gt;, et arrêter d&#039;y penser.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pablo task:start https://acme.atlassian.net/browse/XXX-123
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Le temps de cerveau est la ressource&lt;/h2&gt;
&lt;p&gt;Le coût caché du développement moderne, ce ne sont pas les trois minutes que prend la vérification d&#039;un pipeline CI ; c&#039;est la taxe cognitive de garder cet onglet ouvert à l&#039;arrière de votre tête. Le deep work exige de longues périodes d&#039;attention ininterrompues. Quand vous le fracturez avec un ping-pong administratif : rafraîchir GitHub, courir après des approbations, se demander si la QA a pris votre ticket. Vous ne perdez pas seulement du temps, vous brûlez l&#039;énergie mentale exacte dont vous avez besoin pour résoudre des problèmes difficiles. Je n&#039;ai pas construit PABLO juste pour taper moins de commandes bash, ni pour automatiser pour automatiser. Je l&#039;ai construit pour qu&#039;il serve de bouclier cognitif. En déléguant la bureaucratie de la mise en production à une boucle PHP bête et inlassable, j&#039;ai cessé d&#039;être un routeur humain pour mes propres tâches. Je peux de nouveau être simplement un ingénieur.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;PABLO est public&lt;/h2&gt;
&lt;p&gt;PABLO est public : &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/korbeil/pablo&quot;&gt;github.com/korbeil/pablo&lt;/a&gt;. Il est construit pour exactement un utilisateur, le workflow de mon équipe, mes CLIs, ma tolérance à l&#039;attente ; rien de tout cela n&#039;est universel. Le publier revient à tendre à quelqu&#039;un un fichier de config bien tenu : quelque chose à lire, à emprunter et à casser. La machine à états est une table précisément pour que la vôtre remplace la mienne sans combat.&lt;/p&gt;
&lt;p&gt;Si vous voulez l&#039;essayer, la version courte :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/korbeil/pablo &amp;amp;&amp;amp; cd pablo
./bin/install.sh        # deps, liens agents/commandes, démarre le scheduler,
                        # finit avec pablo system:doctor
pablo system:doctor     # quel CLI manque ou est déconnecté
pablo project:new       # ajouter un projet ; ou déposer un YAML dans ~/.pablo/projects/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Vous avez besoin de PHP 8.4+ et des CLIs (&lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;acli&lt;/code&gt;, &lt;code&gt;linear&lt;/code&gt;, &lt;code&gt;opencode&lt;/code&gt;) chacun déjà authentifié. Puis &lt;code&gt;pablo task:start &amp;lt;issue-url&amp;gt;&lt;/code&gt; sur quelque chose de petit, &lt;code&gt;/pablo-commit-and-pr&lt;/code&gt;, et arrêtez d&#039;y penser. Si vous préférez un dashboard web au CLI, &lt;code&gt;pablo web&lt;/code&gt; le sert sur le port 8321.&lt;/p&gt;
&lt;p&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; src=&quot;https://jolicode.com//media/cache/content/2026/pablo-orchestrator/pablo-start.png&quot; alt=&quot;Démarrage PABLO&quot; /&gt;&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/castor-1-8-windows-self-update-et-des-binaires-verifiables</id>
        <published>2026-09-22T10:41:00+02:00</published>
        <updated>2026-09-22T10:41:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/castor-1-8-windows-self-update-et-des-binaires-verifiables"/>
        <title>Castor 1.8: Windows, self-update, et des binaires vérifiables</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="open-source" />            <category term="castor" />            <reactions:summary total="15">                <reactions:reaction emoji="🚀" shortname="rocket" count="5"/>                <reactions:reaction emoji="🎉" shortname="party" count="4"/>                <reactions:reaction emoji="❤️" shortname="heart" count="3"/>                <reactions:reaction emoji="👏" shortname="clapclap" count="2"/>                <reactions:reaction emoji="👍" shortname="plus1" count="1"/>            </reactions:summary>
        <summary><![CDATA[La dernière fois que nous avons parlé de Castor sur ce blog, c&#039;était pour annoncer la sortie de la 1.0, en octobre 2025. Depuis, notre rongeur préféré a pris un rythme mensuel : une version mineure toutes…]]></summary>
        <content type="html">
            &lt;p&gt;La dernière fois que nous avons parlé de Castor sur ce blog, c&#039;était pour annoncer &lt;a href=&quot;https://jolicode.com/blog/le-task-runner-castor-est-maintenant-disponible-en-version-1&quot;&gt;la sortie de la 1.0&lt;/a&gt;, en octobre 2025. Depuis, notre rongeur préféré a pris un rythme mensuel : une version mineure toutes les quatre à cinq semaines, avec à chaque fois quelques helpers et quelques correctifs, parfois une dépréciation pour préparer la 2.0.&lt;/p&gt;
&lt;p&gt;La 1.8 casse un peu ce rythme : 81 commits et 33 pull requests depuis la 1.7.0 de début août, et presque aucun n&#039;ajoute de helper. Le travail est parti dans les recoins de Castor que vous ne regardez jamais : comment il arrive sur votre machine, comment il se met à jour, et ce qui prouve que le binaire que vous lancez est bien celui que notre CI a construit. Ah, et il tourne sous Windows maintenant 🪟.&lt;/p&gt;
&lt;h2&gt;Ce que vous avez peut-être raté depuis la 1.0.0&lt;/h2&gt;
&lt;p&gt;Sept versions mineures sont passées depuis l&#039;article sur la 1.0, et certaines méritent mieux qu&#039;une ligne dans le changelog. Au cas où vous en auriez sauté quelques-unes :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PHP 8.4 est le minimum depuis la 1.6, et les binaires statiques embarquent PHP 8.5 depuis la 1.3. Si vous êtes coincés en 8.2 ou 8.3, Castor vous le dit et vous oriente vers le binaire statique ;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;#[AsArgsAfterOptionEnd]&lt;/code&gt; (1.2) donne à une tâche tout ce qui est tapé après &lt;code&gt;--&lt;/code&gt;, tel quel. Pratique pour un &lt;code&gt;castor phpunit -- --filter foobar&lt;/code&gt; qui transmet le reste à une autre CLI ;&lt;/li&gt;
&lt;li&gt;Le contexte a appris deux choses en 1.4 : &lt;code&gt;input&lt;/code&gt;, pour alimenter le stdin d&#039;un process (un mot de passe, plutôt que de le mettre sur la ligne de commande), et &lt;code&gt;supportsInteraction()&lt;/code&gt;, pour savoir si vous êtes dans un TTY ou dans une CI avant d&#039;appeler &lt;code&gt;toInteractive()&lt;/code&gt; ;&lt;/li&gt;
&lt;li&gt;Un fichier &lt;code&gt;.castor.context&lt;/code&gt; à la racine du projet définit le contexte par défaut, derrière &lt;code&gt;--context&lt;/code&gt; et &lt;code&gt;CASTOR_CONTEXT&lt;/code&gt; (1.6). Commitez-le, ou gitignorez-le pour un défaut personnel ;&lt;/li&gt;
&lt;li&gt;L&#039;autocomplétion des chemins est devenue plus maligne : &lt;code&gt;#[AsPathArgument]&lt;/code&gt; et &lt;code&gt;#[AsPathOption]&lt;/code&gt; acceptent un &lt;code&gt;directory&lt;/code&gt; et un &lt;code&gt;filter&lt;/code&gt; (1.4) ;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dispatch()&lt;/code&gt; et &lt;code&gt;event_dispatcher()&lt;/code&gt; (1.7) laissent vos tâches dispatcher leurs propres événements, en plus de ceux que Castor émet déjà ;&lt;/li&gt;
&lt;li&gt;Le repack n&#039;a plus besoin de &lt;code&gt;jolicode/castor&lt;/code&gt; dans votre &lt;code&gt;composer.json&lt;/code&gt; (1.3), et &lt;code&gt;--castor-file&lt;/code&gt; pointe Castor vers un fichier racine qui ne s&#039;appelle pas &lt;code&gt;castor.php&lt;/code&gt; (1.1) ;&lt;/li&gt;
&lt;li&gt;Plus petits : &lt;code&gt;terminal()&lt;/code&gt; pour la taille du terminal, &lt;code&gt;slug()&lt;/code&gt; pour slugifier une chaîne, un lien vers la définition de la tâche dans &lt;code&gt;castor help&lt;/code&gt;, et un en-tête silencieux quand Castor tourne dans un agent IA (moins de tokens gachés !).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Le &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/castor/blob/main/CHANGELOG.md&quot;&gt;changelog&lt;/a&gt; contient le reste. Passons à la 1.8.&lt;/p&gt;
&lt;h2&gt;Castor sous Windows&lt;/h2&gt;
&lt;p&gt;Castor fournit un phar Windows depuis les premières releases. Mais un phar a besoin de PHP, et « installer PHP sous Windows » n&#039;est pas aussi fluide que sur les autres plateformes. Les binaires statiques, ceux qui embarquent PHP, n&#039;existaient que pour Linux et macOS.&lt;/p&gt;
&lt;p&gt;La 1.8 ajoute &lt;code&gt;castor.windows-amd64.exe&lt;/code&gt; à chaque release : un fichier, PHP 8.5 dedans, rien à installer. Déposez-le dans un dossier de votre &lt;code&gt;PATH&lt;/code&gt; et c&#039;est terminé :&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;curl.exe&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &quot;https://github.com/jolicode/castor/releases/latest/download/castor.windows-amd64.exe&quot;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;Lso C:\&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;un dossier de votre PATH&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-9&quot;&gt;castor.exe&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;
Sous WSL, gardez le binaire statique Linux : même si ça fonctionne, le .exe tournerait comme un process Windows via l&#039;interop, ce qui n&#039;est pas ce que vous attendez d&#039;un shell Linux.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Si vous distribuez votre propre CLI construite sur Castor (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://castor.jolicode.com/going-further/extending-castor/repack/&quot;&gt;repack&lt;/a&gt;, puis &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://castor.jolicode.com/going-further/extending-castor/compile/&quot;&gt;compile&lt;/a&gt;), &lt;code&gt;castor:compile&lt;/code&gt; a gagné une option &lt;code&gt;--os=windows&lt;/code&gt; : vos tâches peuvent devenir un exécutable Windows elles aussi.&lt;/p&gt;
&lt;p&gt;La première tentative pour supporter les binaires Windows remonte à octobre 2025, juste après la 1.0, et il a fallu quelques impasses avant d&#039;aboutir à une solution fonctionnelle. Nous construisons les binaires statiques avec &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://static-php.dev/&quot;&gt;static-php-cli&lt;/a&gt;, un super outil sous Linux et macOS, mais moins évident sous Windows. Dans le désordre : le runner &lt;code&gt;windows-latest&lt;/code&gt; embarque un Visual Studio que static-php-cli ne reconnaît pas (il veut le 2019 ou le 2022), le &lt;code&gt;tar&lt;/code&gt; de git bash réécrit les chemins Windows dans votre dos, et MSVC abandonne sur les chemins de sources de plus de 260 caractères. Chacun de ces soucis se corrige en cinq minutes. Chacun coûte aussi un run de CI complet pour être découvert 😅. Si vous aimez ce genre d&#039;archéologie, toute l&#039;histoire est dans &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/castor/pull/879&quot;&gt;la pull request&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Installer et mettre à jour Castor&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;castor self-update&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Jusqu&#039;ici, mettre à jour Castor voulait dire relancer l&#039;installeur, ou aller chercher le nouveau phar à la main sur la page des releases. Castor se met maintenant à jour tout seul, comme le font Composer et PHPStan :&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;castor&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; self-update&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Il retrouve comment il a été installé, télécharge le phar ou le binaire statique qui correspond à votre plateforme, et se remplace lui-même. La version précédente est conservée à côté : un &lt;code&gt;--rollback&lt;/code&gt; la remet en place si la nouvelle se comporte mal. Vous avez installé Castor globalement avec Composer ? La commande lance &lt;code&gt;composer global update&lt;/code&gt; pour vous.&lt;/p&gt;
&lt;p&gt;Castor vous disait « une nouvelle version est disponible » depuis deux ans. Il peut enfin y faire quelque chose.&lt;/p&gt;
&lt;h3&gt;Snapshots&lt;/h3&gt;
&lt;p&gt;Chaque push sur &lt;code&gt;main&lt;/code&gt; publie maintenant une pre-release &lt;code&gt;snapshot&lt;/code&gt;. C&#039;est le chemin le plus court pour essayer un correctif avant sa sortie :&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;castor&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; self-update&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --snapshot&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# ou, en partant de zéro&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;curl&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &quot;https://castor.jolicode.com/install&quot;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; |&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; bash&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; -s&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --version=snapshot&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Un snapshot affiche une version du type &lt;code&gt;v1.7.0-14-g4531440&lt;/code&gt; : la dernière release, le nombre de commits depuis, et le commit à partir duquel il a été construit. &lt;code&gt;castor self-update&lt;/code&gt; sans l&#039;option vous ramène sur la dernière stable.&lt;/p&gt;
&lt;p&gt;Égoïstement, c&#039;est aussi pour nous : « tu peux essayer le snapshot ? » se demande bien plus facilement dans une issue que « tu peux cloner le dépôt et construire le phar ? ».&lt;/p&gt;
&lt;h3&gt;Des binaires vérifiables&lt;/h3&gt;
&lt;p&gt;Un task runner tourne sur votre laptop et dans votre CI, et pour certains d&#039;entre vous sur des serveurs de production. Un task runner qui se met à jour tout seul télécharge du code et l&#039;exécute. Ça mérite mieux qu&#039;un &lt;code&gt;curl | bash&lt;/code&gt; et un acte de foi.&lt;/p&gt;
&lt;p&gt;Chaque phar et chaque binaire statique est maintenant publié avec une &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations&quot;&gt;attestation d&#039;artefact GitHub&lt;/a&gt; : une déclaration signée que ce fichier précis a été construit par le workflow GitHub Actions de Castor, à partir de ce commit. Chaque release contient aussi un fichier &lt;code&gt;SHA256SUMS&lt;/code&gt;, attesté lui aussi.&lt;/p&gt;
&lt;p&gt;Ensuite, tout ce qui télécharge Castor les vérifie :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;l&#039;installeur et &lt;code&gt;self-update&lt;/code&gt; comparent le checksum du binaire avec &lt;code&gt;SHA256SUMS&lt;/code&gt; ;&lt;/li&gt;
&lt;li&gt;si la &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://cli.github.com/&quot;&gt;CLI GitHub&lt;/a&gt; (2.49 ou plus) est installée et connectée, les deux lancent en plus &lt;code&gt;gh attestation verify&lt;/code&gt; dessus ;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;castor:repack&lt;/code&gt; refuse un phar Castor sans attestation (il existe un &lt;code&gt;--allow-unattested&lt;/code&gt; pour les releases publiées avant les attestations) ;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;castor:compile&lt;/code&gt; vérifie le checksum de l&#039;archive static-php-cli qu&#039;il télécharge.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Vous pouvez aussi le faire à la main, sur n&#039;importe quel fichier d&#039;une release :&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;gh&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; attestation&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; verify&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; castor.linux-amd64&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --repo&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; jolicode/castor&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;c-alert c-alert--tip&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 46 72&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M45.7 23.2C45.7 10.7 35.5.5 23 .5S.3 10.7.3 23.2c0 1.8.2 3.6.7 5.4.6 2.9 1.7 4.7 3.2 7.2.3.6.7 1.2 1.1 1.9.5.8.9 1.6 1.4 2.3 2 3.3 3.2 5.2 3.2 9.1v9.4c0 2.4 1.7 4.3 4 4.7 1 5.1 4 8.3 9.1 8.3s8.2-3.2 9.1-8.3c2.3-.4 4-2.4 4-4.7v-9.4c0-3.9 1.2-5.9 3.2-9.1.4-.7.9-1.5 1.4-2.3.4-.7.8-1.3 1.1-1.9 1.5-2.5 2.6-4.3 3.2-7.2.5-1.8.7-3.6.7-5.4M31.2 50.9H15.287v-1.917c0-.416 0-.75-.087-1.083h16c0 .333-.087.667-.087 1.083V50.9zm-1.016 7.5H15.603c-.44 0-.703-.308-.703-.615V55.4h15.986v2.385c.088.307-.263.615-.702.615m-7.124 8c-.87 0-3.089 0-3.96-3h8c-.871 3-3.168 3-4.04 3m17.091-38.664c-.468 2.072-1.216 3.484-2.526 5.65-.375.564-.655 1.129-1.03 1.788-.468.753-.842 1.506-1.216 2.071-1.123 1.883-2.153 3.578-2.808 5.555h-18.53c-.654-1.977-1.59-3.672-2.807-5.555-.374-.659-.842-1.318-1.216-2.071-.375-.659-.749-1.318-1.03-1.789-1.31-2.26-2.059-3.577-2.527-5.743a16.5 16.5 0 0 1-.561-4.236C5.9 13.708 13.761 5.8 23.4 5.8s17.5 7.908 17.5 17.606c-.187 1.412-.374 2.824-.749 4.33&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Astuce&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Le script d&#039;installation a eu droit au même traitement : il télécharge dans un fichier temporaire privé, nettoie quoi qu&#039;il arrive, et se lit en entier avant d&#039;exécuter quoi que ce soit. Une connexion coupée en plein milieu ne peut plus lancer la moitié d&#039;un script.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;Dans vos tâches&lt;/h2&gt;
&lt;h3&gt;Des helpers plus sûrs&lt;/h3&gt;
&lt;p&gt;Tant que nous étions dans un mood sécurité, nous sommes repassé sur les helpers avec une seule question : et si cette valeur venait d&#039;une saisie utilisateur ? Une poignée de comportements ont changé. Aucun ne devrait casser une tâche existante, et ils sont tous dans le &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/castor/blob/main/CHANGELOG.md&quot;&gt;changelog&lt;/a&gt;, mais quelques-uns valent le coup d&#039;être mis en avant :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;http_download()&lt;/code&gt; ne garde que le dernier segment du nom de fichier envoyé par le serveur : un téléchargement atterrit toujours dans le dossier de votre projet, quoi qu&#039;en dise le serveur ;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ssh_run()&lt;/code&gt; échappe le chemin distant, et refuse un hôte, un utilisateur ou un chemin de clé contenant un métacaractère shell ;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zip()&lt;/code&gt; avec un mot de passe préfère l&#039;extension PHP zip au binaire &lt;code&gt;zip&lt;/code&gt; : le binaire prend le mot de passe sur sa ligne de commande, lisible par tous les utilisateurs de la machine pendant la création de l&#039;archive. &lt;code&gt;zip_binary()&lt;/code&gt; le fait toujours, et prévient désormais ;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;run_php()&lt;/code&gt; passe son script au process enfant en argument, au lieu d&#039;une variable d&#039;environnement que n&#039;importe quel process Castor aurait ramassée sans se poser de question ;&lt;/li&gt;
&lt;li&gt;le dossier de cache est créé en &lt;code&gt;0700&lt;/code&gt;, respecte &lt;code&gt;XDG_CACHE_HOME&lt;/code&gt;, et le binaire du watcher derrière &lt;code&gt;watch()&lt;/code&gt; y est extrait plutôt que dans un chemin fixe de &lt;code&gt;/tmp&lt;/code&gt; partagé avec tous les utilisateurs de la machine ;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;encrypt_with_password()&lt;/code&gt; dérive sa clé avec les limites « moderate » de libsodium (Argon2id, 3 passes, 256 Mio) au lieu des « interactive ». Ce qui a été chiffré par un Castor plus ancien se déchiffre toujours. Ce qui est chiffré par la 1.8 demande la 1.8 ou plus.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Intercepter les signaux&lt;/h3&gt;
&lt;p&gt;Retour au quotidien. Un classique : une tâche démarre un serveur, et vous aimeriez que &lt;code&gt;CTRL+C&lt;/code&gt; arrête le serveur, pas la tâche. Jusqu&#039;ici, le &lt;code&gt;SIGINT&lt;/code&gt; arrêtait Castor lui-même, et ce que vous aviez prévu après le serveur ne s&#039;exécutait jamais.&lt;/p&gt;
&lt;p&gt;Le contexte a gagné &lt;code&gt;withTrappedSignals()&lt;/code&gt;, qui transmet le signal au process en cours à la place :&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-2&quot;&gt; Castor\Attribute\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;AsTask&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-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Castor\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;context&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-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Castor\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;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&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;#[AsTask()]&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; serve&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-10&quot;&gt;    // A CTRL+C stops the server, but not the task&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;&#039;./my-server&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, context: &lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;context&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;withTrappedSignals&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;withAllowFailure&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-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;&#039;echo &quot;the server has been stopped&quot;&#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;Sans argument, il intercepte &lt;code&gt;SIGINT&lt;/code&gt; et &lt;code&gt;SIGTERM&lt;/code&gt;. Passez votre propre liste si besoin, &lt;code&gt;withTrappedSignals([\SIGINT, \SIGQUIT])&lt;/code&gt; par exemple. Les détails sont dans &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://castor.jolicode.com/going-further/interacting-with-castor/signals/#trapping-signals-sent-to-castor&quot;&gt;la documentation des signaux&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Monter un paquet distant&lt;/h3&gt;
&lt;p&gt;Castor a deux façons d&#039;amener des tâches externes dans un projet. &lt;code&gt;import()&lt;/code&gt; ajoute des fonctions et des tâches dans votre application. &lt;code&gt;mount()&lt;/code&gt; branche un sous-projet entier, avec son propre préfixe de namespace et son propre dossier de travail. Seul &lt;code&gt;import()&lt;/code&gt; connaissait les paquets Composer, &lt;code&gt;mount()&lt;/code&gt; ne fonctionnait qu&#039;avec des dossiers locaux. Ils partagent maintenant la même résolution :&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; Castor\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;mount&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-8&quot;&gt;mount&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;composer://vendor/package&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;project:package&#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;Les tâches du paquet apparaissent sous &lt;code&gt;project:package:&lt;/code&gt; et s&#039;exécutent depuis le dossier du paquet installé, exactement comme un mount local. C&#039;est le montage que nous voulions pour une boîte à outils qu&#039;une équipe maintient dans son propre dépôt (un kit de déploiement, une stack Docker) et monte dans chaque projet.&lt;/p&gt;
&lt;p&gt;Un correctif est tombé de ce chantier : un &lt;code&gt;import()&lt;/code&gt; distant ne change plus le dossier de travail des tâches qu&#039;il définit. Il n&#039;aurait jamais dû. Seul un &lt;code&gt;mount()&lt;/code&gt; explicite fait ça.&lt;/p&gt;
&lt;h2&gt;Une dépréciation pour préparer la 2.0&lt;/h2&gt;
&lt;p&gt;Celle-ci mérite une minute, parce que vous dépendez peut-être de l&#039;ancien comportement sans le savoir.&lt;/p&gt;
&lt;p&gt;Aujourd&#039;hui, seul &lt;code&gt;run()&lt;/code&gt; s&#039;exécute dans le dossier de travail du contexte. Tout le reste, &lt;code&gt;fs()&lt;/code&gt;, &lt;code&gt;finder()&lt;/code&gt; et les fonctions PHP natives comme &lt;code&gt;mkdir()&lt;/code&gt; ou &lt;code&gt;file_get_contents()&lt;/code&gt;, résout les chemins relatifs depuis l&#039;endroit où vous avez tapé &lt;code&gt;castor&lt;/code&gt;. Lancez &lt;code&gt;castor build&lt;/code&gt; depuis un sous-dossier : &lt;code&gt;file_get_contents(&#039;composer.json&#039;)&lt;/code&gt; cherche au mauvais endroit alors que &lt;code&gt;run(&#039;cat composer.json&#039;)&lt;/code&gt; non. Pas terrible.&lt;/p&gt;
&lt;p&gt;Castor 2.0 changera son propre dossier courant pour le dossier de travail du contexte : un chemin relatif voudra dire la même chose partout. Il suit aussi les blocs &lt;code&gt;with(workingDirectory: ...)&lt;/code&gt;, et restaure le dossier précédent quand ils se terminent.&lt;/p&gt;
&lt;p&gt;Vous pouvez l&#039;activer dès aujourd&#039;hui avec une ligne en haut de votre &lt;code&gt;castor.php&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-9&quot;&gt;defined&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;CASTOR_USE_CHDIR&#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-9&quot;&gt; define&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;CASTOR_USE_CHDIR&#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&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Le&lt;code&gt;defined()&lt;/code&gt; n&#039;est pas décoratif : un projet monté ou un paquet importé peut définir la constante lui aussi, et un second &lt;code&gt;define()&lt;/code&gt; nu lève une erreur.&lt;/p&gt;
&lt;p&gt;Ne pas définir la constante est déprécié en 1.8, et le nouveau comportement devient le défaut en 2.0. Pour garder l&#039;ancien comportement encore un moment sans la dépréciation, définissez-la à &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--warning&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 62&quot;&gt;&lt;path fill-rule=&quot;evenodd&quot; d=&quot;m41.198 3.519 27.854 47.924C71.752 56.135 68.377 62 62.806 62H7.098c-5.402 0-8.947-5.865-6.077-10.557L28.875 3.52c2.7-4.692 9.622-4.692 12.323 0Zm-8.586 2.944L5.427 52.936C4.274 54.725 5.592 57 7.734 57h54.37c2.306 0 3.624-2.275 2.471-4.063L37.39 6.464c-.988-1.95-3.79-1.95-4.778 0ZM33 45.9c1.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.9s-1.6-.3-2.3-.9c-.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-.5m2.2-27.1c1.7 0 3 1.3 3 3v15.3c0 1.7-1.3 3-3 3s-3-1.3-3-3V21.8c0-1.7 1.3-3 3-3&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Avertissement&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Une fois activée, un chemin relatif donné en ligne de commande (&lt;code&gt;castor castor:compile foo.phar&lt;/code&gt;, un argument de tâche) se résout depuis la racine du projet, pas depuis le dossier où vous avez invoqué &lt;code&gt;castor&lt;/code&gt;. Ça ne compte que si vous lancez Castor depuis un sous-dossier, mais vérifiez vos tâches avant de basculer.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;Le mot de la fin&lt;/h2&gt;
&lt;p&gt;Si vous ne retenez qu&#039;une chose de la 1.8 : le binaire que vous lancez est le nôtre, preuve à l&#039;appui, et il se met à jour tout seul. Vos collègues sous Windows ont le même outil que tout le monde. En bonus, &lt;code&gt;CTRL+C&lt;/code&gt; fait enfin ce que vous attendez devant un serveur.&lt;/p&gt;
&lt;p&gt;La mise à jour tient désormais en une commande :&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;castor&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; self-update&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;La liste complète des changements est dans le &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/castor/blob/main/CHANGELOG.md&quot;&gt;changelog&lt;/a&gt;. Et si l&#039;histoire de static-php-cli vous a donné envie de savoir comment sont construits les binaires Linux, macOS et Windows, dites-le-nous : ça pourrait être le prochain article 🦫&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/castor-1-8-windows-self-update-and-binaries-you-can-verify</id>
        <published>2026-09-22T10:41:00+02:00</published>
        <updated>2026-09-22T10:41:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/castor-1-8-windows-self-update-and-binaries-you-can-verify"/>
        <title>Castor 1.8: Windows, self-update, and binaries you can verify</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="open-source" />            <category term="castor" />            <reactions:summary total="6">                <reactions:reaction emoji="❤️" shortname="heart" count="2"/>                <reactions:reaction emoji="🚀" shortname="rocket" count="2"/>                <reactions:reaction emoji="🎉" shortname="party" count="2"/>            </reactions:summary>
        <summary><![CDATA[The last time we talked about Castor on this blog was to announce the 1.0 release, in October 2025. Since then, our favorite rodent has settled into a monthly rhythm: a minor version every four to five…]]></summary>
        <content type="html">
            &lt;p&gt;The last time we talked about Castor on this blog was to announce &lt;a href=&quot;https://jolicode.com/blog/the-castor-task-runner-is-now-stable&quot;&gt;the 1.0 release&lt;/a&gt;, in October 2025. Since then, our favorite rodent has settled into a monthly rhythm: a minor version every four to five weeks, with a few helpers and fixes each time, sometimes a deprecation to prepare 2.0.&lt;/p&gt;
&lt;p&gt;1.8 breaks the rhythm a bit: 81 commits and 33 pull requests since 1.7.0 in early August, and almost none of them add a helper. The work went into the parts of Castor you never look at: how it lands on your machine, how it updates, and what proves that the binary you run is the one our CI built. Oh, and it runs on Windows now 🪟.&lt;/p&gt;
&lt;h2&gt;What you may have missed since 1.0.0&lt;/h2&gt;
&lt;p&gt;Seven minor versions went by since the 1.0 article, and a few of them deserve more than a line in the changelog. In case you skipped some:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PHP 8.4 is the minimum since 1.6, and the static binaries embed PHP 8.5 since 1.3. If you are stuck on 8.2 or 8.3, Castor tells you so and points you to the static binary;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;#[AsArgsAfterOptionEnd]&lt;/code&gt; (1.2) gives a task everything typed after &lt;code&gt;--&lt;/code&gt;, untouched. Handy for a &lt;code&gt;castor phpunit -- --filter foobar&lt;/code&gt; that forwards the rest to another CLI;&lt;/li&gt;
&lt;li&gt;The context learned two things in 1.4: &lt;code&gt;input&lt;/code&gt;, to feed a process&#039;s stdin (a password, rather than putting it on the command line), and &lt;code&gt;supportsInteraction()&lt;/code&gt;, to tell whether you are in a TTY or in a CI before calling &lt;code&gt;toInteractive()&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;.castor.context&lt;/code&gt; file at the root of the project sets the default context, below &lt;code&gt;--context&lt;/code&gt; and &lt;code&gt;CASTOR_CONTEXT&lt;/code&gt; (1.6). Commit it, or gitignore it for a personal default;&lt;/li&gt;
&lt;li&gt;Path autocomplete got smarter: &lt;code&gt;#[AsPathArgument]&lt;/code&gt; and &lt;code&gt;#[AsPathOption]&lt;/code&gt; take a &lt;code&gt;directory&lt;/code&gt; and a &lt;code&gt;filter&lt;/code&gt; (1.4);&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dispatch()&lt;/code&gt; and &lt;code&gt;event_dispatcher()&lt;/code&gt; (1.7) let your tasks dispatch their own events, on top of the ones Castor already fires;&lt;/li&gt;
&lt;li&gt;Repack no longer needs &lt;code&gt;jolicode/castor&lt;/code&gt; in your &lt;code&gt;composer.json&lt;/code&gt; (1.3), and &lt;code&gt;--castor-file&lt;/code&gt; points Castor to a root file that is not named &lt;code&gt;castor.php&lt;/code&gt; (1.1);&lt;/li&gt;
&lt;li&gt;Smaller ones: &lt;code&gt;terminal()&lt;/code&gt; for the terminal size, &lt;code&gt;slug()&lt;/code&gt; to slugify a string, a link to the task definition in &lt;code&gt;castor help&lt;/code&gt;, and a silent header when Castor runs inside an AI agent (fewer tokens wasted!).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/castor/blob/main/CHANGELOG.md&quot;&gt;changelog&lt;/a&gt; has the rest. Now, 1.8.&lt;/p&gt;
&lt;h2&gt;Castor on Windows&lt;/h2&gt;
&lt;p&gt;Castor has shipped a Windows phar since the first releases. A phar needs PHP though, and &amp;quot;install PHP on Windows&amp;quot; is not something as smooth as other platforms. The static binaries, the ones with PHP baked in, only existed for Linux and macOS.&lt;/p&gt;
&lt;p&gt;1.8 adds &lt;code&gt;castor.windows-amd64.exe&lt;/code&gt; to every release: one file, PHP 8.5 inside, nothing to install. Drop it in a directory of your &lt;code&gt;PATH&lt;/code&gt; and you are done:&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;curl.exe&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &quot;https://github.com/jolicode/castor/releases/latest/download/castor.windows-amd64.exe&quot;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;Lso C:\&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;a directory &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;in&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; your PATH&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-9&quot;&gt;castor.exe&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;
Under WSL, keep the Linux static binary: even if it works, the .exe would run as a Windows process through interop, which is not what you want from a Linux shell.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;If you ship your own CLI built on Castor (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://castor.jolicode.com/going-further/extending-castor/repack/&quot;&gt;repack&lt;/a&gt;, then &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://castor.jolicode.com/going-further/extending-castor/compile/&quot;&gt;compile&lt;/a&gt;), &lt;code&gt;castor:compile&lt;/code&gt; gained a &lt;code&gt;--os=windows&lt;/code&gt; option, so your tasks can become a Windows executable as well.&lt;/p&gt;
&lt;p&gt;The first attempt to add support for Windows binaries dates back to October 2025, right after 1.0, and it took a couple of dead ends before landing. We build the static binaries with &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://static-php.dev/&quot;&gt;static-php-cli&lt;/a&gt;, a great tool on Linux and macOS, and a bumpier ride on Windows. In no particular order: the &lt;code&gt;windows-latest&lt;/code&gt; runner ships a Visual Studio that static-php-cli does not recognize (it wants 2019 or 2022), git bash&#039;s &lt;code&gt;tar&lt;/code&gt; rewrites Windows paths behind your back, and MSVC gives up on source paths longer than 260 characters. Each one is a five-minute fix. Each one also costs a full CI run to find out 😅. If you enjoy that kind of archaeology, the whole story is in &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/castor/pull/879&quot;&gt;the pull request&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Installing and updating Castor&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;castor self-update&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Until now, updating Castor meant running the installer again, or fetching the new phar by hand from the releases page. Castor now updates itself, like Composer and PHPStan do:&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;castor&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; self-update&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It finds out how it was installed, downloads the phar or static binary matching your platform, and swaps itself out. The previous version is kept next to it: a &lt;code&gt;--rollback&lt;/code&gt; brings it back if the new one misbehaves. Installed globally with Composer? The command runs &lt;code&gt;composer global update&lt;/code&gt; for you.&lt;/p&gt;
&lt;p&gt;Castor had been telling you &amp;quot;a new version is available&amp;quot; for two years. It can finally do something about it.&lt;/p&gt;
&lt;h3&gt;Snapshots&lt;/h3&gt;
&lt;p&gt;Every push on &lt;code&gt;main&lt;/code&gt; now publishes a &lt;code&gt;snapshot&lt;/code&gt; pre-release. It is the shortest path to try a fix before it is released:&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;castor&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; self-update&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --snapshot&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;# or, from scratch&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;curl&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &quot;https://castor.jolicode.com/install&quot;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; |&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; bash&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; -s&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --version=snapshot&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A snapshot reports a version like &lt;code&gt;v1.7.0-14-g4531440&lt;/code&gt;: the last release, the number of commits since, and the commit it was built from. &lt;code&gt;castor self-update&lt;/code&gt; without the option puts you back on the latest stable.&lt;/p&gt;
&lt;p&gt;Selfishly, this is also for us: &amp;quot;can you try the snapshot?&amp;quot; is a much easier thing to ask in an issue than &amp;quot;can you clone the repository and build the phar?&amp;quot;.&lt;/p&gt;
&lt;h3&gt;Binaries you can verify&lt;/h3&gt;
&lt;p&gt;A task runner runs on your laptop and in your CI, and for some of you on production servers. A self-updating one downloads code and runs it. That deserves better than &lt;code&gt;curl | bash&lt;/code&gt; and a leap of faith.&lt;/p&gt;
&lt;p&gt;Every phar and static binary is now published with a &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations&quot;&gt;GitHub artifact attestation&lt;/a&gt;: a signed statement that this exact file was built by Castor&#039;s own GitHub Actions workflow, from this commit. Each release also ships a &lt;code&gt;SHA256SUMS&lt;/code&gt; file, attested as well.&lt;/p&gt;
&lt;p&gt;Then everything that downloads Castor checks them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the installer and &lt;code&gt;self-update&lt;/code&gt; compare the binary&#039;s checksum with &lt;code&gt;SHA256SUMS&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;if the &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://cli.github.com/&quot;&gt;GitHub CLI&lt;/a&gt; (2.49 or later) is installed and logged in, both also run &lt;code&gt;gh attestation verify&lt;/code&gt; on it;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;castor:repack&lt;/code&gt; refuses a Castor phar without attestation (there is an &lt;code&gt;--allow-unattested&lt;/code&gt; for releases published before attestations existed);&lt;/li&gt;
&lt;li&gt;&lt;code&gt;castor:compile&lt;/code&gt; checks the checksum of the static-php-cli archive it downloads.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can do it by hand too, on any file of a release:&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;gh&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; attestation&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; verify&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; castor.linux-amd64&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --repo&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; jolicode/castor&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;div class=&quot;c-alert c-alert--tip&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 46 72&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M45.7 23.2C45.7 10.7 35.5.5 23 .5S.3 10.7.3 23.2c0 1.8.2 3.6.7 5.4.6 2.9 1.7 4.7 3.2 7.2.3.6.7 1.2 1.1 1.9.5.8.9 1.6 1.4 2.3 2 3.3 3.2 5.2 3.2 9.1v9.4c0 2.4 1.7 4.3 4 4.7 1 5.1 4 8.3 9.1 8.3s8.2-3.2 9.1-8.3c2.3-.4 4-2.4 4-4.7v-9.4c0-3.9 1.2-5.9 3.2-9.1.4-.7.9-1.5 1.4-2.3.4-.7.8-1.3 1.1-1.9 1.5-2.5 2.6-4.3 3.2-7.2.5-1.8.7-3.6.7-5.4M31.2 50.9H15.287v-1.917c0-.416 0-.75-.087-1.083h16c0 .333-.087.667-.087 1.083V50.9zm-1.016 7.5H15.603c-.44 0-.703-.308-.703-.615V55.4h15.986v2.385c.088.307-.263.615-.702.615m-7.124 8c-.87 0-3.089 0-3.96-3h8c-.871 3-3.168 3-4.04 3m17.091-38.664c-.468 2.072-1.216 3.484-2.526 5.65-.375.564-.655 1.129-1.03 1.788-.468.753-.842 1.506-1.216 2.071-1.123 1.883-2.153 3.578-2.808 5.555h-18.53c-.654-1.977-1.59-3.672-2.807-5.555-.374-.659-.842-1.318-1.216-2.071-.375-.659-.749-1.318-1.03-1.789-1.31-2.26-2.059-3.577-2.527-5.743a16.5 16.5 0 0 1-.561-4.236C5.9 13.708 13.761 5.8 23.4 5.8s17.5 7.908 17.5 17.606c-.187 1.412-.374 2.824-.749 4.33&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Astuce&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
The installer script got the same treatment: it downloads to a private temporary file, cleans up whatever happens, and reads itself entirely before executing anything. A connection dropped halfway through can no longer run half a script.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;Inside your tasks&lt;/h2&gt;
&lt;h3&gt;Safer helpers&lt;/h3&gt;
&lt;p&gt;While we were in a security mood, we went through the helpers with one question: what if this value comes from user input? A handful of behaviors changed. None should break an existing task, and they are all in the &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/castor/blob/main/CHANGELOG.md&quot;&gt;changelog&lt;/a&gt;, but a few are worth knowing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;http_download()&lt;/code&gt; only keeps the last segment of the file name sent by the server, so a download always lands in your project directory, whatever the server says;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ssh_run()&lt;/code&gt; quotes the remote path, and refuses a host, user or key path containing a shell metacharacter;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zip()&lt;/code&gt; with a password prefers the PHP zip extension to the &lt;code&gt;zip&lt;/code&gt; binary: the binary takes the password on its command line, readable by every user of the machine while the archive is built. &lt;code&gt;zip_binary()&lt;/code&gt; still does, and now warns about it;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;run_php()&lt;/code&gt; passes its script to the child process as an argument, instead of an environment variable that any Castor process would have happily picked up;&lt;/li&gt;
&lt;li&gt;the cache directory is created in &lt;code&gt;0700&lt;/code&gt;, honors &lt;code&gt;XDG_CACHE_HOME&lt;/code&gt;, and the watcher binary behind &lt;code&gt;watch()&lt;/code&gt; is extracted there rather than in a fixed path of &lt;code&gt;/tmp&lt;/code&gt; shared with every user of the machine;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;encrypt_with_password()&lt;/code&gt; derives its key with libsodium&#039;s &amp;quot;moderate&amp;quot; limits (Argon2id, 3 passes, 256 MiB) instead of the &amp;quot;interactive&amp;quot; ones. Anything encrypted by an older Castor still decrypts. Anything encrypted by 1.8 needs 1.8 or later.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Trapping signals&lt;/h3&gt;
&lt;p&gt;Back to daily life. A classic: a task starts a server, and you would like &lt;code&gt;CTRL+C&lt;/code&gt; to stop the server, not the task. Until now, the &lt;code&gt;SIGINT&lt;/code&gt; stopped Castor itself, and whatever you had planned after the server never ran.&lt;/p&gt;
&lt;p&gt;The context gained &lt;code&gt;withTrappedSignals()&lt;/code&gt;, which forwards the signal to the running process instead:&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-2&quot;&gt; Castor\Attribute\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;AsTask&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-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Castor\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;context&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-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Castor\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;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&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;#[AsTask()]&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; serve&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-10&quot;&gt;    // A CTRL+C stops the server, but not the task&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;&#039;./my-server&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, context: &lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;context&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;withTrappedSignals&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;withAllowFailure&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-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;&#039;echo &quot;the server has been stopped&quot;&#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;Without arguments it traps &lt;code&gt;SIGINT&lt;/code&gt; and &lt;code&gt;SIGTERM&lt;/code&gt;. Pass your own list if you need to, &lt;code&gt;withTrappedSignals([\SIGINT, \SIGQUIT])&lt;/code&gt; for instance. The details are in &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://castor.jolicode.com/going-further/interacting-with-castor/signals/#trapping-signals-sent-to-castor&quot;&gt;the signals documentation&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Mounting a remote package&lt;/h3&gt;
&lt;p&gt;Castor has two ways to bring external tasks into a project. &lt;code&gt;import()&lt;/code&gt; pulls functions and tasks into your application. &lt;code&gt;mount()&lt;/code&gt; plugs in a whole sub-project, with its own namespace prefix and its own working directory. Only &lt;code&gt;import()&lt;/code&gt; knew about Composer packages, &lt;code&gt;mount()&lt;/code&gt; wanted a local directory. They now share the same resolution:&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; Castor\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;mount&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-8&quot;&gt;mount&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;composer://vendor/package&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;project:package&#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;The tasks of the package show up under &lt;code&gt;project:package:&lt;/code&gt; and run from the directory of the installed package, exactly like a local mount. This is the setup we wanted for a toolkit a team maintains in its own repository (a deployment kit, a Docker stack) and mounts in every project.&lt;/p&gt;
&lt;p&gt;One fix fell out of the rework: a remote &lt;code&gt;import()&lt;/code&gt; no longer changes the working directory of the tasks it defines. It never should have. Only an explicit &lt;code&gt;mount()&lt;/code&gt; does that.&lt;/p&gt;
&lt;h2&gt;One deprecation to prepare 2.0&lt;/h2&gt;
&lt;p&gt;This one deserves a minute, because you may depend on the old behavior without knowing it.&lt;/p&gt;
&lt;p&gt;Today, only &lt;code&gt;run()&lt;/code&gt; executes in the working directory of the context. Everything else, &lt;code&gt;fs()&lt;/code&gt;, &lt;code&gt;finder()&lt;/code&gt; and plain PHP functions such as &lt;code&gt;mkdir()&lt;/code&gt; or &lt;code&gt;file_get_contents()&lt;/code&gt;, resolves relative paths from wherever you typed &lt;code&gt;castor&lt;/code&gt;. Run &lt;code&gt;castor build&lt;/code&gt; from a subdirectory: &lt;code&gt;file_get_contents(&#039;composer.json&#039;)&lt;/code&gt; looks in the wrong place while &lt;code&gt;run(&#039;cat composer.json&#039;)&lt;/code&gt; does not. Not great.&lt;/p&gt;
&lt;p&gt;Castor 2.0 will change its own current directory to the working directory of the context, so a relative path means the same thing everywhere. It follows &lt;code&gt;with(workingDirectory: ...)&lt;/code&gt; blocks too, and restores the previous directory when they end.&lt;/p&gt;
&lt;p&gt;You can opt in today with one line at the top of your &lt;code&gt;castor.php&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-9&quot;&gt;defined&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;CASTOR_USE_CHDIR&#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-9&quot;&gt; define&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;CASTOR_USE_CHDIR&#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&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;defined()&lt;/code&gt; guard is not decorative: a mounted project or an imported package may define the constant too, and a second bare &lt;code&gt;define()&lt;/code&gt; throws.&lt;/p&gt;
&lt;p&gt;Not defining the constant is deprecated in 1.8, and the new behavior becomes the default in 2.0. To keep the old behavior a while longer without the deprecation, define it to &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--warning&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 62&quot;&gt;&lt;path fill-rule=&quot;evenodd&quot; d=&quot;m41.198 3.519 27.854 47.924C71.752 56.135 68.377 62 62.806 62H7.098c-5.402 0-8.947-5.865-6.077-10.557L28.875 3.52c2.7-4.692 9.622-4.692 12.323 0Zm-8.586 2.944L5.427 52.936C4.274 54.725 5.592 57 7.734 57h54.37c2.306 0 3.624-2.275 2.471-4.063L37.39 6.464c-.988-1.95-3.79-1.95-4.778 0ZM33 45.9c1.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.9s-1.6-.3-2.3-.9c-.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-.5m2.2-27.1c1.7 0 3 1.3 3 3v15.3c0 1.7-1.3 3-3 3s-3-1.3-3-3V21.8c0-1.7 1.3-3 3-3&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Avertissement&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Once enabled, a relative path given on the command line (&lt;code&gt;castor castor:compile foo.phar&lt;/code&gt;, a task argument) resolves from the project root, not from the directory you invoked &lt;code&gt;castor&lt;/code&gt; in. It only matters when you run Castor from a subdirectory, but check your tasks before switching.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;A final word&lt;/h2&gt;
&lt;p&gt;If you remember one thing from 1.8: the binary you run is provably ours, and it keeps itself up to date. Your Windows colleagues get the same tool as everyone else. As a bonus, &lt;code&gt;CTRL+C&lt;/code&gt; finally does what you expect in front of a server.&lt;/p&gt;
&lt;p&gt;Updating takes one command from now on:&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;castor&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; self-update&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The complete list of changes is in the &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/castor/blob/main/CHANGELOG.md&quot;&gt;changelog&lt;/a&gt;. And if the static-php-cli story made you curious about how the Linux, macOS and Windows binaries are built, tell us: it could be the next article 🦫&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/from-an-ade-to-an-orchestrator-building-pablo</id>
        <published>2026-09-21T11:42:00+02:00</published>
        <updated>2026-09-21T11:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/from-an-ade-to-an-orchestrator-building-pablo"/>
        <title>From an ADE to an Orchestrator: Building PABLO</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="ia" />            <category term="ai" />            <reactions:summary total="5">                <reactions:reaction emoji="🚀" shortname="rocket" count="3"/>                <reactions:reaction emoji="🎉" shortname="party" count="1"/>                <reactions:reaction emoji="👀" shortname="eyes" count="1"/>            </reactions:summary>
        <summary><![CDATA[My agents got faster. My brain did not.
Where we left off
In my previous article, I described my working environment: an Agent Development Environment, not an IDE with a chat panel bolted on, but a tool…]]></summary>
        <content type="html">
            &lt;p&gt;&lt;em&gt;My agents got faster. My brain did not.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Where we left off&lt;/h2&gt;
&lt;p&gt;In &lt;a href=&quot;https://jolicode.com/blog/the-agent-development-environment-a-new-unit-of-work&quot;&gt;my previous article&lt;/a&gt;, I described my working environment: an Agent Development Environment, not an IDE with a chat panel bolted on, but a tool built around tasks, worktrees, and agents. I pick an issue, the ADE opens an isolated worktree with an agent already running, and I direct that agent instead of typing code. On top sit the agents I refined over months, all read-only by construction; &lt;code&gt;build&lt;/code&gt; is the only one allowed to touch code.&lt;/p&gt;
&lt;p&gt;The gains from these agents are concentrated in one place: understanding. At my job we have no focus teams, so anyone can be handed any task: a stock synchronization bug in the morning, a feature in code I have never opened in the afternoon, the next ticket in retail. Each task could be a different domain with its own business rules, and I switch between them three or four times a day. The &lt;code&gt;task-analyst&lt;/code&gt; agent reads the ticket, its parents and the related code, then hands me the whole picture in one read.&lt;/p&gt;
&lt;p&gt;Understanding a ticket is one thing, but a feature is not finished when the branch is pushed. Between code and production sits a second job made of waiting and checking: CI status, reviews, QA. All of that lives in GitHub and Jira, outside my worktrees. I had automated the thinking and kept the waiting.&lt;/p&gt;
&lt;p&gt;This article is about the tool I built to close that gap: PABLO, a Symfony application. At its heart sits a state machine, something PHP developers have shipped for a decade under a less fashionable name than the one the AI ecosystem uses today.&lt;/p&gt;
&lt;h2&gt;The chores nobody automated&lt;/h2&gt;
&lt;p&gt;Here is what my morning actually looked like: before writing a single line, I had to process a mental checklist across five or six open pull requests and two or three projects.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Is CI green? The ADE only shows the status for the current worktree&#039;s PR; the rest live in browser tabs.&lt;/li&gt;
&lt;li&gt;Has anyone reviewed them, and is it time to ask again without becoming the person who asks every ninety minutes?&lt;/li&gt;
&lt;li&gt;Has QA scheduled them, or broken them?&lt;/li&gt;
&lt;li&gt;Has feedback landed on the ticket? Find it, recreate a worktree for a branch I deleted last week.&lt;/li&gt;
&lt;li&gt;Is CI red? Then everything the agents do for me is useless until I get things moving myself: notice the failure, create a worktree, pick the agent, launch it, wait.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this is hard, and that is exactly the problem: each item costs a context switch, the thing I had spent a year eliminating. The minutes were never what hurt; what hurt was the interleaving: check CI, back to a task, remember no one has reviewed, back again.&lt;/p&gt;
&lt;p&gt;My agents had stripped away the noise, making the work effortless to understand. But the cost of monitoring all those tasks was still mine. A dozen moving parts across three tools, and in that system, something has to poll. It was me.&lt;/p&gt;
&lt;h2&gt;Enter PABLO&lt;/h2&gt;
&lt;p&gt;So I made the poller: PABLO, for &lt;em&gt;Personal Assistant for Boring Logic &amp;amp; Operations&lt;/em&gt;. The acronym came after the name, but boring is the honest word in it. Nothing PABLO does is clever: it looks at pull requests, reads timestamps, compares them with ten minutes ago, and concludes something changed.&lt;/p&gt;
&lt;p&gt;The ADE is still where I work, and PABLO stays one level above it, answering a single question on a loop: given the state of this pull request, is there something an agent should be doing, and if so, which one? When the answer is yes, PABLO automatically launches that agent through the ADE CLI, in the right worktree, exactly as I would have.&lt;/p&gt;
&lt;p&gt;Under the hood sits a Symfony console application with three entry points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;pablo&lt;/code&gt; CLI;&lt;/li&gt;
&lt;li&gt;the console;&lt;/li&gt;
&lt;li&gt;a small read-only dashboard.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is one exception to that picture: &lt;code&gt;/pablo-commit-and-pr&lt;/code&gt; is an OpenCode command, not a PABLO one. The agent writes the commit message and the pull request description (the only part of the flow where an agent beats plain code), then hands the task back to PABLO, which moves the task to the &lt;code&gt;draft&lt;/code&gt; state and takes over.&lt;/p&gt;
&lt;p&gt;And finally, a background scheduler (systemd on Linux, launchd on macOS) wakes the whole thing up every five minutes. State polling runs every ten minutes by default, the worktree sync every twelve. Both cadences live in each project&#039;s YAML, and last-run stamps are written only on success, so failures simply retry on the next tick.&lt;/p&gt;
&lt;p&gt;That separation matters more than it looks. PABLO only ever asks for an agent to be launched in a worktree and to be told when the run finishes, so everything PABLO knows about the ADE fits behind one interface: &lt;code&gt;AgentLauncherInterface&lt;/code&gt;. The interface makes the ADE swappable: if a better one shows up next month, I rewrite one class. The swap already happened once: I was working with &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.onorca.dev/&quot;&gt;Orca&lt;/a&gt; before, and I work with &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://openchamber.dev/&quot;&gt;OpenChamber&lt;/a&gt; nowadays. One coupling goes deeper: when the ADE refuses a worktree, the escape hatch is a headless &lt;code&gt;opencode run&lt;/code&gt;. PABLO truly only depends on OpenCode.&lt;/p&gt;
&lt;p&gt;Why PHP for a background daemon in 2026? It is the fastest language I think in, and the only user is me.&lt;/p&gt;
&lt;p&gt;One last decision: PABLO stores no tokens and talks to no API. Everything comes through CLIs already authenticated on my machine (&lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;acli&lt;/code&gt;, &lt;code&gt;linear&lt;/code&gt;, &lt;code&gt;orca&lt;/code&gt;, &lt;code&gt;openchamber&lt;/code&gt;), so there is nothing to rotate or leak. When something is missing, &lt;code&gt;pablo system:doctor&lt;/code&gt; says so, before a cron job fails quietly at 3 a.m.&lt;/p&gt;
&lt;p&gt;The intelligence was always in the agents. What was missing was something dumb enough to run every five minutes forever without getting bored.&lt;/p&gt;
&lt;h2&gt;Graph engineering, or: a state machine with a better name&lt;/h2&gt;
&lt;p&gt;If you have read anything about AI agents lately, you have seen &amp;quot;graph engineering&amp;quot;: modeling work as nodes and edges instead of one enormous prompt. Strip away the fashionable vocabulary, and it is just a state machine.
Because software development is messy, PABLO’s state machine needs to be flexible. Several states need to easily fall back to &lt;code&gt;draft&lt;/code&gt;, and a manual command might yank a task out of a state at any time. Heavy state machine frameworks require you to declare every single allowed transition upfront and block everything else. That is too rigid for my workflow, so I skipped the frameworks entirely and built a simple engine: states, transitions, and a loose rule about what is allowed from where.
In most agent frameworks, the nodes are reasoning steps (plan, search, summarize, critique), and the graph lives inside the task. In PABLO, the nodes are the states a pull request goes through in a real team: drafted, CI failing, waiting for review, changes requested, waiting for QA. The graph is not the agent: it is the process I already work in. That is what keeps the graph stable after the next model release, with prompts and models living inside the nodes.&lt;/p&gt;
&lt;p&gt;So the state machine is a table. Literally one table, in one class:&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;State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;Draft&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value &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;emoji&#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;📝&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;label&#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;draft&#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;onEnter&#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-5&quot;&gt;self&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 class=&quot;syntax-1&quot;&gt;&#039;enterDraft&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;], &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;polled&#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-5&quot;&gt;State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;CiRed&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value &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;emoji&#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;🔴&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;label&#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;ci-red&#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;onEnter&#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-5&quot;&gt;self&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 class=&quot;syntax-1&quot;&gt;&#039;enterCiRed&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;], &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;polled&#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;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each row of the table declares how to display the state, what runs on entry, and whether the poller watches it. Next to the first table, a second table says what each polled state may check:&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;public&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; const&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; POLL_CHECKS&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-5&quot;&gt;    State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;Draft&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value         &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;checkCiRed&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;checkCiGreen&#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;    State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;CiRed&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value         &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;checkCiGreen&#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;    State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;ReadyToReview&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value &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;checkCiRed&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;checkReviews&#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;    State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;WaitingReview&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value &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;checkCiRed&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;checkReviews&#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;    State&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;NeedsTesting&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;value  &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;checkFailureSignal&#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;That is the whole engine. Adding a step is a row and a method, not a refactor. The single on-enter handler is shared by the poller, the commands and manual overrides, so transitions never drift apart. Same idea for the trackers: one provider interface, and the state machine has no idea which tracker it is talking to.&lt;/p&gt;
&lt;h2&gt;The states, and what wakes them up&lt;/h2&gt;
&lt;p&gt;There are nine states. No &lt;code&gt;todo&lt;/code&gt;, no &lt;code&gt;init&lt;/code&gt;, on purpose: an issue I have not started has no task and just shows up in the list of what is assigned to me. Entering &lt;code&gt;in-progress&lt;/code&gt; creates the task, the branch, the worktree and the first agent run; a merge closes the task from anywhere and deletes the worktree.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Entered by&lt;/th&gt;
&lt;th&gt;What happens on entry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🔨 &lt;code&gt;in-progress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pablo task:start &amp;lt;issue-url&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;creates the branch and the worktree, runs &lt;code&gt;task-analyst&lt;/code&gt; once, plus the project&#039;s startup script if it has one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥱 &lt;code&gt;waiting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pablo task:waiting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing, and polling stops. Toggling the state back restores the previous state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📝 &lt;code&gt;draft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/pablo-commit-and-pr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;commit, push, draft pull request opened&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔴 &lt;code&gt;ci-red&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;poller: CI is failing&lt;/td&gt;
&lt;td&gt;runs &lt;code&gt;ci-analyst&lt;/code&gt; on the failing checks and their logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;👀 &lt;code&gt;ready-to-review&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;poller: CI is green&lt;/td&gt;
&lt;td&gt;marks the pull request ready on GitHub, then settles immediately into the next state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;👀 &lt;code&gt;waiting-review&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;reached from the previous one&lt;/td&gt;
&lt;td&gt;nothing, this is where a task waits for humans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🧪 &lt;code&gt;needs-testing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;poller: an approving review landed&lt;/td&gt;
&lt;td&gt;records the timestamp that becomes the QA baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔁 &lt;code&gt;request-changes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;poller: changes requested&lt;/td&gt;
&lt;td&gt;flips the pull request back to draft, runs &lt;code&gt;pr-feedback&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🚨 &lt;code&gt;testing-failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;poller: the QA failure signal fired&lt;/td&gt;
&lt;td&gt;flips the pull request back to draft, runs &lt;code&gt;task-feedback&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The shape of the thing, as a graph:&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/pablo-orchestrator/mermaid-diagram-2026-09-21-100259.png&quot; data-original-width=&quot;1312&quot; data-original-height=&quot;1840&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/pablo-orchestrator/mermaid-diagram-2026-09-21-100259.46a836e7.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/pablo-orchestrator/mermaid-diagram-2026-09-21-100259.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1312 / 1840)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/pablo-orchestrator/mermaid-diagram-2026-09-21-100259.png&quot; alt=&quot;Mermaid graph&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;p&gt;Look at the arrows, not the boxes: every arrow into &lt;code&gt;draft&lt;/code&gt; is a command I typed; every other arrow is the poller. I decide when work leaves my hands; the machine handles the rest.&lt;/p&gt;
&lt;p&gt;Three details took far more iterations than I expected.&lt;/p&gt;
&lt;p&gt;The first: what counts as a review. My own reviews are excluded: replying to a comment on my own pull request creates a review under my name, which would otherwise send me back to draft just for answering a question. Bot reviews are ignored unless whitelisted. Only reviews after GitHub&#039;s last &lt;code&gt;ready_for_review&lt;/code&gt; timeline event count (a real timestamp, unlike the summary verdict), so a stale approval cannot leak into this round. The latest review per reviewer wins, and anything that is not an approval beats every approval: if someone took the trouble to write, I read the feedback before QA does.&lt;/p&gt;
&lt;p&gt;The second: exactly one way back into &lt;code&gt;draft&lt;/code&gt;, and it is a command. No push detection: a raw &lt;code&gt;git push&lt;/code&gt; moves nothing. That sounds restrictive until you consider that I push dozens of times a day, often just to run CI. An explicit command is a signal. A push is noise.&lt;/p&gt;
&lt;p&gt;The third: what I deliberately chose not to treat as events. CI pending triggers nothing. CI red in &lt;code&gt;needs-testing&lt;/code&gt; triggers nothing either: a broken build will surface at merge time, and interrupting QA helps nobody. Half of designing a state machine is choosing what is not an event, and that half gets almost no attention.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/pablo-orchestrator/pablo-dashboard.png&quot; data-original-width=&quot;2188&quot; data-original-height=&quot;1740&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/pablo-orchestrator/pablo-dashboard.881dff31.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/pablo-orchestrator/pablo-dashboard.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2188 / 1740)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/pablo-orchestrator/pablo-dashboard.png&quot; alt=&quot;PABLO web dashboard&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h2&gt;What &amp;quot;green&amp;quot; actually means&lt;/h2&gt;
&lt;p&gt;Two of those transitions hang on one question: is CI green? It sounds like a fact you look up. It is not.&lt;/p&gt;
&lt;p&gt;My first version: red on failure, green when everything passes, pending otherwise. That rule worked everywhere except the project where I needed PABLO most.&lt;/p&gt;
&lt;p&gt;That project runs CircleCI, which publishes its deployment gates to GitHub as status contexts: &lt;code&gt;deploy-code-approval-ppr&lt;/code&gt;, &lt;code&gt;deploy-code-approval-qlf&lt;/code&gt;. These are manual gates on every pipeline, including feature branches where nobody clicks them (we do not deploy to qualification on every commit). They sit at &lt;code&gt;PENDING&lt;/code&gt; forever, or arrive as &lt;code&gt;ACTION_REQUIRED&lt;/code&gt;, which my rule counted as failure, so &amp;quot;Is CI green?&amp;quot; was never yes.&lt;/p&gt;
&lt;p&gt;The fix is one line of configuration in PABLO:&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;ci&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;  ignore_checks&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: [&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;approval&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;Any check whose name, workflow or status context contains that substring is dropped from the question entirely: it can neither turn the verdict red nor hold it at pending forever. Two gates, one word.&lt;/p&gt;
&lt;p&gt;That single configuration key forced me to realize something: a green CI is a policy, not a fact. What GitHub calls a &amp;quot;CI check&amp;quot; is actually a noisy pile of unit tests, manual deployment gates, and chatty bots. Because no tool can magically guess which of those actually matter for a given branch, PABLO pushes that decision to the project&#039;s YAML file. To keep the engine simple, I added two more blunt rules: a repository with zero checks is considered green, and the substring matching is kept intentionally dumb.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/pablo-orchestrator/github-approval-gates.png&quot; data-original-width=&quot;1783&quot; data-original-height=&quot;842&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/pablo-orchestrator/github-approval-gates.d5f1a2bd.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/pablo-orchestrator/github-approval-gates.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1783 / 842)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/pablo-orchestrator/github-approval-gates.png&quot; alt=&quot;github approval gates&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h2&gt;The one exception to my own dogma&lt;/h2&gt;
&lt;p&gt;In my previous article I made a point of my agents being read-only, not by instruction but by construction: the frontmatter (see my previous article to understand what a frontmatter is) denies &lt;code&gt;edit&lt;/code&gt; and &lt;code&gt;write&lt;/code&gt;. PABLO&#039;s four analysts follow the same rule: cheap, fast model, forbidden from writing.&lt;/p&gt;
&lt;p&gt;But there are five agents. The fifth is &lt;code&gt;rebase-conflict-resolver&lt;/code&gt;, the only place where I broke my own rule on purpose. Every twelve hours, the sync job rebases each task worktree onto the primary branch. On a conflict, git aborts and resets the worktree, and an agent is launched to redo the rebase and resolve every conflict. Its frontmatter shows the cost: &lt;code&gt;edit: allow&lt;/code&gt;, &lt;code&gt;write: allow&lt;/code&gt;, &lt;code&gt;git push*: allow&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Why let it in? The sync is a dry run by default. One configuration key turns the report into action: &lt;code&gt;auto_apply: true&lt;/code&gt;, set only where I know the shape of the work.&lt;/p&gt;
&lt;p&gt;Its blast radius is bounded like everything else. It may write code and push, but every command acting on the process rather than the code (merging, approving, commenting) is denied. It can rewrite my branch; it cannot approve it, merge it, or speak for me. The dangerous permission was never &lt;code&gt;edit&lt;/code&gt;; it was acting as me in front of other people. Two more guard rails: a dirty branch (uncommitted files remaining) is never rebased, and when the agent takes over a rebase, PABLO prints a clear, copiable command to see the agent session in OpenCode.&lt;/p&gt;
&lt;p&gt;Git does the remaining safety work. The push is always &lt;code&gt;--force-with-lease&lt;/code&gt;, never a bare &lt;code&gt;--force&lt;/code&gt;. If I pushed myself while the cron ran, the lease check fails, the worktree rolls back, and the job retries twelve hours later. My push always wins. When a conflict genuinely needs a human, the agent prints &lt;code&gt;PABLO_CONFLICT_UNRESOLVABLE&lt;/code&gt; and pushes nothing.&lt;/p&gt;
&lt;p&gt;The honest part: this agent can do damage, unlike the other four. I accept that risk because everything it writes lands in a pull request I review before merge. That is my own code. In exchange, I have not had a conflicting pull request in a while.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/pablo-orchestrator/pablo-rebase.png&quot; data-original-width=&quot;2105&quot; data-original-height=&quot;824&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/pablo-orchestrator/pablo-rebase.e9c09366.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/pablo-orchestrator/pablo-rebase.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2105 / 824)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/pablo-orchestrator/pablo-rebase.png&quot; alt=&quot;PABLO web rebase reporting&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h2&gt;Not everything deserves an LLM&lt;/h2&gt;
&lt;p&gt;In my previous article, I described two of my favorite agent commands: daily prompts that would call the &lt;code&gt;gh&lt;/code&gt; CLI, parse my open pull requests, and use an LLM to write a polite Slack message asking the team for reviews and QA. It felt like magic at the time. Neither survived. They are now simply &lt;code&gt;pablo show:prs&lt;/code&gt;: an ordinary PHP script reading from the poller&#039;s cache, printing the exact same Slack-ready markdown with a review queue, a divider, and a QA queue. No LLM involved.&lt;/p&gt;
&lt;p&gt;The same instinct shaped the web dashboard, rendered from the poller&#039;s cache, and it leads to something I did not expect. A tool built to orchestrate AI agents turns out to be timestamps, file locks, parsed CLI output and a table of allowed transitions. The intelligence is in five markdown files; everything else is unglamorous plumbing that decides when to open the tap.&lt;/p&gt;
&lt;h2&gt;What actually changed&lt;/h2&gt;
&lt;p&gt;The clearest difference: I did not check less, I stopped checking by myself.&lt;/p&gt;
&lt;p&gt;No walking my pull requests for red pipelines, no chasing reviews. One list, &lt;code&gt;pablo tasks&lt;/code&gt; or the web dashboard, tells me what is waiting on me; everything else is somebody else&#039;s turn.&lt;/p&gt;
&lt;p&gt;The CI case alone made PABLO worth building. CI goes red at eleven at night, the poller notices within ten minutes and launches &lt;code&gt;ci-analyst&lt;/code&gt; in the right worktree; by morning the diagnosis is waiting. The analysis was already automated by my agents; what changed is that the noticing and the waiting are automated too.&lt;/p&gt;
&lt;p&gt;A full task now goes like this: &lt;code&gt;pablo task:start&lt;/code&gt; on a Jira link, work with the agent, &lt;code&gt;/pablo-commit-and-pr&lt;/code&gt;, and stop thinking about 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-8&quot;&gt;pablo&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; task:start&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; https://acme.atlassian.net/browse/XXX-123&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Brain time is the resource&lt;/h2&gt;
&lt;p&gt;The hidden cost of modern development isn&#039;t the three minutes it takes to check a CI pipeline; it&#039;s the cognitive tax of keeping that tab open in the back of your head.
Deep work requires long, unbroken stretches of attention. When you fracture it with administrative ping-pong: refreshing GitHub, chasing approvals, wondering if QA picked up your ticket. You aren&#039;t just losing time, you are burning the exact mental energy you need to solve hard problems.
I didn&#039;t build PABLO just to write fewer bash commands, or to automate for its own sake. I built it to act as a cognitive shield. By offloading the bureaucracy of shipping to a dumb, relentless PHP loop, I stopped acting as a human router for my own tasks. I get to just be an engineer again.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;PABLO is public&lt;/h2&gt;
&lt;p&gt;PABLO is public: &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/korbeil/pablo&quot;&gt;github.com/korbeil/pablo&lt;/a&gt;. It is built for exactly one user, my team&#039;s workflow, my CLIs, my tolerance for waiting; none of that is universal. Publishing it is like handing someone a well-kept config file: something to read, borrow from and break. The state machine is a table precisely so that yours can replace mine without a fight.&lt;/p&gt;
&lt;p&gt;If you want to try it, the short version:&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;git&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; clone&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; https://github.com/korbeil/pablo&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; &amp;#x26;&amp;#x26; &lt;/span&gt;&lt;span class=&quot;syntax-9&quot;&gt;cd&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; pablo&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;./bin/install.sh&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;        # deps, agent/command links, starts the scheduler,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;                        # finishes with pablo system:doctor&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;pablo&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; system:doctor&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;     # which CLI is missing or logged out&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;pablo&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; project:new&lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;       # add a project; or drop a YAML in ~/.pablo/projects/&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You need PHP 8.4+ and the CLIs (&lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;acli&lt;/code&gt;, &lt;code&gt;linear&lt;/code&gt;, &lt;code&gt;opencode&lt;/code&gt;) each already authenticated. Then &lt;code&gt;pablo task:start &amp;lt;issue-url&amp;gt;&lt;/code&gt; on something small, &lt;code&gt;/pablo-commit-and-pr&lt;/code&gt;, and stop thinking about it. If you prefer a web dashboard over the CLI, &lt;code&gt;pablo web&lt;/code&gt; serves it on port 8321.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/pablo-orchestrator/pablo-start.png&quot; data-original-width=&quot;1442&quot; data-original-height=&quot;268&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/pablo-orchestrator/pablo-start.c78a1c12.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/pablo-orchestrator/pablo-start.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(1442 / 268)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/pablo-orchestrator/pablo-start.png&quot; alt=&quot;PABLO start&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/introducing-beberlei-metrics-v3-0-0</id>
        <published>2026-09-09T13:42:00+02:00</published>
        <updated>2026-09-09T13:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/introducing-beberlei-metrics-v3-0-0"/>
        <title>Introducing beberlei/metrics v3.0.0</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="php" />            <category term="performance" />            <category term="influxdb" />            <category term="grafana" />            <category term="opentelemetry" />            <reactions:summary total="20">                <reactions:reaction emoji="❤️" shortname="heart" count="6"/>                <reactions:reaction emoji="🎉" shortname="party" count="4"/>                <reactions:reaction emoji="🚀" shortname="rocket" count="3"/>                <reactions:reaction emoji="👏" shortname="clapclap" count="3"/>                <reactions:reaction emoji="👍" shortname="plus1" count="4"/>            </reactions:summary>
        <summary><![CDATA[Since 2012, beberlei/metrics has been doing one small job well: giving PHP applications a single, consistent API to send metrics (counters, timings, gauges...) without tying the calling code to a specific…]]></summary>
        <content type="html">
            &lt;p&gt;Since 2012, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/beberlei/metrics&quot;&gt;beberlei/metrics&lt;/a&gt; has been doing one small job well: giving PHP applications a single, consistent API to send metrics (counters, timings, gauges...) without tying the calling code to a specific backend. Swap StatsD for Prometheus, or send to both at once, and the instrumented code never changes.&lt;/p&gt;
&lt;p&gt;Version 3.0 is the biggest release in the project&#039;s history. It drops everything older than PHP 8.4, rewrites the core API around strict types, and - the part I&#039;m most excited about - &lt;strong&gt;ships four brand-new collectors&lt;/strong&gt;: Chain, OpenTelemetry, InfluxDB v2 and AWS CloudWatch.&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;
Every breaking change is documented in detail, with before/after examples, in the &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/beberlei/metrics/blob/3.x/UPGRADE.md&quot;&gt;UPGRADE.md&lt;/a&gt; guide. The full list of changes lives in &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/beberlei/metrics/blob/3.x/CHANGELOG.md&quot;&gt;CHANGELOG.md&lt;/a&gt;.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;What is beberlei/metrics, again?&lt;/h2&gt;
&lt;p&gt;It&#039;s a thin abstraction layer: you ask a &lt;code&gt;Factory&lt;/code&gt; for a collector, and you call the same four methods no matter what&#039;s behind 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-2&quot;&gt;$collector &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \Beberlei\Metrics\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Factory&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;statsd&#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;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;increment&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;foo.bar&#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;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;decrement&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;foo.bar&#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;$start &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; hrtime&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-10&quot;&gt;// ... do work ...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$milliseconds &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-8&quot;&gt;hrtime&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-2&quot;&gt; $start) &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; 1_000_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 class=&quot;syntax-2&quot;&gt;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;timing&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;foo.bar&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, $milliseconds);&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;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;flush&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;Collectors that buffer their calls (most of them do) send everything on &lt;code&gt;flush()&lt;/code&gt;, which the Symfony bundle wires automatically to &lt;code&gt;kernel.terminate&lt;/code&gt; / &lt;code&gt;console.terminate&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;As of 3.0, the supported backends are: &lt;strong&gt;Chain&lt;/strong&gt;, &lt;strong&gt;CloudWatch&lt;/strong&gt;, &lt;strong&gt;Doctrine DBAL&lt;/strong&gt;, &lt;strong&gt;DogStatsD&lt;/strong&gt;, &lt;strong&gt;Graphite&lt;/strong&gt;, &lt;strong&gt;InfluxDB v1&lt;/strong&gt;, &lt;strong&gt;InfluxDB v2&lt;/strong&gt;, &lt;strong&gt;Logger&lt;/strong&gt;, &lt;strong&gt;Null&lt;/strong&gt;, &lt;strong&gt;OpenTelemetry&lt;/strong&gt;, &lt;strong&gt;Prometheus&lt;/strong&gt;, &lt;strong&gt;StatsD&lt;/strong&gt;, and &lt;strong&gt;Telegraf&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;What&#039;s new in 3.0&lt;/h2&gt;
&lt;h3&gt;Four new collectors&lt;/h3&gt;
&lt;p&gt;This is the headline change: four new backends, covering the tools most PHP shops actually run in production today.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OpenTelemetry&lt;/strong&gt;: records measurements on a &lt;code&gt;MeterProviderInterface&lt;/code&gt;, mapping each call to the instrument that matches its semantics: &lt;code&gt;UpDownCounter&lt;/code&gt; for &lt;code&gt;measure()&lt;/code&gt;/&lt;code&gt;increment()&lt;/code&gt;/&lt;code&gt;decrement()&lt;/code&gt;, &lt;code&gt;Histogram&lt;/code&gt; for &lt;code&gt;timing()&lt;/code&gt;, &lt;code&gt;Gauge&lt;/code&gt; for &lt;code&gt;gauge()&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;$collector &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \Beberlei\Metrics\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Factory&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;opentelemetry&#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;meter_provider&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $meterProvider,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;    &#039;tags&#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;dc&#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;west&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;], &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// optional&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;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;increment&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;foo.bar&#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;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;flush&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(); &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// forwards to $meterProvider-&gt;forceFlush()&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;InfluxDbV2&lt;/strong&gt;: writes points to an InfluxDB 2.x/3.x bucket through the official &lt;code&gt;influxdata/influxdb-client-php&lt;/code&gt; client (v3 servers accept the same write API in compatibility mode):&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;$collector &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \Beberlei\Metrics\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Factory&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;influxdb_v2&#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;write_api&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $client&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;createWriteApi&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;tags&#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;dc&#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;west&#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;&lt;strong&gt;CloudWatch&lt;/strong&gt;: publishes data points through the &lt;code&gt;PutMetricData&lt;/code&gt; API via the official AWS SDK:&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;$collector &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \Beberlei\Metrics\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Factory&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;cloudwatch&#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;client&#039;&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; =&gt;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $client, &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// Aws\CloudWatch\CloudWatchClient&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;    &#039;namespace&#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;my_app&#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;tags&#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;dc&#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;west&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;], &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// turned into CloudWatch dimensions&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;strong&gt;Chain&lt;/strong&gt;: dispatches every call to a list of other collectors, so the same metric can go to StatsD &lt;em&gt;and&lt;/em&gt; a logger without touching the calling 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-2&quot;&gt;$collector &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; \Beberlei\Metrics\Collector\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Chain&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;    \Beberlei\Metrics\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Factory&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;statsd&#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;    \Beberlei\Metrics\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Factory&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;logger&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, [&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;logger&#039;&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&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;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;increment&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;foo.bar&#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;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;flush&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;code&gt;Chain&lt;/code&gt; also implements &lt;code&gt;GaugeableCollectorInterface&lt;/code&gt;: &lt;code&gt;gauge()&lt;/code&gt; calls are silently skipped on the collectors that don&#039;t support gauges instead of erroring out.&lt;/p&gt;
&lt;h3&gt;A stricter, tag-native API&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;CollectorInterface&lt;/code&gt; moves to native type declarations across the board: every method now returns &lt;code&gt;void&lt;/code&gt;, and every metric method accepts an &lt;code&gt;array $tags = []&lt;/code&gt; argument directly:&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;interface&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span class=&quot;syntax-6&quot;&gt;CollectorInterface&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; measure&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; $variable, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $value, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;array&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $tags &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;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&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;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; increment&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; $variable, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;array&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $tags &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;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&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;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; decrement&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; $variable, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;array&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $tags &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;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&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;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; timing&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; $variable, &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 class=&quot;syntax-4&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $time, &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;array&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $tags &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;:&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; void&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;    public&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; function&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; flush&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 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;timing()&lt;/code&gt; now accepts integer or floating-point milliseconds, and every collector preserves the fractional part instead of truncating it on the way to the backend.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;TaggableCollector::setTags()&lt;/code&gt; is gone: it required collectors to be mutable, which no longer fits a &lt;code&gt;final&lt;/code&gt;, strictly-typed API. Tags are now either passed per call, or fixed once in the constructor:&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;// Before (2.x)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$collector &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \Beberlei\Metrics\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Factory&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;null_inlinetaggable&#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;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;setTags&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;dc&#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;west&#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;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;increment&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;foo.bar&#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;// After (3.0)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$collector &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; \Beberlei\Metrics\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Factory&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;null&#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;$collector&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;increment&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;foo.bar&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, [&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;dc&#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;west&#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;On top of that, every collector is now guaranteed to never let an error or exception from the underlying client reach the instrumented application: a metrics call should never be the thing that crashes your request.&lt;/p&gt;
&lt;h3&gt;The Symfony bundle grew up&lt;/h3&gt;
&lt;p&gt;Every configured collector now gets an autowiring alias, so you inject a specific one without touching the container 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-4&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Beberlei\Metrics\Collector\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;CollectorInterface&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; Symfony\Component\DependencyInjection\Attribute\&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;Target&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;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;MyService&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-4&quot;&gt;        private&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; CollectorInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $collector,              &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// the default collector&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;prom&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;)] &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;private&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt; CollectorInterface&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; $prom, &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;// a named one&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;The &lt;code&gt;beberlei_metrics.collector&lt;/code&gt; service alias is gone in favour of injecting &lt;code&gt;CollectorInterface&lt;/code&gt; directly, every collector is now tagged &lt;code&gt;kernel.reset&lt;/code&gt; (so long-running workers get a clean state on container reset), and the bundle no longer loads an XML services file: collector prototypes are registered programmatically instead.&lt;/p&gt;
&lt;h3&gt;A full demo application&lt;/h3&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/beberlei/metrics/tree/3.x/examples&quot;&gt;&lt;code&gt;examples/&lt;/code&gt;&lt;/a&gt; folder now ships a Symfony application wired to &lt;em&gt;every&lt;/em&gt; collector, backed by a Docker stack (&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/docker-starter&quot;&gt;jolicode/docker-starter&lt;/a&gt; + &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://castor.jolicode.com&quot;&gt;Castor&lt;/a&gt;) that provisions Grafana dashboards out of the box for Prometheus, Graphite/StatsD/DogStatsD, InfluxDB v1 and v2, PostgreSQL, and CloudWatch (via LocalStack). One &lt;code&gt;castor start&lt;/code&gt;, and every backend already has a dashboard waiting for it.&lt;/p&gt;
&lt;p&gt;&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/metrics/home.png&quot; data-original-width=&quot;2028&quot; data-original-height=&quot;1630&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/metrics/home.6309ff3b.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/metrics/home.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2028 / 1630)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/metrics/home.png&quot; alt=&quot;Homepage&quot; /&gt;&lt;/picture&gt;
&lt;picture class=&quot;js-dialog-target&quot; data-original-url=&quot;/media/original/2026/metrics/influx1.png&quot; data-original-width=&quot;2106&quot; data-original-height=&quot;1459&quot;&gt;&lt;source type=&quot;image/webp&quot; srcset=&quot;/media/cache/content-webp/2026/metrics/influx1.89059c29.webp&quot; /&gt;&lt;source type=&quot;image/png&quot; srcset=&quot;/media/cache/content/2026/metrics/influx1.png&quot; /&gt;&lt;img loading=&quot;lazy&quot; decoding=&quot;async&quot; style=&quot;width: 996px; ; aspect-ratio: calc(2106 / 1459)&quot; src=&quot;https://jolicode.com//media/cache/content/2026/metrics/influx1.png&quot; alt=&quot;InfluxDB v1&quot; /&gt;&lt;/picture&gt;&lt;/p&gt;
&lt;h3&gt;Housekeeping&lt;/h3&gt;
&lt;p&gt;The dependencies moved on too: the abandoned &lt;code&gt;corley/influxdb-sdk&lt;/code&gt; is replaced by InfluxData&#039;s own v1 client, &lt;code&gt;influxdb/influxdb-php&lt;/code&gt;, and &lt;code&gt;jimdo/prometheus_client_php&lt;/code&gt; by its actively maintained fork, &lt;code&gt;promphp/prometheus_client_php&lt;/code&gt;. Every collector class, plus &lt;code&gt;Factory&lt;/code&gt; itself, is now &lt;code&gt;final&lt;/code&gt;. The test suite runs on native PHPUnit instead of the Symfony bridge, PHPStan and PHP-CS-Fixer are part of CI, and Travis has been replaced by GitHub Actions.&lt;/p&gt;
&lt;h2&gt;Breaking changes at a glance&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;2.x&lt;/th&gt;
&lt;th&gt;3.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PHP&lt;/td&gt;
&lt;td&gt;&amp;gt;= 5.6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;gt;= 8.4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Symfony (bundle)&lt;/td&gt;
&lt;td&gt;any (best effort)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;gt;= 6.4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;psr/log&lt;/td&gt;
&lt;td&gt;&lt;code&gt;^1.0 || ^2.0 || ^3.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;^3.0&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The rest, in short:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The Zabbix and Librato collectors are removed with no replacement: relay through Telegraf or DogStatsD if you still need those backends.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Collector\Collector&lt;/code&gt; → &lt;code&gt;Collector\CollectorInterface&lt;/code&gt;, &lt;code&gt;Collector\GaugeableCollector&lt;/code&gt; → &lt;code&gt;Collector\GaugeableCollectorInterface&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;InfluxDB&lt;/code&gt; is renamed &lt;code&gt;InfluxDbV1&lt;/code&gt; (it only ever spoke the v1 API), with a new inner client.&lt;/li&gt;
&lt;li&gt;DoctrineDBAL now stores a full datetime instead of a date-only column.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This list is not exhaustive: see &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/beberlei/metrics/blob/3.x/UPGRADE.md&quot;&gt;UPGRADE.md&lt;/a&gt; for every change with a before/after snippet.&lt;/p&gt;
&lt;h2&gt;Migrating from 2.x&lt;/h2&gt;
&lt;p&gt;For most projects, migration is a matter of renaming a handful of interfaces, moving &lt;code&gt;setTags()&lt;/code&gt; calls into constructor arguments or per-call &lt;code&gt;$tags&lt;/code&gt;, updating the &lt;code&gt;type&lt;/code&gt; of any &lt;code&gt;influxdb&lt;/code&gt; entry in your bundle configuration, and removing the &lt;code&gt;zabbix&lt;/code&gt;/&lt;code&gt;librato&lt;/code&gt; ones. The &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/beberlei/metrics/blob/3.x/UPGRADE.md&quot;&gt;UPGRADE.md&lt;/a&gt; guide walks through each case with real code.&lt;/p&gt;
&lt;p&gt;beberlei/metrics 3.0 is on &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/beberlei/metrics&quot;&gt;GitHub&lt;/a&gt; and &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://packagist.org/packages/beberlei/metrics&quot;&gt;Packagist&lt;/a&gt;. Try the &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/beberlei/metrics/tree/3.x/examples&quot;&gt;demo application&lt;/a&gt; to see every collector reporting to a live Grafana dashboard in a few minutes.&lt;/p&gt;
&lt;p&gt;Special thanks to Benjamin Eberlei for his initial work on this library and for trusting me as a core maintainer. Anyone who knows me knows I&#039;m obsessed with metrics, dashboards, and performance, so working on 3.0 was a real privilege. Fun fact: I actually started this refactoring back in 2024... time flies!&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/migrating-from-webpack-encore-to-vite-with-reprise</id>
        <published>2026-08-27T13:42:00+02:00</published>
        <updated>2026-08-27T13:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/migrating-from-webpack-encore-to-vite-with-reprise"/>
        <title>Migrating from Webpack Encore to Vite with Reprise</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="symfony" />            <category term="frontend" />            <category term="webpack" />            <category term="vite" />            <category term="reprise" />            <reactions:summary total="13">                <reactions:reaction emoji="🚀" shortname="rocket" count="3"/>                <reactions:reaction emoji="🎉" shortname="party" count="2"/>                <reactions:reaction emoji="👍" shortname="plus1" count="3"/>                <reactions:reaction emoji="🤯" shortname="mindblown" count="1"/>                <reactions:reaction emoji="👏" shortname="clapclap" count="2"/>                <reactions:reaction emoji="❤️" shortname="heart" count="2"/>            </reactions:summary>
        <summary><![CDATA[We maintain a high-traffic project for one of our clients: 5 websites served by a single Symfony application, 800 Twig templates, and a React + Tailwind front end that has been built by Webpack Encore…]]></summary>
        <content type="html">
            &lt;p&gt;We maintain a high-traffic project for one of our clients: 5 websites served by a single Symfony application, 800 Twig templates, and a React + Tailwind front end that has been built by Webpack Encore for years. A front-end build that was starting to weigh on us: over a minute of Webpack, a &lt;code&gt;NODE_OPTIONS=--max_old_space_size=4096&lt;/code&gt; to keep the heap from blowing up, a 200-line &lt;code&gt;webpack.config.js&lt;/code&gt; driving two separate builds, and no hot reload for developers.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://symfony.com/blog/introducing-symfony-reprise-the-symfony-integration-layer-for-modern-bundlers&quot;&gt;Webpack Encore is reaching the end of its life&lt;/a&gt;, and Symfony has released its official successor for Vite and Rsbuild: &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://symfony.com/bundles/reprise/current/index.html&quot;&gt;Reprise&lt;/a&gt;. The bundle was still marked experimental when we migrated (in 0.7, then 0.8), but it was clearly the direction the framework is taking. So we took the plunge — and since then, &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://symfony.com/blog/symfony-reprise-1-0-0-released&quot;&gt;1.0&lt;/a&gt; has been released, with the same backward compatibility promise as Symfony.&lt;/p&gt;
&lt;p&gt;In this article, I&#039;m going to tell you how that migration went. We&#039;ll first look at what Reprise does and what the mechanical migration involves, then at the real topics that kept us busy: the implicit contract our code base had with Webpack, a collection of &amp;quot;webpack-isms&amp;quot; that only show up at runtime, and finally setting up hot reload in our Docker stack. To make you want to read all the way through: the build went from 75-100 seconds to about fifteen seconds, and we removed 583 npm packages along the way 🎉.&lt;/p&gt;
&lt;h2&gt;Did you say Reprise?&lt;/h2&gt;
&lt;p&gt;Unlike Encore, which reimplemented the whole build chain on top of Webpack, Reprise only provides the Symfony glue: generating &lt;code&gt;entrypoints.json&lt;/code&gt; and &lt;code&gt;manifest.json&lt;/code&gt;, the &lt;code&gt;reprise_entry_*&lt;/code&gt; Twig functions, and dev server support. Everything else (Sass, TypeScript, React, code splitting, minification) is handled natively by Vite.&lt;/p&gt;
&lt;p&gt;The mechanical migration is quickly done:&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;composer&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; remove&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; symfony/webpack-encore-bundle&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;composer&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; require&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; symfony/reprise&lt;/span&gt;&lt;/span&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; add&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --dev&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; vite&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; @symfony/reprise&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One &lt;code&gt;vite.config.ts&lt;/code&gt; per build, &lt;code&gt;encore_entry_script_tags&lt;/code&gt; becoming &lt;code&gt;reprise_entry_script_tags&lt;/code&gt; in the templates, and that&#039;s pretty much it. On paper, a few hours of work.&lt;/p&gt;
&lt;p&gt;The contrast between the two configs illustrates the philosophy nicely. Before, we had 200 lines of Encore&#039;s chained API, where every bundler capability has to be declared explicitly:&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;// webpack.config.js (excerpt)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;Encore.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;setOutputPath&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;web/build/&#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-8&quot;&gt;setPublicPath&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;/build&#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-8&quot;&gt;addEntry&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;js/app&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;./assets/scripts/main.tsx&#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-8&quot;&gt;addStyleEntry&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;css/app&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;./assets/styles/main.css&#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;    // … 6 more entries&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-8&quot;&gt;disableSingleRuntimeChunk&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-8&quot;&gt;enablePostCssLoader&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-8&quot;&gt;enableReactPreset&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-8&quot;&gt;enableTypeScriptLoader&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-8&quot;&gt;autoProvideVariables&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;bazinga-translator&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Translator&#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-8&quot;&gt;addPlugin&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-8&quot;&gt; ESLintPlugin&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-8&quot;&gt;addPlugin&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-8&quot;&gt; StylelintPlugin&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ &lt;/span&gt;&lt;span class=&quot;syntax-10&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;    .&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;copyFiles&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;([{ from: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;./assets/images&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, to: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;images/[path][name].[ext]?[hash:8]&#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-8&quot;&gt;configureFilenames&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ js: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;[name].js?[chunkhash]&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;/* … */&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;And after? About fifty lines, where Vite does the heavy lifting natively. TypeScript and React no longer need a loader, and the ESLint/Stylelint plugins are replaced by the &lt;code&gt;lint&lt;/code&gt; scripts already present in the CI:&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;// vite.config.ts (excerpt)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;export&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; default&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; defineConfig&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(({ &lt;/span&gt;&lt;span class=&quot;syntax-12&quot;&gt;mode&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-2&quot;&gt;    build: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        sourcemap: mode &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;!==&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;production&#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;        rollupOptions: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            input: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;                &#039;js/app&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;./assets/scripts/main.tsx&#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;css/app&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;./assets/styles/main.css&#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;                // … 6 more entries&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;    plugins: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;        react&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;        tailwindcss&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;        symfony&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ outputPath: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;web/build&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, publicPath: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;/build/&#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;        copyStable&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;([{ from: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;assets/images&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, to: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;images&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; }], &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;/build/&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;web/build&#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;Did you notice the &lt;code&gt;copyStable&lt;/code&gt; on the last line? It isn&#039;t provided by Reprise, and that&#039;s precisely the subject of the next chapter. Because as we&#039;re about to see, the real topic of a bundler migration isn&#039;t the bundler config itself.&lt;/p&gt;
&lt;h2&gt;The implicit contract with Webpack&lt;/h2&gt;
&lt;p&gt;Vite hashes file names by default, that&#039;s its cache-busting model: &lt;code&gt;app.js&lt;/code&gt; becomes &lt;code&gt;app-B7fAYn0O.js&lt;/code&gt;, and manifest.json maps between the two. Except that our project had exactly the opposite contract, invisible as long as you don&#039;t go looking for it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;more than &lt;strong&gt;300 templates&lt;/strong&gt; reference images with hardcoded paths, along the lines of &lt;code&gt;asset(&#039;/build/images/logo.svg&#039;)&lt;/code&gt;, never going through a manifest;&lt;/li&gt;
&lt;li&gt;close to &lt;strong&gt;250 templates&lt;/strong&gt; use a home-made &lt;code&gt;|inline&lt;/code&gt; Twig filter that reads SVGs &lt;strong&gt;straight from disk&lt;/strong&gt; to inline them in the HTML;&lt;/li&gt;
&lt;li&gt;cache-busting is global, through a query string, based on a &lt;code&gt;REVISION&lt;/code&gt; file produced at deploy time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In other words, the physical paths of the copied files have to stay stable, and have done so for years. Rewriting 300 templates was obviously out of the question. On top of that, the emails sent by the application also use some of these assets (the fonts in particular), so they have to remain available at the same URLs.&lt;/p&gt;
&lt;p&gt;Reprise did offer (in 0.7) a &lt;code&gt;copy&lt;/code&gt; option to replace Encore&#039;s &lt;code&gt;copyFiles()&lt;/code&gt;, but it systematically hashed the names of the copied files, with no opt-out. Encore let you choose your pattern (&lt;code&gt;images/[path][name].[ext]?[hash:8]&lt;/code&gt; in our case: stable path on disk, hash in a query string). Our initial answer fit in a forty-line Vite plugin that reproduced Encore&#039;s contract:&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;// vite-plugin-copy-stable.ts (excerpt)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;generateBundle&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(_options, bundle) {&lt;/span&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; (&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; { file, logicalName } &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; files&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(entries)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;        const&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; source &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; readFileSync&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-5&quot;&gt;        const&lt;/span&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-8&quot;&gt; createHash&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 class=&quot;syntax-8&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(source).&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;digest&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;hex&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;slice&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;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;8&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 file keeps its logical path…&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-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;emitFile&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ type: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;asset&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, fileName: logicalName, source });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;        // … and the hash goes into the manifest value, as a query string&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        manifestEntries[keyPrefix &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; logicalName] &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-2&quot;&gt;publicPath&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;}${&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;logicalName&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-2&quot;&gt;hash&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-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-10&quot;&gt;    // then merge manifestEntries into the manifest.json emitted by Reprise&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 result: not a single template modified (apart from the 4 base layouts), the JS and CSS benefit from Vite&#039;s native hashing through &lt;code&gt;entrypoints.json&lt;/code&gt;, and everything else keeps its stable paths.&lt;/p&gt;
&lt;p&gt;This need felt universal enough to propose it upstream: &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/symfony/reprise/pull/81&quot;&gt;symfony/reprise#81&lt;/a&gt; adds a per-entry &lt;code&gt;hash: false&lt;/code&gt; option to &lt;code&gt;copy&lt;/code&gt;, which reproduces exactly that contract. It was merged and released in Reprise 0.8 a few days later: our forty lines of plugin gave way to a single line of config, with strictly identical output trees and manifests.&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--tip&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 46 72&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M45.7 23.2C45.7 10.7 35.5.5 23 .5S.3 10.7.3 23.2c0 1.8.2 3.6.7 5.4.6 2.9 1.7 4.7 3.2 7.2.3.6.7 1.2 1.1 1.9.5.8.9 1.6 1.4 2.3 2 3.3 3.2 5.2 3.2 9.1v9.4c0 2.4 1.7 4.3 4 4.7 1 5.1 4 8.3 9.1 8.3s8.2-3.2 9.1-8.3c2.3-.4 4-2.4 4-4.7v-9.4c0-3.9 1.2-5.9 3.2-9.1.4-.7.9-1.5 1.4-2.3.4-.7.8-1.3 1.1-1.9 1.5-2.5 2.6-4.3 3.2-7.2.5-1.8.7-3.6.7-5.4M31.2 50.9H15.287v-1.917c0-.416 0-.75-.087-1.083h16c0 .333-.087.667-.087 1.083V50.9zm-1.016 7.5H15.603c-.44 0-.703-.308-.703-.615V55.4h15.986v2.385c.088.307-.263.615-.702.615m-7.124 8c-.87 0-3.089 0-3.96-3h8c-.871 3-3.168 3-4.04 3m17.091-38.664c-.468 2.072-1.216 3.484-2.526 5.65-.375.564-.655 1.129-1.03 1.788-.468.753-.842 1.506-1.216 2.071-1.123 1.883-2.153 3.578-2.808 5.555h-18.53c-.654-1.977-1.59-3.672-2.807-5.555-.374-.659-.842-1.318-1.216-2.071-.375-.659-.749-1.318-1.03-1.789-1.31-2.26-2.059-3.577-2.527-5.743a16.5 16.5 0 0 1-.561-4.236C5.9 13.708 13.761 5.8 23.4 5.8s17.5 7.908 17.5 17.606c-.187 1.412-.374 2.824-.749 4.33&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Astuce&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Before estimating a bundler migration, take an inventory of who consumes your assets and through which channel (manifest, hardcoded paths, disk reads, CDN). That&#039;s where the real workload hides, not in the config.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;The webpack-isms that only show up at runtime&lt;/h2&gt;
&lt;p&gt;Once the build was green, we thought we were in the clear. But the CI was waiting for us, with a lot of failing Behat scenarios. All the problems I&#039;m about to list share the same trait: the build passes, the typecheck passes, and yet the site no longer works at runtime.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;global&lt;/code&gt; doesn&#039;t exist&lt;/h3&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;global.Translator &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Translator;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Webpack silently aliases &lt;code&gt;global&lt;/code&gt; to &lt;code&gt;window&lt;/code&gt;. Vite doesn&#039;t:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Uncaught ReferenceError: global is not defined
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The error happens at the module top level, so the whole bundle dies: not a single line of JS runs on the site any more. The nastiest part: since &lt;code&gt;@types/node&lt;/code&gt; is installed, &lt;code&gt;global&lt;/code&gt; is perfectly typed and &lt;code&gt;tsc&lt;/code&gt; doesn&#039;t flinch. The fix is trivial (&lt;code&gt;window.Translator = …&lt;/code&gt;), but you still need to know those assignments exist.&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--tip&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 46 72&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M45.7 23.2C45.7 10.7 35.5.5 23 .5S.3 10.7.3 23.2c0 1.8.2 3.6.7 5.4.6 2.9 1.7 4.7 3.2 7.2.3.6.7 1.2 1.1 1.9.5.8.9 1.6 1.4 2.3 2 3.3 3.2 5.2 3.2 9.1v9.4c0 2.4 1.7 4.3 4 4.7 1 5.1 4 8.3 9.1 8.3s8.2-3.2 9.1-8.3c2.3-.4 4-2.4 4-4.7v-9.4c0-3.9 1.2-5.9 3.2-9.1.4-.7.9-1.5 1.4-2.3.4-.7.8-1.3 1.1-1.9 1.5-2.5 2.6-4.3 3.2-7.2.5-1.8.7-3.6.7-5.4M31.2 50.9H15.287v-1.917c0-.416 0-.75-.087-1.083h16c0 .333-.087.667-.087 1.083V50.9zm-1.016 7.5H15.603c-.44 0-.703-.308-.703-.615V55.4h15.986v2.385c.088.307-.263.615-.702.615m-7.124 8c-.87 0-3.089 0-3.96-3h8c-.871 3-3.168 3-4.04 3m17.091-38.664c-.468 2.072-1.216 3.484-2.526 5.65-.375.564-.655 1.129-1.03 1.788-.468.753-.842 1.506-1.216 2.071-1.123 1.883-2.153 3.578-2.808 5.555h-18.53c-.654-1.977-1.59-3.672-2.807-5.555-.374-.659-.842-1.318-1.216-2.071-.375-.659-.749-1.318-1.03-1.789-1.31-2.26-2.059-3.577-2.527-5.743a16.5 16.5 0 0 1-.561-4.236C5.9 13.708 13.761 5.8 23.4 5.8s17.5 7.908 17.5 17.606c-.187 1.412-.374 2.824-.749 4.33&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Astuce&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Search for &lt;code&gt;global.&lt;/code&gt; in your code before migrating, it will save you from discovering the problem in the CI like we did.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;Dynamic &lt;code&gt;require()&lt;/code&gt; calls&lt;/h3&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;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;ReactSVG&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; src&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;={&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;require&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;`../../../images/icons/&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;path&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-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; /&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern relies on Webpack&#039;s &lt;em&gt;context modules&lt;/em&gt;, which embedded the whole &lt;code&gt;icons/&lt;/code&gt; folder to resolve the expression at runtime. Vite doesn&#039;t implement them:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Uncaught ReferenceError: require is not defined
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The exception blows up on the first render of a component with an icon, and React reacts by unmounting the entire tree: completely blank result pages, without a single message for the user. Since our icons were already copied at stable paths (see above), a direct URL was enough: &lt;code&gt;src={&lt;/code&gt;/build/images/icons/${path}&lt;code&gt;}&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;The CSS &lt;code&gt;url()&lt;/code&gt;s that don&#039;t follow&lt;/h3&gt;
&lt;p&gt;With &lt;code&gt;@tailwindcss/postcss&lt;/code&gt;, CSS &lt;code&gt;@import&lt;/code&gt;s are inlined &lt;strong&gt;without rebasing relative paths&lt;/strong&gt;. A &lt;code&gt;url(&#039;../../fonts/brand-400.woff2&#039;)&lt;/code&gt; written in an imported file ends up as-is in the final CSS, resolves from the root and returns a 404: webfonts and background images gone.&lt;/p&gt;
&lt;p&gt;The official &lt;code&gt;@tailwindcss/vite&lt;/code&gt; plugin rewrites those same URLs to the emitted asset (&lt;code&gt;url(/build/brand-400-Dm0XPNJo.woff2)&lt;/code&gt;), on top of being faster. That&#039;s the integration Tailwind recommends when you build with Vite. URL rebasing on the PostCSS side has indeed been fixed several times upstream (see issue &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/tailwindlabs/tailwindcss/issues/16636&quot;&gt;#16636&lt;/a&gt;, closed since), but a file imported from our own code still replayed the problem for us in 4.3.3. I can only recommend that you stop going through PostCSS if you&#039;re using Tailwind v4 with Vite.&lt;/p&gt;
&lt;h3&gt;The CommonJS vendor file&lt;/h3&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;import&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Routing &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;../../../vendor/friendsofsymfony/jsrouting-bundle/Resources/public/js/router.min.js&#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;This one is devious: it works in build mode (Rollup&#039;s commonjs plugin handles the interop), but crashes in dev only:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;The requested module &#039;…/router.min.js&#039; does not provide an export named &#039;default&#039;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Vite only prebundles &lt;code&gt;node_modules&lt;/code&gt;, so it serves the CJS file from &lt;code&gt;vendor/&lt;/code&gt; as-is to a browser expecting an ES module. The solution is the &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.npmjs.com/package/fos-router&quot;&gt;&lt;code&gt;fos-router&lt;/code&gt;&lt;/a&gt; npm package, which is exactly the same as the one shipped in the Symfony bundle. As a bonus, the npm package is written in TypeScript: &lt;code&gt;tsc&lt;/code&gt; immediately flushed out a dozen &lt;code&gt;window.location = url&lt;/code&gt; that had been lying dormant for years (since the vendor module was &lt;code&gt;any&lt;/code&gt;, everything coming out of it escaped typing).&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--tip&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 46 72&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M45.7 23.2C45.7 10.7 35.5.5 23 .5S.3 10.7.3 23.2c0 1.8.2 3.6.7 5.4.6 2.9 1.7 4.7 3.2 7.2.3.6.7 1.2 1.1 1.9.5.8.9 1.6 1.4 2.3 2 3.3 3.2 5.2 3.2 9.1v9.4c0 2.4 1.7 4.3 4 4.7 1 5.1 4 8.3 9.1 8.3s8.2-3.2 9.1-8.3c2.3-.4 4-2.4 4-4.7v-9.4c0-3.9 1.2-5.9 3.2-9.1.4-.7.9-1.5 1.4-2.3.4-.7.8-1.3 1.1-1.9 1.5-2.5 2.6-4.3 3.2-7.2.5-1.8.7-3.6.7-5.4M31.2 50.9H15.287v-1.917c0-.416 0-.75-.087-1.083h16c0 .333-.087.667-.087 1.083V50.9zm-1.016 7.5H15.603c-.44 0-.703-.308-.703-.615V55.4h15.986v2.385c.088.307-.263.615-.702.615m-7.124 8c-.87 0-3.089 0-3.96-3h8c-.871 3-3.168 3-4.04 3m17.091-38.664c-.468 2.072-1.216 3.484-2.526 5.65-.375.564-.655 1.129-1.03 1.788-.468.753-.842 1.506-1.216 2.071-1.123 1.883-2.153 3.578-2.808 5.555h-18.53c-.654-1.977-1.59-3.672-2.807-5.555-.374-.659-.842-1.318-1.216-2.071-.375-.659-.749-1.318-1.03-1.789-1.31-2.26-2.059-3.577-2.527-5.743a16.5 16.5 0 0 1-.561-4.236C5.9 13.708 13.761 5.8 23.4 5.8s17.5 7.908 17.5 17.606c-.187 1.412-.374 2.824-.749 4.33&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Astuce&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
These four failures share one trait: they leave no trace on the server side. The page returns a 200, the Symfony logs are empty, and the symptom only exists in the browser console: a &lt;code&gt;pageerror&lt;/code&gt;, a &lt;code&gt;console.error&lt;/code&gt;, or a failed asset request.
If you have e2e tests, hook Playwright&#039;s &lt;code&gt;pageerror&lt;/code&gt;, &lt;code&gt;console&lt;/code&gt; and &lt;code&gt;requestfailed&lt;/code&gt; listeners into them, if only for the duration of the migration: that&#039;s the net that turns those silent bugs into red tests, instead of making you discover them by eye, page by page.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;When the site depends on a bundler bug&lt;/h2&gt;
&lt;p&gt;Here&#039;s my favourite anecdote from this migration. After the switch to Vite, a &lt;a href=&quot;https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker&quot;&gt;visual regression&lt;/a&gt; test stubbornly refused to pass: on one variant of the site, the logo was displayed 60% too big.&lt;/p&gt;
&lt;p&gt;We checked everything: the copied SVG file, identical; the CSS rules, identical and in the same order; the HTML, identical. As a last resort, the production Webpack manifest, which told a funny story:&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-1&quot;&gt;&quot;build/images/logo-pro.svg&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;/build/images/logo-pro.e42c1969.svg&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A hash in the file name, where all the other copied entries used a query string. And the content of that hashed file was &lt;strong&gt;not&lt;/strong&gt; the requested file: it was another SVG with the same name, located in an &lt;code&gt;icons/&lt;/code&gt; subfolder, with a different CSS class, hence a different size.&lt;/p&gt;
&lt;p&gt;The explanation: the images emitted by file-loader (including the entire &lt;code&gt;icons/&lt;/code&gt; folder, pulled in by the dynamic &lt;code&gt;require()&lt;/code&gt; seen above) were &lt;strong&gt;overwriting the manifest keys&lt;/strong&gt; of copied files bearing the same name. For years, the site had been serving the wrong file in that spot, and that accidental rendering had become the reference, all the way into our screenshot baselines. Our cleaner Vite build was finally serving the right file… and therefore breaking the test.&lt;/p&gt;
&lt;p&gt;We audited the project&#039;s 31 name collisions (only one other was visible) and pointed the templates at the right file, explicitly. What I take away from this story is that a bundler is above all a resolution system: changing it reveals every accidental resolution your site depends on without you knowing.&lt;/p&gt;
&lt;h2&gt;One pixel of difference&lt;/h2&gt;
&lt;p&gt;Still on the visual regression side: three baselines moved by exactly &lt;strong&gt;1 pixel&lt;/strong&gt; after the migration. A centred button whose total width (icon sized in &lt;code&gt;em&lt;/code&gt; + text) falls half a pixel differently. The cause is the change of CSS minifier: cssnano (configured with &lt;code&gt;calc: false&lt;/code&gt; precisely to avoid that kind of rounding) gave way to esbuild.&lt;/p&gt;
&lt;p&gt;0.01% of the pixels, invisible to the eye, but perfectly reproducible. Vite&#039;s rendering is deterministic down to the pixel from one run to the next: we compared captures taken two days apart, zero pixel of difference.&lt;/p&gt;
&lt;p&gt;A corollary that applies to everyone doing screenshot tests: &lt;strong&gt;never regenerate your baselines on the dev server&lt;/strong&gt;. Unminified CSS produces the same rounding discrepancies there, compared to the built rendering your CI compares against, and you&#039;ll spend a long time wondering why &amp;quot;it passes locally&amp;quot;. On our side, the screenshot update task rebuilds automatically before capturing.&lt;/p&gt;
&lt;h2&gt;Wiring HMR into the Docker stack&lt;/h2&gt;
&lt;p&gt;HMR (Hot Module Replacement) is the most visible gain of the migration for developers, and it deserves some attention. A small confession first: with Encore, our &amp;quot;watch&amp;quot; merely wrote files to disk, and the real dev server was designed to run inside Docker for Linux PCs, and on the host machine (so outside Docker) for Macs (because the ones from that era suffered too much). With Vite, we wanted HMR inside the stack, like everything else, and for everyone.&lt;/p&gt;
&lt;h3&gt;The problems we ran into&lt;/h3&gt;
&lt;p&gt;We had three problems to solve.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reaching the server from the browser.&lt;/strong&gt; We first over-engineered it: a dedicated Traefik route, TLS, service discovery. Then we settled on a far simpler solution, already adopted on another one of our projects: publish the container port on the host and serve at &lt;code&gt;http://localhost:5173&lt;/code&gt;. Browsers treat &lt;code&gt;localhost&lt;/code&gt; as a secure origin, so no mixed content from an HTTPS page and no certificate to manage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The lifecycle.&lt;/strong&gt; Our first setup ran Vite in an ephemeral &lt;code&gt;compose run&lt;/code&gt; container. Bad idea: a killed watch leaves behind a zombie that squats port 5173 and keeps overwriting files on the sly. So we defined a dedicated Compose service instead: &lt;code&gt;up&lt;/code&gt; always reuses or recreates the same container, which makes the zombie impossible.&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;# docker-compose.dev.yml (excerpt)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;vite&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;    image&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;${PROJECT_NAME}-builder&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    command&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;bash -c &quot;until [ -d node_modules/.bin ]; do sleep 2; done; yarn run ${VITE_SCRIPT:-dev}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    ports&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-1&quot;&gt;&quot;127.0.0.1:${PROJECT_VITE_PORT:-5173}:5173&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;until node_modules&lt;/code&gt; isn&#039;t decorative: on the stack&#039;s first start, Docker starts the service before &lt;code&gt;yarn install&lt;/code&gt; has run, and you don&#039;t want a service in a crash loop.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The container pitfalls.&lt;/strong&gt; Two classics worth knowing. Vite&#039;s watcher crawls the whole project by default: with a &lt;code&gt;vendor/&lt;/code&gt; holding 100,000 files, the inotify limit blows up. So you have to exclude it explicitly. And beware of your exclusion patterns: our &lt;code&gt;**/var/**&lt;/code&gt;, meant for the Symfony cache, matched &lt;code&gt;/var/www&lt;/code&gt;, the container&#039;s working directory. The whole project was ignored by the watcher, so HMR was inoperative: the server runs, the page loads, and nothing updates. Anchor your patterns to the project directory:&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;watch: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    ignored: [&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;vendor&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;var&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;web&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;syntax-12&quot;&gt;dir&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; path.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;resolve&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(__dirname, dir, &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&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One last refinement, consistency between modes: our build tasks stop the dev server if it&#039;s running (otherwise the pages go back to the built assets while an orphan server keeps running for nothing), and the watch task starts it. That way you can&#039;t end up in an in-between state without knowing.&lt;/p&gt;
&lt;h3&gt;What about worktrees?&lt;/h3&gt;
&lt;p&gt;With AI gradually becoming unavoidable when it comes to gaining efficiency, we work more and more with git worktrees, each with its own complete, isolated Docker stack. That&#039;s a native feature of our &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/docker-starter&quot;&gt;docker-starter&lt;/a&gt; template: the Compose project name is suffixed with the worktree, and all host ports are shifted automatically, which lets several stacks run in parallel.&lt;/p&gt;
&lt;p&gt;The Vite server port simply joins that mechanism: each worktree has its own, and two developments running in parallel each have their own HMR.&lt;/p&gt;
&lt;p&gt;One detail was left to sort out: Symfony generates absolute asset URLs from the configured &lt;code&gt;base_urls&lt;/code&gt;, which know nothing about the shifted port. Inside a worktree, pages were therefore fetching their assets from the main checkout&#039;s stack, and it took us a while to figure out why. Our solution: a configurable port suffix in the &lt;code&gt;base_urls&lt;/code&gt;, empty by default (and in production):&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/framework.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;    assets&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;        base_urls&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-1&quot;&gt;&#039;https://%http.domain.front%%http.public_port_suffix%&#039;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And rather than asking every developer to fill it in by hand, the Castor 🦫 task that starts the stack detects the worktree and syncs the value into a local &lt;code&gt;parameters_override.yaml&lt;/code&gt; file (gitignored, meant for personal config):&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;
Our project still uses parameters.yaml files, we haven&#039;t migrated to environment variables and the associated .env files.&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-10&quot;&gt;// Excerpt from the task, called by `castor up`&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$line &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;http.public_port_suffix: &#039;:%d&#039;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;get_worktree_ports&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;get_worktree_name&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;())[&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;https&#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;// … created or updated in parameters_override.yaml, without touching the other keys&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A new worktree is thus usable with a single command, HMR included.&lt;/p&gt;
&lt;h2&gt;What we lost along the way&lt;/h2&gt;
&lt;p&gt;For the sake of completeness, here&#039;s what the migration cost us:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;svgo minification of the copied images was dropped, to be redone at the source if the weight becomes an issue;&lt;/li&gt;
&lt;li&gt;ts-loader ran a static code analysis on every build, but Vite doesn&#039;t. So we added a new step in the CI that runs a &lt;code&gt;yarn tsc --noEmit&lt;/code&gt; to fill the gap (don&#039;t forget it, it&#039;s a real safety net that disappears otherwise);&lt;/li&gt;
&lt;li&gt;Reprise was experimental at the time of the migration, with an API liable to move from one version to the next. That point has sorted itself out since: 1.0 has been released and adopts Symfony&#039;s backward compatibility promise. Our upgrade from 0.8 boiled down to changing the version constraint, without a single line of code to touch.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;A migration largely delegated to AI&lt;/h2&gt;
&lt;p&gt;One last point that may interest you: we let an AI do the bulk of this migration. Not the decision to migrate, nor the structuring choices (the asset contract, the way HMR was wired), but most of the mechanical work and, above all, the iteration on the problems we ran into.&lt;/p&gt;
&lt;p&gt;What made that possible isn&#039;t the AI itself, it&#039;s the safety net that already existed around the project: a complete CI with Behat, PHPUnit and our &lt;a href=&quot;https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker&quot;&gt;e2e Playwright tests with screenshots&lt;/a&gt;. Every webpack-ism from the previous chapter was caught by a test, not by a human: the 19 red Behat scenarios for the phantom &lt;code&gt;global&lt;/code&gt;, the screenshots for the oversized logo or the one-pixel difference, the font 404s in the captures. Every time, the AI could read the report, reproduce the problem locally, fix it, and restart the CI, without us having to step in other than to validate the choices.&lt;/p&gt;
&lt;p&gt;Without that test coverage, the same migration would have required a visual review of dozens of pages on every iteration, and we probably wouldn&#039;t have dared to delegate that much. It&#039;s a good argument, if you were short of one, for investing in visual regression tests before taking on this kind of undertaking.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;We&#039;ve seen in this article that the mechanical migration from Encore to Reprise takes a few hours, and that the real work happens elsewhere: in the implicit contract your code base has with its bundler, and in the handful of webpack-isms that only reveal themselves at runtime. That&#039;s where you should look if you have to estimate such a migration.&lt;/p&gt;
&lt;p&gt;The result is worth it: our build is five to six times faster, we&#039;re back on node&#039;s default config with 1 GB of heap (instead of the 4 GB previously required), we removed 583 npm packages, the config has been divided by three, and developers finally have hot reload with React fast refresh. As a bonus, it also means deployment is more than a minute faster, which is appreciable!&lt;/p&gt;
&lt;p&gt;Being an early adopter of an experimental bundle also has its upsides: the main point of friction we ran into ended up as an &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/symfony/reprise/pull/81&quot;&gt;upstream contribution&lt;/a&gt;, merged and released within a few days. The next team migrating a site with frozen asset paths will have a config option where we initially wrote a local plugin.&lt;/p&gt;

        </content>
    </entry>    <entry>
        <id>https://jolicode.com/blog/migrer-de-webpack-encore-vers-vite-avec-reprise</id>
        <published>2026-08-27T13:42:00+02:00</published>
        <updated>2026-08-27T13:42:00+02:00</updated>
        <link type="text/html" rel="alternate" href="https://jolicode.com/blog/migrer-de-webpack-encore-vers-vite-avec-reprise"/>
        <title>Migrer de Webpack Encore vers Vite avec Reprise</title>
        <author>
            <name>JoliCode Team</name>
            <uri>https://jolicode.com/</uri>
        </author>            <category term="symfony" />            <category term="frontend" />            <category term="webpack" />            <category term="vite" />            <category term="reprise" />            <reactions:summary total="25">                <reactions:reaction emoji="🚀" shortname="rocket" count="7"/>                <reactions:reaction emoji="👏" shortname="clapclap" count="5"/>                <reactions:reaction emoji="❤️" shortname="heart" count="4"/>                <reactions:reaction emoji="👍" shortname="plus1" count="5"/>                <reactions:reaction emoji="🤯" shortname="mindblown" count="4"/>            </reactions:summary>
        <summary><![CDATA[Nous maintenons, pour un de nos clients, un projet à fort trafic : 5 sites servis par une même application Symfony, 800 templates Twig, et un front React + Tailwind buildé depuis des années par Webpack…]]></summary>
        <content type="html">
            &lt;p&gt;Nous maintenons, pour un de nos clients, un projet à fort trafic : 5 sites servis par une même application Symfony, 800 templates Twig, et un front React + Tailwind buildé depuis des années par Webpack Encore. Un build front qui commençait sérieusement à peser : plus d&#039;une minute de Webpack, un &lt;code&gt;NODE_OPTIONS=--max_old_space_size=4096&lt;/code&gt; pour ne pas exploser la heap, un &lt;code&gt;webpack.config.js&lt;/code&gt; de 200 lignes pilotant deux builds distincts, et aucun hot reload pour les développeurs.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://symfony.com/blog/introducing-symfony-reprise-the-symfony-integration-layer-for-modern-bundlers&quot;&gt;Webpack Encore arrive en fin de vie&lt;/a&gt;, et Symfony a publié son successeur officiel pour Vite et Rsbuild : &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://symfony.com/bundles/reprise/current/index.html&quot;&gt;Reprise&lt;/a&gt;. Le bundle était encore marqué expérimental quand nous avons migré (en 0.7, puis 0.8), mais c&#039;était clairement la direction que prend le framework. Nous avons donc sauté le pas. Et depuis, la &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://symfony.com/blog/symfony-reprise-1-0-0-released&quot;&gt;1.0&lt;/a&gt; est sortie, apportant ainsi la même promesse de rétrocompatibilité que Symfony.&lt;/p&gt;
&lt;p&gt;Je vais vous raconter dans cet article comment s&#039;est passée cette migration. Nous verrons d&#039;abord ce que fait Reprise et ce qu&#039;implique la migration mécanique, puis les vrais sujets qui nous ont occupés : le contrat implicite que notre base de code avait avec Webpack, une collection de « webpack-ismes » qui ne se révèlent qu&#039;au runtime, et enfin la mise en place du hot reload dans notre stack Docker. Pour vous donner envie de lire jusqu&#039;au bout : le build est passé de 75-100 secondes à une quinzaine de secondes, et nous avons supprimé 583 packages npm au passage 🎉.&lt;/p&gt;
&lt;h2&gt;Vous avez dit Reprise ?&lt;/h2&gt;
&lt;p&gt;Contrairement à Encore qui réimplémentait toute la chaîne de build par-dessus Webpack, Reprise ne fournit que la glue Symfony : la génération d&#039;&lt;code&gt;entrypoints.json&lt;/code&gt; et de &lt;code&gt;manifest.json&lt;/code&gt;, les fonctions Twig &lt;code&gt;reprise_entry_*&lt;/code&gt;, et le support du dev server. Tout le reste (Sass, TypeScript, React, code splitting, minification), c&#039;est Vite qui le fait nativement.&lt;/p&gt;
&lt;p&gt;La migration mécanique est vite plié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-8&quot;&gt;composer&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; remove&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; symfony/webpack-encore-bundle&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;composer&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; require&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; symfony/reprise&lt;/span&gt;&lt;/span&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; add&lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt; --dev&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; vite&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; @symfony/reprise&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Un &lt;code&gt;vite.config.ts&lt;/code&gt; par build, &lt;code&gt;encore_entry_script_tags&lt;/code&gt; qui devient &lt;code&gt;reprise_entry_script_tags&lt;/code&gt; dans les templates, et c&#039;est à peu près tout. Sur le papier, quelques heures de travail.&lt;/p&gt;
&lt;p&gt;Le contraste entre les deux configs illustre bien la philosophie. Avant, nous avions 200 lignes d&#039;API chaînée Encore, où chaque capacité du bundler doit être déclarée explicitement :&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;// webpack.config.js (extrait)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;Encore.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;setOutputPath&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;web/build/&#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-8&quot;&gt;setPublicPath&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;/build&#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-8&quot;&gt;addEntry&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;js/app&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;./assets/scripts/main.tsx&#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-8&quot;&gt;addStyleEntry&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;css/app&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;./assets/styles/main.css&#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;    // … 6 autres entrées&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-8&quot;&gt;disableSingleRuntimeChunk&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-8&quot;&gt;enablePostCssLoader&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-8&quot;&gt;enableReactPreset&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-8&quot;&gt;enableTypeScriptLoader&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-8&quot;&gt;autoProvideVariables&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;bazinga-translator&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;Translator&#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-8&quot;&gt;addPlugin&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-8&quot;&gt; ESLintPlugin&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-8&quot;&gt;addPlugin&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-8&quot;&gt; StylelintPlugin&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ &lt;/span&gt;&lt;span class=&quot;syntax-10&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;    .&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;copyFiles&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;([{ from: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;./assets/images&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, to: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;images/[path][name].[ext]?[hash:8]&#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-8&quot;&gt;configureFilenames&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ js: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;[name].js?[chunkhash]&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-10&quot;&gt;/* … */&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;Après ? Une cinquantaine de lignes où Vite fait le gros du travail nativement. TypeScript et React n&#039;ont plus besoin de loader, et les plugins ESLint/Stylelint sont remplacés par les scripts &lt;code&gt;lint&lt;/code&gt; déjà présents dans la CI :&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;// vite.config.ts (extrait)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;export&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt; default&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; defineConfig&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(({ &lt;/span&gt;&lt;span class=&quot;syntax-12&quot;&gt;mode&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-2&quot;&gt;    build: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        sourcemap: mode &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;!==&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;production&#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;        rollupOptions: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;            input: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-1&quot;&gt;                &#039;js/app&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;./assets/scripts/main.tsx&#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;css/app&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;./assets/styles/main.css&#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;                // … 6 autres entrées&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;    plugins: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;        react&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;        tailwindcss&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;        symfony&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ outputPath: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;web/build&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, publicPath: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;/build/&#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;        copyStable&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;([{ from: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;assets/images&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, to: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;images&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; }], &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;/build/&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;web/build&#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;Vous remarquez le &lt;code&gt;copyStable&lt;/code&gt; sur la dernière ligne ? Il n&#039;est pas fourni par Reprise, et c&#039;est justement le sujet du chapitre suivant. Car comme nous allons le voir, le vrai sujet d&#039;une migration de bundler n&#039;est pas la config du bundler en elle-même.&lt;/p&gt;
&lt;h2&gt;Le contrat implicite avec Webpack&lt;/h2&gt;
&lt;p&gt;Vite hashe les noms de fichiers par défaut, c&#039;est son modèle de cache-busting : &lt;code&gt;app.js&lt;/code&gt; devient &lt;code&gt;app-B7fAYn0O.js&lt;/code&gt;, et le manifest.json fait la correspondance. Sauf que notre projet avait un contrat exactement inverse, invisible tant qu&#039;on ne le cherche pas :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;plus de &lt;strong&gt;300 templates&lt;/strong&gt; référencent des images en dur, façon &lt;code&gt;asset(&#039;/build/images/logo.svg&#039;)&lt;/code&gt;, sans jamais passer par un manifest ;&lt;/li&gt;
&lt;li&gt;près de &lt;strong&gt;250 templates&lt;/strong&gt; utilisent un filtre Twig maison &lt;code&gt;|inline&lt;/code&gt; qui lit les SVG &lt;strong&gt;directement sur le disque&lt;/strong&gt; pour les inliner dans le HTML ;&lt;/li&gt;
&lt;li&gt;le cache-busting est global, par query string, à partir d&#039;un fichier &lt;code&gt;REVISION&lt;/code&gt; produit au déploiement.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Autrement dit, les chemins physiques des fichiers copiés doivent rester stables, et ce depuis des années. Réécrire 300 templates n&#039;était évidemment pas envisageable. De plus, les emails envoyés par l&#039;application utilisent également certains de ces assets (notamment les fonts), ils doivent donc rester disponibles aux mêmes urls.&lt;/p&gt;
&lt;p&gt;Reprise proposait bien (en 0.7) une option &lt;code&gt;copy&lt;/code&gt; pour remplacer le &lt;code&gt;copyFiles()&lt;/code&gt; d&#039;Encore, mais elle hashait systématiquement les noms de fichiers copiés, sans opt-out. Encore laissait choisir son pattern (&lt;code&gt;images/[path][name].[ext]?[hash:8]&lt;/code&gt; chez nous : chemin stable sur disque, hash en query string). Notre réponse initiale a tenu en un plugin Vite d&#039;une quarantaine de lignes, qui reproduisait le contrat d&#039;Encore :&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;// vite-plugin-copy-stable.ts (extrait)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-8&quot;&gt;generateBundle&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(_options, bundle) {&lt;/span&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; (&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; { file, logicalName } &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; files&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(entries)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-5&quot;&gt;        const&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; source &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; readFileSync&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-5&quot;&gt;        const&lt;/span&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-8&quot;&gt; createHash&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 class=&quot;syntax-8&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(source).&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;digest&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;hex&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;slice&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;, &lt;/span&gt;&lt;span class=&quot;syntax-3&quot;&gt;8&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;        // Le fichier garde son chemin logique…&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-2&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;emitFile&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;({ type: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;asset&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, fileName: logicalName, source });&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-10&quot;&gt;        // … et le hash part dans la valeur du manifest, en query string&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;        manifestEntries[keyPrefix &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; logicalName] &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-2&quot;&gt;publicPath&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;}${&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;logicalName&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-2&quot;&gt;hash&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-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-10&quot;&gt;    // puis fusion de manifestEntries dans le manifest.json émis par Reprise&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;Résultat : zéro template modifié (hors les 4 layouts de base), le JS et le CSS profitent du hashing natif de Vite via &lt;code&gt;entrypoints.json&lt;/code&gt;, et tout le reste garde ses chemins stables.&lt;/p&gt;
&lt;p&gt;Ce besoin nous a semblé suffisamment universel pour le proposer upstream : &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/symfony/reprise/pull/81&quot;&gt;symfony/reprise#81&lt;/a&gt; ajoute une option &lt;code&gt;hash: false&lt;/code&gt; par entrée &lt;code&gt;copy&lt;/code&gt;, qui reproduit exactement ce contrat. Elle a été mergée et publiée dans Reprise 0.8 quelques jours plus tard : nos quarante lignes de plugin ont disparu au profit d&#039;une ligne de config, avec des arborescences et des manifests strictement identiques.&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--tip&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 46 72&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M45.7 23.2C45.7 10.7 35.5.5 23 .5S.3 10.7.3 23.2c0 1.8.2 3.6.7 5.4.6 2.9 1.7 4.7 3.2 7.2.3.6.7 1.2 1.1 1.9.5.8.9 1.6 1.4 2.3 2 3.3 3.2 5.2 3.2 9.1v9.4c0 2.4 1.7 4.3 4 4.7 1 5.1 4 8.3 9.1 8.3s8.2-3.2 9.1-8.3c2.3-.4 4-2.4 4-4.7v-9.4c0-3.9 1.2-5.9 3.2-9.1.4-.7.9-1.5 1.4-2.3.4-.7.8-1.3 1.1-1.9 1.5-2.5 2.6-4.3 3.2-7.2.5-1.8.7-3.6.7-5.4M31.2 50.9H15.287v-1.917c0-.416 0-.75-.087-1.083h16c0 .333-.087.667-.087 1.083V50.9zm-1.016 7.5H15.603c-.44 0-.703-.308-.703-.615V55.4h15.986v2.385c.088.307-.263.615-.702.615m-7.124 8c-.87 0-3.089 0-3.96-3h8c-.871 3-3.168 3-4.04 3m17.091-38.664c-.468 2.072-1.216 3.484-2.526 5.65-.375.564-.655 1.129-1.03 1.788-.468.753-.842 1.506-1.216 2.071-1.123 1.883-2.153 3.578-2.808 5.555h-18.53c-.654-1.977-1.59-3.672-2.807-5.555-.374-.659-.842-1.318-1.216-2.071-.375-.659-.749-1.318-1.03-1.789-1.31-2.26-2.059-3.577-2.527-5.743a16.5 16.5 0 0 1-.561-4.236C5.9 13.708 13.761 5.8 23.4 5.8s17.5 7.908 17.5 17.606c-.187 1.412-.374 2.824-.749 4.33&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Astuce&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Avant d&#039;estimer une migration de bundler, inventoriez qui consomme vos assets et par quel canal (manifest, chemins en dur, lecture disque, CDN). C&#039;est là que se cache la vraie charge de travail, pas dans la config.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;Les webpack-ismes qui ne se voient qu&#039;au runtime&lt;/h2&gt;
&lt;p&gt;Une fois le build vert, nous pensions être tirés d&#039;affaires. Mais la CI nous attendait au tournant, avec de nombreux scénarios Behat en échec. Tous les problèmes que je vais lister ici ont le même point commun : le build passe, le typecheck passe, et pourtant le site ne fonctionne plus au runtime.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;global&lt;/code&gt; n&#039;existe pas&lt;/h3&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;global.Translator &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Translator;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Webpack aliasse silencieusement &lt;code&gt;global&lt;/code&gt; vers &lt;code&gt;window&lt;/code&gt;. Vite, non :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Uncaught ReferenceError: global is not defined
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;L&#039;erreur survient au top-level du module, donc c&#039;est tout le bundle qui meurt : plus une seule ligne de JS ne s&#039;exécute sur le site. Le plus vicieux : &lt;code&gt;@types/node&lt;/code&gt; étant installé, &lt;code&gt;global&lt;/code&gt; est parfaitement typé et &lt;code&gt;tsc&lt;/code&gt; ne bronche pas. Le fix est trivial (&lt;code&gt;window.Translator = …&lt;/code&gt;), encore faut-il savoir que ces assignations existent.&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--tip&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 46 72&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M45.7 23.2C45.7 10.7 35.5.5 23 .5S.3 10.7.3 23.2c0 1.8.2 3.6.7 5.4.6 2.9 1.7 4.7 3.2 7.2.3.6.7 1.2 1.1 1.9.5.8.9 1.6 1.4 2.3 2 3.3 3.2 5.2 3.2 9.1v9.4c0 2.4 1.7 4.3 4 4.7 1 5.1 4 8.3 9.1 8.3s8.2-3.2 9.1-8.3c2.3-.4 4-2.4 4-4.7v-9.4c0-3.9 1.2-5.9 3.2-9.1.4-.7.9-1.5 1.4-2.3.4-.7.8-1.3 1.1-1.9 1.5-2.5 2.6-4.3 3.2-7.2.5-1.8.7-3.6.7-5.4M31.2 50.9H15.287v-1.917c0-.416 0-.75-.087-1.083h16c0 .333-.087.667-.087 1.083V50.9zm-1.016 7.5H15.603c-.44 0-.703-.308-.703-.615V55.4h15.986v2.385c.088.307-.263.615-.702.615m-7.124 8c-.87 0-3.089 0-3.96-3h8c-.871 3-3.168 3-4.04 3m17.091-38.664c-.468 2.072-1.216 3.484-2.526 5.65-.375.564-.655 1.129-1.03 1.788-.468.753-.842 1.506-1.216 2.071-1.123 1.883-2.153 3.578-2.808 5.555h-18.53c-.654-1.977-1.59-3.672-2.807-5.555-.374-.659-.842-1.318-1.216-2.071-.375-.659-.749-1.318-1.03-1.789-1.31-2.26-2.059-3.577-2.527-5.743a16.5 16.5 0 0 1-.561-4.236C5.9 13.708 13.761 5.8 23.4 5.8s17.5 7.908 17.5 17.606c-.187 1.412-.374 2.824-.749 4.33&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Astuce&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Cherchez &lt;code&gt;global.&lt;/code&gt; dans votre code avant de migrer, cela vous évitera de découvrir le problème en CI comme nous.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;Les &lt;code&gt;require()&lt;/code&gt; dynamiques&lt;/h3&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;&amp;#x3C;&lt;/span&gt;&lt;span class=&quot;syntax-5&quot;&gt;ReactSVG&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt; src&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;={&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;require&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;`../../../images/icons/&lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;path&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-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; /&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ce pattern repose sur les &lt;em&gt;context modules&lt;/em&gt; de Webpack, qui embarquait tout le dossier &lt;code&gt;icons/&lt;/code&gt; pour résoudre l&#039;expression au runtime. Vite ne les implémente pas :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Uncaught ReferenceError: require is not defined
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;L&#039;exception éclate au premier rendu d&#039;un composant avec icône, et React réagit en démontant tout l&#039;arbre : pages de résultats intégralement vides, sans un message pour l&#039;utilisateur. Nos icônes étant déjà copiées à chemins stables (voir plus haut), une URL directe a suffi : &lt;code&gt;src={&lt;/code&gt;/build/images/icons/${path}&lt;code&gt;}&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Les &lt;code&gt;url()&lt;/code&gt; CSS qui ne suivent pas&lt;/h3&gt;
&lt;p&gt;Avec &lt;code&gt;@tailwindcss/postcss&lt;/code&gt;, les &lt;code&gt;@import&lt;/code&gt; CSS sont inlinés &lt;strong&gt;sans rebaser les chemins relatifs&lt;/strong&gt;. Un &lt;code&gt;url(&#039;../../fonts/brand-400.woff2&#039;)&lt;/code&gt; écrit dans un fichier importé se retrouve tel quel dans le CSS final, se résout depuis la racine et répond 404 : webfonts et images de fond ont disparues.&lt;/p&gt;
&lt;p&gt;Le plugin officiel &lt;code&gt;@tailwindcss/vite&lt;/code&gt; réécrit ces mêmes URLs vers l&#039;asset émis ( &lt;code&gt;url(/build/brand-400-Dm0XPNJo.woff2)&lt;/code&gt;) en plus d&#039;être plus rapide. C&#039;est l&#039;intégration que Tailwind recommande quand on build avec Vite. Le rebasing côté PostCSS a bien été corrigé à plusieurs reprises upstream (voir l&#039;issue &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/tailwindlabs/tailwindcss/issues/16636&quot;&gt;#16636&lt;/a&gt;, fermée depuis), mais un fichier importé depuis notre propre code nous rejouait toujours le problème en 4.3.3. Je ne peux que vous recommander de ne plus passer par PostCSS si vous utilisez Tailwind v4 avec Vite.&lt;/p&gt;
&lt;h3&gt;Le fichier vendor en CommonJS&lt;/h3&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;import&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt; Routing &lt;/span&gt;&lt;span class=&quot;syntax-4&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt; &#039;../../../vendor/friendsofsymfony/jsrouting-bundle/Resources/public/js/router.min.js&#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;Celui-là est retors : il fonctionne en build (le plugin commonjs de Rollup fait l&#039;interop), mais crashe uniquement en dev :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;The requested module &#039;…/router.min.js&#039; does not provide an export named &#039;default&#039;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Vite ne prébundle que &lt;code&gt;node_modules&lt;/code&gt;, et sert donc le fichier CJS de &lt;code&gt;vendor/&lt;/code&gt; tel quel à un navigateur qui attend un module ES. La solution est le package npm &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://www.npmjs.com/package/fos-router&quot;&gt;&lt;code&gt;fos-router&lt;/code&gt;&lt;/a&gt;, qui est exactement le même que celui packagé dans le bundle Symfony. En prime, le package npm est en typescript : &lt;code&gt;tsc&lt;/code&gt; a immédiatement débusqué une dizaine de &lt;code&gt;window.location = url&lt;/code&gt; qui dormaient depuis des années (le module vendor étant &lt;code&gt;any&lt;/code&gt;, tout ce qui en sortait échappait au typage).&lt;/p&gt;

&lt;div class=&quot;c-alert c-alert--tip&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 46 72&quot;&gt;&lt;path fill-rule=&quot;nonzero&quot; d=&quot;M45.7 23.2C45.7 10.7 35.5.5 23 .5S.3 10.7.3 23.2c0 1.8.2 3.6.7 5.4.6 2.9 1.7 4.7 3.2 7.2.3.6.7 1.2 1.1 1.9.5.8.9 1.6 1.4 2.3 2 3.3 3.2 5.2 3.2 9.1v9.4c0 2.4 1.7 4.3 4 4.7 1 5.1 4 8.3 9.1 8.3s8.2-3.2 9.1-8.3c2.3-.4 4-2.4 4-4.7v-9.4c0-3.9 1.2-5.9 3.2-9.1.4-.7.9-1.5 1.4-2.3.4-.7.8-1.3 1.1-1.9 1.5-2.5 2.6-4.3 3.2-7.2.5-1.8.7-3.6.7-5.4M31.2 50.9H15.287v-1.917c0-.416 0-.75-.087-1.083h16c0 .333-.087.667-.087 1.083V50.9zm-1.016 7.5H15.603c-.44 0-.703-.308-.703-.615V55.4h15.986v2.385c.088.307-.263.615-.702.615m-7.124 8c-.87 0-3.089 0-3.96-3h8c-.871 3-3.168 3-4.04 3m17.091-38.664c-.468 2.072-1.216 3.484-2.526 5.65-.375.564-.655 1.129-1.03 1.788-.468.753-.842 1.506-1.216 2.071-1.123 1.883-2.153 3.578-2.808 5.555h-18.53c-.654-1.977-1.59-3.672-2.807-5.555-.374-.659-.842-1.318-1.216-2.071-.375-.659-.749-1.318-1.03-1.789-1.31-2.26-2.059-3.577-2.527-5.743a16.5 16.5 0 0 1-.561-4.236C5.9 13.708 13.761 5.8 23.4 5.8s17.5 7.908 17.5 17.606c-.187 1.412-.374 2.824-.749 4.33&quot;/&gt;&lt;/svg&gt;
            &lt;/span&gt;
                        &lt;strong&gt;Astuce&lt;/strong&gt;
    &lt;/p&gt;
    &lt;div class=&quot;c-alert__content&quot;&gt;
                &lt;p&gt;
Ces quatre pannes ont un point commun : elles ne laissent aucune trace côté serveur. La page répond 200, les logs Symfony sont vides, et le symptôme n&#039;existe que dans la console du navigateur : un &lt;code&gt;pageerror&lt;/code&gt;, un &lt;code&gt;console.error&lt;/code&gt;, ou une requête d&#039;asset en échec.
Si vous avez des tests e2e, branchez-y les listeners &lt;code&gt;pageerror&lt;/code&gt;, &lt;code&gt;console&lt;/code&gt; et &lt;code&gt;requestfailed&lt;/code&gt; de Playwright, ne serait-ce que le temps de la migration : c&#039;est le filet qui transforme ces bugs silencieux en tests rouges, au lieu de vous les faire découvrir à l&#039;œil nu, page par page.&lt;/p&gt;
        &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;Quand le site dépend d&#039;un bug du bundler&lt;/h2&gt;
&lt;p&gt;Voici mon anecdote préférée de cette migration. Après le passage à Vite, un test de &lt;a href=&quot;https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker&quot;&gt;régression visuelle&lt;/a&gt; refusait obstinément de passer : sur une déclinaison du site, le logo s&#039;affichait 60 % trop grand.&lt;/p&gt;
&lt;p&gt;Nous avons tout vérifié : le fichier SVG copié, identique ; les règles CSS, identiques et dans le même ordre ; le HTML, identique. En dernier recours, le manifest Webpack de production, qui racontait une drôle d&#039;histoire :&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-1&quot;&gt;&quot;build/images/logo-pro.svg&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;/build/images/logo-pro.e42c1969.svg&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Un hash dans le nom de fichier, là où toutes les autres entrées copiées utilisaient une query string. Et le contenu de ce fichier hashé n&#039;était &lt;strong&gt;pas&lt;/strong&gt; le fichier demandé : c&#039;était un autre SVG du même nom, situé dans un sous-dossier &lt;code&gt;icons/&lt;/code&gt;, avec une classe CSS différente, donc une taille différente.&lt;/p&gt;
&lt;p&gt;L&#039;explication : les images émises par file-loader (dont tout le dossier &lt;code&gt;icons/&lt;/code&gt;, embarqué par le &lt;code&gt;require()&lt;/code&gt; dynamique vu plus haut) &lt;strong&gt;écrasaient les clés du manifest&lt;/strong&gt; des fichiers copiés portant le même nom. Depuis des années, le site servait le mauvais fichier à cet endroit, et ce rendu accidentel était devenu la référence, jusque dans nos baselines de screenshots. Notre build Vite, plus propre, servait enfin le bon fichier… et cassait donc le test.&lt;/p&gt;
&lt;p&gt;Nous avons audité les 31 collisions de noms du projet (une seule autre était visible) et pointé les templates vers le bon fichier, explicitement. Ce que je retiens de cette histoire, c&#039;est qu&#039;un bundler est avant tout un système de résolution : en changer révèle toutes les résolutions accidentelles dont votre site dépend sans que vous le sachiez.&lt;/p&gt;
&lt;h2&gt;Un pixel de différence&lt;/h2&gt;
&lt;p&gt;Toujours côté régressions visuelles : trois baselines ont bougé d&#039;exactement &lt;strong&gt;1 pixel&lt;/strong&gt; après la migration. Un bouton centré, dont la largeur totale (icône dimensionnée en &lt;code&gt;em&lt;/code&gt; + texte) tombe à un demi-pixel près différemment. En cause, le changement de minifieur CSS : cssnano (configuré avec &lt;code&gt;calc: false&lt;/code&gt; précisément pour éviter ce genre d&#039;arrondis) a laissé la place à esbuild.&lt;/p&gt;
&lt;p&gt;0,01 % des pixels, invisible à l&#039;œil, mais parfaitement reproductible. Le rendu de Vite est déterministe au pixel près d&#039;un run à l&#039;autre : nous avons comparé des captures à deux jours d&#039;écart, zéro pixel de différence.&lt;/p&gt;
&lt;p&gt;Corollaire qui vaut pour tous ceux qui font des tests de screenshots : &lt;strong&gt;ne régénérez jamais vos baselines sur le dev server&lt;/strong&gt;. Le CSS non minifié y produit les mêmes écarts d&#039;arrondis face au rendu buildé que compare votre CI, et vous chercherez longtemps pourquoi « ça passe en local ». Chez nous, la task de mise à jour des screenshots rebuild d&#039;office avant de capturer.&lt;/p&gt;
&lt;h2&gt;Brancher le HMR dans la stack Docker&lt;/h2&gt;
&lt;p&gt;Le HMR (Hot Module Replacement ou remplacement de module à chaud) est le gain le plus visible de la migration pour les développeurs, et il mérite qu&#039;on s&#039;y attarde. Petite confession d&#039;abord : avec Encore, notre « watch » se contentait d&#039;écrire les fichiers sur le disque, et le vrai dev-server était pensé pour tourner dans Docker pour les PC sous Linux, sur la machine hôte (donc en dehors de Docker) pour les Macs (car ceux de l&#039;époque souffraient trop). Avec Vite, nous voulions le HMR dans la stack, comme tout le reste, et pour tout le monde.&lt;/p&gt;
&lt;h3&gt;Les problèmes rencontrés&lt;/h3&gt;
&lt;p&gt;Nous avons eu trois problèmes à résoudre.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Atteindre le serveur depuis le navigateur.&lt;/strong&gt; Nous avons d&#039;abord sur-conçu la chose : route Traefik dédiée, TLS, service discovery. Puis nous avons retenu une solution bien plus simple, déjà adoptée sur un autre de nos projets : publier le port du conteneur sur l&#039;hôte et servir en &lt;code&gt;http://localhost:5173&lt;/code&gt;. Les navigateurs traitent &lt;code&gt;localhost&lt;/code&gt; comme une origine sûre, donc pas de mixed content depuis une page HTTPS et zéro certificat à gérer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Le cycle de vie.&lt;/strong&gt; Notre premier montage lançait Vite dans un conteneur &lt;code&gt;compose run&lt;/code&gt; éphémère. Mauvaise idée : un watch tué laisse un zombie qui squatte le port 5173 et continue d&#039;écraser les fichiers en douce. Nous avons donc plutôt défini un service Compose dédié : &lt;code&gt;up&lt;/code&gt; réutilise ou recrée toujours le même conteneur, ce qui rend le zombie impossible.&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;# docker-compose.dev.yml (extrait)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;vite&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;    image&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&quot;${PROJECT_NAME}-builder&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    command&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;: &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;bash -c &quot;until [ -d node_modules/.bin ]; do sleep 2; done; yarn run ${VITE_SCRIPT:-dev}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-4&quot;&gt;    ports&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-1&quot;&gt;&quot;127.0.0.1:${PROJECT_VITE_PORT:-5173}:5173&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Le &lt;code&gt;until node_modules&lt;/code&gt; n&#039;est pas décoratif : au premier démarrage de la stack, Docker démarre le service avant que &lt;code&gt;yarn install&lt;/code&gt; ne soit passé, et on ne veut pas d&#039;un service en crash-loop.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Les pièges du conteneur.&lt;/strong&gt; Deux classiques à connaître. Le watcher de Vite crawle par défaut tout le projet : avec un &lt;code&gt;vendor/&lt;/code&gt; de 100 000 fichiers, la limite inotify explose. Il faut donc l&#039;exclure explicitement. Et méfiez-vous de vos patterns d&#039;exclusion : notre &lt;code&gt;**/var/**&lt;/code&gt;, pensé pour le cache Symfony, matchait &lt;code&gt;/var/www&lt;/code&gt;, le dossier de travail du conteneur. Tout le projet était ignoré par le watcher, donc le HMR était inopérant : le serveur tourne, la page se charge, et rien ne se met à jour. Ancrez vos patterns au dossier du projet :&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;watch: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;    ignored: [&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;vendor&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;var&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;web&#039;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;syntax-12&quot;&gt;dir&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; path.&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;resolve&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(__dirname, dir, &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&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Dernier raffinement, la cohérence entre les modes : nos tasks de build stoppent le serveur de dev s&#039;il tourne (sinon les pages repassent sur les assets buildés pendant qu&#039;un serveur orphelin continue de tourner pour rien), et la task de watch le démarre. On ne peut ainsi pas se retrouver dans un état intermédiaire sans le savoir.&lt;/p&gt;
&lt;h3&gt;Et les worktrees ?&lt;/h3&gt;
&lt;p&gt;Avec l&#039;IA devenant progressivement incontournable pour gagner en efficacité, nous travaillons de plus en plus avec des worktrees git, chacun avec sa stack Docker complète et isolée. C&#039;est une feature native de notre template &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/jolicode/docker-starter&quot;&gt;docker-starter&lt;/a&gt; : le nom de projet Compose est suffixé par le worktree, et tous les ports hôtes sont décalés automatiquement, ce qui permet d&#039;avoir plusieurs stacks qui tournent en parallèle.&lt;/p&gt;
&lt;p&gt;Le port du serveur Vite rejoint simplement ce mécanisme : chaque worktree a le sien, et deux développements en parallèle ont chacun leur HMR.&lt;/p&gt;
&lt;p&gt;Il restait un détail à régler : Symfony génère des URLs d&#039;assets absolues à partir des &lt;code&gt;base_urls&lt;/code&gt; configurées, qui ne connaissent pas le port décalé. Dans un worktree, les pages allaient donc chercher leurs assets sur la stack du checkout principal, et on a mis un moment à comprendre pourquoi. Notre solution : un suffixe de port paramétrable dans les &lt;code&gt;base_urls&lt;/code&gt;, vide par défaut (et en production) :&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/framework.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;    assets&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;        base_urls&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-1&quot;&gt;&#039;https://%http.domain.front%%http.public_port_suffix%&#039;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Et plutôt que de demander à chaque développeur de le renseigner à la main, la task Castor 🦫 qui démarre la stack détecte le worktree et synchronise la valeur dans un fichier &lt;code&gt;parameters_override.yaml&lt;/code&gt; local (gitignoré, prévu pour la config personnelle) :&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;
Notre projet utilise encore des fichiers parameters.yaml, nous n&#039;avons pas migré vers des variables d&#039;environnement et les .env associés.&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-10&quot;&gt;// Extrait de la task, appelée par `castor up`&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span class=&quot;syntax-2&quot;&gt;$line &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;http.public_port_suffix: &#039;:%d&#039;&quot;&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;, &lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;get_worktree_ports&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;syntax-8&quot;&gt;get_worktree_name&lt;/span&gt;&lt;span class=&quot;syntax-2&quot;&gt;())[&lt;/span&gt;&lt;span class=&quot;syntax-1&quot;&gt;&#039;https&#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;// … créé ou mis à jour dans parameters_override.yaml, sans toucher aux autres clés&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Un nouveau worktree est ainsi utilisable en une seule commande, HMR compris.&lt;/p&gt;
&lt;h2&gt;Ce que nous avons perdu au passage&lt;/h2&gt;
&lt;p&gt;Pour être complet, voici ce que la migration nous a coûté :&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;la minification svgo des images copiées a été abandonnée, à refaire à la source si le poids devient un sujet ;&lt;/li&gt;
&lt;li&gt;ts-loader faisait une analyse statique de code à chaque build, mais Vite ne le fait pas. Nous avons donc ajouté une nouvelle step dans la CI qui lance un &lt;code&gt;yarn tsc --noEmit&lt;/code&gt; pour combler le trou (ne l&#039;oubliez pas, c&#039;est un vrai filet qui disparaît sinon) ;&lt;/li&gt;
&lt;li&gt;Reprise était expérimental au moment de la migration, avec une API susceptible de bouger d&#039;une version à l&#039;autre. Ce point s&#039;est réglé tout seul depuis : la 1.0 est sortie et adopte la promesse de rétrocompatibilité de Symfony. Notre montée depuis la 0.8 s&#039;est résumée à changer la contrainte de version, sans une ligne de code à toucher.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Une migration largement déléguée à l&#039;IA&lt;/h2&gt;
&lt;p&gt;Un dernier point qui vous intéressera peut-être : nous avons laissé une IA faire le gros de cette migration. Pas la décision de migrer ni les choix structurants (le contrat d&#039;assets, le mode de câblage du HMR), mais l&#039;essentiel du travail mécanique et surtout l&#039;itération sur les problèmes rencontrés.&lt;/p&gt;
&lt;p&gt;Ce qui a rendu cela possible, ce n&#039;est pas l&#039;IA elle-même, c&#039;est le filet de sécurité qui existait déjà autour du projet : une CI complète avec Behat, PHPUnit et nos &lt;a href=&quot;https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker&quot;&gt;tests e2e Playwright avec screenshots&lt;/a&gt;. Chaque webpack-isme du chapitre précédent a été détecté par un test, pas par un humain : les 19 scénarios Behat rouges pour le &lt;code&gt;global&lt;/code&gt; fantôme, les screenshots pour le logo trop grand ou le pixel de différence, les 404 des fonts dans les captures. À chaque fois, l&#039;IA a pu lire le rapport, reproduire le problème en local, corriger, et relancer la CI, sans que nous ayons à intervenir autrement que pour valider les choix.&lt;/p&gt;
&lt;p&gt;Sans cette couverture de tests, la même migration aurait demandé une relecture visuelle de dizaines de pages à chaque itération, et nous n&#039;aurions probablement pas osé déléguer autant. C&#039;est un bon argument, si vous en manquiez, pour investir dans des tests de régression visuelle avant d&#039;entreprendre ce genre de chantier.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Nous avons vu dans cet article que la migration mécanique d&#039;Encore vers Reprise tient en quelques heures, et que le vrai travail se joue ailleurs : dans le contrat implicite que votre base de code entretient avec son bundler, et dans les quelques webpack-ismes qui ne se révèlent qu&#039;au runtime. C&#039;est là qu&#039;il faut chercher si vous devez estimer une telle migration.&lt;/p&gt;
&lt;p&gt;Le résultat en vaut la peine : notre build est cinq à six fois plus rapide, nous repassons sur la config par défaut de node avec 1 Go de heap (au lieu des 4 Go nécessaires auparavant), nous avons supprimé 583 packages npm, la config a été divisée par trois, et les développeurs ont enfin un hot reload avec fast-refresh React. En bonus, cela signifie également que le déploiement est également plus rapide de plus d&#039;une minute, c&#039;est appréciable !&lt;/p&gt;
&lt;p&gt;Être early adopter d&#039;un bundle expérimental a aussi ses bons côtés : le principal point de friction rencontré a fini en &lt;a rel=&quot;nofollow noopener noreferrer&quot; href=&quot;https://github.com/symfony/reprise/pull/81&quot;&gt;contribution upstream&lt;/a&gt;, mergée et publiée en quelques jours. La prochaine équipe qui migrera un site aux chemins d&#039;assets figés aura une option de config là où nous avions initialement écrit un plugin local.&lt;/p&gt;

        </content>
    </entry>    <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" />            <reactions:summary total="25">                <reactions:reaction emoji="👍" shortname="plus1" count="5"/>                <reactions:reaction emoji="❤️" shortname="heart" count="5"/>                <reactions:reaction emoji="🚀" shortname="rocket" count="4"/>                <reactions:reaction emoji="👏" shortname="clapclap" count="5"/>                <reactions:reaction emoji="🤯" shortname="mindblown" count="6"/>            </reactions:summary>
        <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" />            <reactions:summary total="45">                <reactions:reaction emoji="😄" shortname="smile" count="4"/>                <reactions:reaction emoji="🤯" shortname="mindblown" count="16"/>                <reactions:reaction emoji="👏" shortname="clapclap" count="18"/>                <reactions:reaction emoji="🚀" shortname="rocket" count="5"/>                <reactions:reaction emoji="👀" shortname="eyes" count="2"/>            </reactions:summary>
        <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></feed>
