<?xml version="1.0"?>
<rss version="2.0"><channel><title>Plan&#xE8;te PHP</title><description>Agr&#xE9;gateur de flux RSS sur le PHP francophone</description><link>http://www.planete-php.fr/rss.php</link><language>fr-fr</language><generator>AFUP</generator><managingEditor>planetephpfr@afup.org</managingEditor><item><title>Le mythe du side project obligatoire</title><link>https://www.jdecool.fr/blog/2026/09/10/le-mythe-du-side-project-obligatoire.html</link><author/><date>Wed, 09 Sep 2026 22:00:00 +0000</date><description><![CDATA[<p>Il y a un mythe fortement ancré dans la tête des développeurs, sur l’importance d’avoir des side projects. Cassons-le immédiatement. Non, il n’est pas obligatoire d’avoir un side project pour être un bon développeur.</p>

<p>C’est une injonction que j’entends régulièrement: avoir un profil Github vide serait le signe d’un manque de passion ou d’implication. On voit même parfois passer des offres d’emploi où il est fortement recommandé d’avoir un side project.</p>

<!--more-->

<p>Bien entendu, quand l’envie est là, un side project apporte quelque chose qu’il n’est pas toujours facile d’avoir en entreprise: un terrain de jeu et d’expérimentation où l’erreur n’a aucune conséquence. Pas de client, pas de contrainte de production, pas d’échéance. Il est alors plus facile de pouvoir tester une architecture, un nouveau framework, une nouvelle bibliothèque. Faire, défaire et recommencer autant de fois qu’on le souhaite.</p>

<p>Durant ma carrière, j’ai connu des entreprises qui l’avaient très bien compris et qui avaient intégré cette idée dans le cadre du travail. Des projets annexes, sur le temps de l’équipe. Cela peut prendre la forme de prototypes, de preuves de concept (POC) ou d’applications plus fun et créatives. C’est d’ailleurs gagnant-gagnant: les collaborateurs montent en compétence, et ce sont souvent des moments agréables qui valorisent l’entreprise.</p>

<p>Les side projects ne sont en aucun cas une obligation et ne devraient certainement pas être une exigence de recrutement. C’est un espace d’apprentissage utile, que l’entreprise a tout intérêt à créer elle-même.</p>
]]></description></item><item><title>L'AFUP f&#xE9;d&#xE8;re 32 assos autour d'une lettre ouverte pour la survie des &#xE9;v&#xE9;nements techniques</title><link>https://afup.org/news/1265-l-afup-federe-32-assos-autour-d-une-lettre-ouverte-pour-la-survie-des-evenements-techniques</link><author/><date>Tue, 08 Sep 2026 06:36:00 +0000</date><description><![CDATA[<p>32 associations, équipes animatrices de communautés et organisations d'événements techniques français publient ce mardi 8 septembre une lettre ouverte à l'initiative de l'AFUP pour alerter l'écosystème sur la fragilité économique croissante des conférences, meetups et forums techniques du pays. Budgets sponsoring resserrés, billetterie en baisse, adhésions en recul : les signaux d'alerte se multiplient, et événements comme communautés sont en danger de disparition.</p>
<p>Parmi les signataires figurent des événements comme <strong>Paris Web, Mixit, DevLille, BDX I/O, Cloud Native Days</strong>, mais aussi des communautés tech comme <strong>Symfony ou la PHP Foundation</strong>, qui organisent chaque année plusieurs dizaines de rendez-vous techniques réunissant des milliers de développeurs, développeuses et professionnel·le·s du secteur à travers la France.</p>
<h3 id="content-les-evenements-techniques-en-danger-quand-leur-existence-est-la-plus-necessaire">Les événements techniques, en danger quand leur existence est la plus nécessaire</h3>
<p>Le texte pointe un paradoxe : alors que l'intelligence artificielle bouleverse en profondeur les métiers techniques, les événements qui permettent aux professionnel·le·s de comprendre et de s'approprier collectivement ces changements sont parmi les premiers postes de dépense sacrifiés par les entreprises.
Les signataires rappellent également que ces rassemblements offrent une valeur de formation que ni la documentation, ni les outils ne peuvent reproduire seuls, et qu'ils jouent un rôle irremplaçable dans la transmission des compétences et la vitalité des projets open source dont dépendent silencieusement la majorité des entreprises françaises.</p>
<h3 id="content-sponsoriser-acheter-des-billets-adherer-trois-gestes-concrets-pour-ne-pas-perdre-ce-qui-ne-se-remplace-pas">« Sponsoriser, acheter des billets, adhérer : trois gestes concrets pour ne pas perdre ce qui ne se remplace pas »</h3>
<p><em>« Nous demandons aux entreprises trois gestes simples : sponsoriser les événements techniques à hauteur de leurs moyens, acheter des places pour leurs équipes, et adhérer aux associations qui les portent »</em>, déclarent les signataires de la lettre ouverte. <em>« Ce ne sont pas des gestes de générosité, mais des investissements directs dans la compétence et la résilience de leurs propres équipes. »</em>
La lettre ouverte, disponible dans son intégralité à l’adresse <a href="https://lettreouverte.afup.org">lettreouverte.afup.org</a>, précise notamment l'intérêt d'acheter les places en amont, ces achats finançant directement les échéances auprès des prestataires des événements.</p>
<h3 id="content-liste-des-signataires-au-8-septembre-2026">Liste des signataires au 8 septembre 2026</h3>
<ul>
<li>AFPY</li>
<li>AFUP</li>
<li>Agile Nantes</li>
<li>Agile Tour Strasbourg</li>
<li>AlpesCraft</li>
<li>BDX I/O</li>
<li>Breizhcamp</li>
<li>Cloud Native Provence</li>
<li>Cloud Nord</li>
<li>Communauté Laravel France</li>
<li>Communauté Symfony</li>
<li>CTO Lyon</li>
<li>Dev With AI</li>
<li>DevLille</li>
<li>DevQuest</li>
<li>Drupal France</li>
<li>LavaJUG</li>
<li>LyonJS</li>
<li>Mixit</li>
<li>OpenGento</li>
<li>Paris Web</li>
<li>PHP Foundation</li>
<li>Python Lyon</li>
<li>React Native Connection</li>
<li>Riviera Dev</li>
<li>SnowCamp</li>
<li>Sunny Tech</li>
<li>Swift Connection</li>
<li>Sylius Party</li>
<li>Tech'Work</li>
<li>Volcamp</li>
<li>Web Days</li>
</ul>
<p><strong>Prenez part au mouvement ! Relayez la lettre ouverte, prenez vos places pour cet événement technique qui vous intéresse, adhérez à une association, sponsorisez, même à un petit niveau, votre prochain rendez-vous technique. Alors, vous êtes avec nous ?</strong></p>
]]></description></item><item><title>Souverainet&#xE9; : de l&#x2019;IA agentique qui garde vos donn&#xE9;es en Europe</title><link>https://les-tilleuls.coop/blog/souverainete-de-lia-agentique-qui-garde-vos-donnees-en-europe</link><author>Julien Lary</author><date>Mon, 07 Sep 2026 13:06:01 +0000</date><description><![CDATA[<div class="container pt-48 pb-12">
<p class="wp-block-paragraph">Face à une actualité internationale qui nous pousse à réduire nos dépendances technologiques vis-à-vis des acteurs américains (infrastructure comme IA), on peut construire un agent IA pleinement opérationnel 100 % hébergé et exécuté en Europe. Notre assistant de conférence tourne sur <strong>une stack open source européenne</strong>, du runtime jusqu&rsquo;à l&rsquo;application, fait son inférence en France avec Mistral, et ne stocke que le strict nécessaire côté données utilisateur·ices. La souveraineté, ici, c&rsquo;est le résultat d&rsquo;une série de choix d&rsquo;architecture pris dès le développement. On détaille ces choix dans cet article. Vous pouvez retrouver <a href="https://les-tilleuls.coop/blog/comment-nous-avons-cree-un-assistant-ia-pour-lapi-platform-conference-2026" data-type="link" data-id="https://les-tilleuls.coop/blog/comment-nous-avons-cree-un-assistant-ia-pour-lapi-platform-conference-2026" target="_blank" rel="noreferrer noopener">la partie 1</a> et <a href="https://les-tilleuls.coop/blog/transformer-une-api-en-boite-a-outils-pour-agents-larchitecture-de-notre-assistant-de-conference" data-type="link" data-id="https://les-tilleuls.coop/blog/transformer-une-api-en-boite-a-outils-pour-agents-larchitecture-de-notre-assistant-de-conference" target="_blank" rel="noreferrer noopener">la partie 2</a> de ce retour d’expérience sur notre blog.</p>

<figure class="wp-block-image aligncenter size-large"><img fetchpriority="high" decoding="async" width="1024" height="569" src="https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-III-1024x569.webp" alt="Assistant conférence et souveraineté européenne." class="wp-image-19494" srcset="https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-III-1024x569.webp 1024w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-III-600x333.webp 600w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-III-300x167.webp 300w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-III-768x426.webp 768w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-III-40x22.webp 40w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-III-1080x600.webp 1080w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-III.webp 1500w" sizes="(max-width: 1024px) 100vw, 1024px" /><figcaption class="wp-element-caption">Assistant conférence et souveraineté européenne.</figcaption></figure>

<h2 class="wp-block-heading decorative-title">Contexte et enjeux</h2>

<p class="wp-block-paragraph">Le choix devenu commun est de passer par AWS, GCP, Azure et OpenAI ou Anthropic quand il s&rsquo;agit d&rsquo;un projet basé sur l&rsquo;IA et qui nécessite un hébergement. Toutes ces entreprises étant soumises au cadre juridique des États-Unis, les données de l&rsquo;application et de ses utilisateurs deviennent accessibles. Pour beaucoup d&rsquo;organisations européennes, issues du secteur public, santé, ou simplement soumises au RGPD, ça pose des questions de conformité et de dépendance stratégique.</p>

<h2 class="wp-block-heading decorative-title">Inférence locale avec Mistral</h2>

<p class="wp-block-paragraph">Le modèle qui fait tourner l&rsquo;assistant vient de Mistral AI, une entreprise française, et l&rsquo;inférence a lieu sur le territoire de l&rsquo;UE. Côté intégration, ça se résume à déclarer la plateforme et utiliser une clé d&rsquo;API dans l&rsquo;application.</p>

<p class="wp-block-paragraph">Toutes les données (requêtes des utilisateur·ices, contexte de l&rsquo;événement, appels d&rsquo;outils générés par le modèle) restent dans la juridiction européenne. Pour un responsable de traitement au sens du RGPD, ça simplifie beaucoup les choses : plus besoin d&rsquo;analyse d&rsquo;impact sur les transferts hors UE (AIPD), plus de clauses contractuelles types pour les flux d&rsquo;inférence, plus d&rsquo;incertitudes sur où le traitement a lieu.</p>

<h2 class="wp-block-heading decorative-title">Réversibilité et interopérabilité</h2>

<p class="wp-block-paragraph">Une souveraineté qui enfermerait l&rsquo;application chez un seul prestataire ne ferait que déplacer le problème de dépendance. On a donc pensé l&rsquo;architecture pour garantir la réversibilité à chaque niveau.</p>

<ul class="wp-block-list">
<li><strong>Le modèle est interchangeable :</strong> Symfony AI abstrait le fournisseur d&rsquo;inférence derrière une interface générique. Passer de Mistral à un autre fournisseur, ou à un modèle à poids ouverts auto-hébergé sur une infrastructure GPU dédiée, c&rsquo;est une simple modification de configuration et non pas une réécriture du code métier. Le prompt, les outils et l&rsquo;interface restent inchangés.</li>



<li><strong>Le client est interchangeable aussi :</strong> comme le produit s&rsquo;articule autour d&rsquo;un serveur MCP (voir la <a href="https://les-tilleuls.coop/blog/comment-nous-avons-cree-un-assistant-ia-pour-lapi-platform-conference-2026">Partie 1</a>), les échanges passent par un protocole ouvert, sans SDK propriétaire entre l&rsquo;agent et le serveur. L&rsquo;interface de chat sous Symfony pourrait être remplacée par n&rsquo;importe quel autre agent compatible MCP sans toucher au code serveur.</li>
</ul>

<h2 class="wp-block-heading decorative-title">Une stack technologique européenne et open source</h2>

<p class="wp-block-paragraph">Le tableau ci-dessous récapitule la stack complète. Il n’y a aucune dépendance logicielle ni infrastructure hors d&rsquo;Europe :</p>

<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th><strong>Couche</strong></th><th><strong>Composant</strong></th><th><strong>Origine</strong></th></tr></thead><tbody><tr><td>Serveur web et runtime</td><td>FrankenPHP avec Caddy</td><td>Les-Tilleuls.coop (FR) / open source</td></tr><tr><td>Framework applicatif</td><td>Symfony</td><td>SensioLabs (FR) / open source</td></tr><tr><td>Couche API et MCP</td><td>API Platform</td><td>Les-Tilleuls.coop (FR) / open source</td></tr><tr><td>Streaming temps réel</td><td>Mercure</td><td>Les-Tilleuls.coop (FR) / open source</td></tr><tr><td>Framework d&rsquo;agent</td><td>Symfony AI</td><td>Symfony (FR/UE) / open source</td></tr><tr><td>Inférence IA</td><td>Mistral</td><td>Mistral AI (FR)</td></tr><tr><td>Base de données</td><td>PostgreSQL</td><td>open source</td></tr><tr><td>Hébergement</td><td>Clever Cloud</td><td>Clever Cloud (FR)</td></tr></tbody></table></figure>

<p class="wp-block-paragraph">Chaque composant open source est auditable et peut être auto-hébergé intégralement, sans communication cachée vers des tiers hors Europe.</p>

<h2 class="wp-block-heading decorative-title">Minimisation des données dès la conception</h2>

<p class="wp-block-paragraph">La conformité RGPD est plus simple à gérer quand on ne collecte que l&rsquo;essentiel dès le départ.</p>

<ul class="wp-block-list">
<li><strong>Identités éphémères :</strong> à l&rsquo;authentification via GitHub, le jeton est validé auprès de l&rsquo;API tierce pour instancier un utilisateur en mémoire, le temps de la requête. Cette identité n&rsquo;est jamais persistée en base, seul l&rsquo;identifiant numérique stable de GitHub est conservé, pour l&rsquo;associer aux votes et commentaires.</li>



<li><strong>Données minimales stockées :</strong> un vote ou un commentaire, c&rsquo;est l&rsquo;identifiant GitHub, le pseudo public, le contenu et un horodatage. Rien d&rsquo;autre.</li>



<li><strong>Pas de stockage d&rsquo;IP :</strong> le rate limiting s&rsquo;appuie sur l&rsquo;identifiant GitHub de l&rsquo;utilisateur·ice connecté·e, pas sur l&rsquo;adresse réseau. Les outils d&rsquo;écriture sont protégés sans qu&rsquo;on ait besoin de journaliser la moindre IP.</li>



<li><strong>Masquage des données sensibles :</strong> la couche temps réel peut faire transiter des jetons d&rsquo;autorisation dans les paramètres de requête, donc le serveur filtre explicitement ces variables avant qu&rsquo;elles atterrissent dans les logs.</li>
</ul>

<h2 class="wp-block-heading decorative-title">Cohérence entre résidence et sécurité des données</h2>

<p class="wp-block-paragraph">Héberger les données en Europe ne sert à rien si l&rsquo;application a des failles de sécurité par ailleurs. La <a href="https://les-tilleuls.coop/blog/transformer-une-api-en-boite-a-outils-pour-agents-larchitecture-de-notre-assistant-de-conference" data-type="link" data-id="https://les-tilleuls.coop/blog/transformer-une-api-en-boite-a-outils-pour-agents-larchitecture-de-notre-assistant-de-conference" target="_blank" rel="noreferrer noopener">Partie 2</a> détaille les mesures concrètes : validation systématique des tokens GitHub, rate limiting par utilisateur·ice, Content-Security-Policy à base de nonce, et commentaires marqués comme données non vérifiées pour limiter les risques d&rsquo;injection de prompt indirecte.</p>

<h2 class="wp-block-heading decorative-title">Conclusion</h2>

<p class="wp-block-paragraph">On perçoit parfois la souveraineté comme une contrainte qui réduit les performances ou complique le développement. Ce projet montre le contraire : une stack open source européenne combinée à l&rsquo;inférence Mistral a suffi pour livrer un agent fonctionnel (streaming, appels d&rsquo;outils, persistance) sans les barrières réglementaires liées aux transferts de données.</p>

<p class="wp-block-paragraph">Cinq principes ont guidé la démarche :</p>

<ol class="wp-block-list">
<li>Une inférence localisée, isolée derrière une couche d&rsquo;abstraction logicielle.</li>



<li>Un protocole ouvert, pour éviter le verrouillage technologique.</li>



<li>Des briques open source auditables (FrankenPHP, Symfony, API Platform, Mercure).</li>



<li>Un hébergement basé en Europe.</li>



<li>La minimisation des données intégrée directement dans le code.</li>
</ol>

<h2 class="wp-block-heading decorative-title">On se retrouve la semaine prochaine ?</h2>

<p class="wp-block-paragraph">L&rsquo;assistant <a href="https://mcp.con.api-platform.com/" data-type="link" data-id="https://mcp.con.api-platform.com/" target="_blank" rel="noreferrer noopener">est en ligne dès maintenant</a>, avant même l&rsquo;ouverture des portes : de quoi préparer votre venue, repérer les talks à ne pas manquer et construire votre agenda à l&rsquo;avance. Rendez-vous les 17 et 18 septembre à EuraTechnologies pour la sixième édition de l&rsquo;<a href="https://api-platform.com/fr/con/2026/" data-type="link" data-id="https://api-platform.com/fr/con/2026/" target="_blank" rel="noreferrer noopener">API Platform Conference</a> !</p>

<p class="wp-block-paragraph"></p>
</div><p>Cet article, <a href="https://les-tilleuls.coop/blog/souverainete-de-lia-agentique-qui-garde-vos-donnees-en-europe">Souveraineté : de l&rsquo;IA agentique qui garde vos données en Europe</a>, est paru en premier sur <a href="https://les-tilleuls.coop">Les-Tilleuls.coop</a>.</p>
]]></description></item><item><title>Transformer une API en bo&#xEE;te &#xE0; outils pour agents : l&#x2019;architecture de notre assistant de conf&#xE9;rence</title><link>https://les-tilleuls.coop/blog/transformer-une-api-en-boite-a-outils-pour-agents-larchitecture-de-notre-assistant-de-conference</link><author>Julien Lary</author><date>Mon, 07 Sep 2026 13:04:48 +0000</date><description><![CDATA[<div class="container pt-48 pb-12">
<p class="wp-block-paragraph">La <a href="https://les-tilleuls.coop/blog/comment-nous-avons-cree-un-assistant-ia-pour-lapi-platform-conference-2026" data-type="link" data-id="https://les-tilleuls.coop/blog/comment-nous-avons-cree-un-assistant-ia-pour-lapi-platform-conference-2026" target="_blank" rel="noreferrer noopener">première partie</a> de ce retour d’expérience expliquait le choix d&rsquo;architecture : construire un serveur MCP plutôt qu&rsquo;un chatbot isolé, pour que le service soit accessible à n&rsquo;importe quel agent IA, l&rsquo;interface de chat devenant simplement son premier client. Cet article rentre dans le détail technique de ce service et du client qui s&rsquo;y connecte. La <a href="https://les-tilleuls.coop/blog/souverainete-de-lia-agentique-qui-garde-vos-donnees-en-europe" data-type="link" data-id="https://les-tilleuls.coop/blog/souverainete-de-lia-agentique-qui-garde-vos-donnees-en-europe" target="_blank" rel="noreferrer noopener">partie 3</a> évoque les outils souverains qu&rsquo;on a utilisés.</p>

<p class="wp-block-paragraph">La décision d&rsquo;architecture la plus structurante a été de s&rsquo;appuyer sur l&rsquo;écosystème existant plutôt que d&rsquo;écrire du code de transport MCP à la main. Pas de dispatcher JSON-RPC, pas de sérialiseur de schéma, pas de routeur d&rsquo;outils développés maison : API Platform expose maintenant les ressources d&rsquo;API comme des outils MCP via un seul attribut. La couche State, qui alimente déjà le REST et le GraphQL, fait le travail de handler d&rsquo;outils sans qu&rsquo;on ait rien à changer. Le reste de la stack : <a href="https://les-tilleuls.coop/technologies/frankenphp" target="_blank" rel="noreferrer noopener">FrankenPHP</a>, <a href="https://mercure.rocks/" target="_blank" rel="noreferrer noopener">Mercure</a>, <a href="https://les-tilleuls.coop/masterclass/formations/formation-symfony-ai" target="_blank" rel="noreferrer noopener">Symfony AI</a> et <a href="https://mistral.ai/" data-type="link" data-id="https://mistral.ai/" target="_blank" rel="noreferrer noopener">Mistral</a> s&rsquo;articule autour de cette brique centrale. On détaille chaque composant dans les sections qui suivent.</p>

<figure class="wp-block-image aligncenter size-large"><img decoding="async" width="1024" height="569" src="https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-II-1024x569.webp" alt="L'architecture de notre assistant de conférence" class="wp-image-19490" srcset="https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-II-1024x569.webp 1024w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-II-600x333.webp 600w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-II-300x167.webp 300w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-II-768x426.webp 768w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-II-40x22.webp 40w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-II-1080x600.webp 1080w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-II.webp 1500w" sizes="(max-width: 1024px) 100vw, 1024px" /><figcaption class="wp-element-caption">L&rsquo;architecture de notre assistant de conférence</figcaption></figure>

<h2 class="wp-block-heading decorative-title">La forme du système</h2>

<p class="wp-block-paragraph">L&rsquo;interface de chat n&rsquo;est qu&rsquo;un client parmi d&rsquo;autres. Claude Desktop, un assistant d&rsquo;IDE ou un script maison, du moment qu&rsquo;ils parlent MCP, ils se connectent au même serveur et accèdent aux mêmes 21 outils. Cette interopérabilité découle directement des choix d&rsquo;architecture qu&rsquo;on a faits au départ.</p>

<h2 class="wp-block-heading decorative-title">Un attribut transforme une ressource en outil</h2>

<p class="wp-block-paragraph">Le support MCP d&rsquo;API Platform permet de déclarer des outils directement dans les métadonnées de la ressource. Voici la ressource Feedback, qui gère les votes et les commentaires, avec deux de ses outils :</p>
</div><div class="my-4 text-sm">
	<pre><code class="language-php">#[ApiResource(
    operations: [],
    mcp: [
        &#039;talk-feedback&#039; =&gt; new McpTool(
            name: &#039;talk-feedback&#039;,
            description: &#039;Read the community feedback for an API Platform Conference 2026 talk: number of votes, average rating and the comments (with author). Public — no authentication needed.&#039;,
            input: FeedbackInput::class,
            processor: TalkFeedbackProcessor::class,
            structuredContent: true,
        ),
        &#039;vote-talk&#039; =&gt; new McpTool(
            name: &#039;vote-talk&#039;,
            description: &#039;Rate an API Platform Conference 2026 talk (1–5). Requires a GitHub access token (sent as a Bearer token); one vote per talk per user — voting again updates your rating. Returns the updated feedback.&#039;,
            input: VoteInput::class,
            processor: VoteTalkProcessor::class,
            structuredContent: true,
        ),
        &#039;comment-talk&#039; =&gt; new McpTool(
            name: &#039;comment-talk&#039;,
            description: &#039;Post a comment on an API Platform Conference 2026 talk. Requires a GitHub access token (sent as a Bearer token); the comment is attributed to your GitHub login. Returns the updated feedback.&#039;,
            input: CommentInput::class,
            processor: CommentTalkProcessor::class,
            structuredContent: true,
        ),
    ],
)]
final class Feedback {/* properties */}
</code></pre>
</div><div class="container py-12">
<p class="wp-block-paragraph">Cette déclaration suffit à définir l&rsquo;outil : pas de handler JSON-RPC à maintenir à la main, pas de schéma de validation, pas de table de routage. À partir de cette config, API Platform fait trois choses :</p>

<ul class="wp-block-list">
<li>il génère le JSON Schema d&rsquo;entrée depuis la classe input typée, pour que l&rsquo;agent sache formuler des requêtes valides ;</li>



<li>il sérialise la ressource en contenu structuré des champs typés plutôt qu&rsquo;un bloc de texte formaté ;</li>



<li>il route l&rsquo;appel vers le processor pour les écritures, ou vers un provider pour les lectures.</li>
</ul>

<p class="wp-block-paragraph">Sur les douze ressources du projet (Talk, Speaker, Event, Agenda, Feedback&#8230;), ce modèle génère vingt-et-un outils. La description textuelle fait ici office de prompt : le modèle la lit à chaque décision pour juger si l&rsquo;outil est pertinent et quels arguments lui passer.</p>

<h2 class="wp-block-heading decorative-title">La couche State remplit un double rôle</h2>

<p class="wp-block-paragraph">Les outils MCP réutilisent la couche State classique d&rsquo;API Platform. Un outil de lecture s&rsquo;appuie sur une implémentation de <code>ProviderInterface</code>, un outil d&rsquo;écriture sur <code>ProcessorInterface</code>. Pas de duplication, pas de code séparé rien que pour les agents.</p>

<p class="wp-block-paragraph">Voici la structure simplifiée de la classe <code>VoteTalkProcessor</code> associée à l&rsquo;outil vote-talk :</p>
</div><div class="my-4 text-sm">
	<pre><code class="language-php">final readonly class VoteTalkProcessor implements ProcessorInterface
{
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Feedback
{
    /* Security &amp; validation */

    $talk = $this-&gt;conference-&gt;findTalk($data-&gt;slug)
        ?? throw new \RuntimeException(\sprintf(&#039;Unknown talk &quot;%s&quot;.&#039;, $data-&gt;slug));

    $existing = $this-&gt;em-&gt;getRepository(Vote::class)-&gt;findOneBy([
        &#039;talkSlug&#039; =&gt; $talk-&gt;slug,
        &#039;authorId&#039; =&gt; $user-&gt;githubId,
    ]);

    /* Upsert logic: adding or replacing existing vote for user */

    return $this-&gt;feedback-&gt;summary($talk-&gt;slug, $talk-&gt;title, $message);
}
}

</code></pre>
</div><div class="container py-12">
<p class="wp-block-paragraph">Cette même classe pourrait tout aussi bien traiter une requête REST POST équivalente. La logique métier (vérification de l’identité, unicité du vote par utilisateur·ice, mise à jour en base et retour du feedback) reste centralisée à un seul endroi,&nbsp; MCP n&rsquo;est qu&rsquo;un protocole de transport en surface. Pour ajouter une capacité, il faut écrire un provider ou un processor et de le déclarer avec McpTool : le transport et la sérialisation, eux, sont pris en charge automatiquement.</p>

<h2 class="wp-block-heading decorative-title">Des entrées typées, validées une seule fois</h2>

<p class="wp-block-paragraph">Chaque outil qui modifie des données déclare une classe d&rsquo;entrée typée, comme VoteInput ou CommentInput. API Platform s&rsquo;en sert pour générer le schéma d&rsquo;entrée de l&rsquo;outil : si l&rsquo;agent envoie un type incorrect ou oublie un paramètre obligatoire, la validation bloque la requête avant même d&rsquo;arriver à notre code. Les règles qu&rsquo;un schéma ne peut pas exprimer (une note qui doit être entre 1 et 5, une longueur de texte maximale) sont vérifiées dans le processor. La structure est validée à la frontière, le sens métier dans le handler.</p>

<h2 class="wp-block-heading decorative-title">FrankenPHP : un runtime persistant</h2>

<p class="wp-block-paragraph">Le serveur tourne sur FrankenPHP en mode worker, le runtime PHP basé sur Caddy. Contrairement au PHP-FPM classique, qui réinstancie le kernel Symfony à chaque requête, le mode worker ne l&rsquo;initialise qu&rsquo;une fois et traite ensuite plusieurs milliers de requêtes avec. Ça supprime la latence de démarrage, un vrai sujet quand l&rsquo;agent enchaîne plusieurs appels d&rsquo;outils dans un même tour de conversation.</p>

<p class="wp-block-paragraph">Le fichier de configuration Caddyfile gère l&rsquo;orchestration du mode worker, du hub Mercure et du service de fichiers statiques :</p>
</div><div class="my-4 text-sm">
	<pre><code class="language-bash">{$SERVER_NAME:localhost} {
    root /app/public
    mercure { /* Configuration des jetons JWT et accès anonymes */ }
    php_server {
        worker {
            file ./public/index.php
        }
    }
}
</code></pre>
</div><div class="container py-12">
<p class="wp-block-paragraph">Le mode worker a une conséquence directe : l&rsquo;état des services persiste entre les requêtes. Un service qui garde une valeur en cache sans la nettoyer la refilera à la requête suivante. Symfony fournit l&rsquo;interface <code>ResetInterface</code> pour ça. Exemple avec le nonce de sécurité (Content-Security-Policy), réinitialisé à chaque requête :</p>
</div><div class="my-4 text-sm">
	<pre><code class="language-php">final class CspNonceProvider implements ResetInterface
{
    private ?string $nonce = null;

    public function getNonce(): string
    {
        return $this-&gt;nonce ??= bin2hex(random_bytes(16));
    }

    public function reset(): void
    {
        $this-&gt;nonce = null;
    }
}

</code></pre>
</div><div class="container py-12">
<p class="wp-block-paragraph">Sans cet appel à <code>reset()</code>, le nonce du premier visiteur se retrouverait partagé avec les requêtes suivantes : un problème de sécurité propre au cycle de vie persistant du mode worker.</p>

<h2 class="wp-block-heading decorative-title">Flux de réponses avec Mercure et Live Components</h2>

<p class="wp-block-paragraph">L&rsquo;interface de chat repose sur un composant Symfony UX Live Component. Pour rester réactif, le système affiche le message de l&rsquo;utilisateur·ice immédiatement, puis restitue la réponse de l&rsquo;assistant au fil de sa génération, token par token. Le traitement se fait en deux étapes.</p>

<p class="wp-block-paragraph">La première, <code>submit()</code>, enregistre le message, affiche un indicateur d&rsquo;attente et génère un canal (topic) Mercure à usage unique puisque l&rsquo;agent n&rsquo;est pas encore sollicité. La seconde, <code>reply()</code>, se déclenche automatiquement dès que le client est connecté au canal : elle appelle l&rsquo;agent en streaming, publie chaque token sur le canal au fur et à mesure, puis enregistre le texte Markdown complet dans l&rsquo;état du composant Live une fois terminé. C&rsquo;est ce découpage qui garde l&rsquo;interface réactive.</p>

<p class="wp-block-paragraph">Un détail technique à gérer dans la boucle de streaming : les caractères multi-octets.</p>
</div><div class="my-4 text-sm">
	<pre><code class="language-php">// Prepare context window
$messageBag = $this-&gt;buildMessageBag();
$execution = $this-&gt;conference-&gt;call($messageBag, [&#039;stream&#039; =&gt; true]);

foreach ($execution-&gt;asTextStream() as $delta) {
    $text = $delta-&gt;getText();

    if (&#039;&#039; === $text) {
        continue;
    }

    $answer .= $text;
// Publishes longest valid-UTF-8 prefix &amp; leaves the incomplete tail for the next call
    $this-&gt;bufferAndPublish($topic, $byteBuffer, $text);
}
// Flush whatever was buffered
$this-&gt;flush($topic, $byteBuffer);
</code></pre>
</div><div class="container py-12">
<p class="wp-block-paragraph">Les tokens générés par un modèle de langage ne s&rsquo;alignent pas forcément sur les limites UTF-8 : un caractère codé sur plusieurs octets peut se retrouver coupé en deux entre deux fragments. Pour éviter que json_encode plante sur un flux binaire partiel, les fragments incomplets sont mis de côté dans un tampon, et seul le préfixe UTF-8 valide est publié tout de suite. Mercure s&rsquo;occupe de diffuser ces fragments vers le navigateur via Server-Sent Events. Et si un fragment se perd en route, le message complet est de toute façon réaffiché depuis l&rsquo;état du Live Component à la fin de la requête.</p>

<h2 class="wp-block-heading decorative-title">Le client : Symfony AI et Mistral</h2>

<p class="wp-block-paragraph">L&rsquo;interface graphique s&rsquo;appuie sur un agent Symfony AI. Pour chacun des vingt-et-un outils MCP déclarés côté serveur, le client implémente une classe passerelle dédiée avec l&rsquo;attribut <code>#[AsTool]</code>. On a préféré ça à la découverte dynamique à l&rsquo;exécution : l&rsquo;ensemble des outils reste figé et versionné dans le code du client, ce qui rend le comportement de l&rsquo;agent prévisible.</p>

<p class="wp-block-paragraph">La configuration de l&rsquo;agent adopte une structure déclarative :</p>
</div><div class="my-4 text-sm">
	<pre><code class="language-yaml">ai:
  platform:
    mistral:
      api_key: &#039;%env(MISTRAL_API_KEY)%&#039;
  agent:
    conference:
      platform: &#039;ai.platform.mistral&#039;
      model:
        name: &#039;%env(AI_MODEL)%&#039;
        options:
          temperature: &#039;%env(AI_TEMPERATURE)%&#039;
      prompt:
        text: |
          You are the assistant for the API Platform Conference 2026
          (17–18 September 2026, EuraTechnologies, Lille — organized by Les-Tilleuls.coop).
          You help attendees explore the agenda, talks, speakers, schedule, partners and practical information.
          Rules:

</code></pre>
</div><div class="container py-12">
<p class="wp-block-paragraph">Symfony AI abstrait le fournisseur d&rsquo;inférence derrière l&rsquo;interface AgentInterface. Changer de modèle ou de fournisseur devient une simple modification de configuration, sans toucher à la logique de l&rsquo;agent.</p>

<h2 class="wp-block-heading decorative-title">Sécurisation de l&rsquo;environnement de production</h2>

<p class="wp-block-paragraph">Laisser un agent autonome écrire en base de données via un protocole ouvert, ça ne se fait pas sans plusieurs couches de sécurité.</p>

<ul class="wp-block-list">
<li><strong>Authentification sur les écritures :</strong> la lecture reste anonyme, mais chaque modification exige un jeton d&rsquo;accès GitHub, validé auprès de l&rsquo;API officielle de GitHub à chaque requête. L&rsquo;identité est vérifiée strictement côté serveur pour empêcher toute falsification.</li>



<li><strong>Rate limiting :</strong> les outils d&rsquo;écriture utilisent un token bucket par utilisateur·ice, basé sur l&rsquo;identifiant numérique stable de GitHub. Ça limite les votes ou commentaires abusifs sans avoir besoin de stocker les adresses IP des visiteurs.</li>



<li><strong>Données venant des utilisateur·ices :</strong> l&rsquo;outil talk-feedback renvoie des textes saisis par le public, qui finissent injectés dans le contexte du modèle. Pour limiter le risque d&rsquo;injection de prompt indirecte, ces données sont explicitement marquées comme du contenu non vérifié, et le prompt système interdit à l&rsquo;agent de les interpréter comme des instructions.</li>



<li><strong>Durcissement côté client :</strong> un listener centralisé applique des en-têtes de sécurité sur toutes les routes : Content-Security-Policy à base de nonce, X-Content-Type-Options, Referrer-Policy, protection contre le clickjacking.</li>
</ul>

<h2 class="wp-block-heading decorative-title">L&rsquo;infrastructure de déploiement</h2>

<p class="wp-block-paragraph">FrankenPHP assemble l&rsquo;application en un seul exécutable, avec le serveur web, le runtime PHP et le hub Mercure dans le même processus. L&rsquo;image Docker qui en résulte est déployée sur Clever Cloud. Le temps réel via Mercure tourne sans infrastructure additionnelle, ce qui réduit d&rsquo;autant la surface à maintenir en prod.</p>

<h2 class="wp-block-heading decorative-title">Synthèse des technologies utilisées</h2>

<ul class="wp-block-list">
<li><strong>API Platform :</strong> gestion des ressources, de la couche State et exposition native des outils MCP.</li>



<li><strong>Symfony 8.1 :</strong> socle applicatif global.</li>



<li><strong>FrankenPHP :</strong> runtime d&rsquo;exécution en mode worker.</li>



<li><strong>Mercure :</strong> protocole de transmission des flux temps réel vers le client.</li>



<li><strong>PostgreSQL :</strong> persistance des votes, des commentaires et des agendas.</li>



<li><strong>Symfony AI :</strong> couche d&rsquo;abstraction pour l&rsquo;agent et la gestion des outils d&rsquo;inférence.</li>



<li><strong>Mistral :</strong> infrastructure d&rsquo;inférence.</li>
</ul>

<p class="wp-block-paragraph">En s&rsquo;appuyant sur l&rsquo;écosystème <a href="https://les-tilleuls.coop/technologies/symfony" data-type="link" data-id="https://les-tilleuls.coop/technologies/symfony" target="_blank" rel="noreferrer noopener">Symfony</a> et API Platform, on démontre qu&rsquo;il est possible de bâtir un serveur MCP robuste, performant et sécurisé sans surcouche complexe. Du streaming en temps réel via Mercure aux optimisations du mode worker de FrankenPHP, l&rsquo;architecture privilégie la réutilisabilité métier plutôt que le couplage à une simple interface web. En traitant le serveur comme le produit central et le chat comme un client parmi d&rsquo;autres, cette approche offre un socle pérenne pour n&rsquo;importe quel agent IA. <a href="https://les-tilleuls.coop/blog/souverainete-de-lia-agentique-qui-garde-vos-donnees-en-europe" data-type="link" data-id="https://les-tilleuls.coop/blog/souverainete-de-lia-agentique-qui-garde-vos-donnees-en-europe" target="_blank" rel="noreferrer noopener">La troisième partie</a> de ce retour d&rsquo;expérience abordera le volet de la souveraineté.</p>
</div><p>Cet article, <a href="https://les-tilleuls.coop/blog/transformer-une-api-en-boite-a-outils-pour-agents-larchitecture-de-notre-assistant-de-conference">Transformer une API en boîte à outils pour agents : l&rsquo;architecture de notre assistant de conférence</a>, est paru en premier sur <a href="https://les-tilleuls.coop">Les-Tilleuls.coop</a>.</p>
]]></description></item><item><title>Comment nous avons cr&#xE9;&#xE9; un assistant IA pour l&#x2019;API Platform Conference 2026</title><link>https://les-tilleuls.coop/blog/comment-nous-avons-cree-un-assistant-ia-pour-lapi-platform-conference-2026</link><author>Julien Lary</author><date>Mon, 07 Sep 2026 13:03:32 +0000</date><description><![CDATA[<div class="container pt-48 pb-12">
<p class="wp-block-paragraph">Trouver le talk qui correspond à vos objectifs, savoir qui va briller sur la scène <a href="https://les-tilleuls.coop/technologies/frankenphp" data-type="link" data-id="https://les-tilleuls.coop/technologies/frankenphp" target="_blank" rel="noreferrer noopener">FrankenPHP</a>, caler votre agenda&#8230; La billetterie de l&rsquo;<a href="https://api-platform.com/fr/con/2026/" target="_blank" rel="noreferrer noopener">API Platform Conference 2026</a> ferme dans quelques jours, et on a voulu vous accompagner jusqu&rsquo;au bout avec un nouvel outil : un assistant IA avec qui échanger directement sur la conférence. On vous raconte ce projet en trois articles : celui-ci pose le contexte général, <a href="https://les-tilleuls.coop/blog/transformer-une-api-en-boite-a-outils-pour-agents-larchitecture-de-notre-assistant-de-conference" data-type="link" data-id="https://les-tilleuls.coop/blog/transformer-une-api-en-boite-a-outils-pour-agents-larchitecture-de-notre-assistant-de-conference" target="_blank" rel="noreferrer noopener">le second détaille l&rsquo;architecture</a>, et <a href="https://les-tilleuls.coop/blog/souverainete-de-lia-agentique-qui-garde-vos-donnees-en-europe" data-type="link" data-id="https://les-tilleuls.coop/blog/souverainete-de-lia-agentique-qui-garde-vos-donnees-en-europe" target="_blank" rel="noreferrer noopener">le troisième</a> revient sur notre choix d&rsquo;outils souverains. Prêt·es à tester ?</p>

<figure class="wp-block-image aligncenter size-large"><img decoding="async" width="1024" height="569" src="https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-I-1024x569.webp" alt="Meet your conference assistant" class="wp-image-19485" srcset="https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-I-1024x569.webp 1024w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-I-600x333.webp 600w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-I-300x167.webp 300w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-I-768x426.webp 768w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-I-40x22.webp 40w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-I-1080x600.webp 1080w, https://les-tilleuls.coop/wp-content/uploads/2026/09/mcp-api-con-I.webp 1500w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>

<h2 class="wp-block-heading decorative-title">Les prémices du projet</h2>

<p class="wp-block-paragraph">Chaque année, l&rsquo;API Platform Conference propose à Lille deux jours de programme chargé : des dizaines de conférences sur plusieurs tracks, des intervenant·es venu·es du monde entier, des ateliers, et toute la logistique qui va avec un événement en pleine croissance. Et chaque année, les mêmes questions reviennent côté participant·es : qu&rsquo;est-ce qui se passe en ce moment, c&rsquo;est quoi la prochaine conférence en salle 2, qui parle de tel sujet, où sont les stands des partenaires&#8230; On a voulu y répondre avec un assistant conversationnel.</p>

<p class="wp-block-paragraph">La façon évidente de faire, c&rsquo;est un chatbot classique : une interface, un LLM, un prompt, un accès aux données câblé derrière. C&rsquo;est d&rsquo;ailleurs par là qu&rsquo;on a commencé. Mais en cours de route, une question de conception a fait bifurquer tout le projet, et la réponse s&rsquo;est révélée beaucoup plus réutilisable qu&rsquo;une simple fenêtre de chat. C&rsquo;est ce recadrage qu&rsquo;on raconte dans cet article, parce que c&rsquo;est la leçon la plus transposable qu&rsquo;on en a tirée, et elle ne doit presque rien à la technique.</p>

<p class="wp-block-paragraph">L&rsquo;idée de départ vient de <a href="https://techready.live/" target="_blank" rel="noreferrer noopener">techready.live</a>, qui a montré tout ce qu&rsquo;un bon compagnon d&rsquo;événement peut apporter aux participant·es. Notre question a été : à quoi ça ressemble, cette expérience, à l&rsquo;heure des agents IA ?</p>

<h2 class="wp-block-heading decorative-title">Une question qui a changé la conception</h2>

<p class="wp-block-paragraph">La question qu&rsquo;on s&rsquo;est posée : quand l&rsquo;assistant doit savoir « quelle est la prochaine conférence en salle 2 », où vit cette connaissance ?</p>

<p class="wp-block-paragraph">Si la réponse est « dans le chatbot », alors le chatbot, c&rsquo;est le produit. Et toute autre façon d&rsquo;accéder à la même info (Claude Desktop, un assistant d&rsquo;IDE comme Cursor, le bot Slack d&rsquo;une équipe, le script perso d&rsquo;un·e collègue) devrait être reconstruite de zéro, avec sa propre copie de l&rsquo;accès aux données, des règles métier et du prompt. La fenêtre de chat deviendrait à la fois l&rsquo;interface et le plafond de verre.</p>

<p class="wp-block-paragraph">On a donc inversé la logique. On a d&rsquo;abord construit la connaissance de la conférence et les actions possibles pour un·e participant·e comme un service autonome, et traité l&rsquo;interface de chat comme son premier client et pas comme le produit lui-même. Ce service s&rsquo;appuie sur le Model Context Protocol (MCP), le standard ouvert pour exposer des outils à des agents IA. Le chat en est un consommateur parmi d&rsquo;autres ; n&rsquo;importe quel agent compatible MCP peut en être un autre.</p>

<h2 class="wp-block-heading decorative-title">Des capacités, pas des endpoints</h2>

<p class="wp-block-paragraph">Concevoir un serveur MCP, ça change subtilement la manière de penser par rapport à une API REST classique. Au lieu de raisonner en ressources et en endpoints, on raisonne en capacités et en verbes les actions concrètes qu&rsquo;un·e utilisateur·ice fait vraiment.</p>

<p class="wp-block-paragraph">Notre serveur s&rsquo;organise autour des besoins réels d&rsquo;un·e participant·e pendant l&rsquo;événement :</p>

<ul class="wp-block-list">
<li><strong>Explorer le programme :</strong> lister et rechercher des conférences, récupérer les détails d&rsquo;une session ou d&rsquo;un·e intervenant·e, parcourir l&rsquo;historique d&rsquo;un·e speaker, lister les catégories et les partenaires, lire les infos générales de l&rsquo;événement.</li>



<li><strong>Se repérer dans le temps :</strong> savoir ce qui se passe maintenant et ce qui vient ensuite.</li>



<li><strong>Participer :</strong> noter une conférence, laisser un commentaire, lire les retours de la communauté.</li>



<li><strong>Planifier :</strong> ajouter une conférence à son agenda personnel, la retirer, le consulter.</li>
</ul>

<p class="wp-block-paragraph">Le projet compte vingt outils au total. Ce qui compte, c&rsquo;est qu&rsquo;ils portent des noms d&rsquo;actions humaines, pas des noms de tables SQL. « Les prochaines conférences », c&rsquo;est une capacité que n&rsquo;importe quel·le participant·e comprend ; une query string avec un offset et une limite, c&rsquo;est de la tuyauterie. Et quand celui qui consomme l&rsquo;API est un modèle de langage qui doit décider quel outil appeler, c&rsquo;est ce cadrage sémantique qui l&rsquo;aide à choisir le bon outil et à lui passer les bons arguments.</p>

<h2 class="wp-block-heading decorative-title">La description de l&rsquo;outil est l&rsquo;interface</h2>

<p class="wp-block-paragraph">Le plus surprenant dans ce développement, c&rsquo;est la documentation. Sur une API classique, la doc de référence s&rsquo;adresse aux développeur·euses et reste, dans les faits, optionnelle : le code marche, que la doc soit bonne ou non. API Platform fait déjà mieux avec une doc OpenAPI générée automatiquement à partir du code, toujours à jour. Mais sur un serveur MCP, la description de chaque outil est lue par le modèle à chaque décision qu&rsquo;il prend. Ce n&rsquo;est plus de la documentation sur l&rsquo;interface, c&rsquo;est l&rsquo;interface. Elle décide si l&rsquo;agent appelle l&rsquo;outil, et s&rsquo;il l&rsquo;appelle correctement.</p>

<p class="wp-block-paragraph">Une description du genre « Voter pour une conférence » n&rsquo;apprend quasiment rien au modèle. Voici celle qu&rsquo;on a fini par écrire pour l&rsquo;outil de notation :</p>

<p class="wp-block-paragraph"><em>Rate an API Platform Conference 2026 talk (1–5). Requires a GitHub access token (sent as a Bearer token); one vote per talk per user — voting again updates your rating. Returns the updated feedback.</em></p>

<p class="wp-block-paragraph">En trois phrases, le modèle sait quelle plage de notes est valide, qu&rsquo;une authentification est requise, que revoter met à jour la note au lieu de la dupliquer, et à quoi ressemble la réponse. On a passé plus de temps à écrire et réécrire ces descriptions que sur n&rsquo;importe quel handler. Sur un projet MCP, ce sont elles qui font la différence entre un projet qui marche et un qui ne marche pas, ça vaut le coup de les traiter comme du vrai prompt engineering.</p>

<h2 class="wp-block-heading decorative-title">L&rsquo;authentification comme frontière produit</h2>

<p class="wp-block-paragraph">L&rsquo;essentiel du serveur est accessible sans création de compte : lire le programme ne demande rien. Écrire, en revanche : noter une conférence, laisser un commentaire ou se construire un agenda&nbsp; demande une connexion GitHub. C&rsquo;est un choix assumé, pas un détail technique : il définit qui a le droit de modifier les données et garde une trace des contributions.</p>

<p class="wp-block-paragraph">Cette règle est portée par l&rsquo;outil lui-même, pas par un client en particulier. Un agent qui essaie de voter sans token reçoit une réponse en langage clair lui expliquant qu&rsquo;une connexion est nécessaire. Notre interface de chat traduit ça par un bouton « Se connecter avec GitHub » ; un autre client le présenterait autrement. Mais la règle vit à un seul endroit, sur le serveur, donc chaque client l&rsquo;applique sans avoir à la réécrire.</p>

<h2 class="wp-block-heading decorative-title">Bring your own agent</h2>

<p class="wp-block-paragraph">Quand le produit est un service et non une appli figée, la distribution change de nature : ce n&rsquo;est plus un app store, mais un petit bout de configuration. Un·e développeur·euse ajoute quelques lignes à son client IA préféré, et son assistant sait répondre aux questions sur la conférence et enregistrer ses votes. Pareil pour un assistant d&rsquo;IDE ou un script écrit pour un besoin précis : chaque client accède aux mêmes vingt outils et au même comportement, sans qu&rsquo;on ait à redévelopper quoi que ce soit de notre côté.</p>

<p class="wp-block-paragraph">Pour que ce soit concret, on publie <a href="https://mcp.con.api-platform.com/">une page statique</a> avec des instructions d&rsquo;installation copier-coller pour les clients les plus courants. Le « bring your own agent », ça ne marche que si ça prend deux minutes à mettre en place.</p>

<p class="wp-block-paragraph">Notre interface de chat garde toute son utilité : c&rsquo;est le point d&rsquo;entrée que la plupart des participant·es utiliseront, et on détaille sa construction dans la Partie 2. Mais ce n&rsquo;est qu&rsquo;une porte d&rsquo;entrée parmi d&rsquo;autres, pas là où se concentre la valeur du projet.</p>

<h2 class="wp-block-heading decorative-title">Retours d&rsquo;expérience et bonnes pratiques</h2>

<p class="wp-block-paragraph">Quelques principes qui ont tenu la route, et qu&rsquo;on appliquerait à n&rsquo;importe quel projet MCP :</p>

<ul class="wp-block-list">
<li><strong>Modéliser le domaine par des verbes.</strong> Quand les capacités portent le nom des actions de l&rsquo;utilisateur·ice, les frontières entre outils se dessinent toutes seules, et le modèle choisit plus fiablement quel outil appeler.</li>



<li><strong>Traiter les descriptions comme de l&rsquo;UX.</strong> Ça prend du temps à écrire et à tester contre le comportement réel du modèle.</li>



<li><strong>Préférer la sortie structurée à la prose formatée.</strong> Du contenu typé laisse chaque client (bulle de chat, panneau d&rsquo;IDE, interface vocale) libre de le présenter comme il veut.</li>



<li><strong>Mettre la logique de contrôle côté serveur.</strong> Un client peut oublier l&rsquo;authentification, les <em>rate limits</em> ou la validation ; le serveur, lui, doit les appliquer systématiquement.</li>



<li><strong>Voir l&rsquo;interface comme un client, pas comme le produit.</strong> Ça reste utile d&rsquo;en soigner une, mais ça ne doit pas devenir le seul moyen d&rsquo;interagir.</li>
</ul>

<p class="wp-block-paragraph">Au final, ce qui compte dans ce projet, ce n&rsquo;est pas tellement la fenêtre de chat qu&rsquo;on a construite, mais le socle technique sur lequel n&rsquo;importe quel futur client IA peut venir s&rsquo;appuyer..</p>

<h2 class="wp-block-heading decorative-title">La suite du cheminement</h2>

<p class="wp-block-paragraph">Dans la <a href="https://les-tilleuls.coop/blog/transformer-une-api-en-boite-a-outils-pour-agents-larchitecture-de-notre-assistant-de-conference" data-type="link" data-id="https://les-tilleuls.coop/blog/transformer-une-api-en-boite-a-outils-pour-agents-larchitecture-de-notre-assistant-de-conference" target="_blank" rel="noreferrer noopener">Partie 2</a>, on rentre dans le détail technique : comment API Platform transforme une ressource en outil MCP avec un seul attribut, comment le mode worker de FrankenPHP garde le serveur actif, et comment les réponses sont streamées jusqu&rsquo;au navigateur avec Mercure. La <a href="https://les-tilleuls.coop/blog/souverainete-de-lia-agentique-qui-garde-vos-donnees-en-europe" data-type="link" data-id="https://les-tilleuls.coop/blog/souverainete-de-lia-agentique-qui-garde-vos-donnees-en-europe">Partie 3</a> montre que toute cette infrastructure peut tourner sur une stack entièrement européenne et open source, inférence comprise.&nbsp;</p>

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

<div class="c-alert c-alert--tip">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 46 72"><path fill-rule="nonzero" d="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"/></svg>
            </span>
                        <strong>Astuce</strong>
    </p>
    <div class="c-alert__content">
                <p>
Avant d'estimer une migration de bundler, inventoriez qui consomme vos assets et par quel canal (manifest, chemins en dur, lecture disque, CDN). C'est là que se cache la vraie charge de travail, pas dans la config.</p>
        </div>
</div>

<h2>Les webpack-ismes qui ne se voient qu'au runtime</h2>
<p>Une fois le build vert, nous pensions être tirés d'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.</p>
<h3><code>global</code> n'existe pas</h3>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">global.Translator </span><span class="syntax-4">=</span><span class="syntax-2"> Translator;</span></span></code></pre>
<p>Webpack aliasse silencieusement <code>global</code> vers <code>window</code>. Vite, non :</p>
<pre><code>Uncaught ReferenceError: global is not defined
</code></pre>
<p>L'erreur survient au top-level du module, donc c'est tout le bundle qui meurt : plus une seule ligne de JS ne s'exécute sur le site. Le plus vicieux : <code>@types/node</code> étant installé, <code>global</code> est parfaitement typé et <code>tsc</code> ne bronche pas. Le fix est trivial (<code>window.Translator = …</code>), encore faut-il savoir que ces assignations existent.</p>

<div class="c-alert c-alert--tip">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 46 72"><path fill-rule="nonzero" d="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"/></svg>
            </span>
                        <strong>Astuce</strong>
    </p>
    <div class="c-alert__content">
                <p>
Cherchez <code>global.</code> dans votre code avant de migrer, cela vous évitera de découvrir le problème en CI comme nous.</p>
        </div>
</div>

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

<div class="c-alert c-alert--tip">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 46 72"><path fill-rule="nonzero" d="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"/></svg>
            </span>
                        <strong>Astuce</strong>
    </p>
    <div class="c-alert__content">
                <p>
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'existe que dans la console du navigateur : un <code>pageerror</code>, un <code>console.error</code>, ou une requête d'asset en échec.
Si vous avez des tests e2e, branchez-y les listeners <code>pageerror</code>, <code>console</code> et <code>requestfailed</code> de Playwright, ne serait-ce que le temps de la migration : c'est le filet qui transforme ces bugs silencieux en tests rouges, au lieu de vous les faire découvrir à l'œil nu, page par page.</p>
        </div>
</div>

<h2>Quand le site dépend d'un bug du bundler</h2>
<p>Voici mon anecdote préférée de cette migration. Après le passage à Vite, un test de <a href="https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker">régression visuelle</a> refusait obstinément de passer : sur une déclinaison du site, le logo s'affichait 60 % trop grand.</p>
<p>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'histoire :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-1">"build/images/logo-pro.svg"</span><span class="syntax-2">: </span><span class="syntax-1">"/build/images/logo-pro.e42c1969.svg"</span></span></code></pre>
<p>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'était <strong>pas</strong> le fichier demandé : c'était un autre SVG du même nom, situé dans un sous-dossier <code>icons/</code>, avec une classe CSS différente, donc une taille différente.</p>
<p>L'explication : les images émises par file-loader (dont tout le dossier <code>icons/</code>, embarqué par le <code>require()</code> dynamique vu plus haut) <strong>écrasaient les clés du manifest</strong> 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.</p>
<p>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'est qu'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.</p>
<h2>Un pixel de différence</h2>
<p>Toujours côté régressions visuelles : trois baselines ont bougé d'exactement <strong>1 pixel</strong> après la migration. Un bouton centré, dont la largeur totale (icône dimensionnée en <code>em</code> + texte) tombe à un demi-pixel près différemment. En cause, le changement de minifieur CSS : cssnano (configuré avec <code>calc: false</code> précisément pour éviter ce genre d'arrondis) a laissé la place à esbuild.</p>
<p>0,01 % des pixels, invisible à l'œil, mais parfaitement reproductible. Le rendu de Vite est déterministe au pixel près d'un run à l'autre : nous avons comparé des captures à deux jours d'écart, zéro pixel de différence.</p>
<p>Corollaire qui vaut pour tous ceux qui font des tests de screenshots : <strong>ne régénérez jamais vos baselines sur le dev server</strong>. Le CSS non minifié y produit les mêmes écarts d'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'office avant de capturer.</p>
<h2>Brancher le HMR dans la stack Docker</h2>
<p>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'on s'y attarde. Petite confession d'abord : avec Encore, notre « watch » se contentait d'é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'époque souffraient trop). Avec Vite, nous voulions le HMR dans la stack, comme tout le reste, et pour tout le monde.</p>
<h3>Les problèmes rencontrés</h3>
<p>Nous avons eu trois problèmes à résoudre.</p>
<p><strong>Atteindre le serveur depuis le navigateur.</strong> Nous avons d'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'hôte et servir en <code>http://localhost:5173</code>. Les navigateurs traitent <code>localhost</code> comme une origine sûre, donc pas de mixed content depuis une page HTTPS et zéro certificat à gérer.</p>
<p><strong>Le cycle de vie.</strong> Notre premier montage lançait Vite dans un conteneur <code>compose run</code> éphémère. Mauvaise idée : un watch tué laisse un zombie qui squatte le port 5173 et continue d'écraser les fichiers en douce. Nous avons donc plutôt défini un service Compose dédié : <code>up</code> réutilise ou recrée toujours le même conteneur, ce qui rend le zombie impossible.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># docker-compose.dev.yml (extrait)</span></span>
<span class="line"><span class="syntax-4">vite</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">    image</span><span class="syntax-2">: </span><span class="syntax-1">"${PROJECT_NAME}-builder"</span></span>
<span class="line"><span class="syntax-4">    command</span><span class="syntax-2">: </span><span class="syntax-1">bash -c "until [ -d node_modules/.bin ]; do sleep 2; done; yarn run ${VITE_SCRIPT:-dev}"</span></span>
<span class="line"><span class="syntax-4">    ports</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-2">        - </span><span class="syntax-1">"127.0.0.1:${PROJECT_VITE_PORT:-5173}:5173"</span></span></code></pre>
<p>Le <code>until node_modules</code> n'est pas décoratif : au premier démarrage de la stack, Docker démarre le service avant que <code>yarn install</code> ne soit passé, et on ne veut pas d'un service en crash-loop.</p>
<p><strong>Les pièges du conteneur.</strong> Deux classiques à connaître. Le watcher de Vite crawle par défaut tout le projet : avec un <code>vendor/</code> de 100 000 fichiers, la limite inotify explose. Il faut donc l'exclure explicitement. Et méfiez-vous de vos patterns d'exclusion : notre <code>**/var/**</code>, pensé pour le cache Symfony, matchait <code>/var/www</code>, 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 :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">watch: {</span></span>
<span class="line"><span class="syntax-2">    ignored: [</span><span class="syntax-1">'vendor'</span><span class="syntax-2">, </span><span class="syntax-1">'var'</span><span class="syntax-2">, </span><span class="syntax-1">'web'</span><span class="syntax-2">].</span><span class="syntax-8">map</span><span class="syntax-2">((</span><span class="syntax-12">dir</span><span class="syntax-2">) </span><span class="syntax-5">=></span><span class="syntax-2"> path.</span><span class="syntax-8">resolve</span><span class="syntax-2">(__dirname, dir, </span><span class="syntax-1">'**'</span><span class="syntax-2">)),</span></span>
<span class="line"><span class="syntax-2">},</span></span></code></pre>
<p>Dernier raffinement, la cohérence entre les modes : nos tasks de build stoppent le serveur de dev s'il tourne (sinon les pages repassent sur les assets buildés pendant qu'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.</p>
<h3>Et les worktrees ?</h3>
<p>Avec l'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'est une feature native de notre template <a rel="nofollow noopener noreferrer" href="https://github.com/jolicode/docker-starter">docker-starter</a> : 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'avoir plusieurs stacks qui tournent en parallèle.</p>
<p>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.</p>
<p>Il restait un détail à régler : Symfony génère des URLs d'assets absolues à partir des <code>base_urls</code> 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 <code>base_urls</code>, vide par défaut (et en production) :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># config/packages/framework.yaml</span></span>
<span class="line"><span class="syntax-4">framework</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">    assets</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">        base_urls</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-2">            - </span><span class="syntax-1">'https://%http.domain.front%%http.public_port_suffix%'</span></span></code></pre>
<p>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 <code>parameters_override.yaml</code> local (gitignoré, prévu pour la config personnelle) :</p>

<div class="c-alert c-alert--note">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 70 71"><path fill-rule="nonzero" d="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"/></svg>
            </span>
                        <strong>Info</strong>
    </p>
    <div class="c-alert__content">
                <p>
Notre projet utilise encore des fichiers parameters.yaml, nous n'avons pas migré vers des variables d'environnement et les .env associés.</p>
        </div>
</div>

<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">// Extrait de la task, appelée par `castor up`</span></span>
<span class="line"><span class="syntax-2">$line </span><span class="syntax-4">=</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">"http.public_port_suffix: ':%d'"</span><span class="syntax-2">, </span><span class="syntax-8">get_worktree_ports</span><span class="syntax-2">(</span><span class="syntax-8">get_worktree_name</span><span class="syntax-2">())[</span><span class="syntax-1">'https'</span><span class="syntax-2">]);</span></span>
<span class="line"><span class="syntax-10">// … créé ou mis à jour dans parameters_override.yaml, sans toucher aux autres clés</span></span></code></pre>
<p>Un nouveau worktree est ainsi utilisable en une seule commande, HMR compris.</p>
<h2>Ce que nous avons perdu au passage</h2>
<p>Pour être complet, voici ce que la migration nous a coûté :</p>
<ul>
<li>la minification svgo des images copiées a été abandonnée, à refaire à la source si le poids devient un sujet ;</li>
<li>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 <code>yarn tsc --noEmit</code> pour combler le trou (ne l'oubliez pas, c'est un vrai filet qui disparaît sinon) ;</li>
<li>Reprise était expérimental au moment de la migration, avec une API susceptible de bouger d'une version à l'autre. Ce point s'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'est résumée à changer la contrainte de version, sans une ligne de code à toucher.</li>
</ul>
<h2>Une migration largement déléguée à l'IA</h2>
<p>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'assets, le mode de câblage du HMR), mais l'essentiel du travail mécanique et surtout l'itération sur les problèmes rencontrés.</p>
<p>Ce qui a rendu cela possible, ce n'est pas l'IA elle-même, c'est le filet de sécurité qui existait déjà autour du projet : une CI complète avec Behat, PHPUnit et nos <a href="https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker">tests e2e Playwright avec screenshots</a>. 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 <code>global</code> 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'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.</p>
<p>Sans cette couverture de tests, la même migration aurait demandé une relecture visuelle de dizaines de pages à chaque itération, et nous n'aurions probablement pas osé déléguer autant. C'est un bon argument, si vous en manquiez, pour investir dans des tests de régression visuelle avant d'entreprendre ce genre de chantier.</p>
<h2>Conclusion</h2>
<p>Nous avons vu dans cet article que la migration mécanique d'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'au runtime. C'est là qu'il faut chercher si vous devez estimer une telle migration.</p>
<p>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'une minute, c'est appréciable !</p>
<p>Être early adopter d'un bundle expérimental a aussi ses bons côtés : le principal point de friction rencontré a fini en <a rel="nofollow noopener noreferrer" href="https://github.com/symfony/reprise/pull/81">contribution upstream</a>, mergée et publiée en quelques jours. La prochaine équipe qui migrera un site aux chemins d'assets figés aura une option de config là où nous avions initialement écrit un plugin local.</p>]]></description></item><item><title>Penser probl&#xE8;me avant de penser solution</title><link>https://www.jdecool.fr/blog/2026/08/26/penser-probleme-avant-de-penser-solution.html</link><author/><date>Tue, 25 Aug 2026 22:00:00 +0000</date><description><![CDATA[<p>En tant que développeur, quand on démarre une tâche ou un projet, on pense facilement au framework que l’on va utiliser, à la base de données, aux patterns que l’on va pouvoir mettre en place. Et si c’était une erreur ?</p> <!--more--> <p>Car en réfléchissant de la sorte, on prend déjà des décisions avant même que le problème ne soit posé. On choisit une solution, une architecture, un outil, et ensuite on cherche la justification. N’est-ce pas penser à l’envers ?</p> <p>Penser problème avant de penser solution, c’est d’abord clarifier le besoin auquel on doit répondre. Quelle est la douleur, pour qui, et dans quel but ? Ce n’est pas en pensant technique, à la manière dont on va l’implémenter dans le code et dans le produit, que l’on trouvera ces réponses.</p> <p>L’IA amplifie aujourd’hui ce phénomène. Elle produit une implémentation pour n’importe quelle demande. Le coût de mise en place d’une solution a drastiquement diminué, mais celui de se tromper de problème n’a pas bougé.</p> <p>Poser le problème, c’est déjà la moitié du chemin de fait. C’est du problème que découle la solution, et de la solution que découlent les outils à mettre en place. Pas l’inverse.</p> ]]></description></item><item><title>La PHP Foundation, partenaire du Forum PHP 2026 !</title><link>https://afup.org/news/1263-la-php-foundation-partenaire-du-forum-php-2026</link><author/><date>Tue, 25 Aug 2026 06:41:00 +0000</date><description><![CDATA[<h3 id="content-un-stand-dans-notre-hall-sponsors">Un stand dans notre hall sponsors</h3>
<p>Si les événements AFUP ont toujours mis en avant l'action de la PHP Foundation, cette édition passe à l'étape supérieure. L'organisme qui veille à la qualité et assure l'évolution du langage au niveau mondial sera présent sur un stand au sein de notre hall sponsors. Grâce à cette visibilité renforcée et ce point de rendez-vous dédié , profitez du <a href="https://event.afup.org">Forum PHP 2026</a> pour venir échanger avec les devs qui oeuvrent au quotidien pour PHP : Sebastian Bergmann, Benjamin Eberlei, Gina Banyard et Alexandre Daubois vous attendront !</p>
<h3 id="content-une-presence-marquee-egalement-sur-scene">Une présence marquée également sur scène</h3>
<p>Ce sont 5 talks au programme qui seront présenté par des membres de la fondation. Sebastian Bergmann présentera &quot;Speed is a byproduct of trust&quot; et &quot;Past, Present, Future: The PHPUnit Story&quot;, Benjamin Eberlei présentera &quot;Leveraging Production Data for AI Assisted Software Development&quot;, Gina Banyard proposera &quot;Le progrès réside dans les BC Breaks&quot; et Alexandre Daubois présentera &quot;L’Ecosystem Security Team de la PHP Foundation de l’intérieur&quot;. Profitez du meilleur de PHP, proposé par les devs qui agissent à l'année pour la qualité du langage !</p>
<p><strong>L'AFUP est particulièrement fière de ce partenariat, qui marque ainsi le renforcement des liens entre la communauté française et l'action portée par la fondation. PHP rocks! Ne manquez pas cette édition, sous l'égide de la PHP Foundation.</strong></p>
]]></description></item><item><title>D&#xE9;marrage de la refonte graphique de notre site</title><link>https://afup.org/news/1262-debut-refonte-graphique-de-notre-site</link><author/><date>Thu, 20 Aug 2026 06:21:00 +0000</date><description><![CDATA[<h2 id="content-les-differents-choix-techniques">Les différents choix techniques</h2>
<p>Dans cet article, on vous explique ce qui a été mis en place pour cette refonte.</p>
<h3 id="content-le-css">Le CSS</h3>
<p>Le premier choix fait par les bénévoles a été <a href="https://tailwindcss.com">Tailwindcss</a>. Nous sommes plusieurs à savoir l'utiliser, ce qui nous simplifie grandement la tâche. Et c'est aussi un choix pragmatique : nous ne sommes pas des devs front, donc il nous faut choisir une technologie que nous maitrisons un minimum.</p>
<p>Pour cela, on utilise simplement le <a href="https://symfony.com/bundles/TailwindBundle/current/index.html">bundle recommandé par Symfony</a> avec l'asset mapper :</p>
<pre data-lang="terminal" class="notranslate">composer require <span class="hl-property">symfonycasts/tailwind-bundle</span>
</pre>
<p>Pour se simplifier la vie pendant la phase de dev, un nouveau conteneur Docker a été ajouté pour lancer le watcher tailwind automatiquement !</p>
<p>Ensuite, plutôt que de tenter un mix avec le CSS existant, nous avons fait le choix de repartir sur une base vierge, avec un nouveau layout.</p>
<p>Cela permet de faire la migration tranquillement, page par page, sans avoir d'impact sur l'existant.</p>
<p>De plus, l'ancien CSS est compilé à partir de fichiers <code>.scss</code> avec une vielle version de Webpack, tandis que ce nouveau design est basé sur <a href="https://symfony.com/doc/current/frontend/asset_mapper.html">l'asset mapper</a> de Symfony.</p>
<h3 id="content-les-composants-twig">Les composants Twig</h3>
<p>Une des premières critiques de Tailwind est la quantité de classes se retrouvant dans le html, par exemple :</p>
<pre data-lang="html" class="notranslate">&lt;<span class="hl-keyword">button</span> <span class="hl-property">class</span>=&quot;items-center justify-center rounded-lg border border-transparent bg-clip-border text-sm font-medium whitespace-nowrap px-3 py-2 bg-ruby-500 text-white hover:bg-ruby-700&quot;&gt;
	Mon bouton
&lt;/<span class="hl-keyword">button</span>&gt;
</pre>
<p>Bien évidement, on n'a pas envie de s'amuser à copier/coller ce HTML partout où on a besoin d'un bouton, c'est pourquoi on a opté pour utiliser des composants Twig.</p>
<p>Avec cette librairie, on peut créer des balises HTML custom, et notre exemple devient :</p>
<pre data-lang="html" class="notranslate">&lt;twig:Button <span class="hl-property">variant</span>=&quot;primary&quot;&gt;Mon bouton&lt;/twig:Button&gt;
</pre>
<h3 id="content-les-icones">Les icones</h3>
<p>Afficher des icones est devenu beaucoup plus simple dans un projet Symfony depuis l'existence de <a href="https://ux.symfony.com/icons">symfony/ux-icons</a> !</p>
<p>Une simple commande permet de télécharger un svg parmis des milliers de choix provenant de multiples collections :</p>
<pre data-lang="terminal" class="notranslate"><span class="hl-property">bin/console</span> ux:icon:import lucide:broccoli
</pre>
<p>Et ensuite, un composant twig permet de l'utiliser :</p>
<pre data-lang="html" class="notranslate">&lt;twig:Button <span class="hl-property">variant</span>=&quot;primary&quot;&gt;
	&lt;twig:ux:icon <span class="hl-property">name</span>=&quot;lucide:broccoli&quot; <span class="hl-property">class</span>=&quot;h-5&quot; /&gt; Manger un légume
&lt;/twig:Button&gt;
</pre>
<h2 id="content-le-resultat">Le résultat</h2>
<p>La page que vous lisez actuellement (en 2026 en tout cas !) est la première a avoir été refaite. C'est une page plutôt simple, ce qui permet de limiter la quantité de choses à faire : le layout général (header, menu, footer) et l'article.</p>
<p>Le code de cette première étape est visible sur GitHub : <a href="https://github.com/afup/web/pull/2330">https://github.com/afup/web/pull/2330</a></p>
<p>Il reste beaucoup de pages à migrer, les contributions sont toujours les bienvenues 😊</p>
<h2 id="content-un-peu-de-meta">Un peu de méta</h2>
<p>Cet article est le premier du genre, et a été rendu possible après plusieurs étapes techniques :</p>
<ul>
<li>migrer les entités du blog vers Doctrine : <a href="https://github.com/afup/web/pull/2261">#2261</a></li>
<li>migrer le projet en PHP 8.5 : <a href="https://github.com/afup/web/pull/2258">#2258</a></li>
<li>changer de moteur de rendu markdown : <a href="https://github.com/afup/web/pull/2279">#2279</a></li>
<li>installer et configurer une librairie de coloration du code : <a href="https://github.com/afup/web/pull/2336">#2336</a></li>
</ul>
]]></description></item><item><title>SimHash : trouver les pages qui se ressemblent</title><link>https://jolicode.com/blog/simhash-trouver-les-pages-qui-se-ressemblent</link><author>JoliCode Team</author><date>Tue, 18 Aug 2026 07:42:00 +0000</date><description><![CDATA[<p>Il y a quelques semaines, j'ai eu besoin de détecter du contenu dupliqué dans le crawler de <a rel="nofollow noopener noreferrer" href="https://redirection.io/">redirection.io</a>. Pas du dupliqué à la virgule près, ça c'est facile, mais du dupliqué « en gros, c'est la même page ». J'aurais pu utiliser <a href="https://jolicode.com/blog/symfony-ai-simplifier-lanalyse-de-similarites-de-textes-et-linteraction-avec-vos-llms-favoris">des embeddings avec un LLM</a>, mais il me fallait quelque chose de rapide et de gratuit. J'ai finalement utilisé SimHash, un algorithme qui date de 2002 et qui tient en trente lignes de PHP.</p>
<p>Dans cet article, nous allons voir pourquoi <code>md5()</code> ne peut pas nous aider, comment fonctionne SimHash, comment l'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.</p>
<h2>Le problème</h2>
<p>Le crawler de redirection.io parcourt un site et récupère toutes ses pages. À la fin, on veut prévenir l'utilisateur : ces pages ont le même contenu.</p>
<p>Commençons par le cas facile. Deux pages strictement identiques :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$hashA </span><span class="syntax-4">=</span><span class="syntax-9"> md5</span><span class="syntax-2">($contenuA);</span></span>
<span class="line"><span class="syntax-2">$hashB </span><span class="syntax-4">=</span><span class="syntax-9"> md5</span><span class="syntax-2">($contenuB);</span></span></code></pre>
<p>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 :</p>
<blockquote>
<p>Toutes nos paires sont expédiées sous 24 heures depuis notre entrepôt de <strong>Lyon</strong>.</p>
</blockquote>
<!-- -->
<blockquote>
<p>Toutes nos paires sont expédiées sous 24 heures depuis notre entrepôt de <strong>Bordeaux</strong>.</p>
</blockquote>
<p>Trois cents mots identiques, un seul mot qui change. Pour un moteur de recherche, ce sont des doublons. Pour <code>md5()</code>, ce sont deux pages qui n'ont rien à voir.</p>
<h2>Pourquoi les fonctions de hachage habituelles ne nous aident pas</h2>
<p>Le premier réflexe serait de se dire que deux contenus proches donnent deux hash proches. Vérifions :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-9">echo</span><span class="syntax-9"> md5</span><span class="syntax-2">(</span><span class="syntax-1">'Bonjour le monde'</span><span class="syntax-2">), </span><span class="syntax-1">"</span><span class="syntax-3">\n</span><span class="syntax-1">"</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-9">echo</span><span class="syntax-9"> md5</span><span class="syntax-2">(</span><span class="syntax-1">'Bonjour le Monde'</span><span class="syntax-2">), </span><span class="syntax-1">"</span><span class="syntax-3">\n</span><span class="syntax-1">"</span><span class="syntax-2">;</span></span></code></pre>
<pre><code>9cbfb998c9c4f8966d0df57e0065383a
1dacb65f3f3799ba5643cb3409e3aeec
</code></pre>
<p>Une seule lettre change, un <code>m</code> devenu <code>M</code>, et les deux empreintes n'ont plus rien en commun. Ce n'est pas un bug, c'est exactement ce qu'on demande à une fonction de hachage. Ça porte un nom : l'<strong>effet avalanche</strong>. Changer un seul bit en entrée doit faire basculer, en moyenne, la moitié des bits en sortie.</p>
<p>C'est indispensable en cryptographie et pour les tables de hachage. Pour notre problème, c'est l'inverse de ce qu'on veut.</p>
<p>Il nous faudrait une fonction de hachage <strong>qui préserve la ressemblance</strong> : deux textes proches doivent produire deux empreintes proches. Ça existe, ça s'appelle du <em>locality sensitive hashing</em> (hachage sensible à la localité), et SimHash en est le représentant le plus connu.</p>
<p>L'algorithme vient d'un article de Moses Charikar publié en 2002. Google l'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.</p>
<h2>Le principe : faire voter les morceaux du texte</h2>
<p>Imaginez une élection avec <strong>64 questions</strong>, chacune n'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'à la question n°63.</p>
<p>Les électeurs, ce sont les <strong>petits morceaux du texte</strong>. 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.</p>
<p>On dépouille ensuite question par question. Si les « oui » l'emportent, on note 1, sinon 0. On obtient 64 bits, et c'est l'empreinte du document.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/simhash/simhash.png" data-original-width="1536" data-original-height="1024"><source type="image/webp" srcset="/media/cache/content-webp/2026/simhash/simhash.5182b4e2.webp" /><source type="image/png" srcset="/media/cache/content/2026/simhash/simhash.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1536 / 1024)" src="https://jolicode.com//media/cache/content/2026/simhash/simhash.png" alt="simhash" /></picture></p>
<p>Prenons maintenant un document de 300 mots, donc environ 300 électeurs, et changeons un mot. Seuls 2 ou 3 électeurs changent d'avis. Sur la plupart des questions, la majorité était assez large pour que ces quelques voix ne changent rien au résultat. L'empreinte reste presque la même : un ou deux bits basculent, sur les questions où le vote était serré.</p>
<p>À l'inverse, deux documents qui n'ont rien à voir ont des électeurs complètement différents. Les votes n'ont aucune raison de coïncider, et les deux empreintes diffèrent sur environ la moitié des bits.</p>
<p>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.</p>
<h2>Étape 1 : découper le texte en shingles</h2>
<p>Il faut d'abord fabriquer les électeurs. La solution évidente serait de prendre les mots un par un. C'est une mauvaise idée.</p>
<p>Avec des mots isolés, le document devient un sac de mots et l'ordre disparaît complètement. Ces deux phrases auraient exactement la même empreinte :</p>
<pre><code>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…
</code></pre>
<p>Avec des électeurs d'un seul mot, la distance entre ces deux textes est de <strong>0</strong>. Ils sont considérés comme identiques, alors que le second n'a aucun sens.</p>
<p>La parade s'appelle un <strong>shingle</strong> (« bardeau », comme les tuiles d'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'un mot à chaque fois. Avec des shingles de 3 mots, la phrase <code>le chat mange la souris</code> donne :</p>
<pre><code>le chat mange
   chat mange la
        mange la souris
</code></pre>
<p>Les groupes se chevauchent, donc l'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 à <strong>36 bits sur 63</strong>. On est passé de « identiques » à « rien à voir », ce qui est bien le résultat attendu.</p>
<p>Voici le code :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">use</span><span class="syntax-5"> function</span><span class="syntax-2"> Symfony\Component\String\</span><span class="syntax-5">u</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-5">function</span><span class="syntax-8"> shingles</span><span class="syntax-2">(</span><span class="syntax-4">string</span><span class="syntax-2"> $text, </span><span class="syntax-4">int</span><span class="syntax-2"> $size </span><span class="syntax-4">=</span><span class="syntax-3"> 3</span><span class="syntax-2">)</span><span class="syntax-4">:</span><span class="syntax-4"> array</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-2">    $words </span><span class="syntax-4">=</span><span class="syntax-8"> u</span><span class="syntax-2">($text)</span><span class="syntax-4">-></span><span class="syntax-8">lower</span><span class="syntax-2">()</span><span class="syntax-4">-></span><span class="syntax-8">collapseWhitespace</span><span class="syntax-2">()</span><span class="syntax-4">-></span><span class="syntax-8">split</span><span class="syntax-2">(</span><span class="syntax-1">' '</span><span class="syntax-2">);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    if</span><span class="syntax-2"> (\</span><span class="syntax-9">count</span><span class="syntax-2">($words) </span><span class="syntax-4">&#x3C;</span><span class="syntax-2"> $size) {</span></span>
<span class="line"><span class="syntax-4">        return</span><span class="syntax-2"> [];</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">    $shingles </span><span class="syntax-4">=</span><span class="syntax-2"> [];</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    for</span><span class="syntax-2"> ($i </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">, $max </span><span class="syntax-4">=</span><span class="syntax-2"> \</span><span class="syntax-9">count</span><span class="syntax-2">($words) </span><span class="syntax-4">-</span><span class="syntax-2"> $size; $i </span><span class="syntax-4">&#x3C;=</span><span class="syntax-2"> $max; </span><span class="syntax-4">++</span><span class="syntax-2">$i) {</span></span>
<span class="line"><span class="syntax-2">        $shingles[] </span><span class="syntax-4">=</span><span class="syntax-8"> u</span><span class="syntax-2">(</span><span class="syntax-1">' '</span><span class="syntax-2">)</span><span class="syntax-4">-></span><span class="syntax-8">join</span><span class="syntax-2">(\</span><span class="syntax-9">array_slice</span><span class="syntax-2">($words, $i, $size))</span><span class="syntax-4">-></span><span class="syntax-8">toString</span><span class="syntax-2">();</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    return</span><span class="syntax-2"> $shingles;</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-9">var_dump</span><span class="syntax-2">(</span><span class="syntax-8">shingles</span><span class="syntax-2">(</span><span class="syntax-1">'le chat mange la souris'</span><span class="syntax-2">));</span></span></code></pre>
<pre><code>array(3) {
  [0] =&gt; string(13) &quot;le chat mange&quot;
  [1] =&gt; string(13) &quot;chat mange la&quot;
  [2] =&gt; string(15) &quot;mange la souris&quot;
}
</code></pre>
<p>Deux détails comptent :</p>
<ul>
<li><code>-&gt;lower()</code> évite que « Livraison » et « livraison » soient comptés comme deux électeurs différents ;</li>
<li><code>-&gt;collapseWhitespace()</code> normalise les espaces. Sans lui, un simple retour à la ligne dans le HTML suffirait à créer un shingle bidon.</li>
</ul>
<p>Notez enfin qu'un document de N mots produit N - 2 shingles avec <code>$size = 3</code>. Chaque mot apparaît dans 3 shingles au plus, donc <strong>changer un mot ne modifie que 3 électeurs</strong>.</p>
<h2>Étape 2 : hacher chaque shingle</h2>
<p>Chaque shingle doit maintenant produire ses 64 réponses. On lui applique une fonction de hachage classique (ici l'effet avalanche nous arrange : il garantit que deux shingles différents ont des avis indépendants), puis on lit les bits du résultat.</p>
<p>J'utilise <code>xxh3</code>, disponible nativement depuis PHP 8.1. Ce n'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'on l'appelle des centaines de fois par page.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$hash </span><span class="syntax-4">=</span><span class="syntax-9"> unpack</span><span class="syntax-2">(</span><span class="syntax-1">'J'</span><span class="syntax-2">, </span><span class="syntax-9">hash</span><span class="syntax-2">(</span><span class="syntax-1">'xxh3'</span><span class="syntax-2">, </span><span class="syntax-1">'le chat mange'</span><span class="syntax-2">, </span><span class="syntax-3">true</span><span class="syntax-2">))[</span><span class="syntax-3">1</span><span class="syntax-2">];</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-9">printf</span><span class="syntax-2">(</span><span class="syntax-1">"%064b</span><span class="syntax-3">\n</span><span class="syntax-1">"</span><span class="syntax-2">, $hash);</span></span></code></pre>
<p>Le troisième argument de <code>hash()</code> à <code>true</code> demande une sortie <strong>binaire</strong> (8 octets bruts) plutôt qu'hexadécimale. <code>unpack('J', …)</code> interprète ensuite ces 8 octets comme un entier 64 bits non signé, en Big-endian.</p>
<pre><code>1101000100000011111000001000010101011010001111100011111100000011
</code></pre>
<p>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.</p>
<h3>Pourquoi pas  <code>hexdec()</code> ?</h3>
<p>Le réflexe naturel serait plutôt d'écrire ceci :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$hash </span><span class="syntax-4">=</span><span class="syntax-9"> hexdec</span><span class="syntax-2">(</span><span class="syntax-9">hash</span><span class="syntax-2">(</span><span class="syntax-1">'xxh3'</span><span class="syntax-2">, $shingle)); </span><span class="syntax-10">// ✗ NON</span></span></code></pre>
<p>Et ça ne marche pas :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-9">var_dump</span><span class="syntax-2">(</span><span class="syntax-9">hash</span><span class="syntax-2">(</span><span class="syntax-1">'xxh3'</span><span class="syntax-2">, </span><span class="syntax-1">'le chat mange'</span><span class="syntax-2">));       </span><span class="syntax-10">// string(16) "d103e0855a3e3f03"</span></span>
<span class="line"><span class="syntax-9">var_dump</span><span class="syntax-2">(</span><span class="syntax-9">hexdec</span><span class="syntax-2">(</span><span class="syntax-1">'d103e0855a3e3f03'</span><span class="syntax-2">));         </span><span class="syntax-10">// float(1.5061128442206372E+19)</span></span></code></pre>
<p><code>hexdec()</code> renvoie un <strong>float</strong> dès que la valeur dépasse <code>PHP_INT_MAX</code>. Or un float sur 64 bits n'a que 53 bits de mantisse : les 11 bits de poids faible sont perdus. Vos électeurs répondent alors n'importe quoi aux 11 dernières questions, et vous passez la soirée à chercher pourquoi l'algorithme marche mal.</p>
<p>Avec <code>unpack('J', …)</code>, on récupère un vrai <code>int</code>. Il sera parfois négatif, PHP n'ayant pas d'entiers non signés, mais ce n'est pas grave : c'est la <strong>configuration des bits</strong> qui nous intéresse, pas la valeur numérique. Et <code>($hash &gt;&gt; $bit) &amp; 1</code> lit correctement n'importe quel bit, même quand le nombre est négatif.</p>
<h2>Étape 3 : compter les votes</h2>
<p>On tient un compteur par question, initialisé à zéro. Chaque électeur qui répond « oui » l'incrémente, chaque « non » le décrémente.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$bits </span><span class="syntax-4">=</span><span class="syntax-3"> 63</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">$votes </span><span class="syntax-4">=</span><span class="syntax-9"> array_fill</span><span class="syntax-2">(</span><span class="syntax-3">0</span><span class="syntax-2">, $bits, </span><span class="syntax-3">0</span><span class="syntax-2">);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">foreach</span><span class="syntax-2"> ($shingles </span><span class="syntax-4">as</span><span class="syntax-2"> $shingle) {</span></span>
<span class="line"><span class="syntax-2">    $hash </span><span class="syntax-4">=</span><span class="syntax-9"> unpack</span><span class="syntax-2">(</span><span class="syntax-1">'J'</span><span class="syntax-2">, </span><span class="syntax-9">hash</span><span class="syntax-2">(</span><span class="syntax-1">'xxh3'</span><span class="syntax-2">, $shingle, </span><span class="syntax-3">true</span><span class="syntax-2">))[</span><span class="syntax-3">1</span><span class="syntax-2">];</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    for</span><span class="syntax-2"> ($bit </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">; $bit </span><span class="syntax-4">&#x3C;</span><span class="syntax-2"> $bits; </span><span class="syntax-4">++</span><span class="syntax-2">$bit) {</span></span>
<span class="line"><span class="syntax-4">        if</span><span class="syntax-2"> (</span><span class="syntax-3">1</span><span class="syntax-4"> ===</span><span class="syntax-2"> (($hash </span><span class="syntax-4">>></span><span class="syntax-2"> $bit) </span><span class="syntax-4">&#x26;</span><span class="syntax-3"> 1</span><span class="syntax-2">)) {</span></span>
<span class="line"><span class="syntax-4">            ++</span><span class="syntax-2">$votes[$bit];</span></span>
<span class="line"><span class="syntax-2">        } </span><span class="syntax-4">else</span><span class="syntax-2"> {</span></span>
<span class="line"><span class="syntax-4">            --</span><span class="syntax-2">$votes[$bit];</span></span>
<span class="line"><span class="syntax-2">        }</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Le <code>+1 / -1</code> n'est pas un détail de style. Si on se contentait de compter les « oui », il faudrait ensuite comparer à la moitié du nombre d'électeurs. Avec <code>+1 / -1</code>, le seuil est simplement zéro : un compteur positif signifie que les « oui » l'emportent. C'est plus simple à écrire et à lire.</p>
<p>C'est aussi ici qu'on pourrait <strong>pondérer</strong> les électeurs. Rien n'oblige à voter par pas de 1 : on peut donner plus de poids aux shingles rares (à la TF-IDF), ou à ceux qui apparaissent dans un <code>&lt;h1&gt;</code>. C'est la version pondérée de SimHash, et c'est une extension naturelle de ce <code>++$votes[$bit]</code>. Dans notre cas, le vote uniforme suffisait largement.</p>
<h2>Étape 4 : construire l'empreinte</h2>
<p>Il ne reste qu'à convertir les compteurs en bits :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$fingerprint </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">for</span><span class="syntax-2"> ($bit </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">; $bit </span><span class="syntax-4">&#x3C;</span><span class="syntax-2"> $bits; </span><span class="syntax-4">++</span><span class="syntax-2">$bit) {</span></span>
<span class="line"><span class="syntax-4">    if</span><span class="syntax-2"> ($votes[$bit] </span><span class="syntax-4">></span><span class="syntax-3"> 0</span><span class="syntax-2">) {</span></span>
<span class="line"><span class="syntax-2">        $fingerprint </span><span class="syntax-4">|=</span><span class="syntax-3"> 1</span><span class="syntax-4"> &#x3C;&#x3C;</span><span class="syntax-2"> $bit;</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p><code>1 &lt;&lt; $bit</code> fabrique un masque avec un seul bit à 1, à la position voulue, et le <code>|=</code> l'allume dans l'empreinte. Les compteurs négatifs ou nuls laissent le bit à 0.</p>
<h2>La classe complète</h2>
<p>Assemblons tout ça. Voici, à quelques détails près, ce qui tourne en production chez nous. Nous n'utilisons pas <code>symfony/string</code> dans la version finale pour des raisons de performances, je m'en suis servi plus haut pour la lisibilité.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">&#x3C;?</span><span class="syntax-3">php</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">final</span><span class="syntax-5"> class</span><span> </span><span class="syntax-6">SimHash</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    public</span><span class="syntax-5"> function</span><span class="syntax-8"> compute</span><span class="syntax-2">(</span><span class="syntax-4">string</span><span class="syntax-2"> $content, </span><span class="syntax-4">int</span><span class="syntax-2"> $shingleSize </span><span class="syntax-4">=</span><span class="syntax-3"> 3</span><span class="syntax-2">, </span><span class="syntax-4">int</span><span class="syntax-2"> $bits </span><span class="syntax-4">=</span><span class="syntax-3"> 63</span><span class="syntax-2">)</span><span class="syntax-4">:</span><span class="syntax-4"> ?int</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-2">        $words </span><span class="syntax-4">=</span><span class="syntax-9"> preg_split</span><span class="syntax-2">(</span><span class="syntax-1">'/</span><span class="syntax-3">\s</span><span class="syntax-4">+</span><span class="syntax-1">/'</span><span class="syntax-2">, </span><span class="syntax-9">mb_strtolower</span><span class="syntax-2">(</span><span class="syntax-9">trim</span><span class="syntax-2">($content)), </span><span class="syntax-4">-</span><span class="syntax-3">1</span><span class="syntax-2">, </span><span class="syntax-9">PREG_SPLIT_NO_EMPTY</span><span class="syntax-2">);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">        if</span><span class="syntax-2"> (\</span><span class="syntax-9">count</span><span class="syntax-2">($words) </span><span class="syntax-4">&#x3C;</span><span class="syntax-2"> $shingleSize) {</span></span>
<span class="line"><span class="syntax-4">            return</span><span class="syntax-3"> null</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">        }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">        $votes </span><span class="syntax-4">=</span><span class="syntax-9"> array_fill</span><span class="syntax-2">(</span><span class="syntax-3">0</span><span class="syntax-2">, $bits, </span><span class="syntax-3">0</span><span class="syntax-2">);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">        for</span><span class="syntax-2"> ($i </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">, $max </span><span class="syntax-4">=</span><span class="syntax-2"> \</span><span class="syntax-9">count</span><span class="syntax-2">($words) </span><span class="syntax-4">-</span><span class="syntax-2"> $shingleSize; $i </span><span class="syntax-4">&#x3C;=</span><span class="syntax-2"> $max; </span><span class="syntax-4">++</span><span class="syntax-2">$i) {</span></span>
<span class="line"><span class="syntax-2">            $shingle </span><span class="syntax-4">=</span><span class="syntax-9"> implode</span><span class="syntax-2">(</span><span class="syntax-1">' '</span><span class="syntax-2">, \</span><span class="syntax-9">array_slice</span><span class="syntax-2">($words, $i, $shingleSize));</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">            $hash </span><span class="syntax-4">=</span><span class="syntax-9"> unpack</span><span class="syntax-2">(</span><span class="syntax-1">'J'</span><span class="syntax-2">, </span><span class="syntax-9">hash</span><span class="syntax-2">(</span><span class="syntax-1">'xxh3'</span><span class="syntax-2">, $shingle, </span><span class="syntax-3">true</span><span class="syntax-2">))[</span><span class="syntax-3">1</span><span class="syntax-2">];</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">            for</span><span class="syntax-2"> ($bit </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">; $bit </span><span class="syntax-4">&#x3C;</span><span class="syntax-2"> $bits; </span><span class="syntax-4">++</span><span class="syntax-2">$bit) {</span></span>
<span class="line"><span class="syntax-4">                if</span><span class="syntax-2"> (</span><span class="syntax-3">1</span><span class="syntax-4"> ===</span><span class="syntax-2"> (($hash </span><span class="syntax-4">>></span><span class="syntax-2"> $bit) </span><span class="syntax-4">&#x26;</span><span class="syntax-3"> 1</span><span class="syntax-2">)) {</span></span>
<span class="line"><span class="syntax-4">                    ++</span><span class="syntax-2">$votes[$bit];</span></span>
<span class="line"><span class="syntax-2">                } </span><span class="syntax-4">else</span><span class="syntax-2"> {</span></span>
<span class="line"><span class="syntax-4">                    --</span><span class="syntax-2">$votes[$bit];</span></span>
<span class="line"><span class="syntax-2">                }</span></span>
<span class="line"><span class="syntax-2">            }</span></span>
<span class="line"><span class="syntax-2">        }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">        $fingerprint </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">        for</span><span class="syntax-2"> ($bit </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">; $bit </span><span class="syntax-4">&#x3C;</span><span class="syntax-2"> $bits; </span><span class="syntax-4">++</span><span class="syntax-2">$bit) {</span></span>
<span class="line"><span class="syntax-4">            if</span><span class="syntax-2"> ($votes[$bit] </span><span class="syntax-4">></span><span class="syntax-3"> 0</span><span class="syntax-2">) {</span></span>
<span class="line"><span class="syntax-2">                $fingerprint </span><span class="syntax-4">|=</span><span class="syntax-3"> 1</span><span class="syntax-4"> &#x3C;&#x3C;</span><span class="syntax-2"> $bit;</span></span>
<span class="line"><span class="syntax-2">            }</span></span>
<span class="line"><span class="syntax-2">        }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">        return</span><span class="syntax-2"> $fingerprint;</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Trente lignes, zéro dépendance, un seul <code>int</code> en sortie. On peut le stocker dans une colonne <code>BIGINT</code> et l'oublier.</p>
<p>Le <code>?int</code> mérite un mot. Si le texte est plus court que la taille d'un shingle, il n'y a aucun électeur, donc pas d'élection, donc pas d'empreinte. On renvoie <code>null</code> plutôt qu'un <code>0</code> qui ressemblerait à une vraie valeur et polluerait toutes nos comparaisons.</p>
<h2>Comparer deux empreintes : la distance de Hamming</h2>
<p>Nous savons fabriquer des empreintes. Reste à mesurer à quel point deux d'entre elles se ressemblent.</p>
<p>La mesure qui nous intéresse est simple : on compte le nombre de bits qui diffèrent. C'est la <strong>distance de Hamming</strong>.</p>
<pre><code>A     = 1 0 1 1
B     = 1 0 0 1
        ✓ ✓ ✗ ✓   →  distance = 1
</code></pre>
<p>Deux opérations suffisent pour la calculer :</p>
<ul>
<li>un XOR (<code>^</code>), qui met à 1 exactement les bits où les deux nombres diffèrent ;</li>
<li>un décompte des bits à 1 du résultat, opération qu'on appelle <em>popcount</em>.</li>
</ul>
<h3>Trois façons de compter des bits en PHP</h3>
<p>PHP n'expose pas de <code>popcount</code> natif, contrairement au processeur qui a une instruction dédiée. Il y a donc plusieurs options, et le classement m'a surpris.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">// 1. La boucle naïve : on regarde le bit de poids faible, on l'ajoute au total,</span></span>
<span class="line"><span class="syntax-10">//    on décale d'un cran vers la droite, et on recommence jusqu'à épuisement.</span></span>
<span class="line"><span class="syntax-5">function</span><span class="syntax-8"> hamming</span><span class="syntax-2">(</span><span class="syntax-4">int</span><span class="syntax-2"> $a, </span><span class="syntax-4">int</span><span class="syntax-2"> $b)</span><span class="syntax-4">:</span><span class="syntax-4"> int</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-2">    $xor </span><span class="syntax-4">=</span><span class="syntax-2"> $a </span><span class="syntax-4">^</span><span class="syntax-2"> $b;</span></span>
<span class="line"><span class="syntax-2">    $distance </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    while</span><span class="syntax-2"> (</span><span class="syntax-3">0</span><span class="syntax-4"> !==</span><span class="syntax-2"> $xor) {</span></span>
<span class="line"><span class="syntax-2">        $distance </span><span class="syntax-4">+=</span><span class="syntax-2"> $xor </span><span class="syntax-4">&#x26;</span><span class="syntax-3"> 1</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">        $xor </span><span class="syntax-4">>>=</span><span class="syntax-3"> 1</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    return</span><span class="syntax-2"> $distance;</span></span>
<span class="line"><span class="syntax-2">}</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10">// 2. Kernighan : $x &#x26; ($x - 1) efface le bit à 1 le plus à droite.</span></span>
<span class="line"><span class="syntax-10">//    On ne boucle donc qu'autant de fois qu'il y a de bits à 1.</span></span>
<span class="line"><span class="syntax-5">function</span><span class="syntax-8"> hammingKernighan</span><span class="syntax-2">(</span><span class="syntax-4">int</span><span class="syntax-2"> $a, </span><span class="syntax-4">int</span><span class="syntax-2"> $b)</span><span class="syntax-4">:</span><span class="syntax-4"> int</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-2">    $xor </span><span class="syntax-4">=</span><span class="syntax-2"> $a </span><span class="syntax-4">^</span><span class="syntax-2"> $b;</span></span>
<span class="line"><span class="syntax-2">    $distance </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    while</span><span class="syntax-2"> (</span><span class="syntax-3">0</span><span class="syntax-4"> !==</span><span class="syntax-2"> $xor) {</span></span>
<span class="line"><span class="syntax-2">        $xor </span><span class="syntax-4">&#x26;=</span><span class="syntax-2"> $xor </span><span class="syntax-4">-</span><span class="syntax-3"> 1</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">        ++</span><span class="syntax-2">$distance;</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    return</span><span class="syntax-2"> $distance;</span></span>
<span class="line"><span class="syntax-2">}</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10">// 3. Le tricheur : on délègue tout au moteur.</span></span>
<span class="line"><span class="syntax-5">function</span><span class="syntax-8"> hammingSubstr</span><span class="syntax-2">(</span><span class="syntax-4">int</span><span class="syntax-2"> $a, </span><span class="syntax-4">int</span><span class="syntax-2"> $b)</span><span class="syntax-4">:</span><span class="syntax-4"> int</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    return</span><span class="syntax-9"> substr_count</span><span class="syntax-2">(</span><span class="syntax-9">decbin</span><span class="syntax-2">($a </span><span class="syntax-4">^</span><span class="syntax-2"> $b), </span><span class="syntax-1">'1'</span><span class="syntax-2">);</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Sur 200 000 itérations :</p>
<table>
<thead>
<tr>
<th>Implémentation</th>
<th>Temps</th>
</tr>
</thead>
<tbody>
<tr>
<td>Boucle naïve</td>
<td>0,752 s</td>
</tr>
<tr>
<td>Kernighan</td>
<td>0,378 s</td>
</tr>
<tr>
<td><code>substr_count(decbin(...))</code></td>
<td><strong>0,084 s</strong></td>
</tr>
</tbody>
</table>
<p>Le « tricheur » gagne largement, et c'est logique : les deux autres exécutent leur boucle dans la VM PHP, alors que <code>decbin()</code> et <code>substr_count()</code> sont du C compilé. En PHP, la boucle la plus rapide est celle qu'on n'écrit pas.</p>
<p>Cela dit, dans notre code de production, cette fonction ne sert qu'aux tests unitaires.</p>
<h2>Est-ce que ça marche ?</h2>
<p>Vérifions sur un cas réaliste. Prenons une fiche produit de 300 mots environ, et fabriquons quatre variantes :</p>
<ul>
<li><strong>promo</strong> : la même page, avec une phrase de bandeau promotionnel en plus (28 mots) ;</li>
<li><strong>ville</strong> : la même page, où « Lyon » devient « Bordeaux » (1 mot) ;</li>
<li><strong>femme</strong> : la déclinaison femme du produit (4 expressions changées) ;</li>
<li><strong>autre</strong> : une recette de gâteau au chocolat, qui n'a rien à voir.</li>
</ul>
<p>Voici les distances de Hamming obtenues, sur 63 bits :</p>
<table>
<thead>
<tr>
<th></th>
<th>base</th>
<th>promo</th>
<th>ville</th>
<th>femme</th>
<th>autre</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>base</strong></td>
<td>0</td>
<td>5</td>
<td><strong>2</strong></td>
<td>5</td>
<td>27</td>
</tr>
<tr>
<td><strong>promo</strong></td>
<td>5</td>
<td>0</td>
<td>5</td>
<td>10</td>
<td>22</td>
</tr>
<tr>
<td><strong>ville</strong></td>
<td><strong>2</strong></td>
<td>5</td>
<td>0</td>
<td>7</td>
<td>27</td>
</tr>
<tr>
<td><strong>femme</strong></td>
<td>5</td>
<td>10</td>
<td>7</td>
<td>0</td>
<td>28</td>
</tr>
<tr>
<td><strong>autre</strong></td>
<td>27</td>
<td>27</td>
<td>28</td>
<td>28</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>Le cas qui nous intéressait au départ, un seul mot de différence sur 300, donne une distance de <strong>2</strong>. Pour rappel, <code>md5()</code> aurait donné deux empreintes totalement étrangères l'une à l'autre.</p>
<p>Les variantes plus substantielles, une phrase ajoutée ou une déclinaison produit, se situent entre 5 et 10. Elles se ressemblent, mais moins.</p>
<p>Le document sans rapport est à 27, soit un peu moins de la moitié de 63. C'est la valeur théorique attendue : deux documents indépendants ont une chance sur deux de tomber d'accord sur chaque question. Sur 300 paires de textes aléatoires, la moyenne est de <strong>30,8</strong> bits, avec des valeurs entre 20 et 45.</p>
<p>On obtient donc une grille de lecture assez nette :</p>
<table>
<thead>
<tr>
<th>Distance (sur 63 bits)</th>
<th>Interprétation</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>Même contenu, ou différences infimes</td>
</tr>
<tr>
<td>1 à 3</td>
<td>Quasi-doublon</td>
</tr>
<tr>
<td>4 à 10</td>
<td>Documents apparentés</td>
</tr>
<tr>
<td>~31</td>
<td>Aucun rapport</td>
</tr>
</tbody>
</table>
<h2>Bien choisir ses paramètres</h2>
<h3>La taille des shingles</h3>
<p>C'est un arbitrage entre sensibilité et robustesse. Un shingle de 1 mot ignore complètement l'ordre, nous l'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.</p>
<p><strong>3 mots</strong> est la valeur qu'on retrouve un peu partout dans la littérature, et c'est ce que nous utilisons. Ça capture les tournures de phrase sans être hypersensible.</p>
<h3>La longueur minimale du document</h3>
<p>C'est le paramètre qu'on oublie, et c'est le plus important. Reprenons l'élection : à 300 votants le résultat est stable, à 5 votants il bascule dès qu'une personne change d'avis.</p>
<p>Même modification, un mot changé, sur des documents de longueur croissante :</p>
<table>
<thead>
<tr>
<th>Longueur du document</th>
<th>Distance après un mot changé</th>
</tr>
</thead>
<tbody>
<tr>
<td>20 mots</td>
<td>8</td>
</tr>
<tr>
<td>50 mots</td>
<td>3</td>
</tr>
<tr>
<td>100 mots</td>
<td>5</td>
</tr>
<tr>
<td>200 mots</td>
<td>4</td>
</tr>
<tr>
<td>500 mots</td>
<td>1</td>
</tr>
<tr>
<td>1 000 mots</td>
<td>1</td>
</tr>
<tr>
<td>2 000 mots</td>
<td><strong>0</strong></td>
</tr>
</tbody>
</table>
<p><strong>SimHash n'est fiable que sur des textes longs.</strong> Sur 20 mots, changer un mot déplace l'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.</p>
<p>C'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'une détection manquée.</p>
<h3>Le seuil de décision</h3>
<p>Reste à trancher : à partir de quelle distance déclare-t-on un quasi-doublon ?</p>
<p>Nous avons retenu <strong>3</strong>, la même valeur que dans le papier de Google. Le tableau plus haut montre pourquoi c'est raisonnable : à 3 bits sur 63, on attrape « un mot a changé » (distance 2) sans attraper « c'est une variante du produit » (distance 5). Et on reste très loin des 31 bits du hasard.</p>
<p>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'est un réglage empirique, il faut le mesurer sur votre corpus.</p>
<h3>63 bits, pas 64</h3>
<p>Vous avez peut-être tiqué sur le <code>$bits = 63</code> par défaut, alors que je vous parle de 64 bits depuis le début. C'est volontaire :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-9">printf</span><span class="syntax-2">(</span><span class="syntax-1">"%d</span><span class="syntax-3">\n</span><span class="syntax-1">"</span><span class="syntax-2">, </span><span class="syntax-3">1</span><span class="syntax-4"> &#x3C;&#x3C;</span><span class="syntax-3"> 62</span><span class="syntax-2">); </span><span class="syntax-10">//  4611686018427387904</span></span>
<span class="line"><span class="syntax-9">printf</span><span class="syntax-2">(</span><span class="syntax-1">"%d</span><span class="syntax-3">\n</span><span class="syntax-1">"</span><span class="syntax-2">, </span><span class="syntax-3">1</span><span class="syntax-4"> &#x3C;&#x3C;</span><span class="syntax-3"> 63</span><span class="syntax-2">); </span><span class="syntax-10">// -9223372036854775808  ← aïe</span></span></code></pre>
<p>PHP n'a pas d'entiers non signés. Le bit 63 est le bit de signe : l'allumer rend l'empreinte négative. Ça ne casse pas l'algorithme en soi, le XOR et le popcount se moquent du signe, mais ça complique tout le reste de la chaîne :</p>
<ul>
<li>le décalage à droite <code>&gt;&gt;</code> propage le bit de signe ;</li>
<li>la sérialisation JSON devient bizarre ;</li>
<li>le stockage en base échoue si la colonne est un <code>UNSIGNED BIGINT</code>.</li>
</ul>
<p>En se limitant aux bits 0 à 62, l'empreinte reste dans <code>[0, PHP_INT_MAX]</code>. Elle rentre sans discussion dans un <code>BIGINT</code> signé, un <code>UInt64</code> ClickHouse ou un <code>bigint</code> PostgreSQL. On perd un bit sur 64, soit 1,5 % de précision, et on s'épargne toute une catégorie de bugs.</p>
<h2>Comparer à l'échelle</h2>
<p>Nous savons calculer des empreintes, mais nous n'avons pas encore trouvé les doublons.</p>
<p>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'est 500 000 comparaisons et PHP s'en sort. Sur 100 000 pages, c'est 5 milliards, et c'est mort.</p>
<p>Il n'y a pas non plus d'astuce d'indexation évidente, parce que la distance de Hamming n'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 <code>BETWEEN</code> ou un index B-tree classique ne servent à rien.</p>
<p>Notre solution tient en une phrase : <strong>on ne le fait pas en PHP</strong>. On laisse la base de données s'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.</p>
<h3>Chaque base a sa fonction</h3>
<p>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.</p>
<p><strong>MySQL / MariaDB</strong> : <code>BIT_COUNT()</code> fait le popcount, et <code>^</code> le XOR.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">SELECT</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-2">, </span><span class="syntax-3">b</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-2">, </span><span class="syntax-9">BIT_COUNT</span><span class="syntax-2">(</span><span class="syntax-3">a</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2"> ^ </span><span class="syntax-3">b</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2">) </span><span class="syntax-4">AS</span><span class="syntax-2"> distance</span></span>
<span class="line"><span class="syntax-4">FROM</span><span class="syntax-4"> page</span><span class="syntax-2"> a</span></span>
<span class="line"><span class="syntax-4">JOIN</span><span class="syntax-4"> page</span><span class="syntax-2"> b </span><span class="syntax-4">ON</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-4"> &#x3C;</span><span class="syntax-3"> b</span><span class="syntax-2">.</span><span class="syntax-3">url</span></span>
<span class="line"><span class="syntax-4">WHERE</span><span class="syntax-9"> BIT_COUNT</span><span class="syntax-2">(</span><span class="syntax-3">a</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2"> ^ </span><span class="syntax-3">b</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2">) </span><span class="syntax-4">&#x3C;=</span><span class="syntax-3"> 3</span><span class="syntax-2">;</span></span></code></pre>
<p><strong>PostgreSQL</strong> (14 et plus) : <code>bit_count()</code> existe, mais travaille sur des chaînes de bits, d'où le cast. Attention, le XOR sur les entiers s'écrit <code>#</code> et non <code>^</code>, qui est l'exponentiation.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">SELECT</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-2">, </span><span class="syntax-3">b</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-2">, </span><span class="syntax-9">bit_count</span><span class="syntax-2">((</span><span class="syntax-3">a</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2"> # </span><span class="syntax-3">b</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2">)::</span><span class="syntax-5">bit</span><span class="syntax-2">(</span><span class="syntax-3">64</span><span class="syntax-2">)) </span><span class="syntax-4">AS</span><span class="syntax-2"> distance</span></span>
<span class="line"><span class="syntax-4">FROM</span><span class="syntax-4"> page</span><span class="syntax-2"> a</span></span>
<span class="line"><span class="syntax-4">JOIN</span><span class="syntax-4"> page</span><span class="syntax-2"> b </span><span class="syntax-4">ON</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-4"> &#x3C;</span><span class="syntax-3"> b</span><span class="syntax-2">.</span><span class="syntax-3">url</span></span>
<span class="line"><span class="syntax-4">WHERE</span><span class="syntax-9"> bit_count</span><span class="syntax-2">((</span><span class="syntax-3">a</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2"> # </span><span class="syntax-3">b</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2">)::</span><span class="syntax-5">bit</span><span class="syntax-2">(</span><span class="syntax-3">64</span><span class="syntax-2">)) </span><span class="syntax-4">&#x3C;=</span><span class="syntax-3"> 3</span><span class="syntax-2">;</span></span></code></pre>
<p><strong>ClickHouse</strong> : <code>bitCount()</code> et <code>bitXor()</code>, tout en camelCase.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">SELECT</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-2">, </span><span class="syntax-3">b</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-2">, bitCount(bitXor(</span><span class="syntax-3">a</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2">, </span><span class="syntax-3">b</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2">)) </span><span class="syntax-4">AS</span><span class="syntax-2"> distance</span></span>
<span class="line"><span class="syntax-4">FROM</span><span class="syntax-4"> page</span><span class="syntax-2"> a</span></span>
<span class="line"><span class="syntax-4">JOIN</span><span class="syntax-4"> page</span><span class="syntax-2"> b </span><span class="syntax-4">ON</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">crawlId</span><span class="syntax-4"> =</span><span class="syntax-3"> b</span><span class="syntax-2">.</span><span class="syntax-3">crawlId</span></span>
<span class="line"><span class="syntax-4">WHERE</span><span class="syntax-2"> bitCount(bitXor(</span><span class="syntax-3">a</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2">, </span><span class="syntax-3">b</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2">)) </span><span class="syntax-4">&#x3C;=</span><span class="syntax-3"> 3</span><span class="syntax-2">;</span></span></code></pre>
<p><strong>SQLite</strong> : c'est le seul de la bande à n'avoir aucun popcount intégré. Il faut enregistrer une fonction utilisateur depuis PHP.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$pdo</span><span class="syntax-4">-></span><span class="syntax-8">sqliteCreateFunction</span><span class="syntax-2">(</span><span class="syntax-1">'hamming'</span><span class="syntax-2">, </span><span class="syntax-4">static</span><span class="syntax-5"> function</span><span class="syntax-2"> (</span><span class="syntax-4">int</span><span class="syntax-2"> $a, </span><span class="syntax-4">int</span><span class="syntax-2"> $b)</span><span class="syntax-4">:</span><span class="syntax-4"> int</span><span class="syntax-2"> {</span></span>
<span class="line"><span class="syntax-4">    return</span><span class="syntax-9"> substr_count</span><span class="syntax-2">(</span><span class="syntax-9">decbin</span><span class="syntax-2">($a </span><span class="syntax-4">^</span><span class="syntax-2"> $b), </span><span class="syntax-1">'1'</span><span class="syntax-2">);</span></span>
<span class="line"><span class="syntax-2">}, </span><span class="syntax-3">2</span><span class="syntax-2">);</span></span></code></pre>
<p>Ça fonctionne, mais on repasse par la VM PHP à chaque ligne, et on perd tout l'intérêt de la manœuvre. Pour ce genre de travail, SQLite n'est pas le bon outil.</p>
<h3>Ce que nous faisons en production</h3>
<p>Chez nous, les données de crawl vivent dans ClickHouse et la détection tourne à la fin du crawl. Le code ressemble à ça :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$sql </span><span class="syntax-4">=</span><span class="syntax-4"> &#x3C;&#x3C;&#x3C;</span><span class="syntax-4">'SQL'</span></span>
<span class="line"><span class="syntax-4">    SELECT DISTINCT</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-4"> AS</span><span class="syntax-4"> url</span></span>
<span class="line"><span class="syntax-4">    FROM</span><span class="syntax-2"> crawl_urls a</span></span>
<span class="line"><span class="syntax-4">    INNER JOIN</span><span class="syntax-2"> crawl_urls b </span><span class="syntax-4">ON</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">crawlId</span><span class="syntax-4"> =</span><span class="syntax-3"> b</span><span class="syntax-2">.</span><span class="syntax-3">crawlId</span></span>
<span class="line"><span class="syntax-4">    WHERE</span></span>
<span class="line"><span class="syntax-3">        a</span><span class="syntax-2">.</span><span class="syntax-3">projectId</span><span class="syntax-4"> =</span><span class="syntax-2"> :projectId</span></span>
<span class="line"><span class="syntax-4">        AND</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">crawlId</span><span class="syntax-4"> =</span><span class="syntax-2"> :crawlId</span></span>
<span class="line"><span class="syntax-4">        AND</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">contentSimhash</span><span class="syntax-4"> IS NOT NULL</span></span>
<span class="line"><span class="syntax-4">        AND</span><span class="syntax-3"> b</span><span class="syntax-2">.</span><span class="syntax-3">contentSimhash</span><span class="syntax-4"> IS NOT NULL</span></span>
<span class="line"><span class="syntax-4">        AND</span><span class="syntax-3"> a</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-4"> !=</span><span class="syntax-3"> b</span><span class="syntax-2">.</span><span class="syntax-3">url</span></span>
<span class="line"><span class="syntax-4">        AND</span><span class="syntax-2"> bitCount(bitXor(</span><span class="syntax-3">a</span><span class="syntax-2">.</span><span class="syntax-3">contentSimhash</span><span class="syntax-2">, </span><span class="syntax-3">b</span><span class="syntax-2">.</span><span class="syntax-3">contentSimhash</span><span class="syntax-2">)) </span><span class="syntax-4">&#x3C;=</span><span class="syntax-2"> :threshold</span></span>
<span class="line"><span class="syntax-4">    SQL</span><span class="syntax-2">;</span></span></code></pre>
<p>Trois remarques sur cette requête :</p>
<ul>
<li><code>a.url != b.url</code> évite qu'une page soit son propre doublon. Sa distance à elle-même vaut 0, elle passerait tous les seuils du monde ;</li>
<li>le <code>IS NOT NULL</code> des deux côtés, c'est notre <code>?int</code> de tout à l'heure qui revient. Les pages trop courtes n'ont pas d'empreinte, et elles ne doivent pas participer ;</li>
<li>le <code>SELECT DISTINCT</code> et l'absence de <code>a.url &lt; b.url</code> : ici nous ne cherchons pas les paires, nous voulons <strong>marquer</strong> les pages concernées d'un drapeau <code>contentNearDuplicated</code>. Chaque page qui a au moins un voisin proche est signalée.</li>
</ul>
<h3>Le garde-fou</h3>
<p>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 :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">if</span><span class="syntax-2"> ($eligibleCount </span><span class="syntax-4">></span><span class="syntax-5"> CrawlConstants</span><span class="syntax-4">::</span><span class="syntax-3">MAX_URLS_FOR_NEAR_DUPLICATE_DETECTION</span><span class="syntax-2">) {</span></span>
<span class="line"><span class="syntax-11">    $this</span><span class="syntax-4">-></span><span class="syntax-2">logger</span><span class="syntax-4">-></span><span class="syntax-8">info</span><span class="syntax-2">(</span><span class="syntax-1">'Skipping near-duplicate content detection: too many eligible URLs.'</span><span class="syntax-2">, [</span></span>
<span class="line"><span class="syntax-1">        'crawlId'</span><span class="syntax-4"> =></span><span class="syntax-2"> $crawl</span><span class="syntax-4">-></span><span class="syntax-2">id,</span></span>
<span class="line"><span class="syntax-1">        'eligibleCount'</span><span class="syntax-4"> =></span><span class="syntax-2"> $eligibleCount,</span></span>
<span class="line"><span class="syntax-2">    ]);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    return</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Au-delà de 25 000 pages, nous ne faisons pas la détection de quasi-doublons. C'est un compromis assumé : cette analyse tourne sur le chemin critique de fin de crawl, et il vaut mieux une fonctionnalité absente qu'un crawl qui ne se termine jamais. La détection de doublons <strong>exacts</strong>, elle, n'est qu'un <code>GROUP BY</code> : elle continue de tourner quel que soit le volume.</p>
<h2>Aller plus loin : le principe des tiroirs</h2>
<p>Et si 25 000 pages ne suffisaient pas ? Il existe une astuce, décrite dans le papier de Google, qui permet d'indexer les recherches par distance de Hamming.</p>
<p>Le <strong>principe des tiroirs</strong> dit que si vous rangez 3 chaussettes dans 4 tiroirs, au moins un tiroir est forcément vide.</p>
<p>Appliquons-le. On découpe nos 63 bits en <strong>4 blocs</strong>. Si deux empreintes diffèrent d'au plus <strong>3 bits</strong>, alors ces 3 bits se répartissent dans au plus 3 blocs, donc <strong>au moins un bloc est strictement identique</strong> entre les deux empreintes. Et un bloc identique, un index B-tree classique sait le trouver instantanément.</p>
<p>On stocke donc les 4 blocs dans 4 colonnes indexées :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">CREATE</span><span class="syntax-4"> TABLE</span><span class="syntax-8"> page</span><span class="syntax-2"> (</span></span>
<span class="line"><span class="syntax-4">    url</span><span class="syntax-5">     text</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-2">    simhash </span><span class="syntax-5">bigint</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-2">    b0 </span><span class="syntax-5">int</span><span class="syntax-4"> GENERATED</span><span class="syntax-4"> ALWAYS</span><span class="syntax-4"> AS</span><span class="syntax-2"> ((simhash </span><span class="syntax-4">>></span><span class="syntax-3"> 48</span><span class="syntax-2">) &#x26; </span><span class="syntax-3">32767</span><span class="syntax-2">) STORED,</span></span>
<span class="line"><span class="syntax-2">    b1 </span><span class="syntax-5">int</span><span class="syntax-4"> GENERATED</span><span class="syntax-4"> ALWAYS</span><span class="syntax-4"> AS</span><span class="syntax-2"> ((simhash </span><span class="syntax-4">>></span><span class="syntax-3"> 32</span><span class="syntax-2">) &#x26; </span><span class="syntax-3">65535</span><span class="syntax-2">) STORED,</span></span>
<span class="line"><span class="syntax-2">    b2 </span><span class="syntax-5">int</span><span class="syntax-4"> GENERATED</span><span class="syntax-4"> ALWAYS</span><span class="syntax-4"> AS</span><span class="syntax-2"> ((simhash </span><span class="syntax-4">>></span><span class="syntax-3"> 16</span><span class="syntax-2">) &#x26; </span><span class="syntax-3">65535</span><span class="syntax-2">) STORED,</span></span>
<span class="line"><span class="syntax-2">    b3 </span><span class="syntax-5">int</span><span class="syntax-4"> GENERATED</span><span class="syntax-4"> ALWAYS</span><span class="syntax-4"> AS</span><span class="syntax-2"> (simhash &#x26; </span><span class="syntax-3">65535</span><span class="syntax-2">) STORED</span></span>
<span class="line"><span class="syntax-2">);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">CREATE</span><span class="syntax-4"> INDEX</span><span class="syntax-8"> ON</span><span class="syntax-4"> page</span><span class="syntax-2"> (b0);</span></span>
<span class="line"><span class="syntax-4">CREATE</span><span class="syntax-4"> INDEX</span><span class="syntax-8"> ON</span><span class="syntax-4"> page</span><span class="syntax-2"> (b1);</span></span>
<span class="line"><span class="syntax-4">CREATE</span><span class="syntax-4"> INDEX</span><span class="syntax-8"> ON</span><span class="syntax-4"> page</span><span class="syntax-2"> (b2);</span></span>
<span class="line"><span class="syntax-4">CREATE</span><span class="syntax-4"> INDEX</span><span class="syntax-8"> ON</span><span class="syntax-4"> page</span><span class="syntax-2"> (b3);</span></span></code></pre>
<p>La recherche se fait ensuite en deux temps : un <strong>filtrage</strong> par index pour récupérer une poignée de candidats, puis une <strong>vérification</strong> exacte sur ce petit paquet.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">SELECT</span><span class="syntax-3"> p</span><span class="syntax-2">.</span><span class="syntax-3">url</span><span class="syntax-2">, </span><span class="syntax-9">bit_count</span><span class="syntax-2">((</span><span class="syntax-3">p</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2"> # :</span><span class="syntax-4">target</span><span class="syntax-2">)::</span><span class="syntax-5">bit</span><span class="syntax-2">(</span><span class="syntax-3">64</span><span class="syntax-2">)) </span><span class="syntax-4">AS</span><span class="syntax-2"> distance</span></span>
<span class="line"><span class="syntax-4">FROM</span><span class="syntax-4"> page</span><span class="syntax-2"> p</span></span>
<span class="line"><span class="syntax-4">WHERE</span><span class="syntax-2"> (</span></span>
<span class="line"><span class="syntax-3">       p</span><span class="syntax-2">.</span><span class="syntax-3">b0</span><span class="syntax-4"> =</span><span class="syntax-2"> (:</span><span class="syntax-4">target</span><span class="syntax-4"> >></span><span class="syntax-3"> 48</span><span class="syntax-2">) &#x26; </span><span class="syntax-3">32767</span></span>
<span class="line"><span class="syntax-4">    OR</span><span class="syntax-3"> p</span><span class="syntax-2">.</span><span class="syntax-3">b1</span><span class="syntax-4"> =</span><span class="syntax-2"> (:</span><span class="syntax-4">target</span><span class="syntax-4"> >></span><span class="syntax-3"> 32</span><span class="syntax-2">) &#x26; </span><span class="syntax-3">65535</span></span>
<span class="line"><span class="syntax-4">    OR</span><span class="syntax-3"> p</span><span class="syntax-2">.</span><span class="syntax-3">b2</span><span class="syntax-4"> =</span><span class="syntax-2"> (:</span><span class="syntax-4">target</span><span class="syntax-4"> >></span><span class="syntax-3"> 16</span><span class="syntax-2">) &#x26; </span><span class="syntax-3">65535</span></span>
<span class="line"><span class="syntax-4">    OR</span><span class="syntax-3"> p</span><span class="syntax-2">.</span><span class="syntax-3">b3</span><span class="syntax-4"> =</span><span class="syntax-2"> :</span><span class="syntax-4">target</span><span class="syntax-2"> &#x26; </span><span class="syntax-3">65535</span></span>
<span class="line"><span class="syntax-2">  )</span></span>
<span class="line"><span class="syntax-4">  AND</span><span class="syntax-9"> bit_count</span><span class="syntax-2">((</span><span class="syntax-3">p</span><span class="syntax-2">.</span><span class="syntax-3">simhash</span><span class="syntax-2"> # :</span><span class="syntax-4">target</span><span class="syntax-2">)::</span><span class="syntax-5">bit</span><span class="syntax-2">(</span><span class="syntax-3">64</span><span class="syntax-2">)) </span><span class="syntax-4">&#x3C;=</span><span class="syntax-3"> 3</span><span class="syntax-2">;</span></span></code></pre>
<p>Le <code>WHERE</code> avec les <code>OR</code> passe par les index et ne remonte qu'un nombre réduit de lignes. La dernière condition, coûteuse, ne s'applique plus qu'à ces candidats. On est passé d'un balayage complet à une recherche indexée.</p>
<p>Un point mérite d'être souligné : ce filtrage n'a <strong>aucun faux négatif</strong>. Ce n'est pas une heuristique, c'est une garantie mathématique. Toute paire à distance ≤ 3 partage forcément au moins un bloc. Sur 25 000 paires d'empreintes générées à distance ≤ 3, les 25 000 partagent au moins un bloc identique.</p>
<p>En contrepartie, la méthode ne marche que pour le seuil pour lequel on l'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'est le genre d'optimisation qu'on met en place quand on l'a mesurée nécessaire, pas avant.</p>
<h2>Ce que SimHash ne sait pas faire</h2>
<p>Il y a des limites, autant les connaître :</p>
<ul>
<li><strong>Ce n'est pas une mesure de similarité fine.</strong> SimHash répond « proche » ou « pas proche », pas « similaire à 73 % ». La distance de Hamming approxime la similarité cosinus, mais l'approximation est grossière dans les valeurs intermédiaires. Si vous avez besoin d'un vrai score, il vous faut autre chose ;</li>
<li><strong>Ça ne comprend rien au sens.</strong> 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 ;</li>
<li><strong>Ce n'est pas cryptographique.</strong> SimHash est conçu pour que des entrées proches donnent des sorties proches, c'est-à-dire l'inverse des propriétés qu'on attend d'une fonction de hachage sécurisée. Fabriquer une collision est trivial. Ne l'utilisez jamais pour de la sécurité.</li>
</ul>
<p>Il existe enfin une alternative sérieuse : <strong>MinHash</strong>, 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'entier unique gagne largement.</p>
<h2>Conclusion</h2>
<p>SimHash tient en une idée : <strong>faire voter les morceaux d'un document, bit par bit</strong>. Quelques électeurs qui changent d'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.</p>
<p>C'est cette réduction qui rend la chose utilisable. Un document devient un <code>BIGINT</code>. La question « ces pages se ressemblent-elles ? » devient <code>BIT_COUNT(a ^ b) &lt;= 3</code>, une expression que MySQL, PostgreSQL et ClickHouse évaluent nativement, sans jamais remonter une ligne jusqu'à PHP.</p>
<p>Chez nous, ça représente trente lignes de PHP à l'écriture, une requête SQL à la lecture, et un garde-fou à 25 000 URL pour dormir tranquille. Ce n'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'une ville.</p>
<p>Un algorithme de 2002 qui fait toujours le travail. Pas mal non ?</p>]]></description></item><item><title>Quand le cache de Symfony ralentit votre application...</title><link>https://jolicode.com/blog/quand-le-cache-de-symfony-ralentit-votre-application</link><author>JoliCode Team</author><date>Wed, 12 Aug 2026 08:42:00 +0000</date><description><![CDATA[<p>Mettre une valeur en cache, c'est toujours plus rapide, non ? Et bien, pas forcément ! Cet article partage l'analyse d'un problème de performance causé par la protection &quot;anti-stampede&quot; du composant Cache de Symfony, et sur la manière dont nous l'avons diagnostiqué puis corrigé.</p>
<p>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 <em>cache stampede</em>, un mécanisme que nous ne connaissions pas vraiment avant cet épisode.</p>
<p>Comme il est peu documenté et qu'il peut concerner beaucoup d'applications, voici le détail du problème, la démarche de diagnostic, et le correctif que nous avons retenu.</p>
<h2>Le symptôme : une médiathèque très lente</h2>
<p>Sur cette application, la médiathèque de l'admin est propulsée par <a rel="nofollow noopener noreferrer" href="https://mediabundle.jolicode.com/">JoliMediaBundle</a>. Depuis quelque temps, son affichage était devenu très lent : à l'ouverture d'un dossier de médias, le premier affichage prenait entre 10 et 20 secondes de TTFB (<em>Time To First Byte</em>), parfois davantage. Les circonstances de cette lenteur étaient assez curieuses :</p>
<ul>
<li>la <em>première</em> visite d'un dossier était lente, mais les visites suivantes étaient instantanées ;</li>
<li>l'environnement de préproduction, pourtant identique (même code, même configuration, même stockage), était parfaitement fluide ;</li>
<li>sans lien apparent, d'autres parties de l'application souffraient de lenteurs <em>aléatoires</em> : un même endpoint d'API répondait tantôt en 100 ms, tantôt en 5 secondes.</li>
</ul>
<p>Un problème qui ne se reproduit ni en local ni en préproduction, et qui frappe au hasard : le diagnostic s'annonçait laborieux 😅</p>
<h2>Les fausses pistes</h2>
<p>Une médiathèque lente, des fichiers sur un montage réseau : le suspect naturel, c'est le stockage. C'est donc par là que j'ai commencé : vérification des options de montage, mesure des I/O, benchmark de lecture ou d'écriture des fichiers... tout allait bien de ce côté.</p>
<p>Au passage, ce n'est d'ailleurs pas si surprenant : JoliMediaBundle est conçu pour rester performant même lorsque le stockage est lent. Les variations d'images sont pré-générées, les métadonnées sont mises en cache, et les pages d'admin ne déclenchent pas de traitement d'image à la volée. Il faut chercher ailleurs.</p>
<p>Deuxième piste : les endpoints d'API aléatoirement lents. L'un d'eux passe par un <em>transformer</em> 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'étranglement, nous avons ouvert plusieurs pull requests pour les optimiser : index, réécriture de requêtes, réduction du nombre d'allers-retours...</p>
<p>Résultat : des requêtes plus propres, mais aucune amélioration mesurable en production. L'endpoint continuait de mettre parfois 5, voire 10 secondes à répondre. Quand une optimisation SQL ne change rien, c'est souvent que le temps n'est pas passé dans le SQL.</p>
<h2>Profiler plutôt que supposer</h2>
<p>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'empiler les hypothèses. Sur une trace de la médiathèque, une ligne écrasait toutes les autres :</p>
<blockquote>
<p><code>Symfony\Component\Cache\LockRegistry::compute</code> - <strong>14,8 secondes de self-time</strong></p>
</blockquote>
<p>14,8 secondes passées non pas à calculer quoi que ce soit, mais à attendre un <code>flock()</code>. L'application ne passe pas son temps à lire des fichiers ni à exécuter des requêtes, elle attend qu'un verrou posé par le composant Cache de Symfony se libère. Le cache, ce composant que l'on ajoute précisément pour aller <em>plus vite</em>, est donc d'un coup devenu notre goulot d'étranglement... Oups !</p>
<p>Il est temps d'aller lire son code pour comprendre ce qui se passe dans une <code>$cache-&gt;get()</code>.</p>
<h2><code>LockRegistry</code>, la protection anti-stampede de Symfony</h2>
<p>Avant ce debugging, je n'étais pas vraiment familier de cette classe et ne savais pas précisément ce qui se passe lorsqu'on écrit ce code pourtant banal :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$value </span><span class="syntax-4">=</span><span class="syntax-11"> $this</span><span class="syntax-4">-></span><span class="syntax-2">cache</span><span class="syntax-4">-></span><span class="syntax-8">get</span><span class="syntax-2">(</span><span class="syntax-1">'my_key'</span><span class="syntax-2">, </span><span class="syntax-5">function</span><span class="syntax-2"> (</span><span class="syntax-5">ItemInterface</span><span class="syntax-2"> $item)</span><span class="syntax-4">:</span><span class="syntax-4"> array</span><span class="syntax-2"> {</span></span>
<span class="line"><span class="syntax-2">    $item</span><span class="syntax-4">-></span><span class="syntax-8">expiresAfter</span><span class="syntax-2">(</span><span class="syntax-3">3600</span><span class="syntax-2">);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    return</span><span class="syntax-11"> $this</span><span class="syntax-4">-></span><span class="syntax-8">computeSomethingExpensive</span><span class="syntax-2">();</span></span>
<span class="line"><span class="syntax-2">});</span></span></code></pre>
<p>Imaginez une clé de cache très demandée qui expire. Au moment de l'expiration, toutes les requêtes en cours constatent simultanément le <em>cache miss</em>, 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'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'est le <em><a rel="nofollow noopener noreferrer" href="https://en.wikipedia.org/wiki/Cache_stampede">cache stampede</a></em> (la ruée vers le cache), et c'est un vrai problème.</p>
<p>Heureusement, Symfony propose contre ce phénomène deux protections complémentaires:</p>
<ol>
<li>l'expiration probabiliste anticipée (le paramètre &quot;<code>$beta</code>&quot; de <code>CacheInterface::get()</code>) : ce paramètre permet de moduler la probabilité qu'une clé de cache soit re-calculée en avance, même si elle n'est pas encore expirée. Plus une valeur approche de sa date d'expiration, plus il devient probable qu'une requête la recalcule <em>avant</em> l'expiration, ce qui lisse les recalculs dans le temps et évite que tous les recalculs soient groupés à heure fixe ;</li>
<li>le <code>LockRegistry</code> : 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 &quot;foo&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'est donc effectué qu'une seule fois.</li>
</ol>
<p><picture><source type="image/webp" srcset="/media/cache/content-webp/2026/cache-lock/cache-lock-flow.4221c45d.webp" /><source type="image/png" srcset="/media/cache/content/2026/cache-lock/cache-lock-flow.png" /><img loading="lazy" decoding="async" style="width: 714px; ; aspect-ratio: calc(714 / 835)" src="https://jolicode.com//media/cache/content/2026/cache-lock/cache-lock-flow.png" alt="Le diagramme de séquence d'une collision de verrous" /></picture></p>
<p>Le principe est sain. C'est son implémentation qu'il faut connaître pour comprendre notre problème.</p>
<h3>Des verrous posés sur... les fichiers du vendor</h3>
<p>Comment poser un verrou partagé entre tous les processus PHP d'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'un service externe ? La réponse apportée par la classe <code>LockRegistry</code> est astucieuse : en posant des <a rel="nofollow noopener noreferrer" href="https://www.php.net/manual/fr/function.flock.php"><code>flock()</code></a> sur des fichiers dont on est sûr qu'ils existent sur toutes les installations... les fichiers PHP du composant Cache lui-même !</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">// vendor/symfony/cache/LockRegistry.php</span></span>
<span class="line"><span class="syntax-4">private</span><span class="syntax-4"> static</span><span class="syntax-4"> array</span><span class="syntax-2"> $files </span><span class="syntax-4">=</span><span class="syntax-2"> [</span></span>
<span class="line"><span class="syntax-3">    __DIR__</span><span class="syntax-4">.</span><span class="syntax-9">\DIRECTORY_SEPARATOR</span><span class="syntax-4">.</span><span class="syntax-1">'Adapter'</span><span class="syntax-4">.</span><span class="syntax-9">\DIRECTORY_SEPARATOR</span><span class="syntax-4">.</span><span class="syntax-1">'AbstractAdapter.php'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-3">    __DIR__</span><span class="syntax-4">.</span><span class="syntax-9">\DIRECTORY_SEPARATOR</span><span class="syntax-4">.</span><span class="syntax-1">'Adapter'</span><span class="syntax-4">.</span><span class="syntax-9">\DIRECTORY_SEPARATOR</span><span class="syntax-4">.</span><span class="syntax-1">'AbstractTagAwareAdapter.php'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-3">    __DIR__</span><span class="syntax-4">.</span><span class="syntax-9">\DIRECTORY_SEPARATOR</span><span class="syntax-4">.</span><span class="syntax-1">'Adapter'</span><span class="syntax-4">.</span><span class="syntax-9">\DIRECTORY_SEPARATOR</span><span class="syntax-4">.</span><span class="syntax-1">'AdapterInterface.php'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-10">    // ... la liste des fichiers du dossier Adapter/ du composant, soit 24 fichiers actuellement</span></span>
<span class="line"><span class="syntax-2">];</span></span></code></pre>
<p>Chaque clé de cache est affectée à l'un de ces fichiers par un modulo sur son hash :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$key </span><span class="syntax-4">=</span><span class="syntax-5"> self</span><span class="syntax-4">::</span><span class="syntax-2">$files </span><span class="syntax-4">?</span><span class="syntax-9"> abs</span><span class="syntax-2">(</span><span class="syntax-9">crc32</span><span class="syntax-2">($item</span><span class="syntax-4">-></span><span class="syntax-8">getKey</span><span class="syntax-2">())) </span><span class="syntax-4">%</span><span class="syntax-2"> \</span><span class="syntax-9">count</span><span class="syntax-2">(</span><span class="syntax-5">self</span><span class="syntax-4">::</span><span class="syntax-2">$files) </span><span class="syntax-4">:</span><span class="syntax-4"> -</span><span class="syntax-3">1</span><span class="syntax-2">;</span></span></code></pre>
<p>Il faut bien mesurer ce que cela implique :</p>
<ul>
<li>il n'existe que 24 &quot;slots&quot; de verrous pour toute la machine ;</li>
<li>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'hôte - php-fpm comme CLI ;</li>
<li>deux clés qui n'ont rien à voir l'une avec l'autre peuvent tomber sur le même slot, par simple collision de <code>crc32() % 24</code>.</li>
</ul>
<p>Autrement dit : quand un processus recalcule une valeur, il tient un verrou que n'importe quel autre recalcul, de n'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'il dure plusieurs secondes, tout le monde peut le payer.</p>
<p>Ce mécanisme explique au passage l'un de nos symptômes : la médiathèque était rapide en <em>revisite</em> parce que le verrou n'est pris qu'au moment de recalculer une valeur. Tant que la clé est chaude dans le cache, aucun verrou n'est sollicité. Seuls les <em>cache miss</em> paient l'addition.</p>
<h2>À l'assaut des coupables : des workers de calculs coûteux</h2>
<p>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 &quot;Messenger&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 <abbr title="Time To Live">TTL</abbr> 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.</p>
<p>Faisons le calcul :</p>
<ul>
<li>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 ;</li>
<li>un TTL de 120 secondes sur des milliers de domaines vérifiés en continu, cela signifie des recalculs permanents ;</li>
<li>10 workers par machine qui enchaînent ces recalculs, cela signifie qu'à tout instant, une bonne partie des 24 slots de <code>LockRegistry</code> est tenue par un worker en train d'attendre une réponse DNS.</li>
</ul>
<p>Pendant ce temps, côté php-fpm, une requête d'admin arrive : la médiathèque doit calculer les métadonnées d'un dossier froid, appelle <code>$cache-&gt;get()</code>, 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 &quot;perdants&quot; s'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.</p>
<p>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 🎉</p>
<h3>Oui, les workers CLI utilisent <code>LockRegistry</code></h3>
<p>En lisant le code du composant, on pourrait croire que la protection anti-stampede est désactivée en CLI : la méthode <code>setCallbackWrapper()</code> de <code>ContractsTrait</code> <a rel="nofollow noopener noreferrer" href="https://github.com/symfony/symfony/blob/7dbebd843d25b2f72e2f7dfbe044c632941ea924/src/Symfony/Component/Cache/Traits/ContractsTrait.php#L47-L49">contient un opt-out explicite lorsque <code>PHP_SAPI</code> vaut <code>cli</code></a>, les processus CLI étant supposés courts et peu concurrents.</p>
<p>Mais cet opt-out ne fonctionne que sur les branches 4.4 et 5.4. Depuis la branche 6.0, le <a rel="nofollow noopener noreferrer" href="https://github.com/symfony/symfony/commit/3516fc6eb7680a052124cdb5f4f3f4d7078343ac">passage aux propriétés typées</a>) a ajouté directement dans <code>doGet()</code> une initialisation de <code>$this-&gt;callbackWrapper ??= LockRegistry::compute(...);</code>, et lors du <a rel="nofollow noopener noreferrer" href="https://github.com/symfony/symfony/commit/eb749ec88b7b4a70babbffd6cca00db54999f01b">merge de 5.4 dans 6.0</a>, cette ligne a été conservée. Depuis Symfony 6.0, le wrapper est donc systématiquement initialisé à <code>LockRegistry::compute()</code> lors de <code>doGet()</code>, rendant ainsi inopérant l'opt-out pour le CLI. Les workers Messenger, processus CLI de longue durée, utilisent donc bien les mêmes verrous <code>flock()</code> que php-fpm... Et c'est exactement ce qui pose souci dans notre configuration.</p>
<h2>Isoler les callbacks lents tout en conservant des locks</h2>
<p>É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 <em>race conditions</em> lors de recalculs rapides. Le vrai problème, c'est le mélange des genres : des callbacks qui durent plusieurs secondes ne devraient pas partager leurs verrous avec le reste de l'application.</p>
<p>L'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'application continue de bénéficier de <code>LockRegistry</code> pour des callbacks rapides.</p>
<h3>Un pool dédié</h3>
<p>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 :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># config/packages/cache.yaml</span></span>
<span class="line"><span class="syntax-4">framework</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">    cache</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">        pools</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">            dns.cache</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">                adapter</span><span class="syntax-2">: </span><span class="syntax-1">cache.adapter.redis</span></span>
<span class="line"><span class="syntax-4">                provider</span><span class="syntax-2">: </span><span class="syntax-1">'redis://%env(REDIS_HOST)%'</span></span></code></pre>
<p>Le service consommateur cible explicitement ce pool grâce à l'attribut <code>#[Target]</code> :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">final</span><span class="syntax-4"> readonly</span><span class="syntax-5"> class</span><span> </span><span class="syntax-6">DnsResolver</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    public</span><span class="syntax-5"> function</span><span class="syntax-9"> __construct</span><span class="syntax-2">(</span></span>
<span class="line"><span class="syntax-2">        #[Target(</span><span class="syntax-1">'dns.cache'</span><span class="syntax-2">)]</span></span>
<span class="line"><span class="syntax-4">        private</span><span class="syntax-5"> CacheInterface</span><span class="syntax-2"> $cache,</span></span>
<span class="line"><span class="syntax-2">        ...</span></span>
<span class="line"><span class="syntax-2">    ) {</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<h3>Remplacer LockRegistry par un verrouillage basé sur les clés</h3>
<p>Second ingrédient : remplacer, sur ce pool uniquement, la stratégie de <code>LockRegistry</code> par un verrouillage <strong>par clé de cache</strong>, en s'appuyant sur le composant <a rel="nofollow noopener noreferrer" href="https://symfony.com/doc/current/components/lock.html">Lock</a> 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'autre.</p>
<p>Les adapters de cache de Symfony exposent le point d'extension qu'il nous faut : <code>AbstractAdapter::setCallbackWrapper()</code>, qui permet de substituer son propre callable à <code>LockRegistry::compute()</code>. Notre wrapper en reproduit la sémantique :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">final</span><span class="syntax-4"> readonly</span><span class="syntax-5"> class</span><span> </span><span class="syntax-6">DnsCacheStampedeProtection</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    private</span><span class="syntax-4"> const</span><span class="syntax-3"> int</span><span class="syntax-3"> RETRY_DELAY_US</span><span class="syntax-4"> =</span><span class="syntax-3"> 100_000</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    public</span><span class="syntax-5"> function</span><span class="syntax-9"> __construct</span><span class="syntax-2">(</span></span>
<span class="line"><span class="syntax-4">        private</span><span class="syntax-5"> LockFactory</span><span class="syntax-2"> $lockFactory,</span></span>
<span class="line"><span class="syntax-4">        private</span><span class="syntax-4"> float</span><span class="syntax-2"> $lockTtl </span><span class="syntax-4">=</span><span class="syntax-3"> 30.0</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-4">        private</span><span class="syntax-4"> float</span><span class="syntax-2"> $maxWait </span><span class="syntax-4">=</span><span class="syntax-3"> 10.0</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-2">    ) {</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    public</span><span class="syntax-5"> function</span><span class="syntax-9"> __invoke</span><span class="syntax-2">(</span><span class="syntax-4">callable</span><span class="syntax-2"> $callback, </span><span class="syntax-5">ItemInterface</span><span class="syntax-2"> $item, </span><span class="syntax-4">bool</span><span class="syntax-4"> &#x26;</span><span class="syntax-2">$save, </span><span class="syntax-5">CacheInterface</span><span class="syntax-2">&#x26;</span><span class="syntax-5">CacheItemPoolInterface</span><span class="syntax-2"> $pool, \</span><span class="syntax-5">Closure</span><span class="syntax-2"> $setMetadata, </span><span class="syntax-4">?</span><span class="syntax-5">LoggerInterface</span><span class="syntax-2"> $logger </span><span class="syntax-4">=</span><span class="syntax-3"> null</span><span class="syntax-2">, </span><span class="syntax-4">?float</span><span class="syntax-2"> $beta </span><span class="syntax-4">=</span><span class="syntax-3"> null</span><span class="syntax-2">)</span><span class="syntax-4">:</span><span class="syntax-4"> mixed</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-2">        $lock </span><span class="syntax-4">=</span><span class="syntax-11"> $this</span><span class="syntax-4">-></span><span class="syntax-2">lockFactory</span><span class="syntax-4">-></span><span class="syntax-8">createLock</span><span class="syntax-2">(</span><span class="syntax-1">'dns-cache:'</span><span class="syntax-4"> .</span><span class="syntax-2"> $item</span><span class="syntax-4">-></span><span class="syntax-8">getKey</span><span class="syntax-2">(), </span><span class="syntax-11">$this</span><span class="syntax-4">-></span><span class="syntax-2">lockTtl);</span></span>
<span class="line"><span class="syntax-2">        $deadline </span><span class="syntax-4">=</span><span class="syntax-9"> microtime</span><span class="syntax-2">(</span><span class="syntax-3">true</span><span class="syntax-2">) </span><span class="syntax-4">+</span><span class="syntax-11"> $this</span><span class="syntax-4">-></span><span class="syntax-2">maxWait;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">        while</span><span class="syntax-2"> (</span><span class="syntax-3">true</span><span class="syntax-2">) {</span></span>
<span class="line"><span class="syntax-4">            if</span><span class="syntax-2"> ($lock</span><span class="syntax-4">-></span><span class="syntax-8">acquire</span><span class="syntax-2">()) {</span></span>
<span class="line"><span class="syntax-10">                // nous avons gagné la course : on calcule, on sauve, on libère</span></span>
<span class="line"><span class="syntax-4">                try</span><span class="syntax-2"> {</span></span>
<span class="line"><span class="syntax-2">                    $value </span><span class="syntax-4">=</span><span class="syntax-2"> $callback($item, $save);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">                    if</span><span class="syntax-2"> ($save) {</span></span>
<span class="line"><span class="syntax-2">                        $setMetadata($item);</span></span>
<span class="line"><span class="syntax-2">                        $pool</span><span class="syntax-4">-></span><span class="syntax-8">save</span><span class="syntax-2">($item</span><span class="syntax-4">-></span><span class="syntax-8">set</span><span class="syntax-2">($value));</span></span>
<span class="line"><span class="syntax-2">                        $save </span><span class="syntax-4">=</span><span class="syntax-3"> false</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">                    }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">                    return</span><span class="syntax-2"> $value;</span></span>
<span class="line"><span class="syntax-2">                } </span><span class="syntax-4">finally</span><span class="syntax-2"> {</span></span>
<span class="line"><span class="syntax-2">                    $lock</span><span class="syntax-4">-></span><span class="syntax-8">release</span><span class="syntax-2">();</span></span>
<span class="line"><span class="syntax-2">                }</span></span>
<span class="line"><span class="syntax-2">            }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10">            // quelqu'un d'autre calcule cette clé : on attend un peu, puis on</span></span>
<span class="line"><span class="syntax-10">            // tente de relire la valeur qu'il a sauvegardée (avec beta = 0,</span></span>
<span class="line"><span class="syntax-10">            // pour ne pas déclencher d'expiration anticipée)</span></span>
<span class="line"><span class="syntax-9">            usleep</span><span class="syntax-2">(</span><span class="syntax-5">self</span><span class="syntax-4">::</span><span class="syntax-3">RETRY_DELAY_US</span><span class="syntax-2">);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10">            // ... relecture du pool, et calcul sans verrou si le délai</span></span>
<span class="line"><span class="syntax-10">            // d'attente maximal est dépassé : le verrouillage ne doit</span></span>
<span class="line"><span class="syntax-10">            // jamais empêcher d'obtenir une valeur</span></span>
<span class="line"><span class="syntax-2">        }</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>L'extrait ci-dessus est abrégé ; la version complète gère la relecture du pool avec <code>beta = 0</code>, le timeout d'attente et un mode dégradé : si le store de verrous est injoignable ou si l'attente dépasse <code>$maxWait</code>, on calcule sans verrou. Une protection anti-stampede qui empêcherait l'application de fonctionner serait en effet un remède pire que le mal.</p>
<h3>Une compiler pass pour installer ce wrapper</h3>
<p>Reste à &quot;brancher&quot; ce wrapper sur le pool. Petit piège d'intégration : en environnement de dev, le profiler de Symfony décore chaque pool d'un <code>TraceableAdapter</code>, qui n'expose pas <code>setCallbackWrapper()</code>. Un appel effectué à l'exécution sur le service injecté échouerait donc en dev. La solution consiste à passer par une compiler pass, qui ajoute l'appel de méthode sur la définition du service - la <code>CacheCollectorPass</code> de Symfony sait ensuite déplacer ces appels sur l'adapter interne lorsqu'elle installe sa décoration :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">final</span><span class="syntax-4"> readonly</span><span class="syntax-5"> class</span><span> </span><span class="syntax-6">DnsCachePoolPass</span><span class="syntax-4"> implements</span><span> </span><span class="syntax-7">CompilerPassInterface</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    public</span><span class="syntax-5"> function</span><span class="syntax-8"> process</span><span class="syntax-2">(</span><span class="syntax-5">ContainerBuilder</span><span class="syntax-2"> $container)</span><span class="syntax-4">:</span><span class="syntax-4"> void</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-2">        $container</span><span class="syntax-4">-></span><span class="syntax-8">getDefinition</span><span class="syntax-2">(</span><span class="syntax-1">'dns.cache'</span><span class="syntax-2">)</span></span>
<span class="line"><span class="syntax-4">            -></span><span class="syntax-8">addMethodCall</span><span class="syntax-2">(</span><span class="syntax-1">'setCallbackWrapper'</span><span class="syntax-2">, [</span><span class="syntax-4">new</span><span class="syntax-5"> Reference</span><span class="syntax-2">(</span><span class="syntax-5">DnsCacheStampedeProtection</span><span class="syntax-4">::class</span><span class="syntax-2">)])</span></span>
<span class="line"><span class="syntax-2">        ;</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Et c'est tout : le reste de l'application n'a pas bougé d'une ligne, et continue de bénéficier de <code>LockRegistry</code> pour ses callbacks rapides. On aurait peut-être aussi pu jouer avec la priorité du décorateur, mais cette solution n'a pas été explorée.</p>
<h3>Une autre approche : agrandir le pool de verrous</h3>
<p>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'est finalement assez peu, surtout si ça peut suffire à provoquer des collisions entre clés de cache qui n'ont rien à voir les une avec les autres.</p>
<p>Nous avons donc choisi d'agrandir le nombre des verrous disponibles, afin que, pour les pools qui emploient encore <code>LockRegistry</code>, le risque de collision et d'attente indue soit restreint.</p>
<p>En effet, rien n'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 &quot;supports&quot; des appels à <code>flock()</code> pour diluer fortement la probabilité de collision.</p>
<p>L'appel doit être fait le plus tôt possible (en tout cas avant le premier <code>$cache-&gt;get()</code>) ; <code>Kernel::boot()</code> est un bon candidat, puisqu'il couvre à la fois les entrées HTTP et les processus CLI :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">// src/Kernel.php</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Component\Cache\</span><span class="syntax-5">LockRegistry</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-5">class</span><span> </span><span class="syntax-6">Kernel</span><span class="syntax-4"> extends</span><span> </span><span class="syntax-7">BaseKernel</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    public</span><span class="syntax-5"> function</span><span class="syntax-8"> boot</span><span class="syntax-2">()</span><span class="syntax-4">:</span><span class="syntax-4"> void</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-5">        parent</span><span class="syntax-4">::</span><span class="syntax-8">boot</span><span class="syntax-2">();</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10">        // 1000 fichiers immutables créés au déploiement</span></span>
<span class="line"><span class="syntax-5">        LockRegistry</span><span class="syntax-4">::</span><span class="syntax-8">setFiles</span><span class="syntax-2">(</span><span class="syntax-9">glob</span><span class="syntax-2">(</span><span class="syntax-11">$this</span><span class="syntax-4">-></span><span class="syntax-8">getProjectDir</span><span class="syntax-2">() </span><span class="syntax-4">.</span><span class="syntax-1"> '/var/cache-locks/*.lock'</span><span class="syntax-2">));</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>C'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.</p>
<h2>En conclusion</h2>
<p>Après déploiement du correctif, la médiathèque est redevenue fluide, y compris à la première visite d'un dossier - les 10 à 22 secondes de TTFB ont disparu, et les différents endpoints d'API aléatoirement lents se sont &quot;assagi&quot;. Sur l'un d'eux, qui utilise le cache dans un transformer, le premier appel &quot;froid&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'attendre derrière des résolutions DNS qui ne nous concernaient pas.</p>
<p>Au final, il y a quelques bonnes leçons à tirer de cette expérience. D'abord, mettre en cache, ce n'est pas une opération gratuite. On a tendance à considérer <code>$cache-&gt;get()</code> comme un réflexe sans risque : &quot;au pire, ça ne servira à rien, et au mieux, ça accélèrera les choses&quot;. Ce n'est pas tout à fait vrai :</p>
<ul>
<li>dès lors qu'une protection <em>anti-stampede</em> 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'application qui n'ont rien à voir avec lui. Avant de mettre en cache un calcul, posez-vous la question : combien de temps dure-t-il, au pire ?</li>
<li>et au-delà de  cette considération, nous sommes assez partisans de l'approche &quot;moins de code = moins de bugs&quot; : si on n'a pas strictement besoin de cache, autant s'en passer !</li>
</ul>
<p>Exécuter des workers (asynchrones) sur les mêmes machines que des services web (synchrones) amplifie le problème. Même si c'est une pratique économique et courante, cela pose des soucis car <code>LockRegistry</code> 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 !</p>
<p>Le profiling a bien aidé pour comprendre ce problème. L'attente d'un <code>flock()</code> 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'impression que tout va bien..! Si nous n'avions pas eu sous la main une trace qui montre où le temps s'écoule réellement, nous chercherions encore. Peu importe l'outil utilisé pour <em>profiler</em> - <a rel="nofollow noopener noreferrer" href="https://www.blackfire.io/">Blackfire</a> 💛, Sentry, ou tout autre outil capable d'afficher le temps passé fonction par fonction : l'important est d'en avoir un en production, et de le consulter avant de formuler des hypothèses.</p>
<p>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'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!</p>]]></description></item><item><title>La fin de PHP Extension Repository</title><link><![CDATA[https://nahan.fr/la-fin-de-php-extension-repository?pk_campaign=feed&pk_kwd=la-fin-de-php-extension-repository]]></link><author>Jean-Baptiste Nahan</author><date>Mon, 10 Aug 2026 19:03:00 +0000</date><description><![CDATA[<p>Arrêt du PHP Extension Repository : temps, coûts et nouvelles règles rendent le maintien difficile. Pourquoi je ferme le site après des années d'efforts. Comment j'en suis arrivé là ?</p>
The post <a href="https://nahan.fr/la-fin-de-php-extension-repository?pk_campaign=feed&pk_kwd=la-fin-de-php-extension-repository">La fin de PHP Extension Repository</a> first appeared on <a href="https://nahan.fr/">JB Dev Labs</a>.<img src="https://analytics.nahan.fr/piwik.php?idsite=1&amp;rec=1&amp;url=https%3A%2F%2Fnahan.fr%2Fla-fin-de-php-extension-repository%3Fpk_campaign%3Dfeed%26pk_kwd%3Dla-fin-de-php-extension-repository&amp;action_name=La%20fin%20de%20PHP%20Extension%20Repository&amp;urlref=https%3A%2F%2Fnahan.fr%2Ffeed" style="border:0;width:0;height:0" width="0" height="0" alt="" />]]></description></item><item><title>L'Agent Development Environment : une nouvelle unit&#xE9; de travail</title><link>https://jolicode.com/blog/l-agent-development-environment-une-nouvelle-unite-de-travail</link><author>JoliCode Team</author><date>Mon, 10 Aug 2026 08:42:00 +0000</date><description><![CDATA[<p>Régulièrement, quelque chose arrive et change ce que « écrire du code » veut dire. Cette fois, il se pourrait que ça ne veuille plus dire écrire du tout.</p>
<h2>D'où je viens</h2>
<p>Ce qui suit est mon parcours, pas une histoire de PHP. D'autres ont vécu ces mêmes années très différemment.</p>
<p>Mon histoire commence en 2013, quand PHP n'était pas le choix évident qu'il allait devenir, donc j'ai appris le C : pointeurs, gestion mémoire manuelle, segfaults. Ensuite je suis passé à l'Objective-C pour du développement iPad, avec un peu de Smalltalk à côté, pour maintenir un vieux site interne.</p>
<p>Pendant cette période, je suis tombé amoureux du web. J'ai rejoint une entreprise qui faisait du PHP, un langage complètement nouveau pour moi. La stack là-bas, c'était PHP 5.x, pas d'autoloading, presque aucune librairie, et pas de framework. Composer et Symfony existaient déjà, on ne les utilisait simplement pas. Chaque projet démarrait de zéro, et on réinventait beaucoup de roues. C'était plus dur, oui, mais c'était comme ça chez nous.</p>
<p>Puis j'ai déménagé à Paris, et c'est là que j'ai découvert les frameworks. <a rel="nofollow noopener noreferrer" href="https://laravel.com">Laravel</a> et <a rel="nofollow noopener noreferrer" href="https://symfony.com">Symfony</a> faisaient tous les deux du bruit, et j'ai commencé par Laravel. D'un coup, une communauté entière avait déjà résolu les problèmes que je résolvais seul. Pour moi, les frameworks n'étaient pas juste des outils, c'était un multiplicateur.</p>
<p>J'ai fini par passer à Symfony, que j'utilise et que j'aime toujours aujourd'hui. Tout ce dont vous avez besoin est faisable avec, et même plus.</p>
<p>Avec le recul, un motif se répète : j'ai suivi là où la technique m'emmenait. Chaque époque a changé l'unité de mon travail : des lignes de C, aux librairies, aux frameworks. Cet article parle de ce que je pense être l'étape suivante, celle qu'on est en train de vivre.</p>
<h2>La vague des LLM</h2>
<p>Pendant des années, les frameworks ont eu l'air d'être le bout de la route. Puis un nouveau type d'outillage est apparu.</p>
<p>La première vague est arrivée en 2021, avec <a rel="nofollow noopener noreferrer" href="https://github.com/features/copilot">GitHub Copilot</a> : de l'auto-complétion sous stéroïdes. Vous commenciez une ligne, la machine la finissait. Ça semblait magique. En 2023, <a rel="nofollow noopener noreferrer" href="https://cursor.com">Cursor</a> a relevé le niveau : toujours de l'auto-complétion de notre code, mais avec beaucoup plus de choses intégrées, comme discuter avec sa codebase, faire des éditions inline, et écrire des blocs entiers de code depuis une simple instruction. Et depuis 2025, <a rel="nofollow noopener noreferrer" href="https://www.anthropic.com/claude-code">Claude Code</a> a pris la tête sur les agents IA, avec des modèles capables de livrer des features complètes tout seuls.</p>
<p>La progression est claire : d'abord un outil qui complétait notre code plus vite, puis un outil qui en écrivait une partie pour nous, et maintenant des agents qui construisent des features entières. L'unité de travail a encore changé : des frameworks aux features.</p>
<p>Cette vague n'est pas restée entre les mains de quelques gros acteurs. La concurrence a explosé, et les modèles open-weight ont rejoint la course : <a rel="nofollow noopener noreferrer" href="https://www.deepseek.com/en/">DeepSeek</a>, en particulier, a secoué le monde de l'IA en délivrant des performances de niveau frontier pour une fraction du coût, et il était loin d'être seul. Pour nous, développeurs, ça a changé la question de l'accès : les modèles puissants ne sont plus enfermés chez un seul fournisseur. Vous pouvez choisir le modèle qui correspond à chaque tâche, mélanger les fournisseurs dans le même workflow, ou faire tourner des modèles plus petits sur votre propre machine avec des outils comme <a rel="nofollow noopener noreferrer" href="https://ollama.com">Ollama</a>.</p>
<p>Chacune de ces vagues a redéfini une partie de notre métier. Mais à mon avis, ce n'était que le lever de rideau : le vrai changement n'est pas l'agent en lui-même, c'est l'environnement dans lequel on le fait tourner.</p>
<h2>Entrée en scène de l'ADE</h2>
<p>Le premier indice est venu de Cursor lui-même. Avec <a rel="nofollow noopener noreferrer" href="https://cursor.com/blog/2-0">Cursor 2.0</a>, il s'est mis à cacher les choses qu'on croyait essentielles : l'arborescence de fichiers, le panneau git, l'éditeur au centre. Ce qui restait, c'était une conversation avec un agent. Sans le nommer, c'est ça un ADE : un <strong>Agent Development Environment</strong>. Là où un IDE est construit autour de vous en train d'éditer des fichiers, un ADE est construit autour de vous en train de diriger des agents, et de discuter avec eux pour construire des produits.</p>
<p>Le nom est nouveau et pas encore très répandu, mais je l'aime bien, justement parce qu'il trace une ligne claire entre la façon dont on développe aujourd'hui avec des IDE et ce qui arrive ensuite.</p>
<p>Après Cursor, <a rel="nofollow noopener noreferrer" href="https://jean.build/">Jean</a> est allé loin dans le paradigme ADE. Le raisonnement est simple : si les agents travaillent pour vous, vous devriez pouvoir lancer plusieurs tâches en même temps. Jean embarque donc une gestion native des git worktrees : chaque tâche vit dans sa propre copie isolée du dépôt. Combiné à l'intégration des pull requests et des issues <a rel="nofollow noopener noreferrer" href="https://github.com">GitHub</a>, vous pouvez prendre une issue, obtenir un worktree tout neuf, et avoir un agent qui travaille dessus en quelques secondes.</p>
<p>Puis un collègue m'a parlé d'<a rel="nofollow noopener noreferrer" href="https://www.onorca.dev/">Orca</a>. Il fait tout ce que fait Jean, avec encore plus d'intégrations : <a rel="nofollow noopener noreferrer" href="https://www.atlassian.com/software/jira">Jira</a> et <a rel="nofollow noopener noreferrer" href="https://linear.app">Linear</a> sont supportés nativement, donc pendant que je travaille sur quelque chose je peux attraper une issue et créer immédiatement un worktree pour l'attaquer. Chaque tâche a son propre terminal, son navigateur et son contexte, et c'est du bring-your-own-subscription : Claude Code, <a rel="nofollow noopener noreferrer" href="https://openai.com/codex/">Codex</a>, <a rel="nofollow noopener noreferrer" href="https://opencode.ai">OpenCode</a> et les autres, côte à côte.</p>
<p>Rien de la vague des agents n'est perdu en route non plus. Agents, commands, skills : tout ce qu'on a construit se transpose. Un ADE ne remplace pas cette boîte à outils, il se pose par-dessus, et ça ne fait que la renforcer.</p>
<p>C'est pour ça qu'un ADE, ce n'est pas « un IDE avec un panneau IA greffé dessus ». Les primitives sont différentes : pas des fichiers et des buffers, mais des tâches, des worktrees et des agents. L'IDE partait du principe d'un développeur, une copie de travail, un fil de travail. L'ADE part du principe que vous en orchestrez plusieurs à la fois.</p>
<h2>Mon setup au quotidien</h2>
<p>Un avertissement avant de plonger : mon agent CLI principal est OpenCode, donc tous les exemples de cette section tournent autour de lui. Mais rien ici n'est spécifique à OpenCode : la plupart de ces astuces se transposent directement à Claude Code, Codex, ou l'agent que vous préférez.</p>
<h3>Orca lui-même</h3>
<p>Orca embarque beaucoup de features pensées pour vous simplifier la vie, et la première c'est l'intégration avec les outils de suivi d'issues : GitHub issues, Jira et Linear sont tous supportés. De là, vous pouvez prendre une nouvelle issue ou consulter celles sur lesquelles vous travaillez déjà, lire tous les détails, et créer un worktree directement depuis l'issue. Quand vous faites ça, le prompt par défaut est le lien de l'issue (ça va beaucoup nous servir plus loin, gardez le en tête).</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/ade-presentation/orca_issues.png" data-original-width="2255" data-original-height="1415"><source type="image/webp" srcset="/media/cache/content-webp/2026/ade-presentation/orca_issues.8de9d7d7.webp" /><source type="image/png" srcset="/media/cache/content/2026/ade-presentation/orca_issues.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(2255 / 1415)" src="https://jolicode.com//media/cache/content/2026/ade-presentation/orca_issues.png" alt="Les issues Jira listées dans Orca" /></picture></p>
<p>Quand vous jonglez avec plusieurs features ou issues en même temps, chacune dans son worktree, il devient vite difficile de se souvenir de ce qu'il reste à faire sur laquelle. C'est pour ça qu'Orca vous donne un kanban board. Chaque colonne est entièrement personnalisable ; dans mon cas j'en ai quatre :</p>
<ul>
<li>« Todo » contient les features et les issues à traiter ;</li>
<li>« Waiting » est pour les tâches bloquées où j'ai besoin d'un retour de la personne liée à l'issue ;</li>
<li>« In progress » est ce sur quoi je travaille activement ;</li>
<li>« Draft » est pour les tâches où j'ai ouvert une draft pull request sur GitHub. Comme je travaille sur beaucoup de features en même temps, je lance rarement toute la suite de tests en local, donc je laisse la CI le faire sur la draft PR et je reviens vérifier les résultats (quand il y a trop d'échecs, je lance quand même les tests en local). Une fois que tout est vert, j'ouvre la PR pour review et je supprime le worktree local.</li>
</ul>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/ade-presentation/orca_kanban.png" data-original-width="2255" data-original-height="1415"><source type="image/webp" srcset="/media/cache/content-webp/2026/ade-presentation/orca_kanban.1a0474ab.webp" /><source type="image/png" srcset="/media/cache/content/2026/ade-presentation/orca_kanban.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(2255 / 1415)" src="https://jolicode.com//media/cache/content/2026/ade-presentation/orca_kanban.png" alt="Mon kanban board dans Orca" /></picture></p>
<p>Dans la continuité de mon état « Draft », une autre chose que j'aime bien dans Orca, c'est le dock de droite. Il contient un explorateur de fichiers du projet où vous pouvez éditer les fichiers directement, comme dans un IDE. Un deuxième onglet liste toutes les sessions d'agents du worktree courant : quand je suis passé à Orca, il a immédiatement retrouvé toutes mes sessions OpenCode en cours à ce moment-là, donc j'ai pu reprendre sans rien perdre. Un troisième onglet couvre git : fichiers modifiés et stagés, et vous pouvez commit depuis là (ou demander à votre agent de le faire). Et le dernier onglet montre les runs <a rel="nofollow noopener noreferrer" href="https://github.com/features/actions">GitHub Actions</a> de votre pull request, donc quand j'ai une draft PR, je peux vérifier où en est la CI sans jamais quitter Orca.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/ade-presentation/orca_actions.png" data-original-width="2255" data-original-height="1415"><source type="image/webp" srcset="/media/cache/content-webp/2026/ade-presentation/orca_actions.bbe44beb.webp" /><source type="image/png" srcset="/media/cache/content/2026/ade-presentation/orca_actions.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(2255 / 1415)" src="https://jolicode.com//media/cache/content/2026/ade-presentation/orca_actions.png" alt="Le statut des GitHub Actions dans le dock de droite d'Orca" /></picture></p>
<p>Ce sont les trois features que j'utilise le plus, mais il y en a plein d'autres. Les quick commands permettent de préparer des scripts complets à lancer sur un worktree : j'ai une command « Install deps » qui installe tout ce qu'il faut quand j'ai besoin d'outils comme <a rel="nofollow noopener noreferrer" href="https://cs.symfony.com/">PHP CS Fixer</a> ou <a rel="nofollow noopener noreferrer" href="https://phpstan.org">PHPStan</a>. Il y a aussi l'intégration mobile, qui ouvre un tunnel sur le réseau local pour que vous puissiez atteindre vos agents Orca depuis votre téléphone (ajoutez un VPN et ça marche de partout ; je m'en suis servi de temps en temps à la salle de sport). Et ce n'est qu'une fraction de ce qu'Orca propose, il y a beaucoup plus à découvrir.</p>
<h3>Mes agents</h3>
<p>C'est dans les agents que j'ai le plus investi. Encore un avertissement : je travaille principalement avec Jira, donc certains agents ci-dessous sont orientés Jira, mais ils peuvent facilement être adaptés aux GitHub issues ou à Linear si besoin. Voici le détail complet de ce à quoi ressemble mon sélecteur d'agents :</p>
<p><picture><source type="image/webp" srcset="/media/cache/content-webp/2026/ade-presentation/opencode_agents.1e3a7eb5.webp" /><source type="image/png" srcset="/media/cache/content/2026/ade-presentation/opencode_agents.png" /><img loading="lazy" decoding="async" style="width: 552px; ; aspect-ratio: calc(552 / 267)" src="https://jolicode.com//media/cache/content/2026/ade-presentation/opencode_agents.png" alt="Le sélecteur d'agents dans OpenCode, avec mes agents personnalisés" /></picture></p>
<ul>
<li><code>build</code> est l'agent intégré qui fait les modifications. C'est le seul agent de cette liste autorisé à toucher au code.</li>
<li><code>plan</code> est l'autre agent intégré, utilisé simplement pour planifier.</li>
<li><code>heavy-plan</code> est le même que <code>plan</code>, mais adossé à un modèle plus performant : si <code>plan</code> tourne sur un modèle de classe Sonnet, celui-ci tourne sur un équivalent Opus. Je le garde pour les travaux importants ou risqués.</li>
<li><code>jira-analyst</code> prend un lien Jira, regarde tous les détails de l'issue, ses parents, et les PR ouvertes à son sujet, puis résume ce qui est demandé, ce qui a déjà été fait, et ce qui pourrait être fait dans le code. C'est mon point de départ numéro un, 95 % du temps.</li>
<li><code>jira-feedback</code> couvre le tour suivant : la QA a testé une de mes pull requests et a trouvé une erreur. Cet agent revérifie le ticket Jira depuis son lien, trouve la PR associée, checkout la branche en local, et essaie de comprendre le feedback et de faire de premières hypothèses.</li>
<li><code>pr-review-planner</code> intervient quand une PR est ouverte en review et que quelqu'un a laissé des commentaires : il les lit tous et propose comment je pourrais les traiter. Répondre à une review arrête d'être une séance d'archéologie et devient l'exécution d'une checklist.</li>
<li><code>pr-reviewer</code> est pour l'autre côté des reviews : quand j'ai une grosse PR à review, je la review toujours moi-même, et en parallèle je lance cet agent pour qu'une review IA attrape ce que j'aurais, peut-être, manqué.</li>
</ul>
<p>Vous avez peut-être remarqué un motif : à part <code>build</code>, ce sont tous des agents d'analyse. Ils lisent, résument et planifient, mais ils ne touchent pas au code. Cette contrainte est inscrite dans la définition de l'agent elle-même. Voici un extrait abrégé de <code>jira-analyst</code> :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">---</span></span>
<span class="line"><span class="syntax-2">mode: primary</span></span>
<span class="line"><span class="syntax-2">model: opencode-go/deepseek-v4-flash</span></span>
<span class="line"><span class="syntax-2">temperature: 0.2</span></span>
<span class="line"><span class="syntax-16">---</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">You are a technical analyst for Jira issues. When given a Jira issue URL or</span></span>
<span class="line"><span class="syntax-2">issue key, you analyze the ticket and deliver exactly two things: a clear</span></span>
<span class="line"><span class="syntax-2">summary of what the issue is about, and a concrete list of actions the</span></span>
<span class="line"><span class="syntax-2">developer must take to resolve it.</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">You are strictly read-only. You NEVER modify the codebase: no edits, no file</span></span>
<span class="line"><span class="syntax-2">creation, no refactoring, no "quick fixes". Your only output is analysis and</span></span>
<span class="line"><span class="syntax-2">explanation.</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-16">### Investigating the codebase</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">The action plan must be grounded in the real code, not generic advice:</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-8">-</span><span class="syntax-2"> grep/glob for the classes, routes, services, config keys, or error messages</span></span>
<span class="line"><span class="syntax-2">  mentioned in the ticket.</span></span>
<span class="line"><span class="syntax-8">-</span><span class="syntax-2"> Use </span><span class="syntax-11">`git log`</span><span class="syntax-2">/</span><span class="syntax-11">`git blame`</span><span class="syntax-2"> on the affected area to find recent related</span></span>
<span class="line"><span class="syntax-2">  changes: regressions are often introduced by an identifiable commit.</span></span>
<span class="line"><span class="syntax-8">-</span><span class="syntax-2"> Check </span><span class="syntax-11">`gh pr list --search "&#x3C;KEY>"`</span><span class="syntax-2"> for existing or past PRs referencing</span></span>
<span class="line"><span class="syntax-2">  the ticket.</span></span></code></pre>
<p>La règle read-only n'est pas qu'une promesse dans le prompt : le même fichier porte un bloc de permissions qui rend les éditions impossibles dès le départ.</p>
<h4>Les permissions dans OpenCode</h4>
<p>OpenCode résout chaque appel d'outil vers une action parmi trois : <code>allow</code> l'exécute sans demander, <code>ask</code> me demande d'abord, et <code>deny</code> le bloque purement et simplement. Ce qui compte ici, c'est qu'il part de valeurs par défaut permissives, la plupart des permissions sont en <code>allow</code> d'origine, donc un agent d'analyse n'est read-only que si vous le dites explicitement. Dans le frontmatter de l'agent, ça donne ça :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">---</span></span>
<span class="line"><span class="syntax-4">description</span><span class="syntax-2">: </span><span class="syntax-1">Analyze a Jira ticket and produce an action plan</span></span>
<span class="line"><span class="syntax-4">mode</span><span class="syntax-2">: </span><span class="syntax-1">subagent</span></span>
<span class="line"><span class="syntax-4">permission</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">  edit</span><span class="syntax-2">: </span><span class="syntax-1">deny</span></span>
<span class="line"><span class="syntax-4">  webfetch</span><span class="syntax-2">: </span><span class="syntax-1">deny</span></span>
<span class="line"><span class="syntax-4">  bash</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-1">    "*"</span><span class="syntax-2">: </span><span class="syntax-1">deny</span></span>
<span class="line"><span class="syntax-1">    "git *"</span><span class="syntax-2">: </span><span class="syntax-1">allow</span></span>
<span class="line"><span class="syntax-1">    "gh *"</span><span class="syntax-2">: </span><span class="syntax-1">allow</span></span>
<span class="line"><span class="syntax-1">    "jira *"</span><span class="syntax-2">: </span><span class="syntax-1">allow</span></span>
<span class="line"><span class="syntax-2">---</span></span></code></pre>
<p>La clé <code>permission</code> est indexée par nom d'outil : <code>read</code>, <code>glob</code>, <code>grep</code>, <code>list</code>, <code>bash</code>, <code>task</code>, <code>skill</code>, <code>webfetch</code>, <code>websearch</code>. Vous les passez en revue un par un et vous décidez ce que chacun a le droit de faire, et une entrée <code>&quot;*&quot;</code> fixe la valeur par défaut pour tout ce que vous n'avez pas nommé. Notez que <code>edit</code> est la seule exception à la règle une clé pour un outil : elle couvre tout ce qui écrit sur le disque, donc <code>edit</code>, <code>write</code>, <code>patch</code> et <code>multiedit</code> se retrouvent tous derrière ce <code>deny</code> unique. Le reste reste ouvert, donc l'agent peut toujours lire, grep, glob et lancer les commandes dont il a besoin pour enquêter, et c'est tout ce qu'il fera jamais.</p>
<p>C'est dans le bloc <code>bash</code> que ça devient intéressant. Les règles sont matchées par pattern et <strong>la dernière règle qui matche gagne</strong>, donc le catch-all passe en premier et les règles spécifiques viennent après :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">bash</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-1">  "*"</span><span class="syntax-2">: </span><span class="syntax-1">deny</span><span class="syntax-10">           # défaut : rien ne s'exécute</span></span>
<span class="line"><span class="syntax-1">  "git *"</span><span class="syntax-2">: </span><span class="syntax-1">allow</span><span class="syntax-10">      # ...sauf git</span></span>
<span class="line"><span class="syntax-1">  "git push *"</span><span class="syntax-2">: </span><span class="syntax-1">deny</span><span class="syntax-10">  # ...mais jamais push</span></span></code></pre>
<p>Lisez de haut en bas, chaque ligne restreint ou rouvre celle du dessus. Inversez l'ordre et le catch-all avale silencieusement tout le reste. Un dernier piège : les patterns matchent la commande parsée, arguments compris, donc <code>&quot;git&quot;</code> seul ne matche qu'un <code>git</code> nu, tout ce qui a des arguments a besoin du wildcard.</p>
<p>Ce bloc est ce qui transforme « merci de seulement analyser » en quelque chose que l'agent ne peut pas contourner, même si le prompt dérive ou si le ticket essaie de l'entraîner ailleurs.</p>
<p>Et comme ces agents ne font qu'analyser, ils n'ont pas besoin du modèle le plus cher avec une grosse créativité : un modèle rapide et pas cher à faible température fait le travail, de manière déterministe. Si vous voulez les prompts complets, tous mes agents sont disponibles <a rel="nofollow noopener noreferrer" href="https://gist.github.com/Korbeil/f4422dfe58a31e114cfb703edb1f5f21">dans ce gist</a>.</p>
<h4>Quand Orca et les agents s'emboîtent</h4>
<p>Vous vous souvenez du détail d'Orca que je vous avais demandé de garder en tête : quand vous créez un worktree depuis une issue, le prompt par défaut est le lien de l'issue. C'est ici que ça paie. Comme <code>jira-analyst</code> et <code>jira-feedback</code> prennent tous les deux un lien d'issue en entrée, je n'ai presque rien à faire. Je sélectionne mon issue dans l'intégration Jira d'Orca, il ouvre un nouveau worktree, démarre mon agent, et copie le lien. Il ne me reste qu'à basculer sur le bon agent et à le lancer. De « je prends ce ticket » à « un agent l'analyse dans un worktree isolé », il y a deux clics et zéro copier-coller.</p>
<p>La même astuce marche dans l'autre sens, une fois que le worktree a déjà disparu. Quand une PR revient à la vie, parce que la QA a trouvé une erreur ou qu'un reviewer a laissé des commentaires, je crée un nouveau worktree depuis la PR elle-même, dans l'intégration GitHub d'Orca cette fois. L'agent s'ouvre avec le lien de la PR comme prompt, je lance <code>jira-feedback</code> ou <code>pr-review-planner</code> dessus, et tout le reste se fait automatiquement.</p>
<p>Pour vous donner une idée du rythme que tout ça crée : au début de la journée, je vérifie si mes draft PR en cours ont des erreurs. Si ce n'est pas le cas, je prends jusqu'à trois ou quatre issues Jira et je lance mon agent <code>jira-analyst</code> sur toutes, puis je fais généralement de la code review pendant qu'elles tournent. Quand un agent a fini d'analyser, une grosse lecture m'attend : l'idée est de me concentrer d'abord sur la première tâche terminée, de l'envoyer en exécution, puis de prendre la deuxième pendant que la première est occupée, et ainsi de suite, jusqu'à ce que je n'aie plus de tâches et que je reprenne de nouvelles issues.</p>
<h3>Mes commandes</h3>
<p>Là où les agents portent un rôle complet, les commands sont plus proches de recettes : de petits prompts répétables que je lance à la demande. Deux des miennes sont généralistes :</p>
<ul>
<li><code>/commit-and-pr</code> est plutôt direct : elle génère un message de commit et une description de pull request. Si le dépôt courant a une template GitHub disponible, elle l'utilise comme base pour la description de la PR.</li>
<li><code>/activity</code> récupère toute mon activité Jira et GitHub sur les dernières 24 heures, pour m'aider à ne rien oublier pendant mes daily stand-ups.
Les autres commands sont très liées à mes projets, et pour les comprendre il faut savoir une chose de notre workflow : une PR est ouverte, la PR est reviewée, puis elle est mise dans une milestone pour être déployée sur un environnement de qualification à des fins de test. C'est comme ça que j'en suis venu à ces commands :</li>
<li><code>/github-awaiting-review</code> récupère toutes les pull requests ouvertes et en attente de review, sous forme de liste prête à être copiée dans <a rel="nofollow noopener noreferrer" href="https://slack.com">Slack</a>.</li>
<li><code>/github-milestone-triage</code> récupère toutes les pull requests qui ont leur review et qui doivent être testées, et sort une jolie liste que je peux copier dans Slack aussi.</li>
<li><code>/milestone-build</code> prend un lien de milestone en argument, rassemble toutes les PR de cette milestone, et crée une release branch pour qu'on puisse déployer chaque changement qu'elle contient. Elle gère aussi les conflits de merge en chemin.</li>
</ul>
<h3>Mes skills</h3>
<p>Côté skills, je n'en ai aucune faite maison. J'utilise plutôt un catalogue : la plupart des miennes viennent de <a rel="nofollow noopener noreferrer" href="https://github.com/MakFly/superpowers-symfony">superpowers-symfony</a>, une collection de skills orientées Symfony, complétée par quelques skills liées aux projets qui décrivent, par exemple, comment un message de commit ou une description de pull request doit être écrite sur chaque projet. C'est aussi ce qui garde la command <code>commit-and-pr</code> simple : la command n'explique pas comment écrire quoi que ce soit, elle fait juste le travail, et les conventions viennent de ces skills. Ce n'est pas grand-chose, et c'est volontaire : j'ai toujours eu le sentiment qu'on se tourne trop souvent vers les skills, alors que ce qu'on veut vraiment est une command ou un agent.</p>
<h3>La philosophie des permissions</h3>
<p>Vous l'avez peut-être deviné en lisant le détail de mes agents : tout mon outillage IA est read-only par défaut, et <code>build</code> est le seul agent autorisé à écrire du code. Ce n'est pas un accident, c'est la règle dont tout le reste découle. Les agents proposent, l'humain décide. Considérez ça comme mon contrepoint à la mode des « YOLO agents », où on laisse un agent partir en roue libre avec toutes les permissions.</p>
<p>Derrière cette règle, il y a une conviction : tout code généré avec une IA vous appartient. Le modèle l'a écrit, mais c'est votre nom sur le commit. Être généré par une IA n'exempte pas une seule ligne de review : vous devez toujours vérifier ce qu'elle fait, et comment elle le fait, exactement comme du code que vous auriez écrit vous-même.</p>
<p>La meilleure façon de garder cette review supportable est d'investir avant que le code existe. Je lis les sorties de plan en entier, à chaque fois. Je discute des détails avec l'IA, je conteste les parties qui ne correspondent pas à ce que j'avais en tête, et j'ajuste parfois les prompts de mes agents en chemin, pour que l'exécution atterrisse au plus près de ce que j'ai réellement demandé.</p>
<p>Ensuite je lis le code. Tout le code, diff par diff, de la même façon que je reviewerais la PR d'un collègue : est-ce que ça fait ce que le plan disait, est-ce que ça le fait comme je l'aurais fait, et est-ce qu'il y a là-dedans quelque chose que je ne saurais pas expliquer dans six mois ? Investir dans le plan fait qu'il reste très peu de problèmes à ce stade, mais c'est bien le but. La review reste courte parce que le travail a eu lieu en amont, pas parce que je l'ai bâclée.</p>
<h2>Pourquoi ça me rend productif</h2>
<p>Il est temps d'appuyer tout ça avec quelque chose de plus concret que de l'enthousiasme. Les gains ne sont pas uniformes : certains sont spectaculaires, d'autres sont marginaux, et ça vaut la peine d'être honnête sur qui est quoi.</p>
<p>Le cœur de tout ça, ce sont <code>jira-analyst</code> et <code>jira-feedback</code>, les deux agents qui m'ont fait aimer ce workflow. Là où je travaille, on a une équipe « focus » : tout le monde peut travailler sur tout, des stocks au retail en passant par la gestion client. Le changement de contexte entre les issues est énorme, et c'est exactement ce que <code>jira-analyst</code> absorbe : il m'explique tout ce que j'ai besoin de savoir avant de plonger dans ma tâche. Selon l'issue, ça me fait gagner jusqu'à une demi-journée, parfois une journée entière. C'est aussi l'agent dans lequel j'ai le plus investi : son prompt a été amélioré itération après itération pour arriver au point actuel.</p>
<p><code>pr-review-planner</code> est une bénédiction du même ordre. Traiter les retours de review me prenait beaucoup de temps, parce que chaque commentaire demande son contexte : quel code il vise, ce que le reviewer voulait dire, quelles sont les options. Maintenant je lance l'agent, je lis la sortie, et j'ai tout ce qu'il me faut pour décider quoi faire, commentaire par commentaire.</p>
<p>Côté commands, <code>github-awaiting-review</code> et <code>github-milestone-triage</code> ont remplacé une corvée : parcourir toutes les PR, vérifier qui a créé chaque issue et qui la review, construire le message Slack, l'envoyer. Selon le nombre de PR ouvertes, ça pouvait me coûter jusqu'à 20 minutes. Maintenant je lance la command, je reviens plus tard, et c'est fait. Et les minutes ne sont même pas le vrai gain : le vrai gain, c'est que je ne casse plus mon focus pour ça. Un truc aussi simple appartient à un prompt, et mon attention reste sur mes tâches. Et je vais être honnête sur <code>milestone-build</code> : le gain de temps brut est faible, puisque avant elle je faisais simplement un <code>git merge</code> de chaque branche dans une release branch, puis un push. Ce qu'elle enlève, c'est tout ce qu'il y a autour des merges : je ne gère plus les conflits, ni la vérification de quelles PR doivent être dedans, tout ça est automatisé.</p>
<p>Et puis il y a Orca lui-même. La majorité de ma routine quotidienne vit maintenant dedans : je n'ouvre Jira que pour m'assigner des tickets, GitHub pour review les PR des autres développeurs, et Slack pour partager les listes que mes commands préparent (et si un jour je peux faire tout ça depuis Orca, je serai content). Moins d'outils veut dire moins de choses auxquelles penser, et tout va plus vite. Et la gestion des worktrees mérite un dernier mot : j'ai essayé de créer les worktrees à la main, puis de demander à un agent de les créer pour moi. Que des outils comme Jean et Orca s'en occupent nativement est une avancée formidable pour cette philosophie ADE.</p>
<h2>Conclusion</h2>
<p>En regardant tout ce chemin, le motif du début tient toujours : j'ai suivi là où la technique m'emmenait, une fois de plus. Des lignes de C aux librairies, des librairies aux frameworks, des frameworks aux features. Sauf que cette fois, la technique n'est ni un langage ni un framework : c'est un collègue. Il lit les tickets, analyse le code, propose des plans ; je dirige, je review, et je décide.</p>
<p>Et pour être honnête jusqu'au bout : tout n'est pas encore fluide. Les worktrees sont une bénédiction, mais il reste des trous, et le plus gros pour moi est l'infrastructure locale. Faire tourner plusieurs copies du même site veut dire des conflits <a rel="nofollow noopener noreferrer" href="https://www.docker.com">Docker</a>, donc quand je travaille dans un worktree, j'évite au maximum de lancer Docker. On a travaillé sur de l'outillage pour adoucir ça : nos conteneurs sont maintenant préfixés avec le nom du worktree, par exemple. Mais certaines pièces résistent : on utilise <a rel="nofollow noopener noreferrer" href="https://traefik.io">Traefik</a> comme routeur HTTP, et vous ne pouvez pas en faire tourner plus d'un, donc il faudra encore du travail d'outillage avant que tout ça tourne bien côte à côte.</p>
<p>Des aspérités comme celles-ci sont le signe d'un écosystème jeune, pas d'une mauvaise direction. Les IDE ont mis des décennies à devenir ce qu'ils sont ; les ADE commencent à peine, et ils ont déjà changé mon quotidien plus que n'importe quel outil depuis les frameworks. Je ne sais pas quel ADE va gagner, ni si ceux que j'utilise aujourd'hui seront encore là dans cinq ans. Mais je sais que je ne reviendrai pas à un environnement construit pour moi seul : les agents ont rejoint l'équipe, et ils méritent un bon bureau eux aussi.</p>]]></description></item><item><title>D&#xE9;tecter les r&#xE9;gressions visuelles dans la CI avec Playwright et Docker</title><link>https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker</link><author>JoliCode Team</author><date>Thu, 23 Jul 2026 07:41:00 +0000</date><description><![CDATA[<p>Sur un gros site public, le front bouge tout le temps : une migration Tailwind par-ci, un composant React par-là, un bloc CMS qui change de gabarit. Et comme toujours avec le CSS, la modification d’une classe qui semblait anodine peut très bien décaler un bloc trois pages plus loin, sans que personne ne s’en rende compte avant la mise en production.</p>
<p>Sur un de nos projets, nous avions déjà des tests Behat pour le fonctionnel, et des tests PHPUnit pour le métier. Mais aucun de ces tests ne disait « la page d’accueil ne ressemble plus à la page d’accueil ». C’est exactement ce que font les tests de non-régression visuelle, et <a rel="nofollow noopener noreferrer" href="https://playwright.dev/">Playwright</a> le fait très bien nativement.</p>
<p>Dans cet article, nous allons voir la stack que nous avons mise en place sur ce projet : les tests eux-mêmes, les tasks Castor pour les piloter, le passage par Docker pour avoir un rendu stable entre les machines de l’équipe, et enfin comment nous postons les images de diff directement dans un commentaire de la pull request.</p>
<h2>Le principe : <code>toHaveScreenshot()</code></h2>
<p>Playwright, la solution que nous utilisons déjà pour nos tests <abbr title="End to End">E2E</abbr>, propose une assertion faite pour ça : <a rel="nofollow noopener noreferrer" href="https://playwright.dev/docs/test-snapshots"><code>toHaveScreenshot()</code></a>. Elle prend une capture de la page, la compare avec l’image de référence commitée dans le dépôt, et échoue si les deux diffèrent trop. En l'occurrence, Playwright vérifie si le nombre de pixels différents entre les 2 images est inférieur à un seuil configuré.</p>
<p>Notre fichier <code>application/e2e/screenshots.spec.ts</code> couvre les pages structurantes du site : la home, la page de résultats de recherche, une page de détail, etc. Plusieurs pages, autant d'images de référence, et de quoi attraper l’immense majorité des régressions CSS.</p>
<p>Un test ressemble à ça :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-8">test</span><span class="syntax-2">(</span><span class="syntax-1">'homepage screenshot'</span><span class="syntax-2">, </span><span class="syntax-4">async</span><span class="syntax-2"> ({ </span><span class="syntax-12">page</span><span class="syntax-2"> }) </span><span class="syntax-5">=></span><span class="syntax-2"> {</span></span>
<span class="line"><span class="syntax-4">    await</span><span class="syntax-2"> page.</span><span class="syntax-8">goto</span><span class="syntax-2">(homeUrl);</span></span>
<span class="line"><span class="syntax-10">    // The search form is a React component that mounts client-side;</span></span>
<span class="line"><span class="syntax-10">    // wait for it so the layout below does not shift.</span></span>
<span class="line"><span class="syntax-4">    await</span><span class="syntax-2"> page.</span><span class="syntax-8">locator</span><span class="syntax-2">(</span><span class="syntax-1">'#tab-search'</span><span class="syntax-2">).</span><span class="syntax-8">waitFor</span><span class="syntax-2">({ state: </span><span class="syntax-1">'visible'</span><span class="syntax-2"> });</span></span>
<span class="line"><span class="syntax-4">    await</span><span class="syntax-8"> stabilize</span><span class="syntax-2">(page);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    await</span><span class="syntax-8"> expect</span><span class="syntax-2">(page).</span><span class="syntax-8">toHaveScreenshot</span><span class="syntax-2">(</span><span class="syntax-1">'homepage.png'</span><span class="syntax-2">, {</span></span>
<span class="line"><span class="syntax-2">        fullPage: </span><span class="syntax-3">true</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-2">        animations: </span><span class="syntax-1">'disabled'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-2">        maxDiffPixels: </span><span class="syntax-3">100</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-10">        // The header image is picked at random server-side, so mask it.</span></span>
<span class="line"><span class="syntax-2">        mask: [page.</span><span class="syntax-8">getByTestId</span><span class="syntax-2">(</span><span class="syntax-1">'homepage-header-image'</span><span class="syntax-2">)],</span></span>
<span class="line"><span class="syntax-2">    });</span></span>
<span class="line"><span class="syntax-2">});</span></span></code></pre>
<p>Écrire le test en lui-même est donc assez trivial. Toute la difficulté de l’exercice est ailleurs : il faut que la page soit <strong>déterministe</strong>. Un test visuel qui échoue une fois sur trois ne sert à rien, car au bout de deux semaines toute l’équipe relance le job sans même regarder. Nous avons donc passé pas mal de temps, non pas à écrire les tests, mais à supprimer une par une toutes les sources de variation.</p>
<h3>Attendre que la page soit vraiment stable</h3>
<p>Le piège classique : la capture est prise pendant que la page finit de se construire. Images en lazy loading, blocs asynchrones, polices web qui provoquent un reflow au moment où elles arrivent… La capture est techniquement valide, mais elle ne correspond à rien de reproductible.</p>
<p>D’où ce petit helper, appelé dans tous les tests :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">// Wait for the page to be visually stable before taking a full-page screenshot:</span></span>
<span class="line"><span class="syntax-10">// network idle (lazy images / async blocks) + web fonts loaded (avoids reflow).</span></span>
<span class="line"><span class="syntax-4">async</span><span class="syntax-5"> function</span><span class="syntax-8"> stabilize</span><span class="syntax-2">(</span><span class="syntax-12">page</span><span class="syntax-4">:</span><span> </span><span class="syntax-6">Page</span><span class="syntax-2">)</span><span class="syntax-4">:</span><span> </span><span class="syntax-6">Promise</span><span class="syntax-2">&#x3C;</span><span class="syntax-5">void</span><span class="syntax-2">> {</span></span>
<span class="line"><span class="syntax-4">    await</span><span class="syntax-2"> page.</span><span class="syntax-8">waitForLoadState</span><span class="syntax-2">(</span><span class="syntax-1">'networkidle'</span><span class="syntax-2">);</span></span>
<span class="line"><span class="syntax-4">    await</span><span class="syntax-2"> page.</span><span class="syntax-8">evaluate</span><span class="syntax-2">(() </span><span class="syntax-5">=></span><span class="syntax-2"> document.fonts.ready);</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Et quand ça ne suffit pas, on attend explicitement l’élément qui pose problème. Sur la home, c’est le formulaire de recherche (un composant React monté côté client) ; sur une autre page, c’est un Swiper qui se réorganise <em>après</em> le <code>networkidle</code> :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">await</span><span class="syntax-2"> page.</span><span class="syntax-8">locator</span><span class="syntax-2">(</span><span class="syntax-1">'.js-swiper-expertises.c-swiperinitialized'</span><span class="syntax-2">).</span><span class="syntax-8">first</span><span class="syntax-2">().</span><span class="syntax-8">waitFor</span><span class="syntax-2">({ state: </span><span class="syntax-1">'visible'</span><span class="syntax-2"> });</span></span></code></pre>
<p>La classe <code>c-swiperinitialized</code> n’est ajoutée qu’une fois le carrousel initialisé : c’est donc un bon signal pour savoir que le rendu final est atteint.</p>
<h3>Masquer ce qui est volontairement aléatoire</h3>
<p>Certaines zones ne seront jamais stables, et c’est normal : c’est le produit qui le veut. L’image d’en-tête de la home est tirée au sort côté serveur. Sur la page d'un point de vente, le bloc FAQ s’affiche aléatoirement, et les horaires d’ouverture mettent en avant le jour courant… qui change tous les jours.</p>
<p>Plutôt que de chercher à contourner le problème, on peut simplement demander à Playwright de masquer ces zones : elles seront recouvertes d’un aplat avant la comparaison.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/ci-playwright/playwright-elements-masques.png" data-original-width="1280" data-original-height="1026"><source type="image/webp" srcset="/media/cache/content-webp/2026/ci-playwright/playwright-elements-masques.a2d770bf.webp" /><source type="image/png" srcset="/media/cache/content/2026/ci-playwright/playwright-elements-masques.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1280 / 1026)" src="https://jolicode.com//media/cache/content/2026/ci-playwright/playwright-elements-masques.png" alt="Les éléments masqués par Playwright" /></picture></p>
<p>Pour parvenir à cela, il faut lister les éléments à masquer directement dans la config de Playwright :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">mask: [</span></span>
<span class="line"><span class="syntax-2">    page.</span><span class="syntax-8">getByTestId</span><span class="syntax-2">(</span><span class="syntax-1">'faq-block'</span><span class="syntax-2">), </span><span class="syntax-10">// display randomly</span></span>
<span class="line"><span class="syntax-2">    page.</span><span class="syntax-8">getByTestId</span><span class="syntax-2">(</span><span class="syntax-1">'store-timetable'</span><span class="syntax-2">), </span><span class="syntax-10">// current day open by default (changes daily)</span></span>
<span class="line"><span class="syntax-2">],</span></span></code></pre>
<p>Nous utilisons ici des <code>data-testid</code> plutôt que des classes CSS : cela donne un point d’accroche stable, qui ne bougera pas à la prochaine refonte du style.</p>

<div class="c-alert c-alert--note">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 70 71"><path fill-rule="nonzero" d="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"/></svg>
            </span>
                        <strong>Info</strong>
    </p>
    <div class="c-alert__content">
                <p>
Vous pourriez aussi choisir de masquer vous-même certains éléments, en <a rel="nofollow noopener noreferrer" href="https://playwright.dev/docs/test-snapshots#stylepath">incluant une feuille de style dédiée aux tests</a> et qui ferait un <code>display: none !important; visibility: hidden !important;</code> sur les éléments ciblés par exemple.</p>
        </div>
</div>

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

<div class="c-alert c-alert--note">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 70 71"><path fill-rule="nonzero" d="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"/></svg>
            </span>
                        <strong>Info</strong>
    </p>
    <div class="c-alert__content">
                <p>
Sur ce projet, nos tests ne tournent volontairement que sur Chrome. Donc nous évitons de télécharger Firefox et WebKit pour rien. Pour cela, on ajoute la config suivante dans le fichier <code>playwright.config.ts</code> :</p>
        </div>
</div>

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

<div class="c-alert c-alert--note">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 70 71"><path fill-rule="nonzero" d="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"/></svg>
            </span>
                        <strong>Info</strong>
    </p>
    <div class="c-alert__content">
                <p>
Mettre la logique dans une task Castor plutôt que dans le YAML de GitHub Actions a un avantage non négligeable : on peut la lancer en local avec une option <code>--dry-run</code> qui construit et affiche le commentaire sans rien envoyer. Débugger un rendu Markdown sans avoir à pousser un commit pour chaque essai, c’est appréciable.</p>
        </div>
</div>

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

<div class="c-alert c-alert--note">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 70 71"><path fill-rule="nonzero" d="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"/></svg>
            </span>
                        <strong>Info</strong>
    </p>
    <div class="c-alert__content">
                <p>
Nous utilisons des runners GitHub qui sont self-hostés et tournent sur une machine située sur l'infra du client et qui a donc accès à l'instance de préproduction. Dans la plupart des situations, ce n'est pas le cas, il vous faudra donc trouver comment rendre accessible publiquement ces images (hébergement S3-like, service dédié, etc).</p>
        </div>
</div>

<p>Le commentaire est ensuite construit à la main en Markdown :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$body </span><span class="syntax-4">.=</span><span class="syntax-1"> "## ❌ Régression visuelle E2E</span><span class="syntax-3">\n\n</span><span class="syntax-1">"</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">$body </span><span class="syntax-4">.=</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">"Des screenshots ont changé sur ce run ([logs](%s)).</span><span class="syntax-3">\n\n</span><span class="syntax-1">"</span><span class="syntax-2">, $runUrl);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">foreach</span><span class="syntax-2"> ($screenshots </span><span class="syntax-4">as</span><span class="syntax-2"> $screenshot) {</span></span>
<span class="line"><span class="syntax-2">    $base </span><span class="syntax-4">=</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">'%s/%s/%s/%s'</span><span class="syntax-2">, $publicBaseUrl, $runKey, $screenshot[</span><span class="syntax-1">'testDir'</span><span class="syntax-2">], $screenshot[</span><span class="syntax-1">'name'</span><span class="syntax-2">]);</span></span>
<span class="line"><span class="syntax-2">    $body </span><span class="syntax-4">.=</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">"### `%s`</span><span class="syntax-3">\n\n</span><span class="syntax-1">"</span><span class="syntax-2">, $screenshot[</span><span class="syntax-1">'name'</span><span class="syntax-2">]);</span></span>
<span class="line"><span class="syntax-2">    $body </span><span class="syntax-4">.=</span><span class="syntax-1"> "| Attendu | Obtenu | Diff |</span><span class="syntax-3">\n</span><span class="syntax-1">| --- | --- | --- |</span><span class="syntax-3">\n</span><span class="syntax-1">"</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">    $body </span><span class="syntax-4">.=</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span></span>
<span class="line"><span class="syntax-1">            "| %s | %s | %s |</span><span class="syntax-3">\n\n</span><span class="syntax-1">"</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-3">            null</span><span class="syntax-4"> !==</span><span class="syntax-2"> $screenshot[</span><span class="syntax-1">'expected'</span><span class="syntax-2">] </span><span class="syntax-4">?</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">'![attendu](%s-expected.png)'</span><span class="syntax-2">, $base) </span><span class="syntax-4">:</span><span class="syntax-1"> '—'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-3">            null</span><span class="syntax-4"> !==</span><span class="syntax-2"> $screenshot[</span><span class="syntax-1">'actual'</span><span class="syntax-2">] </span><span class="syntax-4">?</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">'![obtenu](%s-actual.png)'</span><span class="syntax-2">, $base) </span><span class="syntax-4">:</span><span class="syntax-1"> '—'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-2">            \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">'![diff](%s-diff.png)'</span><span class="syntax-2">, $base),</span></span>
<span class="line"><span class="syntax-2">        );</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Quelques détails supplémentaires méritent d’être mentionnés.</p>
<h3>Un commentaire « sticky » plutôt qu’un nouveau à chaque run</h3>
<p>Une PR avec plusieurs allers-retours finirait vite avec une dizaine de commentaires de robot. Pour éviter ça, nous plaçons un marqueur HTML invisible en tête du corps du message :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">// Hidden marker used to identify the sticky E2E screenshots report comment on a PR.</span></span>
<span class="line"><span class="syntax-4">const</span><span class="syntax-3"> E2E_REPORT_MARKER</span><span class="syntax-4"> =</span><span class="syntax-1"> '&#x3C;!-- e2e-screenshots-report -->'</span><span class="syntax-2">;</span></span></code></pre>
<p>Il suffit ensuite de parcourir les commentaires de la PR à la recherche de ce marqueur :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">const</span><span class="syntax-3"> E2E_GITHUB_API_BASE</span><span class="syntax-4"> =</span><span class="syntax-1"> 'https://api.github.com'</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-5">function</span><span class="syntax-8"> e2e_find_report_comment</span><span class="syntax-2">(</span><span class="syntax-4">array</span><span class="syntax-2"> $options, </span><span class="syntax-4">string</span><span class="syntax-2"> $repo, </span><span class="syntax-4">string</span><span class="syntax-2"> $pr)</span><span class="syntax-4">:</span><span class="syntax-4"> ?int</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-2">    $page </span><span class="syntax-4">=</span><span class="syntax-3"> 1</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">    do</span><span class="syntax-2"> {</span></span>
<span class="line"><span class="syntax-2">        $listUrl </span><span class="syntax-4">=</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">'%s/repos/%s/issues/%s/comments?per_page=100&#x26;page=%d'</span><span class="syntax-2">, </span><span class="syntax-3">E2E_GITHUB_API_BASE</span><span class="syntax-2">, $repo, $pr, $page);</span></span>
<span class="line"><span class="syntax-2">        $comments </span><span class="syntax-4">=</span><span class="syntax-9"> http_request</span><span class="syntax-2">(</span><span class="syntax-1">'GET'</span><span class="syntax-2">, $listUrl, $options)</span><span class="syntax-4">-></span><span class="syntax-8">toArray</span><span class="syntax-2">();</span></span>
<span class="line"><span class="syntax-4">        foreach</span><span class="syntax-2"> ($comments </span><span class="syntax-4">as</span><span class="syntax-2"> $comment) {</span></span>
<span class="line"><span class="syntax-4">            if</span><span class="syntax-2"> (</span><span class="syntax-8">str_contains</span><span class="syntax-2">($comment[</span><span class="syntax-1">'body'</span><span class="syntax-2">] </span><span class="syntax-4">??</span><span class="syntax-1"> ''</span><span class="syntax-2">, </span><span class="syntax-3">E2E_REPORT_MARKER</span><span class="syntax-2">)) {</span></span>
<span class="line"><span class="syntax-4">                return</span><span class="syntax-2"> $comment[</span><span class="syntax-1">'id'</span><span class="syntax-2">];</span></span>
<span class="line"><span class="syntax-2">            }</span></span>
<span class="line"><span class="syntax-2">        }</span></span>
<span class="line"><span class="syntax-4">        ++</span><span class="syntax-2">$page;</span></span>
<span class="line"><span class="syntax-2">    } </span><span class="syntax-4">while</span><span class="syntax-2"> (</span><span class="syntax-3">100</span><span class="syntax-4"> ===</span><span class="syntax-2"> \</span><span class="syntax-9">count</span><span class="syntax-2">($comments));</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    return</span><span class="syntax-3"> null</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Les <code>$options</code> sont communes à tous les appels, et regroupent l’authentification et les en-têtes attendus par l’API :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-5">function</span><span class="syntax-8"> e2e_github_options</span><span class="syntax-2">(</span><span class="syntax-4">string</span><span class="syntax-2"> $token)</span><span class="syntax-4">:</span><span class="syntax-4"> array</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    return</span><span class="syntax-2"> [</span></span>
<span class="line"><span class="syntax-1">        'auth_bearer'</span><span class="syntax-4"> =></span><span class="syntax-2"> $token,</span></span>
<span class="line"><span class="syntax-1">        'headers'</span><span class="syntax-4"> =></span><span class="syntax-2"> [</span></span>
<span class="line"><span class="syntax-1">            'Accept'</span><span class="syntax-4"> =></span><span class="syntax-1"> 'application/vnd.github+json'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-1">            'X-GitHub-Api-Version'</span><span class="syntax-4"> =></span><span class="syntax-1"> '2022-11-28'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-2">        ],</span></span>
<span class="line"><span class="syntax-2">    ];</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Et selon qu’on a trouvé un commentaire existant ou non, on fait un <code>PATCH</code> sur celui-ci ou un <code>POST</code> d’un nouveau :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$options </span><span class="syntax-4">=</span><span class="syntax-8"> e2e_github_options</span><span class="syntax-2">($token);</span></span>
<span class="line"><span class="syntax-2">$existingId </span><span class="syntax-4">=</span><span class="syntax-8"> e2e_find_report_comment</span><span class="syntax-2">($options, $repo, $pr);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">if</span><span class="syntax-2"> (</span><span class="syntax-3">null</span><span class="syntax-4"> !==</span><span class="syntax-2"> $existingId) {</span></span>
<span class="line"><span class="syntax-2">    $url </span><span class="syntax-4">=</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">'%s/repos/%s/issues/comments/%d'</span><span class="syntax-2">, </span><span class="syntax-3">E2E_GITHUB_API_BASE</span><span class="syntax-2">, $repo, $existingId);</span></span>
<span class="line"><span class="syntax-9">    http_request</span><span class="syntax-2">(</span><span class="syntax-1">'PATCH'</span><span class="syntax-2">, $url, [</span><span class="syntax-4">...</span><span class="syntax-2">$options, </span><span class="syntax-1">'json'</span><span class="syntax-4"> =></span><span class="syntax-2"> [</span><span class="syntax-1">'body'</span><span class="syntax-4"> =></span><span class="syntax-2"> $body]])</span><span class="syntax-4">-></span><span class="syntax-8">getContent</span><span class="syntax-2">();</span></span>
<span class="line"><span class="syntax-8">    io</span><span class="syntax-2">()</span><span class="syntax-4">-></span><span class="syntax-8">success</span><span class="syntax-2">(\</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">'Updated PR #%s comment with %d screenshot(s).'</span><span class="syntax-2">, $pr, \</span><span class="syntax-9">count</span><span class="syntax-2">($screenshots)));</span></span>
<span class="line"><span class="syntax-2">} </span><span class="syntax-4">else</span><span class="syntax-2"> {</span></span>
<span class="line"><span class="syntax-2">    $url </span><span class="syntax-4">=</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">'%s/repos/%s/issues/%s/comments'</span><span class="syntax-2">, </span><span class="syntax-3">E2E_GITHUB_API_BASE</span><span class="syntax-2">, $repo, $pr);</span></span>
<span class="line"><span class="syntax-9">    http_request</span><span class="syntax-2">(</span><span class="syntax-1">'POST'</span><span class="syntax-2">, $url, [</span><span class="syntax-4">...</span><span class="syntax-2">$options, </span><span class="syntax-1">'json'</span><span class="syntax-4"> =></span><span class="syntax-2"> [</span><span class="syntax-1">'body'</span><span class="syntax-4"> =></span><span class="syntax-2"> $body]])</span><span class="syntax-4">-></span><span class="syntax-8">getContent</span><span class="syntax-2">();</span></span>
<span class="line"><span class="syntax-8">    io</span><span class="syntax-2">()</span><span class="syntax-4">-></span><span class="syntax-8">success</span><span class="syntax-2">(\</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">'Posted a comment on PR #%s with %d screenshot(s).'</span><span class="syntax-2">, $pr, \</span><span class="syntax-9">count</span><span class="syntax-2">($screenshots)));</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>La même fonction de recherche est réutilisée par la task <code>qa:e2e-clear-report</code> : quand les tests repassent au vert, on retrouve le commentaire par son marqueur et on le supprime (<code>DELETE</code>), pour que la PR ne garde pas la trace d’un problème déjà corrigé.</p>
<h3>Dédoublonner les retries</h3>
<p>En CI, Playwright réessaie deux fois (<code>retries: process.env.CI ? 2 : 0</code>) et écrit les résultats de chaque tentative dans des dossiers frères <code>&lt;test&gt;-retryN</code>. Sans traitement, la même régression apparaîtrait donc trois fois dans le commentaire. Nous regroupons les images par test et par capture, en ne gardant que la tentative la plus élevée :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">// Retries live in sibling "&#x3C;test>-retryN" directories; strip the suffix for a stable key.</span></span>
<span class="line"><span class="syntax-2">$testDir </span><span class="syntax-4">=</span><span class="syntax-9"> basename</span><span class="syntax-2">($dir);</span></span>
<span class="line"><span class="syntax-2">$retry </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">if</span><span class="syntax-2"> (</span><span class="syntax-9">preg_match</span><span class="syntax-2">(</span><span class="syntax-1">'/</span><span class="syntax-4">^</span><span class="syntax-1">(.</span><span class="syntax-4">*</span><span class="syntax-1">)-retry(</span><span class="syntax-3">\d</span><span class="syntax-4">+</span><span class="syntax-1">)</span><span class="syntax-4">$</span><span class="syntax-1">/'</span><span class="syntax-2">, $testDir, $matches)) {</span></span>
<span class="line"><span class="syntax-2">    $testDir </span><span class="syntax-4">=</span><span class="syntax-2"> $matches[</span><span class="syntax-3">1</span><span class="syntax-2">];</span></span>
<span class="line"><span class="syntax-2">    $retry </span><span class="syntax-4">=</span><span class="syntax-2"> (</span><span class="syntax-5">int</span><span class="syntax-2">) $matches[</span><span class="syntax-3">2</span><span class="syntax-2">];</span></span>
<span class="line"><span class="syntax-2">}</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">$key </span><span class="syntax-4">=</span><span class="syntax-2"> $testDir </span><span class="syntax-4">.</span><span class="syntax-1"> '/'</span><span class="syntax-4"> .</span><span class="syntax-2"> $name;</span></span>
<span class="line"><span class="syntax-4">if</span><span class="syntax-2"> (</span><span class="syntax-9">isset</span><span class="syntax-2">($screenshots[$key]) </span><span class="syntax-4">&#x26;&#x26;</span><span class="syntax-2"> $screenshots[$key][</span><span class="syntax-1">'retry'</span><span class="syntax-2">] </span><span class="syntax-4">>=</span><span class="syntax-2"> $retry) {</span></span>
<span class="line"><span class="syntax-4">    continue</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<h3>Purger les vieilles images</h3>
<p>Enfin, comme nous poussons des images sur un serveur partagé à chaque échec, il faut éviter que le dossier ne grossisse indéfiniment. Un <code>find</code> sur les dossiers de run trop anciens fait l’affaire. Notez le <code>allowFailure</code> : le ménage ne doit pas faire échouer le rapport s’il se passe mal.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">// Prune old run directories so the shared folder does not grow forever.</span></span>
<span class="line"><span class="syntax-8">ssh_run</span><span class="syntax-2">(</span></span>
<span class="line"><span class="syntax-2">    \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span></span>
<span class="line"><span class="syntax-1">        'find %s -mindepth 1 -maxdepth 1 -type d -mtime +%d -exec rm -rf {} +'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-9">        escapeshellarg</span><span class="syntax-2">($remoteDir),</span></span>
<span class="line"><span class="syntax-2">        $retentionDays,</span></span>
<span class="line"><span class="syntax-2">    ),</span></span>
<span class="line"><span class="syntax-2">    host: $remoteHost,</span></span>
<span class="line"><span class="syntax-2">    user: $remoteUser,</span></span>
<span class="line"><span class="syntax-2">    allowFailure: </span><span class="syntax-3">true</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-2">);</span></span></code></pre>
<p>Au bout du compte, toute la logique pour poster les régressions visuelles en commentaire dans GitHub représente environ 200 lignes de PHP, mais cette amélioration nous fait gagner du confort au quotidien : on passe d’un « le job E2E est rouge » à un « ah oui, le footer a pris 4px » sans quitter la page de la PR.</p>
<h2>Le cas des fixtures aléatoires</h2>
<p>Il reste une dernière source de variation, et c’est celle qui nous a demandé le plus de tâtonnements.</p>
<p>Nos fixtures utilisent <a rel="nofollow noopener noreferrer" href="https://github.com/nelmio/alice">nelmio/alice</a>, et donc Faker, ce qui implique de l’aléatoire : des titres, des prix, des descriptions, des noms générés à la volée. Or une page de détail ne peut évidemment pas produire une capture stable si le prix affiché change à chaque chargement des fixtures.</p>
<p>Nous avions déjà fait en sorte d'éviter les soucis d'aléatoire dès le début du projet car les tests Behat avaient le même besoin d'avoir des contenus stables dans le temps, et la solution tient en une ligne de configuration :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># application/config/packages/nelmio_alice.yaml</span></span>
<span class="line"><span class="syntax-4">when@dev</span><span class="syntax-2">: </span><span class="syntax-4">&#x26;</span><span class="syntax-6">dev</span></span>
<span class="line"><span class="syntax-4">    nelmio_alice</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">        locale</span><span class="syntax-2">: </span><span class="syntax-1">'fr_FR'</span><span class="syntax-10"> # Default locale for the Faker Generator</span></span>
<span class="line"><span class="syntax-4">        seed</span><span class="syntax-2">: </span><span class="syntax-3">42</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">when@test</span><span class="syntax-2">: </span><span class="syntax-4">*</span><span class="syntax-2">dev</span></span></code></pre>
<p>Avec un seed fixe, Faker devient déterministe : les mêmes fixtures rechargées produisent exactement les mêmes données, et donc exactement les mêmes pixels. Problème réglé, en apparence.</p>
<p>Sauf que le déterminisme d’un générateur pseudo-aléatoire est positionnel. Le seed garantit une séquence de valeurs, mais pas l’affectation d’une valeur donnée à un objet donné. Si vous ajoutez une entité au milieu d’un fichier de fixtures, par exemple pour un test Behat qui n’a rien à voir avec le visuel, vous consommez un tirage supplémentaire, et tout ce qui vient après décale d’un cran dans la séquence. Les prix changent, les titres changent, et les captures d’écran deviennent rouges à cause d’un test fonctionnel sans aucun rapport.</p>
<p>Le symptôme est assez déroutant la première fois qu’on le rencontre : la PR ne touche pas une ligne de CSS, et pourtant les tests visuels échouent.</p>
<p>La règle que nous nous sommes donnée est simple : <strong>quand une capture change à cause d’un décalage de fixtures, on change la fixture impactée puis on régénère l’image</strong>. Concrètement, on identifie la donnée qui a bougé sur la page (un prix, un titre, un nom) et on lui donne une valeur en dur au lieu de la laisser à Faker :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># Avant : la valeur dépend de la position du tirage dans la séquence.</span></span>
<span class="line"><span class="syntax-4">title</span><span class="syntax-2">: </span><span class="syntax-1">'&#x3C;sentence()>'</span></span>
<span class="line"><span class="syntax-4">price</span><span class="syntax-2">: </span><span class="syntax-1">'&#x3C;numberBetween(100000, 900000)>'</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10"># Après : la valeur est figée, et ne bougera plus jamais.</span></span>
<span class="line"><span class="syntax-4">title</span><span class="syntax-2">: </span><span class="syntax-1">'Une valeur figée pour les tests de screenshots'</span></span>
<span class="line"><span class="syntax-4">price</span><span class="syntax-2">: </span><span class="syntax-3">245000</span></span></code></pre>
<p>Le commentaire de PR est précieux pour ça : il montre immédiatement <em>quelle</em> donnée a changé. Constater qu’un prix est passé de 245 000 € à 312 000 € prend deux secondes, là où le déduire d’un compteur de pixels est impossible.</p>
<p>L’intérêt de procéder ainsi, c’est que la correction est définitive et que l’effort est réparti dans le temps. Nous ne figeons pas toutes les fixtures d’un coup - ce serait un gros chantier, et une bonne partie n’apparaît de toute façon dans aucune capture. Nous figeons uniquement celles qui nous ont réellement posé problème, au moment où elles nous le posent. Au fil des PR, les données visibles sur les pages sous test deviennent progressivement déterministes, et ce type d’échec se raréfie de lui-même.</p>
<h2>En résumé</h2>
<p>Grâce à Playwright, Docker et Castor, nous avons pu mettre en place une détection des régressions visuelles qui reste simple à l’usage :</p>
<ul>
<li><code>toHaveScreenshot()</code> fait tout le travail de comparaison, sans outil externe ni service tiers à payer ;</li>
<li>le passage par Docker garantit un rendu identique sur les postes de l’équipe et en CI, quel que soit l’OS ;</li>
<li>les tasks Castor masquent la plomberie et installent leurs dépendances toutes seules ;</li>
<li>le déterminisme est obtenu par un ensemble de petits réglages : viewport fixe, attente explicite de stabilisation, masques sur les zones volontairement aléatoires et seed sur les fixtures ;</li>
<li>les diffs postés directement dans la pull request rendent chaque échec compréhensible en un coup d’œil.</li>
</ul>
<p>Quelques captures d’écran seulement, et quelques centaines de lignes de configuration : ce n’est évidemment pas une couverture exhaustive du site, et ce n’est pas le but. Mais depuis leur mise en place, les régressions de mise en page sur les pages structurantes sont détectées avant la mise en production, et non plus après.</p>]]></description></item><item><title>JoliMediaSyliusBundle, un nouveau bridge pour vos projets Sylius</title><link>https://jolicode.com/blog/jolimediasyliusbundle-un-nouveau-bridge-pour-vos-projets-sylius</link><author>JoliCode Team</author><date>Mon, 20 Jul 2026 12:42:00 +0000</date><description><![CDATA[<p>Est-il encore nécessaire de présenter l’excellent framework E-commerce <a rel="nofollow noopener noreferrer" href="https://sylius.com/">Sylius</a> ?</p>
<p>Si Sylius s’est imposé dans l’écosystème e-commerce, c’est notamment grâce à sa capacité à s’adapter à des besoins métier très variés sans imposer une architecture rigide. Son système d’extensions permet de faire évoluer progressivement les fonctionnalités tout en conservant les mécanismes du cœur du framework.</p>
<p>Fin d'année dernière, nous avons lancé le <a href="https://jolicode.com/blog/jolimediabundle-un-nouveau-bundle-de-medias-pour-vos-projets-symfony">JoliMediaBundle</a>, un bundle Symfony dédié à la gestion de bibliothèques de medias.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/joli-media-sylius-bundle/sonata-grid-view.png" data-original-width="1203" data-original-height="891"><source type="image/webp" srcset="/media/cache/content-webp/2026/joli-media-sylius-bundle/sonata-grid-view.1cfceef2.webp" /><source type="image/png" srcset="/media/cache/content/2026/joli-media-sylius-bundle/sonata-grid-view.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1203 / 891)" src="https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/sonata-grid-view.png" alt="Le bridge SonataAdmin" /></picture>
<picture class="js-dialog-target" data-original-url="/media/original/2026/joli-media-sylius-bundle/easyadmin-grid-view.png" data-original-width="1204" data-original-height="890"><source type="image/webp" srcset="/media/cache/content-webp/2026/joli-media-sylius-bundle/easyadmin-grid-view.18c21cd6.webp" /><source type="image/png" srcset="/media/cache/content/2026/joli-media-sylius-bundle/easyadmin-grid-view.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1204 / 890)" src="https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/easyadmin-grid-view.png" alt="Le bridge EasyAdmin" /></picture></p>
<p>Il était déjà accompagné de deux bridges pour SonataAdmin et EasyAdmin.</p>
<h2>La Genèse du bridge</h2>
<p>Récemment arrivé chez JoliCode et expert Sylius, j’ai rapidement engagé des discussions autour de l’intégration du MediaBundle dans cet écosystème. C’est ainsi qu’est née l’idée de ce nouveau bridge, visant à connecter harmonieusement la gestion des médias avec Sylius.</p>
<h2>La gestion des médias dans Sylius</h2>
<p>Avant d’introduire ce nouveau bridge, il est utile de faire un état des lieux de la gestion des médias dans Sylius aujourd’hui.</p>
<p>Par défaut, Sylius propose un système simple mais efficace pour associer des images aux principales ressources du catalogue, comme les produits ou les taxons. Cette gestion repose sur des entités d’images directement liées aux ressources, avec quelques métadonnées basiques (type, position, etc.).</p>
<p>Cette approche répond parfaitement aux besoins classiques d’un site e-commerce : illustrer un produit, afficher des visuels de catégories, ou encore gérer des galeries simples.</p>
<p>En revanche, certaines limites apparaissent dès que les besoins deviennent plus transverses. Notamment :</p>
<ul>
<li>la difficulté à réutiliser facilement un même média à plusieurs endroits sans duplication ;</li>
<li>l’absence d’une organisation centralisée des fichiers (dossiers, tags, recherche…) ;</li>
<li>une gestion éclatée des médias, propre à chaque ressource.</li>
</ul>
<p>En pratique, chaque entité embarque ses propres fichiers, ce qui fonctionne bien à petite échelle mais devient rapidement contraignant dès que le volume de médias augmente ou que plusieurs équipes interviennent.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/joli-media-sylius-bundle/product-images-before.png" data-original-width="1684" data-original-height="761"><source type="image/webp" srcset="/media/cache/content-webp/2026/joli-media-sylius-bundle/product-images-before.84a3c34d.webp" /><source type="image/png" srcset="/media/cache/content/2026/joli-media-sylius-bundle/product-images-before.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1684 / 761)" src="https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-images-before.png" alt="La gestion actuelle des images de produits" /></picture></p>
<h2>Le bridge Sylius et JoliMediaBundle</h2>
<p>C’est précisément ce constat qui a motivé la réflexion autour d’une gestion des médias plus centralisée et réutilisable au sein de l’écosystème Sylius, en s’appuyant sur le JoliMediaBundle développé chez JoliCode.
Pour répondre à ces limites, nous avons développé le bridge Sylius pour ce bundle.
L’objectif n’est pas de modifier le fonctionnement de Sylius ni de réécrire sa gestion des médias, mais d’y ajouter une couche d’intégration propre, basée sur ses mécanismes d’extension.</p>
<p>Dans un projet Sylius, les médias sont utilisés dans les images produit, les images de taxons et les avatars administrateurs. Ces usages sont bien intégrés au modèle natif mais restent isolés les uns des autres. Le bridge vient enrichir ce fonctionnement en introduisant une médiathèque centralisée.</p>
<p>Il est également pensé pour être compatible avec la Sylius Stack au sens large, et pas uniquement avec un contexte e-commerce. Il peut ainsi s’intégrer dans des back-offices Sylius utilisés comme base d’application, où la gestion de médias est un besoin transverse à plusieurs domaines fonctionnels. Cela permet d’utiliser la même approche de médiathèque centralisée dans des projets plus généraux construits avec Sylius.</p>
<p>Concrètement, l’approche repose sur des extensions simples et ciblées :</p>
<ul>
<li>extension des entités Sylius concernées lorsque cela est nécessaire ;</li>
<li>remplacement des champs de formulaire dans le back-office pour utiliser le sélecteur de médias du JoliMediaBundle ;</li>
<li>intégration progressive via les points d’extension fournis par Sylius.</li>
</ul>
<p>Cette stratégie permet de conserver les usages métier actuels tout en introduisant une gestion plus cohérente et réutilisable des médias. Chaque image (produit, taxon, administrateur) garde son rôle, mais s’inscrit désormais dans une logique commune de médiathèque.</p>
<p>L’intégration reste volontairement discrète : le bridge agit comme une surcouche qui s’insère dans l’écosystème Sylius sans en modifier les fondations.</p>
<p>C’est cette approche progressive qui rend l’adoption possible dans un projet existant, sans refonte du modèle de données ni rupture fonctionnelle.</p>
<p>La question devient alors plus concrète : comment ce bridge s’insère-t-il techniquement dans Sylius, et quels sont les mécanismes utilisés pour relier proprement la médiathèque au modèle existant ?</p>
<h2>Une intégration simple dans Sylius</h2>
<p>Lorsque vous avez <a rel="nofollow noopener noreferrer" href="https://mediabundle.jolicode.com/getting-started/installation/">installé le Media bundle</a>, il faut activer le bridge Sylius :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">// filepath: config/bundles.php</span></span>
<span class="line"><span class="syntax-4">return</span><span class="syntax-2"> [</span></span>
<span class="line"><span class="syntax-10">    // ...</span></span>
<span class="line"><span class="syntax-2">    JoliCode\MediaBundle\Bridge\Sylius\</span><span class="syntax-5">JoliMediaSyliusBundle</span><span class="syntax-4">::class</span><span class="syntax-4"> =></span><span class="syntax-2"> [</span><span class="syntax-1">'all'</span><span class="syntax-4"> =></span><span class="syntax-3"> true</span><span class="syntax-2">],</span></span>
<span class="line"><span class="syntax-2">];</span></span></code></pre>
<p>Ensuite, on active les routes pour le back-office :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># filepath: config/routes/joli_media.yaml</span></span>
<span class="line"><span class="syntax-4">_joli_media_sylius</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">    resource</span><span class="syntax-2">: </span><span class="syntax-1">"@JoliMediaSyliusBundle/src/Admin/Controller/"</span></span>
<span class="line"><span class="syntax-4">    prefix</span><span class="syntax-2">: </span><span class="syntax-1">/admin/media</span></span></code></pre>
<p>Et enfin, on importe la configuration du package :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># filepath: config/packages/joli_media_sylius.yaml</span></span>
<span class="line"><span class="syntax-4">imports</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-2">    - { </span><span class="syntax-4">resource</span><span class="syntax-2">: </span><span class="syntax-1">"@JoliMediaSyliusBundle/config/app.php"</span><span class="syntax-2"> }</span></span></code></pre>
<p>L’idée est de s’appuyer sur les mécanismes d’extension classiques de Symfony et Sylius, afin de rester le moins intrusif possible.</p>
<h3>Un trait réutilisable pour les médias</h3>
<p>La première brique consiste à utiliser un trait permettant d’ajouter une gestion de média à n’importe quelle entité métier :</p>
<p>Vous pouvez associer un média à une entité Doctrine Sylius, tout en conservant une logique simple côté domaine. Cela synchronisera le champ <code>path</code> existant dans Sylius.</p>
<h3>Extension des entités Sylius</h3>
<p>Cette approche s’intègre naturellement aux entités existantes. Par exemple, pour les images produit :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">namespace App\Entity\Product;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">use Doctrine\ORM\Mapping as ORM;</span></span>
<span class="line"><span class="syntax-8">+use JoliCode\MediaBundle\Bridge\Sylius\Doctrine\ORM\EntityWithMediaImageTrait;</span></span>
<span class="line"><span class="syntax-2">use Sylius\Component\Core\Model\ProductImage as BaseProductImage;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10">#[ORM\Entity]</span></span>
<span class="line"><span class="syntax-10">#[ORM\Table(name: 'sylius_product_image')]</span></span>
<span class="line"><span class="syntax-2">class ProductImage extends BaseProductImage</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-2">   +use EntityWithMediaImageTrait;</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>On étend ainsi une entité Sylius sans en modifier le cœur, en ajoutant uniquement la capacité de manipuler un média via le bundle.</p>
<h3>Intégration dans le back-office</h3>
<p>Enfin, côté administration, l’intégration passe par une extension de formulaire Sylius. Le champ fichier natif est remplacé par un composant dédié au JoliMediaBundle :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-5">class</span><span> </span><span class="syntax-6">ProductImageTypeExtension</span><span class="syntax-4"> extends</span><span> </span><span class="syntax-7">AbstractTypeExtension</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">   public</span><span class="syntax-5"> function</span><span class="syntax-8"> buildForm</span><span class="syntax-2">(</span><span class="syntax-5">FormBuilderInterface</span><span class="syntax-2"> $builder, </span><span class="syntax-4">array</span><span class="syntax-2"> $options)</span><span class="syntax-4">:</span><span class="syntax-4"> void</span></span>
<span class="line"><span class="syntax-2">   {</span></span>
<span class="line"><span class="syntax-2">       $builder</span><span class="syntax-4">-></span><span class="syntax-8">add</span><span class="syntax-2">(</span><span class="syntax-1">'file'</span><span class="syntax-2">, </span><span class="syntax-5">MediaChoiceType</span><span class="syntax-4">::class</span><span class="syntax-2">, [</span></span>
<span class="line"><span class="syntax-1">           'property_path'</span><span class="syntax-4"> =></span><span class="syntax-1"> 'media'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-2">       ]);</span></span>
<span class="line"><span class="syntax-2">   }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">   public</span><span class="syntax-4"> static</span><span class="syntax-5"> function</span><span class="syntax-8"> getExtendedTypes</span><span class="syntax-2">()</span><span class="syntax-4">:</span><span class="syntax-4"> iterable</span></span>
<span class="line"><span class="syntax-2">   {</span></span>
<span class="line"><span class="syntax-4">       yield</span><span class="syntax-5"> ProductImageType</span><span class="syntax-4">::class</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">   }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Cela permet de brancher directement la médiathèque dans l’interface d’administration Sylius, sans casser les formulaires existants.</p>
<p>Ces Forms extensions sont directement fournies par le bridge, il vous suffit de les déclarer dans Symfony:</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># config/services.yaml</span></span>
<span class="line"><span class="syntax-4">services</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">   JoliCode\MediaBundle\Bridge\Sylius\Admin\Form\Extension\AvatarImageTypeExtension</span><span class="syntax-2">: </span><span class="syntax-3">null</span></span>
<span class="line"><span class="syntax-4">   JoliCode\MediaBundle\Bridge\Sylius\Admin\Form\Extension\ProductImageTypeExtension</span><span class="syntax-2">: </span><span class="syntax-3">null</span></span>
<span class="line"><span class="syntax-4">   JoliCode\MediaBundle\Bridge\Sylius\Admin\Form\Extension\TaxonImageTypeExtension</span><span class="syntax-2">: </span><span class="syntax-3">null</span></span></code></pre>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/joli-media-sylius-bundle/product-images-after.png" data-original-width="1717" data-original-height="832"><source type="image/webp" srcset="/media/cache/content-webp/2026/joli-media-sylius-bundle/product-images-after.a0213f32.webp" /><source type="image/png" srcset="/media/cache/content/2026/joli-media-sylius-bundle/product-images-after.png" /><img loading="lazy" decoding="async" style="aspect-ratio: calc(1717 / 832)" src="https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-images-after.png" alt="La gestion des images de produits avec le media bundle" /></picture></p>
<p>Le <code>File</code> input est remplacé par celui du Media bundle.</p>
<p>Pour les plus observateurs, vous pouvez remarquer que nous avons modifié le template pour retirer l’aperçu fourni par Sylius nativement.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/joli-media-sylius-bundle/media-library-list-view.png" data-original-width="1650" data-original-height="1253"><source type="image/webp" srcset="/media/cache/content-webp/2026/joli-media-sylius-bundle/media-library-list-view.dc768947.webp" /><source type="image/png" srcset="/media/cache/content/2026/joli-media-sylius-bundle/media-library-list-view.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1650 / 1253)" src="https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/media-library-list-view.png" alt="La médiathèque (list-view)" /></picture></p>
<p>La Médiathèque est le point fort du Media bundle. Elle permet de visualiser mais également d’organiser votre arborescence.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/joli-media-sylius-bundle/media-library-grid-view.png" data-original-width="1693" data-original-height="939"><source type="image/webp" srcset="/media/cache/content-webp/2026/joli-media-sylius-bundle/media-library-grid-view.98293dc0.webp" /><source type="image/png" srcset="/media/cache/content/2026/joli-media-sylius-bundle/media-library-grid-view.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1693 / 939)" src="https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/media-library-grid-view.png" alt="La médiathèque (grid view)" /></picture></p>
<p>Une vue « Grid » est également disponible pour afficher les images en plus grand format.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/joli-media-sylius-bundle/media-details.png" data-original-width="1711" data-original-height="1295"><source type="image/webp" srcset="/media/cache/content-webp/2026/joli-media-sylius-bundle/media-details.2d396fee.webp" /><source type="image/png" srcset="/media/cache/content/2026/joli-media-sylius-bundle/media-details.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1711 / 1295)" src="https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/media-details.png" alt="Détails du media" /></picture></p>
<p>Une page détails du media permet d’obtenir davantage d’informations ainsi que les options d’intégration.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/joli-media-sylius-bundle/variations.png" data-original-width="1704" data-original-height="1063"><source type="image/webp" srcset="/media/cache/content-webp/2026/joli-media-sylius-bundle/variations.6c243f5a.webp" /><source type="image/png" srcset="/media/cache/content/2026/joli-media-sylius-bundle/variations.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1704 / 1063)" src="https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/variations.png" alt="Variations du media" /></picture></p>
<p>Un onglet « Variations » est disponible afin de consulter les différentes variantes de vos médias, avec leurs tailles, formats et dimensions respectifs.</p>
<p>Il est ainsi possible d’utiliser le système de compression du JoliMediaBundle au lieu du système natif de Sylius (utilisant LiipImagine).</p>
<p>Remplaçons les images dans la liste des produits :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">&#x3C;?</span><span class="syntax-3">php</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">namespace</span><span> </span><span class="syntax-6">App\Grid\Mutator</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Bundle\AdminBundle\Grid\</span><span class="syntax-5">ProductGridInterface</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Bundle\GridBundle\Builder\Field\</span><span class="syntax-5">TwigField</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Component\Grid\Builder\</span><span class="syntax-5">GridBuilderInterface</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Component\Grid\Attribute\</span><span class="syntax-5">AsGridMutator</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Component\Grid\Mutator\</span><span class="syntax-5">GridMutatorInterface</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">#[AsGridMutator(</span></span>
<span class="line"><span class="syntax-2">    grid: </span><span class="syntax-1">'sylius_admin_product'</span><span class="syntax-2">, </span></span>
<span class="line"><span class="syntax-10">    // ou</span></span>
<span class="line"><span class="syntax-2">    grid: </span><span class="syntax-5">ProductGridInterface</span><span class="syntax-4">::</span><span class="syntax-3">NAME</span><span class="syntax-10"> // constante ajoutée sur Sylius 2.3</span></span>
<span class="line"><span class="syntax-2">)]</span></span>
<span class="line"><span class="syntax-5">class</span><span> </span><span class="syntax-6">ReplaceImageFromProductGridMutator</span><span class="syntax-4"> implements</span><span> </span><span class="syntax-7">GridMutatorInterface</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    public</span><span class="syntax-5"> function</span><span class="syntax-9"> __invoke</span><span class="syntax-2">(</span><span class="syntax-5">GridBuilderInterface</span><span class="syntax-2"> $gridBuilder)</span><span class="syntax-4">:</span><span class="syntax-4"> void</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-2">        $gridBuilder</span></span>
<span class="line"><span class="syntax-4">            -></span><span class="syntax-8">withFields</span><span class="syntax-2">(</span></span>
<span class="line"><span class="syntax-5">                TwigField</span><span class="syntax-4">::</span><span class="syntax-8">create</span><span class="syntax-2">(</span><span class="syntax-1">'image'</span><span class="syntax-2">, template: </span><span class="syntax-1">'admin/product/grid/field/image.html.twig'</span><span class="syntax-2">),</span></span>
<span class="line"><span class="syntax-2">            )</span></span>
<span class="line"><span class="syntax-2">        ;</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Ainsi on remplace le field image en utilisant notre propre template Twig.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">&#x3C;!-- templates/admin/product/grid/field/image.html.twig -></span></span>
<span class="line"><span class="syntax-10">{% </span><span class="syntax-4">from</span><span class="syntax-1"> '@JoliMediaSylius/admin/shared/helper/product_image.html.twig'</span><span class="syntax-4"> import</span><span class="syntax-2"> image</span><span class="syntax-10"> %}</span></span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span class="syntax-10">&#x3C;div class="thumbnail-box-image"></span></span>
<span class="line"><span class="syntax-10">   {{ image(</span><span class="syntax-2">data</span><span class="syntax-10">) }}</span></span>
<span class="line"><span class="syntax-10">&#x3C;/div></span></span></code></pre>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/joli-media-sylius-bundle/product-images.png" data-original-width="1716" data-original-height="1051"><source type="image/webp" srcset="/media/cache/content-webp/2026/joli-media-sylius-bundle/product-images.e1601445.webp" /><source type="image/png" srcset="/media/cache/content/2026/joli-media-sylius-bundle/product-images.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1716 / 1051)" src="https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-images.png" alt="Images de produits" /></picture></p>
<p>Le résultat est identique en apparence, mais on peut voir que l’image a été traitée par le JoliMediaBundle en y regardant de plus près :</p>
<p><picture><source type="image/webp" srcset="/media/cache/content-webp/2026/joli-media-sylius-bundle/product-image-inspection.18928b6d.webp" /><source type="image/png" srcset="/media/cache/content/2026/joli-media-sylius-bundle/product-image-inspection.png" /><img loading="lazy" decoding="async" style="width: 382px; ; aspect-ratio: calc(382 / 134)" src="https://jolicode.com//media/cache/content/2026/joli-media-sylius-bundle/product-image-inspection.png" alt="Image du produit avec l’inspecteur" /></picture></p>
<h3>Utilisation dans le shop</h3>
<p>Le mécanisme cœur de Sylius n’étant pas modifié, les images continuent de fonctionner comme auparavant côté front.
Elles peuvent donc être traitées directement par LiipImagine par défaut, sans nécessiter d’adaptation spécifique. Il n’est pas obligatoire, dans un premier temps, d’aller plus loin que cette intégration côté administration.
Il est ensuite possible d’améliorer progressivement <a href="https://jolicode.com/blog/jolimediabundle-un-nouveau-bundle-de-medias-pour-vos-projets-symfony#revenons-a-nos-moutons-pourquoi-un-nouveau-bundle-de-gestion-de-medias-pour-symfony">la qualité du rendu des images</a> en effectuant les ajustements nécessaires côté front, comme décrit dans <a rel="nofollow noopener noreferrer" href="https://mediabundle.jolicode.com/bridges/sylius/#shop">la documentation du bundle</a>.</p>
<h3>Réorganiser votre médiathèque</h3>
<p>Un mécanisme de propagation des changements dans vos entités permet de déplacer ou renommer vos médias librement, sans casser les références existantes dans votre application.
Cette approche facilite non seulement la recherche et l’identification des médias dans le back-office, mais également la réorganisation progressive de la médiathèque au fil du temps, que ce soit pour restructurer une arborescence, harmoniser des noms de fichiers ou regrouper certains médias par domaine fonctionnel.</p>
<h2>Conclusion</h2>
<p>Sylius propose déjà une base solide pour la gestion des médias dans un contexte e-commerce. Le JoliMediaBundle apporte une vision plus transversale et structurée de la gestion des fichiers. Le bridge entre les deux ne cherche pas à opposer ces approches, mais à les faire coexister proprement.
En pratique, cette combinaison permet de conserver la simplicité du modèle Sylius tout en introduisant une médiathèque centralisée, réutilisable et plus adaptée à des projets qui grandissent ou se complexifient.
C’est aussi une manière de prolonger la philosophie même de Sylius : rester extensible, sans imposer de rigidité, tout en laissant la liberté d’adapter l’architecture aux besoins réels du projet.</p>]]></description></item><item><title>Un RAG Magique !</title><link>https://les-tilleuls.coop/blog/un-rag-magique</link><author>Hugo Nicolas</author><date>Fri, 17 Jul 2026 09:56:38 +0000</date><description><![CDATA[<div class="container pt-48 pb-12">
<p class="wp-block-paragraph">Il y a peu, je me suis mis à rejouer à <em><a href="https://magic.wizards.com/fr" target="_blank" rel="noreferrer noopener">Magic: The Gathering</a></em> avec des amis. C’est un jeu de cartes à collectionner dans lequel on construit des decks pour affronter ses adversaires à travers divers modes de jeux possibles. Le jeu existe depuis 1993 et n’a cessé d’évoluer depuis. Résultat : même les joueurs et joueuses les plus expérimenté·es se font parfois surprendre par certaines règles ou interactions de cartes. Alors pour moi, qui n’avait pas joué depuis 20 ans <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f474.png" alt="👴" class="wp-smiley" style="height: 1em; max-height: 1em;" />, autant vous dire qu’à chaque partie, je passe un bon moment sur Internet à chercher telle ou telle règle pour éviter de faire n’importe quoi. C’est là que je me suis dit que ce serait sympa d’avoir à portée de main un petit assistant à qui poser directement mes questions. Pourquoi ne pas simplement ouvrir ChatGPT ou un autre agent conversationnel, me direz-vous ? Déjà parce que je souhaitais éviter au maximum les hallucinations, comme une réponse qui viendrait d’un fil Reddit d’il y a dix ans et ne correspondrait pas ou plus à la réalité. Ensuite, et surtout, parce que c’était l’occasion parfaite de coder un petit truc et d’apprendre en même temps !  C’est comme ça que j’ai décidé de me lancer dans la création d’une application mobile basée sur des LLM et du RAG. Si vous souhaitez jeter un œil au code (encore un peu en chantier, ne jugez pas), il se trouve <a href="https://github.com/JacquesDurand/judge" target="_blank" rel="noreferrer noopener">sur GitHub</a>.</p>

<h2 class="wp-block-heading decorative-title">RAG : qu’est-ce que c’est, et pourquoi ?</h2>

<p class="wp-block-paragraph">Pour nos lecteurs et lectrices francophones, mon collègue Clément a fait un chouette article sur <a href="https://les-tilleuls.coop/blog/introduction-a-larchitecture-rag" target="_blank" rel="noreferrer noopener">l’architecture RAG</a> ainsi qu’un talk au <a href="https://www.youtube.com/watch?v=pJuDhAgCAw8" target="_blank" rel="noreferrer noopener">dernier Forum PHP</a>. C’est d’ailleurs cela qui m’a poussé vers ce choix : je préférais guider le modèle en basant ses réponses sur les règles à jour et la liste des cartes existantes plutôt que de risquer une hallucination. Un chatbot classique aurait aussi potentiellement plus de difficultés, avec de simples recherches web par exemple à déterminer l’interaction entre plusieurs cartes.</p>

<p class="wp-block-paragraph">J’ai donc choisi le corpus suivant, qui en soit devrait permettre de couvrir la majeure partie des cas d’usage classiques :</p>

<ul class="wp-block-list">
<li>la liste complète des règles de Magic à jour, disponible sur <a href="https://magic.wizards.com/en/rules">le site web du jeu</a>.</li>



<li>la liste complète des cartes, téléchargeable via l’API de <a href="https://scryfall.com/">Scryfall</a> en JSON.</li>
</ul>

<p class="wp-block-paragraph">Comme ça, désormais, avec un peu de manipulation de prompt, je peux m’assurer que le chatbot ne répondra qu’en basant ses réponses sur les règles et les cartes, en les citant précisément, et qu’il me répondra “Je ne sais pas” plutôt que d’inventer une réponse.<br><br>“Attends, tu fournis l’ensemble des règles et des cartes au contexte du LLM à chaque question que tu poses ?”</p>

<p class="wp-block-paragraph">Fort heureusement non ! C’est là que la partie RAG intervient. On va tout d’abord préparer un pipeline d’ingestion et d’embedding des données :</p>

<figure class="wp-block-image aligncenter size-large"><img fetchpriority="high" decoding="async" width="1024" height="513" src="https://les-tilleuls.coop/wp-content/uploads/2026/07/pipeline-1024x513.png" alt="" class="wp-image-16588" srcset="https://les-tilleuls.coop/wp-content/uploads/2026/07/pipeline-1024x513.png 1024w, https://les-tilleuls.coop/wp-content/uploads/2026/07/pipeline-600x301.png 600w, https://les-tilleuls.coop/wp-content/uploads/2026/07/pipeline-300x150.png 300w, https://les-tilleuls.coop/wp-content/uploads/2026/07/pipeline-768x385.png 768w, https://les-tilleuls.coop/wp-content/uploads/2026/07/pipeline-1536x770.png 1536w, https://les-tilleuls.coop/wp-content/uploads/2026/07/pipeline-40x20.png 40w, https://les-tilleuls.coop/wp-content/uploads/2026/07/pipeline-1197x600.png 1197w, https://les-tilleuls.coop/wp-content/uploads/2026/07/pipeline.png 1876w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>

<p class="wp-block-paragraph">Pour les règles, l’idée va être de :&nbsp;</p>

<ul class="wp-block-list">
<li>Télécharger la liste des règles au format .txt</li>



<li>Parser et séparer le texte en <em>chunks</em>, qui correspondront chacun à une section (ou sous-section) des règles. À noter que ces chunks n’ont pas de taille fixe : ils s&rsquo;adaptent au paragraphe de la règle, car il est nécessaire d&rsquo;avoir l’intégralité de celui-ci pour conserver un maximum de sens.</li>



<li>Insérer ces chunks en base de données.</li>
</ul>

<p class="wp-block-paragraph">Pour base de données, j’ai choisi PostgreSQL. Grâce à son extension <a href="https://github.com/pgvector/pgvector" target="_blank" rel="noreferrer noopener">pgvector</a>, elle nous permettra par la suite de stocker des versions vectorisées de ces règles, et surtout de les requêter pour faire remonter celles qui ressemblent le plus à la question posée !</p>

<p class="wp-block-paragraph">Une fois insérée, une ligne de règles va pouvoir ressembler à ça en base :&nbsp;</p>

<figure class="wp-block-image aligncenter size-large is-style-default"><img decoding="async" width="1024" height="83" src="https://les-tilleuls.coop/wp-content/uploads/2026/07/base-1024x83.png" alt="" class="wp-image-16590" srcset="https://les-tilleuls.coop/wp-content/uploads/2026/07/base-1024x83.png 1024w, https://les-tilleuls.coop/wp-content/uploads/2026/07/base-600x49.png 600w, https://les-tilleuls.coop/wp-content/uploads/2026/07/base-300x24.png 300w, https://les-tilleuls.coop/wp-content/uploads/2026/07/base-768x62.png 768w, https://les-tilleuls.coop/wp-content/uploads/2026/07/base-1536x124.png 1536w, https://les-tilleuls.coop/wp-content/uploads/2026/07/base-2048x166.png 2048w, https://les-tilleuls.coop/wp-content/uploads/2026/07/base-40x3.png 40w, https://les-tilleuls.coop/wp-content/uploads/2026/07/base-1200x97.png 1200w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>

<p class="wp-block-paragraph">On voit qu’elles y sont bien découpées par sous-section.</p>

<p class="wp-block-paragraph">Pour les plus attentifs·ves, vous avez pu remarquer la colonne “embedding” dans la capture d’écran d&rsquo;au-dessus, c’est l’étape suivante.</p>

<p class="wp-block-paragraph">L’embedding, c’est le moment où l’on va transformer le corps du texte de la règle en un vecteur qui portera au mieux la sémantique de cette règle, et qu’on pourra plus facilement comparer aux vecteurs des questions posées, pour trouver les règles qui s’approchent le plus desdites questions. Pour réaliser cela, j’ai choisi de faire au plus simple. Pas besoin de créer son propre modèle d’embedding, nous allons appeler l’API d’OpenAI (choix arbitraire ici, il est aussi tout à fait possible de choisir un modèle de Voyage, ou Qwen3 par exemple) avec comme modèle <code>text-embedding-3-small</code>, qui est peu coûteux, et fait largement l’affaire pour nos besoins. Le flux est très simple : récupérer chaque ligne de la table de règles, envoyer le contenu à l’API d’embedding, recevoir un vecteur et l’insérer dans la ligne correspondante.</p>

<p class="wp-block-paragraph">Concernant les cartes, même principe au départ :</p>

<ul class="wp-block-list">
<li>Récupérer toutes les cartes au format JSON depuis l’API de Scryfall.</li>



<li>Décoder le body de la réponse, puis effectuer quelques manipulations pour gérer les cas spécifiques (comme les cartes à double face).</li>



<li>Insérer le tout dans notre table en base de données.</li>
</ul>

<p class="wp-block-paragraph">On a finalement une table qui ressemble à ça :&nbsp;</p>

<figure class="wp-block-image aligncenter size-large"><img decoding="async" width="1024" height="155" src="https://les-tilleuls.coop/wp-content/uploads/2026/07/schema-1024x155.png" alt="" class="wp-image-16592" srcset="https://les-tilleuls.coop/wp-content/uploads/2026/07/schema-1024x155.png 1024w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema-600x91.png 600w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema-300x45.png 300w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema-768x116.png 768w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema-1536x232.png 1536w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema-2048x310.png 2048w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema-40x6.png 40w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema-1200x181.png 1200w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>

<p class="wp-block-paragraph">Par contre ici, on va spécifiquement choisir de ne PAS embedder les cartes en vecteurs !&nbsp;</p>

<p class="wp-block-paragraph">En effet, lorsque des questions concernant des cartes seront posées, on ne veut pas qu’il y ait une tentative de comparaison sémantique sur les titres des cartes. Une question à propos de la carte “Lightning bolt” ne doit pas essayer de remonter plusieurs cartes dont le titre aurait un sens similaire, au risque de suggérer de mauvaises informations au LLM derrière.</p>

<p class="wp-block-paragraph">Néanmoins, avoir une tolérance au “fuzzy typing” est tout de même important, et ajouter un index Trigram (grâce à l’extension <a href="https://www.postgresql.org/docs/current/pgtrgm.html">pg_trgm</a>) sur le nom des cartes nous donne cette flexibilité.&nbsp;</p>

<h2 class="wp-block-heading decorative-title">Et le LLM dans tout ça ?</h2>

<p class="wp-block-paragraph">A ce stade, toutes les données sont prêtes. C’était une étape quasi-offline qui nous a permis d’avoir une base de données remplies pour que le LLM ne puisse piocher que dans ces informations. Elle est potentiellement à répéter tous les quelques mois pour avoir les dernières versions des règles et des cartes.</p>

<p class="wp-block-paragraph">Voici un schéma explicatif du flux final :&nbsp;</p>

<figure class="wp-block-image aligncenter size-large"><img loading="lazy" decoding="async" width="881" height="1024" src="https://les-tilleuls.coop/wp-content/uploads/2026/07/schema2-881x1024.png" alt="" class="wp-image-16594" srcset="https://les-tilleuls.coop/wp-content/uploads/2026/07/schema2-881x1024.png 881w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema2-516x600.png 516w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema2-258x300.png 258w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema2-768x893.png 768w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema2-34x40.png 34w, https://les-tilleuls.coop/wp-content/uploads/2026/07/schema2.png 896w" sizes="auto, (max-width: 881px) 100vw, 881px" /></figure>

<p class="wp-block-paragraph">L’application mobile est une petite app React Native / Expo façon chatbot, avec une zone de saisie utilisateur et un espace pour afficher la réponse finale. Je ne rentrerai pas forcément dans les détails ici, mais le code est disponible sur le dépôt pour les curieux·ses !</p>

<p class="wp-block-paragraph">Lorsque l’utilisateur envoie son message, celui-ci est réceptionné par un simple serveur HTTP en Go avec quelques routes d’API disponibles, dont la principale : POST /chat est un passe-plat classique qui va prendre la question, la passer à un service, et renvoyer la réponse formatée de ce service.<br><br>Regardons donc plutôt le service en question :</p>
</div><div class="my-4 text-sm">
	<pre><code class="language-go">func (e *Engine) Answer(ctx context.Context, question string) (*Result, error) {
    p, err := e.prepare(ctx, question)
    if err != nil {
        return nil, err
    }
    answer, err := e.llm.Generate(ctx, p.analysis.AnswerLanguage, p.context)
    if err != nil {
        return nil, err
    }
    return &amp;Result{
        Answer:   answer,
        Analysis: p.analysis,
        Rules:    p.rules,
        Glossary: p.glossary,
        Cards:    p.cards,
    }, nil
}
</code></pre>
</div><div class="container py-12">
<p class="wp-block-paragraph">On peut voir qu’on passe ici par une phase de pré-processing <code>`e.prepare(ctx, question)`</code> au lieu d’essayer de trouver tout de suite la réponse. En effet, ici, toutes nos données sont en anglais en BDD, mais si mon niveau d’anglais est évidemment irréprochable, un bon nombre des cartes que j’ai sont en français. On envoie donc une première requête à un LLM simple et pas trop cher (Anthropic Haïku en l&rsquo;occurrence) pour lui demander de normaliser un peu la question, mais aussi d’essayer de détecter des noms de cartes parmi la question pour les avoir à part et mieux requêter leur table ensuite.</p>

<p class="wp-block-paragraph">Voici le prompt de pré-processing passé à Haïku :</p>

<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><em>You extract structured information from a Magic: The Gathering rules question. The user may write in English, French, or a mix. Respond with ONLY a JSON object (no prose, no code fences) with exactly these keys:</em></p>



<p class="wp-block-paragraph"><em>&#8211; « question_en »: the question rewritten in clear English, suitable for semantic search over the Comprehensive Rules. If it is already English, lightly clean it up.</em></p>



<p class="wp-block-paragraph"><em>&#8211; « cards »: array of Magic card names explicitly named in the question, each given as its canonical ENGLISH name (translate French names, e.g. « Foudre » -> « Lightning Bolt »). Use [] if no specific card is named.</em></p>



<p class="wp-block-paragraph"><em>&#8211; « answer_language »: the ISO 639-1 code of the language the user wrote in (« en », « fr », &#8230;). For a mix, pick the dominant one. When rewriting the question, use the exact canonical ENGLISH keyword names for Magic mechanics rather than paraphrasing them (e.g. « défense talismanique » -> « hexproof », « piétinement » -> « trample », « lien de vie » -> « lifelink »). Common keywords are already substituted for you, but map any that remain.</em></p>
</blockquote>
</blockquote>
</blockquote>
</div><div class="my-4 text-sm">
	<pre><code class="language-go">func (e *Engine) prepare(ctx context.Context, question string) (*prepared, error) {
    analysis, err := e.llm.Preprocess(ctx, question)
    if err != nil {
        return nil, err
    }

    vecs, err := e.embed.Embed(ctx, []string{analysis.QuestionEN})
    if err != nil {
        return nil, err
    }
    query := vecs[0]

    rules, err := retrieval.SearchRules(ctx, e.conn, query, e.k)
    if err != nil {
        return nil, err
    }
    glossary, err := retrieval.SearchGlossary(ctx, e.conn, query, e.k)
    if err != nil {
        return nil, err
    }

    var cards []CardContext
    seen := make(map[string]bool)
    for _, name := range analysis.Cards {
        c, err := retrieval.ResolveCard(ctx, e.conn, name)
        if err != nil {
            return nil, err
        }
        if c == nil || seen[c.OracleID] {
            continue // no confident match, or already added
        }
        seen[c.OracleID] = true
        rulings, err := retrieval.Rulings(ctx, e.conn, c.OracleID)
        if err != nil {
            return nil, err
        }
        cards = append(cards, CardContext{Card: *c, Rulings: rulings})
    }

    return &amp;prepared{
        analysis: analysis,
        rules:    rules,
        glossary: glossary,
        cards:    cards,
        context:  buildContext(question, rules, glossary, cards),
    }, nil
}
</code></pre>
</div><div class="container py-12">
<p class="wp-block-paragraph">Une fois cette étape réalisée (ligne 1 ci-dessus), on peut voir qu’on va, dans l’ordre:</p>

<ul class="wp-block-list">
<li>Embedder la question en vecteurs (nouvel appel à OpenAI).</li>



<li>Aller chercher dans les règles celles qui s’approchent le plus sémantiquement de la question (`retrieval.SearchRules(ctx, e.conn, query, e.k)`). Et grâce à pgvector, rien n’est plus simple : </li>
</ul>
</div><div class="my-4 text-sm">
	<pre><code class="language-go">const q = `
        SELECT rule_number, section_title, body, embedding &lt;=&gt; $1::vector AS dist
        FROM rules
        WHERE embedding IS NOT NULL
        ORDER BY embedding &lt;=&gt; $1::vector
        LIMIT $2`
</code></pre>
</div><div class="container py-12">
<p class="wp-block-paragraph">On fait juste un select sur la table, en effectuant une comparaison de distance sinusoïdale ( l’opérateur ⇔ ici), avec un <code>ORDER BY</code> sur la valeur de cette distance &#8211; plus la distance est petite, plus la similarité est grande &#8211; et on garde les X premiers résultats. X vaut ici 10 et est sur le papier tout à fait modifiable ! Je l’ai mis à 10 après un peu d’expérimentation manuelle, où 5 par exemple ne remontait pas assez de règles pertinentes. Une valeur encore plus élevée risquerait de polluer les résultats envoyés au LLM final avec des informations moins pertinentes.</p>

<ul class="wp-block-list">
<li>On fait la même chose pour le glossaire (que je n’avais pas mentionné, mais qui est rempli de la même façon que les règles)</li>
</ul>

<p class="wp-block-paragraph">Si le pré-processing avait détecté des noms de carte dans la question, on va pour chaque nom ainsi fourni regarder si on trouve les infos de la carte, avec une simple requête par <code>similarity</code> pour le fuzzy matching sur le nom.</p>
</div><div class="my-4 text-sm">
	<pre><code class="language-go">const q = `
        SELECT oracle_id, name, mana_cost, type_line, oracle_text, similarity(name, $1) AS sim
        FROM cards
        WHERE name % $1
        ORDER BY sim DESC, length(name)
        LIMIT 1`
</code></pre>
</div><div class="container py-12">
<p class="wp-block-paragraph">Une fois toutes ces infos remontées, il ne nous reste plus qu’à appeler un LLM (ici Sonnet 5, pour de meilleures capacités de raisonnement et de synthèse qu’Haïku, et toujours un prix raisonnable) avec elles pour renvoyer une réponse construite uniquement sur ces résultats !<br>Voici le prompt :</p>

<p class="wp-block-paragraph"><em>You are a Magic: The Gathering rules assistant for a casual playgroup.</em></p>

<p class="wp-block-paragraph"><em>Answer the question using ONLY the provided context (Comprehensive Rules excerpts, glossary entries, and card data). Follow these rules strictly:</em></p>

<p class="wp-block-paragraph"><em>&#8211; Ground every claim in the context. Do NOT use outside knowledge of the rules, even if you are confident — the context is the single source of truth.</em></p>

<p class="wp-block-paragraph"><em>&#8211; Cite the specific rule numbers you rely on, in parentheses, e.g. (601.2a). Every rule claim needs a citation.</em></p>

<p class="wp-block-paragraph"><em>&#8211; If the provided context does not contain enough to answer correctly, say so plainly (« Je ne suis pas sûr d&rsquo;après les règles récupérées &#8230; ») rather than guessing.</em></p>

<p class="wp-block-paragraph"><em>&#8211; Be concise and concrete. Walk through the interaction step by step when it is subtle.</em></p>

<p class="wp-block-paragraph"><em>&#8211; Card data is from Scryfall.</em></p>

<p class="wp-block-paragraph"><em>&#8211; Write in plain text — no Markdown (no #, *, backticks or tables). Use short paragraphs, and simple « &#8211;  » bullets only if a list genuinely helps. The answer is shown in a mobile chat bubble.</em></p>

<p class="wp-block-paragraph"><em>&#8211; Write your entire answer in the language identified by this ISO code: %s.</em><br><br>La réponse du LLM est alors streamée à l’application mobile, et l’utilisateur la voit s’afficher sur son écran <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f929.png" alt="🤩" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>

<p class="wp-block-paragraph">Voici quelques petits exemples : </p>

<figure class="wp-block-image aligncenter size-large"><img loading="lazy" decoding="async" width="479" height="1024" src="https://les-tilleuls.coop/wp-content/uploads/2026/07/image-479x1024.png" alt="" class="wp-image-16598" srcset="https://les-tilleuls.coop/wp-content/uploads/2026/07/image-479x1024.png 479w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-280x600.png 280w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-140x300.png 140w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-768x1644.png 768w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-718x1536.png 718w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-19x40.png 19w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image.png 957w" sizes="auto, (max-width: 479px) 100vw, 479px" /></figure>

<p class="wp-block-paragraph">Mais si je lui demande des infos qui ne matchent pas :</p>

<figure class="wp-block-image aligncenter size-large"><img loading="lazy" decoding="async" width="1024" height="1001" src="https://les-tilleuls.coop/wp-content/uploads/2026/07/image-1-1024x1001.png" alt="" class="wp-image-16599" srcset="https://les-tilleuls.coop/wp-content/uploads/2026/07/image-1-1024x1001.png 1024w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-1-600x587.png 600w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-1-300x293.png 300w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-1-768x751.png 768w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-1-40x40.png 40w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-1-614x600.png 614w, https://les-tilleuls.coop/wp-content/uploads/2026/07/image-1.png 1080w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>

<h2 class="wp-block-heading decorative-title">La conclusion du MJ</h2>

<p class="wp-block-paragraph">En partant d’un simple débat de table sur une obscure règle de <em>Magic</em> datant de 1995, on a fini par mettre sur pied un pipeline RAG complet, robuste et résolument moderne.</p>

<p class="wp-block-paragraph">De l’ingestion des données au requêtage sémantique dans PostgreSQL avec pgvector, en passant par un double niveau de LLM, notre assistant est désormais paré à toute épreuve. Le tout est propulsé par un serveur en <a href="https://les-tilleuls.coop/technologies/go" data-type="link" data-id="https://les-tilleuls.coop/technologies/go" target="_blank" rel="noreferrer noopener">Go</a> et une application mobile React Native / Expo.</p>

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

<div class="c-alert c-alert--note">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 70 71"><path fill-rule="nonzero" d="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"/></svg>
            </span>
                        <strong>Info</strong>
    </p>
    <div class="c-alert__content">
                <p>
Une autre solution pour rendre nos migrations plus rapides serait de les fusionner. Nous en avions déjà parlé dans un <a href="https://jolicode.com/blog/a-new-way-to-squash-your-doctrine-migrations">précédent article</a>.</p>
        </div>
</div>

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

<div class="c-alert c-alert--note">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 70 71"><path fill-rule="nonzero" d="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"/></svg>
            </span>
                        <strong>Info</strong>
    </p>
    <div class="c-alert__content">
                <p>
Ce cache introduit un piège subtil sur les dates de fixtures. Comme le dump est généré une fois puis rejoué pendant un maximum de 24 h, le « maintenant » vu par les fixtures est celui de la <em>génération</em>, pas celui du test. Une fenêtre large ne pose aucun problème : une entité « publiée il y a 2 jours » le restera, à quelques heures près, pour tous les tests qui repartent du dump. Mais une fenêtre serrée devient un piège : une fixture « expire dans 1 heure » ou « créée il y a 5 minutes » aura déjà franchi son seuil au moment où un test restaure un dump vieux de trois heures. Pour ces cas-là, mieux vaut des marges larges, ou une donnée créée à la volée dans le test plutôt que dans les fixtures partagées.</p>
        </div>
</div>

<p>Enfin, une constante <code>SNAPSHOT_VERSION</code> permet d’invalider <em>tous</em> les snapshots d’un coup, à la main, si on modifie la logique de génération elle-même. La ceinture et les bretelles.</p>
<h2>Restaurer ou reconstruire</h2>
<p>Maintenant que la clé est calculée, le reste est mécanique. Le dump vit dans un dossier persistant, partagé entre les jobs et les builds successifs du runner (le même volume qui sert déjà de cache Composer/Yarn) :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$snapshotFile </span><span class="syntax-4">=</span><span class="syntax-1"> "</span><span class="syntax-2">$HOME</span><span class="syntax-1">/fixtures-snapshots/{</span><span class="syntax-2">$key</span><span class="syntax-1">}.sql.gz"</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">if</span><span class="syntax-2"> (</span><span class="syntax-9">file_exists</span><span class="syntax-2">($snapshotFile)) {</span></span>
<span class="line"><span class="syntax-10">    // Hit : on restaure et on repart</span></span>
<span class="line"><span class="syntax-8">    run</span><span class="syntax-2">(</span><span class="syntax-1">"gunzip -c {</span><span class="syntax-2">$snapshotFile</span><span class="syntax-1">} | mariadb -h mysql -u root -p***"</span><span class="syntax-2">);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    return</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-2">}</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10">// Miss : toute la reconstruction habituelle…</span></span>
<span class="line"><span class="syntax-10">// …puis on sauvegarde pour la prochaine fois :</span></span>
<span class="line"><span class="syntax-8">run</span><span class="syntax-2">(</span><span class="syntax-1">"mariadb-dump --single-transaction --databases geo_fixtures app_fixtures \</span></span>
<span class="line"><span class="syntax-1">     | gzip > {</span><span class="syntax-2">$snapshotFile</span><span class="syntax-1">}.tmp.$$ &#x26;&#x26; mv {</span><span class="syntax-2">$snapshotFile</span><span class="syntax-1">}.tmp.$$ {</span><span class="syntax-2">$snapshotFile</span><span class="syntax-1">}"</span><span class="syntax-2">);</span></span></code></pre>
<p>Deux remarques sur ce bout de code.</p>
<p><strong>Redis et Elasticsearch sont exclus du dump</strong>. Redis et Elasticsearch sont des datastores à part. J'ai préféré gardé leur fonctionnement actuel, c'est à dire que nous continuons à les recharger systématiquement, aussi bien sur un hit que sur un miss. Ce n’est pas gênant : ces deux étapes ne représentent que quelques secondes dans la CI. Le gros du temps à gagner était ailleurs.</p>
<p><strong>L’écriture du dump est atomique</strong>, et ce détail-là fait toute la différence entre un cache qui marche et un cache qui vous cause plus de soucis qu’autre chose. On écrit dans un fichier temporaire unique (<code>.tmp.$$</code>, avec le PID du process), puis on le renomme (<code>mv</code>). Sur nos runners self-hosted partagés, deux pull requests peuvent très bien construire la même clé au même moment, ou un build peut être annulé en plein <code>mariadb-dump</code>. Sans cette précaution, un autre build restaurerait un dump tronqué et échouerait de façon aléatoire, le pire type de bug de CI. Le <code>mv</code> étant atomique sur un même système de fichiers, un fichier final n’existe que s’il est complet.</p>
<h2>Le résultat</h2>
<p>Voici les chiffres, mesurés sur deux runs réels de notre CI (même machine, avant et après) :</p>
<table>
<thead>
<tr>
<th>Étape</th>
<th>Sans cache (reconstruction)</th>
<th>Avec cache (restauration)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Provisionnement de la base</td>
<td>~53 s</td>
<td><strong>~6 s</strong></td>
</tr>
<tr>
<td>Tâche <code>fixtures</code> complète (base + Redis + indexation ES)</td>
<td>~79 s</td>
<td><strong>~14 s</strong></td>
</tr>
</tbody>
</table>
<p>Le provisionnement de la base (la partie qui reconstruisait les schémas, chargeait les fixtures géo et rejouait les fixtures métier) tombe de ~53 s à ~6 s : un <code>gunzip</code> piped dans <code>mariadb</code>, et c’est tout. Le reste de la tâche (rechargement Redis, réindexation Elasticsearch) tourne dans les deux cas, d’où les ~14 s résiduelles.</p>
<p>Pour la grande majorité des pull requests, celles qui ne touchent pas au modèle de données, la préparation de la base passe donc d’une minute à quelques secondes. Et le jour où l’on touche vraiment aux migrations ou aux fixtures, le cache se reconstruit tout seul, sans qu’on ait à y penser, parce que sa clé a changé.</p>
<p>Si vous ne deviez retenir qu’une chose, ce serait celle-ci : ce qui fait la valeur de ce cache, ce n’est ni la compression ni le <code>mariadb-dump</code>, c’est la <strong>clé</strong>. Un cache n’est utile que s’il est à la fois agressif (il évite un maximum de travail) et correct (il ne sert jamais de données périmées). En dérivant la clé du contenu exact qui produit la base, on obtient les deux d’un coup, et plus personne n’a à se demander « faut-il régénérer le dump ? ». La réponse est dans le hash.</p>]]></description></item><item><title>Customiser les grilles Sylius - DX am&#xE9;lior&#xE9;e avec les Grid mutators</title><link>https://jolicode.com/blog/customiser-les-grilles-sylius-dx-amelioree-avec-les-grid-mutators</link><author>JoliCode Team</author><date>Thu, 16 Jul 2026 07:42:00 +0000</date><description><![CDATA[<p>Dans Sylius, les grilles sont responsables de l'affichage des listes du back-office : récupération des données, définition des colonnes, filtres, actions...</p>
<p>Sylius E-commerce fournit un grand nombre de grilles prêtes à l'emploi, qu'il est possible de personnaliser pour répondre aux besoins de votre projet.</p>
<p>Jusqu'à présent, cette personnalisation reposait principalement sur des surcharges de configuration YAML ou sur les Grid events. Ces approches fonctionnent toujours, mais elles atteignent rapidement leurs limites dès que l'on souhaite introduire de la logique métier ou bénéficier d'une API plus moderne et plus agréable à utiliser.</p>
<p>Avec la sortie du GridBundle 1.16, qui sera intégré à Sylius 2.3, une nouvelle approche devient la solution officielle : les Grid mutators. Les anciennes méthodes sont désormais dépréciées et disparaîtront progressivement.</p>
<p>Un Grid mutator est une classe PHP chargée de modifier une grille existante avant sa construction. Il s'appuie sur le même GridBuilder que <a rel="nofollow noopener noreferrer" href="https://stack.sylius.com/grid/index/your_first_grid#php-recommended">les grilles PHP</a>, offrant ainsi une API typée, facilement testable et familière pour les développeurs Symfony.</p>
<p>Dans cet article, nous verrons pourquoi cette évolution était nécessaire, quels sont les avantages des Grid mutators et comment commencer à les utiliser dès aujourd'hui afin d'anticiper les futures migrations de Sylius.</p>
<h2>Un peu d'histoire</h2>
<p>Historiquement, les grilles de Sylius sont déclarées dans la configuration du GridBundle.</p>
<p>Sylius fournit des fichiers de configuration pour toutes ses grilles dont vous pouvez
voir <a rel="nofollow noopener noreferrer" href="https://github.com/Sylius/Sylius/blob/2.3/src/Sylius/Bundle/AdminBundle/Resources/config/grids/product.yml">l'un des fichiers de configuration dans cet exemple</a>.</p>
<p>Bien qu'elles soient encore très puissantes, on ne peut pas appliquer d'intelligence à ces grids, comme retreindre les données selon le profil de l'utilisateur.</p>
<h2>Les grilles PHP</h2>
<p>Mais depuis de nombreuses années, le composant Grid propose de configurer les grilles en utilisant le Grid builder.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">&#x3C;?</span><span class="syntax-3">php</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">namespace</span><span> </span><span class="syntax-6">App\Grid</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> App\Entity\</span><span class="syntax-5">Book</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Bundle\GridBundle\Builder\Field\</span><span class="syntax-5">StringField</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Component\Grid\Attribute\</span><span class="syntax-5">AsGrid</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Component\Grid\Builder\</span><span class="syntax-5">GridBuilderInterface</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">#[AsGrid(</span></span>
<span class="line"><span class="syntax-2">    name: </span><span class="syntax-1">'app_book'</span><span class="syntax-2">,</span></span>
<span class="line"><span class="syntax-2">    resourceClass: </span><span class="syntax-5">Book</span><span class="syntax-4">::class</span></span>
<span class="line"><span class="syntax-2">)]</span></span>
<span class="line"><span class="syntax-4">final</span><span class="syntax-5"> class</span><span> </span><span class="syntax-6">UserGrid</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    public</span><span class="syntax-5"> function</span><span class="syntax-9"> __invoke</span><span class="syntax-2">(</span><span class="syntax-5">GridBuilderInterface</span><span class="syntax-2"> $gridBuilder)</span><span class="syntax-4">:</span><span class="syntax-4"> void</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-2">        $gridBuilder</span></span>
<span class="line"><span class="syntax-4">            -></span><span class="syntax-8">orderBy</span><span class="syntax-2">(</span><span class="syntax-1">'title'</span><span class="syntax-2">, </span><span class="syntax-1">'asc'</span><span class="syntax-2">)</span></span>
<span class="line"><span class="syntax-4">            -></span><span class="syntax-8">withFields</span><span class="syntax-2">(</span></span>
<span class="line"><span class="syntax-5">                StringField</span><span class="syntax-4">::</span><span class="syntax-8">create</span><span class="syntax-2">(</span><span class="syntax-1">'title'</span><span class="syntax-2">)</span></span>
<span class="line"><span class="syntax-4">                    -></span><span class="syntax-8">setLabel</span><span class="syntax-2">(</span><span class="syntax-1">'Titre'</span><span class="syntax-2">),</span></span>
<span class="line"><span class="syntax-5">                StringField</span><span class="syntax-4">::</span><span class="syntax-8">create</span><span class="syntax-2">(</span><span class="syntax-1">'author'</span><span class="syntax-2">)</span></span>
<span class="line"><span class="syntax-4">                    -></span><span class="syntax-8">setLabel</span><span class="syntax-2">(</span><span class="syntax-1">'Auteur'</span><span class="syntax-2">),</span></span>
<span class="line"><span class="syntax-2">            )</span></span>
<span class="line"><span class="syntax-2">        ;</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Comme vous pouvez le voir, la DX est très proche d'un <a rel="nofollow noopener noreferrer" href="https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/query-builder.html#working-with-querybuilder">Doctrine <code>QueryBuilder</code></a> ou d'un <a rel="nofollow noopener noreferrer" href="https://symfony.com/doc/current/forms.html#creating-form-classes">Symfony <code>FormBuilder</code></a>.</p>
<h4>Les avantages :</h4>
<ul>
<li>rapprocher les grilles des FormBuilder et QueryBuilder ;</li>
<li>bénéficier d'une API typée ;</li>
<li>améliorer l'autocomplétion ;</li>
<li>simplifier les tests ;</li>
<li>éviter les manipulations de tableaux YAML.</li>
</ul>
<p>De plus, nous sommes maintenant capables d'ajouter de la logique métier.</p>
<p><strong>Exemples :</strong></p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">if</span><span class="syntax-2"> (</span><span class="syntax-4">!</span><span class="syntax-11">$this</span><span class="syntax-4">-></span><span class="syntax-2">authorizationChecker</span><span class="syntax-4">-></span><span class="syntax-8">isGranted</span><span class="syntax-2">(</span><span class="syntax-1">'ROLE_SUPER_ADMIN'</span><span class="syntax-2">)) {</span></span>
<span class="line"><span class="syntax-2">    $gridBuilder</span><span class="syntax-4">-></span><span class="syntax-8">removeField</span><span class="syntax-2">(</span><span class="syntax-1">'internalNotes'</span><span class="syntax-2">);</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>ou encore</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">if</span><span class="syntax-2"> (</span><span class="syntax-11">$this</span><span class="syntax-4">-></span><span class="syntax-2">featureFlag</span><span class="syntax-4">-></span><span class="syntax-8">isEnabled</span><span class="syntax-2">(</span><span class="syntax-1">'new_catalog'</span><span class="syntax-2">)) {</span></span>
<span class="line"><span class="syntax-2">    $gridBuilder</span><span class="syntax-4">-></span><span class="syntax-8">addField</span><span class="syntax-2">(</span><span class="syntax-4">...</span><span class="syntax-2">);</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<h3>Customiser une grille</h3>
<p>Reprenons pour exemple <a rel="nofollow noopener noreferrer" href="https://github.com/Sylius/Sylius/blob/2.3/src/Sylius/Bundle/AdminBundle/Resources/config/grids/product.yml">la grille des produits</a>
fournie par Sylius.</p>
<p>Je vous propose, à titre d'exemple, de retirer le champ <code>image</code> en voyant d'abord les anciennes méthodes, puis la
nouvelle façon officielle.</p>
<h4>En modifiant dans la config du package</h4>
<p>Comme les grilles de Sylius E-commerce sont actuellement définies dans la configuration Symfony, nous pouvons utiliser un fichier de
configuration pour customiser celle-ci.</p>
<p>Nous pouvons ainsi utiliser ce fichier YAML fourni par Sylius Standard et ajouter la configuration suivante :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># config/packages/_sylius.yaml</span></span>
<span class="line"><span class="syntax-4">sylius_grid</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">    grids</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">        sylius_admin_product</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">            fields</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">                image</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">                    enabled</span><span class="syntax-2">: </span><span class="syntax-3">false</span></span></code></pre>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/customiser-les-grilles-sylius/remove-images.png" data-original-width="1667" data-original-height="1292"><source type="image/webp" srcset="/media/cache/content-webp/2026/customiser-les-grilles-sylius/remove-images.1cc29e91.webp" /><source type="image/png" srcset="/media/cache/content/2026/customiser-les-grilles-sylius/remove-images.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1667 / 1292)" src="https://jolicode.com//media/cache/content/2026/customiser-les-grilles-sylius/remove-images.png" alt="Liste des produits sans les images" /></picture></p>
<p>C'est relativement simple dans ce cas basique, mais, je le rappelle cette façon est dépréciée sur le GridBundle 1.16.</p>
<p>Pour introduire la nouvelle façon, commençons d'abord en utilisant également un fichier de configuration Symfony mais cette fois avec un fichier PHP.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">&#x3C;?</span><span class="syntax-3">php</span></span>
<span class="line"><span class="syntax-10">// config/packages/sylius_grid.php</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">$gridBuilder </span><span class="syntax-4">=</span><span class="syntax-5"> GridBuilder</span><span class="syntax-4">::</span><span class="syntax-8">create</span><span class="syntax-2">(</span><span class="syntax-1">'sylius_admin_product'</span><span class="syntax-2">)</span></span>
<span class="line"><span class="syntax-4">    -></span><span class="syntax-8">removeField</span><span class="syntax-2">(</span><span class="syntax-1">'image'</span><span class="syntax-2">)</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">return</span><span class="syntax-5"> App</span><span class="syntax-4">::</span><span class="syntax-8">config</span><span class="syntax-2">([</span><span class="syntax-1">'sylius_grid'</span><span class="syntax-4"> =></span><span class="syntax-2"> (</span><span class="syntax-4">new</span><span class="syntax-5"> GridConfig</span><span class="syntax-2">())</span><span class="syntax-4">-></span><span class="syntax-8">addGrid</span><span class="syntax-2">($gridBuilder)</span><span class="syntax-4">-></span><span class="syntax-8">toArray</span><span class="syntax-2">()]);</span></span></code></pre>
<p>Nous utilisons ici la classe <code>App</code> fournie par Symfony 7.4 (ou 8.x) pour modifier la configuration du GridBundle.
Il y a de la fioriture dans ce fichier, mais nous voyons déjà l'usage de la méthode <code>removeField</code> du GridBuilder que nous allons pouvoir utiliser dans le Grid mutator.</p>
<p>Cette API de GridBuilder n'est pas réservée aux fichiers de configuration PHP. C'est justement elle qui est exploitée par les Grid mutators.</p>
<h4>En utilisant les Grid mutators</h4>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">&#x3C;?</span><span class="syntax-3">php</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">namespace</span><span> </span><span class="syntax-6">App\Grid\Mutator</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Bundle\AdminBundle\Grid\</span><span class="syntax-5">ProductGridInterface</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Component\Grid\Attribute\</span><span class="syntax-5">AsGridMutator</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Component\Grid\Builder\</span><span class="syntax-5">GridBuilderInterface</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Component\Grid\Mutator\</span><span class="syntax-5">GridMutatorInterface</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">#[AsGridMutator(</span></span>
<span class="line"><span class="syntax-2">    grid: </span><span class="syntax-1">'sylius_admin_product'</span><span class="syntax-2">, </span></span>
<span class="line"><span class="syntax-10">    // ou</span></span>
<span class="line"><span class="syntax-2">    grid: </span><span class="syntax-5">ProductGridInterface</span><span class="syntax-4">::</span><span class="syntax-3">NAME</span><span class="syntax-10"> // constante ajoutée sur Sylius 2.3</span></span>
<span class="line"><span class="syntax-2">)]</span></span>
<span class="line"><span class="syntax-5">class</span><span> </span><span class="syntax-6">RemoveImageFromProductGridMutator</span><span class="syntax-4"> implements</span><span> </span><span class="syntax-7">GridMutatorInterface</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    public</span><span class="syntax-5"> function</span><span class="syntax-9"> __invoke</span><span class="syntax-2">(</span><span class="syntax-5">GridBuilderInterface</span><span class="syntax-2"> $gridBuilder)</span><span class="syntax-4">:</span><span class="syntax-4"> void</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-2">        $gridBuilder</span><span class="syntax-4">-></span><span class="syntax-8">removeField</span><span class="syntax-2">(</span><span class="syntax-1">'image'</span><span class="syntax-2">);</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Comme vous pouvez le voir, nous utilisons simplement un attribut PHP fourni par le GridBundle qui nous injecte directement le GridBuilder.</p>
<p>Sylius 2.3 nous permettra de basculer entre sa configuration de grilles dans la config (dépréciée) et celle en PHP.
Vous pouvez utiliser les mutators dans les deux cas, ce qui facilite la transition.
Nous allons donc probablement devoir migrer nos surcharges de configuration de grilles vers des Grid mutators, ce qui nous amène au chapitre suivant.</p>
<h3>Convertir les grilles</h3>
<p>Si vous utilisez des grilles customs, c'est-à-dire des grilles qui ne sont pas fournies par Sylius directement, vous devez créer un nouveau service qui comportera l'attribut <code>AsGrid</code>.
Afin de vous faciliter cette conversion, il existe un outil officiel : le <a rel="nofollow noopener noreferrer" href="https://github.com/mamazu/grid-config-converter">Sylius Grid Converter</a>.</p>
<p>Et pour les surcharges de grilles Sylius ? A l'heure où j'écris ces lignes, l'option <code>mutator</code> n'existe pas encore, mais elle va arriver très prochainement !</p>
<h3>Et les Grid events ?</h3>
<p>Les Grid events sont l'ancienne solution pour customiser vos grilles si vous souhaitez utiliser du PHP plutôt que de surcharger la config en YAML.
Il faut dans ce cas écouter l'évènement <code>GridDefinitionConverterEvent</code>.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">namespace</span><span> </span><span class="syntax-6">App\Grid</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Component\Grid\Event\</span><span class="syntax-5">GridDefinitionConverterEvent</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Sylius\Component\Grid\Definition\</span><span class="syntax-5">Field</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Component\EventDispatcher\Attribute\</span><span class="syntax-5">AsEventListener</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">#[AsEventListener(event: </span><span class="syntax-1">'sylius.grid.admin_product'</span><span class="syntax-2">, method: </span><span class="syntax-1">'editFields'</span><span class="syntax-2">)]</span></span>
<span class="line"><span class="syntax-4">final</span><span class="syntax-5"> class</span><span> </span><span class="syntax-6">RemoveImageFromProductGridListener</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    public</span><span class="syntax-5"> function</span><span class="syntax-8"> editFields</span><span class="syntax-2">(</span><span class="syntax-5">GridDefinitionConverterEvent</span><span class="syntax-2"> $event)</span><span class="syntax-4">:</span><span class="syntax-4"> void</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-2">        $grid </span><span class="syntax-4">=</span><span class="syntax-2"> $event</span><span class="syntax-4">-></span><span class="syntax-8">getGrid</span><span class="syntax-2">();</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">        $grid</span><span class="syntax-4">-></span><span class="syntax-8">removeField</span><span class="syntax-2">(</span><span class="syntax-1">'image'</span><span class="syntax-2">);</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Cela ressemble beaucoup aux grids mutators, mais vous attaquez directement l'objet <a rel="nofollow noopener noreferrer" href="https://github.com/Sylius/SyliusGridBundle/blob/1.16/src/Component/Definition/Grid.php">Grid definition</a> et non le GridBuilder.
Le builder fournit davantage de helpers pour vous aider à construire cette définition. Un autre inconvénient de cet event listener est qu'il faut deviner le nom de l'event.</p>
<p>Comme nous le disions plus haut, ces Grid events sont dépréciées dans Sylius GridBundle 1.16, alors débarrassez-vous en.</p>
<h2>Conclusion</h2>
<p>Les Grid mutators apportent enfin une manière claire, typée et testable de personnaliser les grilles Sylius. En s'appuyant sur le GridBuilder, ils offrent une API moderne, familière aux développeurs Symfony et bien plus adaptée aux besoins actuels que les anciennes surcharges YAML ou les Grid events.</p>
<p>Avec l'arrivée de GridBundle 1.16 et de Sylius 2.3, ils deviennent la nouvelle approche officielle pour faire évoluer les grilles. L'adoption peut se faire progressivement, puisqu'ils sont compatibles aussi bien avec les grilles déclarées en YAML qu'avec les nouvelles grilles PHP.</p>
<p>Si vous commencez dès aujourd'hui à migrer vos surcharges de configuration et vos Grid events vers des Grid mutators, la transition vers les prochaines versions de Sylius sera largement simplifiée. Vous profiterez également d'une API plus agréable à utiliser, plus facile à maintenir et à déboguer au quotidien.</p>]]></description></item><item><title>S&#xE9;curit&#xE9; PHP : Alexandre Daubois rejoint la PHP Foundation pour renforcer le c&#x153;ur du langage</title><link>https://les-tilleuls.coop/blog/securite-php-alexandre-daubois-rejoint-la-php-foundation-pour-renforcer-le-coeur-du-langage</link><author>C&#xE9;cile Hamerel</author><date>Wed, 15 Jul 2026 07:46:48 +0000</date><description><![CDATA[<div class="container pt-48 pb-12">
<p class="wp-block-paragraph">La sécurité des applications ne dépend pas uniquement du code spécifique développé pour un projet, mais également de la robustesse des briques technologiques fondamentales sur lesquelles il repose. Dans cette optique de sécurisation globale des infrastructures, notre coopérative Les-Tilleuls.coop est heureuse d’annoncer la nomination d&rsquo;<a href="https://x.com/alexdaubois" data-type="link" data-id="https://x.com/alexdaubois" target="_blank" rel="noreferrer noopener">Alexandre Daubois</a> au sein de la PHP Foundation en tant que développeur spécialisé en sécurité. L&rsquo;annonce officielle est disponible directement sur <a href="https://thephp.foundation/blog/2026/07/14/welcoming-alexandre-daubois/" data-type="link" data-id="https://thephp.foundation/blog/2026/07/14/welcoming-alexandre-daubois/" target="_blank" rel="noreferrer noopener">le site web de la fondation</a>.</p>

<figure class="wp-block-image aligncenter size-large is-style-rounded"><img loading="lazy" decoding="async" width="1024" height="614" src="https://les-tilleuls.coop/wp-content/uploads/2023/02/vignette-blog-php-foundation-1024x614.png" alt="The PHP Foundation" class="wp-image-6332" srcset="https://les-tilleuls.coop/wp-content/uploads/2023/02/vignette-blog-php-foundation-1024x614.png 1024w, https://les-tilleuls.coop/wp-content/uploads/2023/02/vignette-blog-php-foundation-600x360.png 600w, https://les-tilleuls.coop/wp-content/uploads/2023/02/vignette-blog-php-foundation-300x180.png 300w, https://les-tilleuls.coop/wp-content/uploads/2023/02/vignette-blog-php-foundation-768x460.png 768w, https://les-tilleuls.coop/wp-content/uploads/2023/02/vignette-blog-php-foundation-40x24.png 40w, https://les-tilleuls.coop/wp-content/uploads/2023/02/vignette-blog-php-foundation.png 1181w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /><figcaption class="wp-element-caption">The PHP Foundation</figcaption></figure>

<h2 class="wp-block-heading decorative-title">La gestion des signalements de sécurité à l&rsquo;ère de l&rsquo;intelligence artificielle</h2>

<p class="wp-block-paragraph">Ces derniers mois, la PHP Foundation a constaté une augmentation significative du nombre de rapports de sécurité soumis sur le dépôt officiel <a href="https://github.com/php/php-src" data-type="link" data-id="https://github.com/php/php-src" target="_blank" rel="noreferrer noopener"><code>php-src</code>.</a> Cette hausse est directement liée à l&rsquo;utilisation croissante des outils d&rsquo;<a href="https://les-tilleuls.coop/intelligence-artificielle" target="_blank" rel="noreferrer noopener">intelligence artificielle</a>, qui facilitent la génération automatisée de signalements.</p>

<p class="wp-block-paragraph">Quelque soit la nature de ces rapports, l&rsquo;équipe restreinte de la fondation doit obligatoirement traiter et valider chaque soumission. Une fois validée, chaque faille potentielle nécessite d&rsquo;être classifiée, corrigée, revue puis intégrée au code source. Face à ce flux constant, le besoin de renforcer l&rsquo;équipe s&rsquo;est avéré nécessaire pour maintenir le rythme des corrections et traiter les problématiques existantes.</p>

<h2 class="wp-block-heading decorative-title">Une expertise technique au service de l&rsquo;écosystème open source</h2>

<p class="wp-block-paragraph">Alexandre consacrera plusieurs heures par semaine au triage et à la résolution de ces rapports de sécurité entrants. Il s&rsquo;appuiera pour cela sur son expérience technique de l&rsquo;écosystème (notamment en tant que membre de la Core Team Symfony et contributeur à FrankenPHP et à PHP core) ainsi que sur sa collaboration préalable avec l&rsquo;Ecosystem Security Team de la fondation.</p>

<p class="wp-block-paragraph">Cette intégration s&rsquo;inscrit dans la continuité de la démarche de Les-Tilleuls.coop, dont les interventions vont de la couche applicative jusqu&rsquo;au cœur même de. En contribuant directement à la maintenance de PHP, la coopérative participe activement à la pérennité et au durcissement de l&rsquo;outil technique principal de ses clients et partenaires. Sur ce dernier point, l&rsquo;implication de la coopérative est structurelle : FrankenPHP, le serveur d&rsquo;application moderne qu&rsquo;édite la coopérative, a officiellement <a href="https://les-tilleuls.coop/blog/30-ans-de-php-frankenphp-fait-desormais-partie-de-lorganisation-php" data-type="link" data-id="https://les-tilleuls.coop/blog/30-ans-de-php-frankenphp-fait-desormais-partie-de-lorganisation-php" target="_blank" rel="noreferrer noopener">rejoint l&rsquo;organisation GitHub de PHP en 2025</a> sous le parrainage de la PHP Foundation.</p>

<p class="wp-block-paragraph">L&rsquo;ensemble des équipes félicite Alexandre Daubois pour cette nomination qui vient valider son expertise et son engagement en faveur d&rsquo;un web open source sécurisé. Face à la recrudescence des failles de sécurité exposées dans le monde entier, <a href="mailto:contact@les-tilleuls.coop" data-type="mailto" data-id="mailto:contact@les-tilleuls.coop">notre équipe reste à vos côtés</a> pour auditer, sécuriser et fiabiliser vos applications au quotidien. </p>

<p class="wp-block-paragraph"></p>
</div><p>Cet article, <a href="https://les-tilleuls.coop/blog/securite-php-alexandre-daubois-rejoint-la-php-foundation-pour-renforcer-le-coeur-du-langage">Sécurité PHP : Alexandre Daubois rejoint la PHP Foundation pour renforcer le cœur du langage</a>, est paru en premier sur <a href="https://les-tilleuls.coop">Les-Tilleuls.coop</a>.</p>
]]></description></item><item><title>Nouveau verbe HTTP : QUERY</title><link>https://blog.eleven-labs.com/fr/http-query-method/</link><author/><date>Wed, 15 Jul 2026 00:00:00 +0000</date><description><![CDATA[<div><p>Depuis le 15 juin 2026, la <a href="https://www.rfc-editor.org/info/rfc10008/" target="_blank">RFC 10008: The HTTP QUERY Method</a> est en <em>"Proposed Standard"</em> après <a href="https://datatracker.ietf.org/doc/rfc10008/" target="_blank">plus de 10 ans en draft</a>. Ce nouveau verbe HTTP va permettre de régler un souci régulier dans nos APIs : gérer les requêtes GET avec beaucoup de paramètres de filtrage.</p>
<h2>Historique d'une problématique</h2>
<p>Dans de nombreux projets web, nous avons toujours des listes : produits, utilisateurs, articles, factures, etc. Quand cette liste est longue, nous avons envie d'y faire des recherches pour filtrer et d'y mettre une pagination.</p>
<p>Mais malheureusement, avec le verbe GET, nous n'avons parfois pas le choix d'avoir des URLs à rallonge.</p>
<pre><div><code><span>?colors=Bleu&amp;clothingSizes=FR+38&amp;minPrice=16224&amp;maxPrice=59945&amp;sort=price_asc</span></code></div></pre>
<p>Pour gagner en visibilité et en praticité (car il peut y avoir des règles sur le nombre de caractères que peut comporter une URL), il arrive de voir des routes de listing en GET transformées en POST. Or, ce verbe, également défini dans une <a href="https://datatracker.ietf.org/doc/html/rfc7231#section-4.3.3" target="_blank">RFC</a>, a cette définition chez <a href="https://developer.mozilla.org/fr/docs/Web/HTTP/Reference/Methods" target="_blank">Mozilla - MDN Web Docs</a> :</p>
<blockquote>
<p>La méthode POST soumet une entité à la ressource spécifiée, provoquant souvent un changement d'état ou des effets secondaires sur le serveur.</p>
</blockquote>
<p>Bref, on tord POST pour nos besoins.</p>
<h2>Comment utiliser QUERY ?</h2>
<p>Grâce à l'IA, j'ai pu créer rapidement un <a href="https://github.com/ElevenMarianne/my-little-api-query" target="_blank">mini projet</a> pour tester le nouveau verbe HTTP (évidemment en PHP). Il s'agit d'une API permettant d'accéder à une liste de timbres. Elle possède des filtres tels qu'une fourchette d'années, le pays, le prix ou encore la couleur.</p>
<p>Symfony a déjà intégré le verbe QUERY dès sa <a href="https://github.com/symfony/http-foundation/releases/tag/v7.4.0-BETA1" target="_blank">version 7.4</a>, alors que la méthode n'était encore qu'un <em>Internet-Draft IETF</em> pour la release d'octobre 2025.</p>
<h3>Filtre par pays et pagination</h3>
<h4>Request</h4>
<pre><div><code><span>curl -si -X QUERY </span><span>'http://localhost:8090/api/stamps'</span><span> \
</span><span>--header </span><span>'Content-Type: application/json'</span><span> \
</span><span>--data </span><span>'{"countries":["France","Belgium"],"page":1,"limit":10}'</span></code></div></pre>
<p>Comme vous pouvez le voir, dans l'appel curl, il suffit d'indiquer -X QUERY (-X permettant de spécifier le verbe HTTP avec GET par défaut).</p>
<p>Pour la déclaration des paramètres, c'est comme pour le POST : dans le --data/-d.</p>
<p>La prise en main de QUERY est facile.</p>
<p>Étudions la réponse.</p>
<h4>Response headers</h4>
<pre><div><code><span>HTTP/1.1 200 OK
</span>Server: nginx/1.27.5
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
X-Powered-By: PHP/8.4.23
Cache-Control: no-cache, private
Date: Wed, 08 Jul 2026 21:03:10 GMT
X-Cache: MISS
X-Robots-Tag: noindex</code></div></pre>
<p>Vous pouvez voir ici que le header <em>X-Cache</em> est <em>MISS</em> car c'est la première fois que j'appelle la route avec ce filtre. Si j'appelle avec le même filtre dans les 60s, le header <em>X-Cache</em> sera <em>HIT</em>.
Je l'ai appelé X-Cache mais vous pouvez le nommer comme vous le voulez, ce n'est pas une norme.</p>
<pre><div><code><span>[</span><span>$result</span><span>, </span><span>$fromCache</span><span>] = </span><span>$cache</span><span>-&gt;getOrCompute(</span><span>$request</span><span>, </span><span>$criteria</span><span>);
</span>
<span></span><span>$response</span><span> = </span><span>new</span><span> JsonResponse(</span><span>$result</span><span>);
</span><span></span><span>$response</span><span>-&gt;headers-&gt;set(</span><span>'X-Cache'</span><span>, </span><span>$fromCache</span><span> ? </span><span>'HIT'</span><span> : </span><span>'MISS'</span><span>);</span></code></div></pre>
<h4>Response body</h4>
<pre><div><code><span>{
</span><span>   </span><span>"items"</span><span>:[
</span>      {
<span>         </span><span>"id"</span><span>:</span><span>503</span><span>,
</span><span>         </span><span>"name"</span><span>:</span><span>"Chemin de fer touristique"</span><span>,
</span><span>         </span><span>"country"</span><span>:</span><span>"Belgium"</span><span>,
</span><span>         </span><span>"year"</span><span>:</span><span>1857</span><span>,
</span><span>         </span><span>"price"</span><span>:</span><span>212.69</span><span>,
</span><span>         </span><span>"color"</span><span>:</span><span>"green"</span><span>,
</span><span>         </span><span>"description"</span><span>:</span><span>null</span><span>
</span>      },
      {
<span>         </span><span>"id"</span><span>:</span><span>415</span><span>,
</span><span>         </span><span>"name"</span><span>:</span><span>"Locomotive \u00e0 vapeur"</span><span>,
</span><span>         </span><span>"country"</span><span>:</span><span>"France"</span><span>,
</span><span>         </span><span>"year"</span><span>:</span><span>1871</span><span>,
</span><span>         </span><span>"price"</span><span>:</span><span>343.35</span><span>,
</span><span>         </span><span>"color"</span><span>:</span><span>"green"</span><span>,
</span><span>         </span><span>"description"</span><span>:</span><span>"Timbre autocollant \u00e9mis pour le carnet du centenaire."</span><span>
</span>      },
      [...]
   ],
<span>   </span><span>"pagination"</span><span>:{
</span><span>      </span><span>"page"</span><span>:</span><span>1</span><span>,
</span><span>      </span><span>"limit"</span><span>:</span><span>10</span><span>,
</span><span>      </span><span>"total"</span><span>:</span><span>20</span><span>,
</span><span>      </span><span>"pages"</span><span>:</span><span>2</span><span>
</span>   }
}</code></div></pre>
<h3>Filtre par année minimum, maximum, pays, couleur et pagination</h3>
<p>Passons un exemple plus complexe.</p>
<p>En passant par le GET, nous aurions eu une URL suivante :</p>
<pre><code>http://localhost:8090/api/stamps?yearMin=1950&amp;yearMax=2000&amp;countries=France&amp;countries=Belgium&amp;color=black&amp;page=1&amp;limit=2
</code></pre>
<p>Mais grâce à QUERY, nous pouvons faire ceci :</p>
<pre><div><code><span>curl -i -X QUERY http://localhost:8090/api/stamps \
</span><span>  -H </span><span>"Content-Type: application/json"</span><span> \
</span><span>  -d </span><span>'{"yearMin":1950,"yearMax":2000,"countries":["France","Belgium"],"color":"black","page":1,"limit":2}'</span></code></div></pre>
<pre><div><code><span>{
</span><span>   </span><span>"items"</span><span>:[
</span>      {
<span>         </span><span>"id"</span><span>:</span><span>483</span><span>,
</span><span>         </span><span>"name"</span><span>:</span><span>"Mus\u00e9e du Louvre"</span><span>,
</span><span>         </span><span>"country"</span><span>:</span><span>"France"</span><span>,
</span><span>         </span><span>"year"</span><span>:</span><span>1952</span><span>,
</span><span>         </span><span>"price"</span><span>:</span><span>450.16</span><span>,
</span><span>         </span><span>"color"</span><span>:</span><span>"black"</span><span>,
</span><span>         </span><span>"description"</span><span>:</span><span>null</span><span>
</span>      },
      {
<span>         </span><span>"id"</span><span>:</span><span>508</span><span>,
</span><span>         </span><span>"name"</span><span>:</span><span>"Phare breton"</span><span>,
</span><span>         </span><span>"country"</span><span>:</span><span>"Belgium"</span><span>,
</span><span>         </span><span>"year"</span><span>:</span><span>1957</span><span>,
</span><span>         </span><span>"price"</span><span>:</span><span>329.26</span><span>,
</span><span>         </span><span>"color"</span><span>:</span><span>"black"</span><span>,
</span><span>         </span><span>"description"</span><span>:</span><span>null</span><span>
</span>      }
   ],
<span>   </span><span>"pagination"</span><span>:{
</span><span>      </span><span>"page"</span><span>:</span><span>1</span><span>,
</span><span>      </span><span>"limit"</span><span>:</span><span>2</span><span>,
</span><span>      </span><span>"total"</span><span>:</span><span>4</span><span>,
</span><span>      </span><span>"pages"</span><span>:</span><span>2</span><span>
</span>   }
}</code></div></pre>
<h3>Le cache</h3>
<p>Dans la classe <a href="https://github.com/ElevenMarianne/my-little-api-query/blob/master/src/Service/StampQueryCacheService.php" target="_blank">StampQueryCacheService</a>, la clé du cache est construite avec :</p>
<ul>
<li>la méthode (QUERY)</li>
<li>le chemin (/api/stamps)</li>
<li>le Content-Type</li>
<li>le body de la requête, canonicalisé (<em>ksortRecursive($body)</em> trie récursivement les clés du JSON pour que <em>{"color":"blue","page":1} et {"page":1,"color":"blue"}</em> donnent la même clé malgré l'ordre différent)</li>
</ul>
<p>Le tout est haché en SHA-256 pour former la clé finale (stamps_query_<em>&lt;hash&gt;</em>).</p>
<p>Cela permet de retrouver le même contenu dans le cache, avec les mêmes filtres.</p>
<pre><div><code><span>/**
</span><span> * </span><span>@return</span><span> array{0: array, 1: bool}
</span><span> */</span><span>
</span><span></span><span>public</span><span> </span><span>function</span><span> </span><span>getOrCompute</span><span>(</span><span>Request </span><span>$request</span><span>, StampSearchCriteria </span><span>$criteria</span><span>): </span><span>array</span><span>
</span><span></span><span>{
</span><span>    </span><span>$key</span><span> = </span><span>$this</span><span>-&gt;buildCacheKey(</span><span>$request</span><span>);
</span><span>    </span><span>$item</span><span> = </span><span>$this</span><span>-&gt;pool-&gt;getItem(</span><span>$key</span><span>);
</span><span>    </span><span>$fromCache</span><span> = </span><span>$item</span><span>-&gt;isHit();
</span>
<span>    </span><span>if</span><span> (!</span><span>$fromCache</span><span>) {
</span><span>        </span><span>$item</span><span>-&gt;set(</span><span>$this</span><span>-&gt;computeResult(</span><span>$criteria</span><span>));
</span><span>        </span><span>$this</span><span>-&gt;pool-&gt;save(</span><span>$item</span><span>);
</span>    }

<span>    </span><span>return</span><span> [</span><span>$item</span><span>-&gt;get(), </span><span>$fromCache</span><span>];
</span>}

<span></span><span>private</span><span> </span><span>function</span><span> </span><span>buildCacheKey</span><span>(</span><span>Request </span><span>$request</span><span>): </span><span>string</span><span>
</span><span></span><span>{
</span><span>    </span><span>$body</span><span> = json_decode(</span><span>$request</span><span>-&gt;getContent(), </span><span>true</span><span>) ?? [];
</span><span>    </span><span>$this</span><span>-&gt;ksortRecursive(</span><span>$body</span><span>);
</span>
<span>    </span><span>$payload</span><span> = [
</span><span>        </span><span>'method'</span><span> =&gt; </span><span>$request</span><span>-&gt;getMethod(),
</span><span>        </span><span>'path'</span><span> =&gt; </span><span>$request</span><span>-&gt;getPathInfo(),
</span><span>        </span><span>'contentType'</span><span> =&gt; </span><span>$request</span><span>-&gt;headers-&gt;get(</span><span>'Content-Type'</span><span>),
</span><span>        </span><span>'body'</span><span> =&gt; </span><span>$body</span><span>,
</span>    ];

<span>    </span><span>return</span><span> </span><span>'stamps_query_'</span><span> . hash(</span><span>'sha256'</span><span>, (</span><span>string</span><span>) json_encode(</span><span>$payload</span><span>));
</span>}</code></div></pre>
<p><em>Cette solution m'a été proposée par mon ami Claude.</em></p>
<h2>Conclusion</h2>
<p>Maintenant que ce verbe est en <em>"Proposed Standard"</em>, il n'y a plus qu'à espérer que l'infra soit rapidement mise à jour (s'il y avait des restrictions) pour pouvoir l'utiliser. Il est déjà possible de saisir le verbe HTTP qu'on veut dans Postman si vous souhaitez tester.</p>
<p></p>
<h2>Sources</h2>
<ul>
<li><a href="https://www.rfc-editor.org/info/rfc10008/" target="_blank">RFC Editor</a></li>
<li><a href="https://datatracker.ietf.org/doc/rfc10008/" target="_blank">DataTracker</a></li>
<li>Repository du projet <a href="https://github.com/ElevenMarianne/my-little-api-query" target="_blank">ElevenMarianne/my-little-api-query</a></li>
</ul></div>]]></description></item></channel></rss>
