<?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>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 09:41:00 +0200</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 14:42:00 +0200</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 11:56:38 +0200</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 10:41:00 +0200</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 09:42:00 +0200</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 09:46:48 +0200</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 02:00:00 +0200</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><item><title>Le programme du Forum PHP 2026 est sorti !</title><link>https://afup.org/news/1261-programme-forumphp2026-sorti</link><author/><date>Tue, 07 Jul 2026 05:00:00 +0200</date><description><![CDATA[<h3>PHP, le cœur battant du programme</h3>
<p>Le langage reste le fil rouge du Forum : des talks techniques vont nous permettre de plonger dans les rouages du langage. On parlera défense du code avec "<em>PHP Défensif : Arrêtez de faire confiance, défendez votre code !</em>" de <strong>Thomas Dutrion</strong>, un état des lieux de l'écosystème avec l'analyse de 236 Go de code PHP par <strong>Jean-François Lépine</strong>, ou encore outillage qualité nouvelle génération avec "<em>Mago, ou comment Rust et l'IA réinventent l'outillage qualité PHP</em>" de <strong>Yoann Blot</strong>.<p>
Du côté des frameworks, on vous emmène bien au-delà des bases ! Avec "<em>PHP dans votre poche : créer des applications mobiles avec NativePHP</em>", <strong>Eric Courtial </strong>explore comment NativePHP Mobile permet à l'écosystème Laravel de produire des applications iOS et Android natives. <strong>Marilena Ruffelaere</strong> vous invitera à plonger dans les rouages méconnus du composant Security, pour transformer vos voteurs en un système d'autorisation robuste, découplé et performant. Et parce que la performance se joue aussi dans les détails, <strong>Mathias Arlaud </strong>démontera une habitude profondément ancrée dans nos applications Symfony et explorera le streaming comme nouveau socle d'une expérience utilisateur moderne. Entre autres !</p>
<h3>IA : sortir du discours, entrer dans la pratique</h3>
<p>L'IA infuse désormais tous les aspects du développement, et le programme l'aborde sous deux angles complémentaires.
<strong>Côté technique</strong>, "<em>Sous le capot du protocole MCP : codons un serveur en PHP pur</em>" d'<strong>Amaury Bouchard</strong> met les mains dans le cambouis, et "<em>Exposer son métier aux agents IA avec API Platform</em>" d'<strong>Antoine Bluchet</strong> explorera une nouvelle façon de penser nos API. <br>
<strong>Mais le Forum PHP fait aussi le choix de nuancer le discours ambiant. </strong>Avec "<em>IA frugale : la meilleure IA est celle qu'on n'utilise pas</em>", <strong>Lucile Mothiron</strong> questionne nos réflexes d'adoption systématique.<strong> Niels Ackermann</strong>, photographe documentaire, élargit le regard au-delà du code pour interroger l'impact de l'IA sur le métier de photojournaliste. Et avec "<em>Alors que tout accélère, nous avons choisi de ralentir</em>", <strong>Xavier Gorse</strong> abordera un sujet trop souvent passé sous silence : l'impact de cette accélération sur la fatigue des équipes.</p>
<h3>Cybersécurité et souveraineté numérique, des sujets sensibles sur lesquels il faut se pencher</h3>
<p>Avec la généralisation des coding agents, l'arrivée de nouvelles réglementations et les questions de dépendance à des acteurs étrangers, certains sujets deviennent incontournables, même quand on préférerait se concentrer sur le code. <br>
Et ces sujets seront portés par des experts bien connus de l'écosystème. <strong>Quentin Adam</strong> nous fera le plaisir de nous rejoindre pour présenter "<em>Souveraineté numérique à l'ère de l'IA</em>",  interrogeant de front nos choix d'outils, d'hébergement et de dépendances. <strong>Guillaume Lours</strong>, avec "<em>Vos coding agents en mode YOLO… mais en toute sécurité</em>" partagera un regard pragmatique sur comment encadrer ces nouveaux outils sans renoncer à leur efficacité. <strong>Rachel Foucard</strong> décryptera pour nous le Cyber Resilience Act et <strong>Alexandre Daubois</strong> nous emmènera découvrir "<em>L'Ecosystem Security Team de la PHP Foundation de l'intérieur</em>", pour un accès rare aux coulisses de la sécurité de l'écosystème.</p>
<h3>Bar IA, nouveaux profils, et toujours la même ambiance</h3>
<p>Nouveauté de cette édition : le bar IA ! L’occasion d’aller à la rencontre d’experts du sujet pour poser sans filtre toutes vos questions, techniques comme organisationnelles, dans un format plus informel et ouvert. Qu’il s’agisse d’assouvir votre curiosité sur une question tech ou de partager vos craintes ou votre enthousiasme sur ce que cela implique pour notre quotidien, ils sont prêts à vous écouter et échanger avec vous !<br>
Cette édition du Forum PHP 2026 fait la part-belle aux orateurs et oratrices bien connues de notre écosystème, comme Gina Banyard, Kévin Dunglas ou encore Nicolas Grekas, tout en invitant de nouveaux profils lors des événements AFUP. Ainsi, sur les 33 speakers au programme, 11 rejoignent pour la première fois la programmation d'un événement AFUP : bienvenue à Marilena Ruffelaere, Kévin Martins, Charlotte Chaumet, entre autres !</p>
<p><strong>Vous l'aurez compris, cette édition du Forum PHP 2026 envoie du lourd. Le Forum PHP 2026 compte bien répondre aux aux défis d'aujourd'hui, pour mieux anticiper l'avenir. Inscrivez-vous, inscrivez-vos équipes pour échanger, apprendre, et repartir avec des solutions concrètes à mettre en œuvre immédiatement. On vous attend !</strong></p>]]></description></item><item><title>La Clean Architecture ne se r&#xE9;sume pas &#xE0; une arborescence de dossiers</title><link>https://www.jdecool.fr/blog/2026/07/03/la-clean-architecture-ne-se-resume-pas-a-une-arborescence-de-dossiers.html</link><author/><date>Fri, 03 Jul 2026 00:00:00 +0200</date><description><![CDATA[<p>Pour beaucoup, la Clean Architecture se résume à un découpage du code en 3 couches: Domain, Application et Infrastructure (avec éventuellement une couche de présentation). Pourtant, si l’on regarde le livre de référence sur le sujet, “Clean Architecture” de Robert C. Martin, ce découpage ne représente qu’une dizaine de pages sur les 400 que compte le livre.</p>

<!--more-->

<p>L’organisation du projet est certainement la partie la plus visible de la Clean Architecture. C’est d’ailleurs ce qui en a fait son succès: une organisation claire, bien définie, où chacun sait où ranger son code. Mais la Clean Architecture ne se limite pas à une organisation de fichiers, c’est bien plus que cela.</p>

<p>L’ouvrage de référence aborde de nombreux autres sujets: on y retrouve les principes SOLID, des notions de cohésion et de couplage du code, la définition des frontières entre modules, la gestion des règles métier, l’isolation de la base de données et la séparation des frameworks. Autant de concepts qui, mis bout à bout, représentent ce qu’est vraiment la Clean Architecture.</p>

<p>Lorsque l’on se concentre sur les dossiers Domain, Application et Infrastructure, on reproduit le plus facile en oubliant le “pourquoi”. Comme toujours, il est essentiel de comprendre les principes fondamentaux, sans quoi impossible d’adapter et d’utiliser cette architecture à bon escient.</p>
]]></description></item><item><title>Profiler un consumer avec Blackfire</title><link>https://jolicode.com/blog/profiler-un-consumer-avec-blackfire</link><author>JoliCode Team</author><date>Thu, 02 Jul 2026 11:42:00 +0200</date><description><![CDATA[<p>Quand on parle de profiling PHP avec Blackfire, on pense généralement à une requête HTTP : un clic sur l'extension navigateur, et hop, le profil apparaît.
Mais que faire quand le code à profiler tourne dans un consumer ou un worker ; une commande Symfony qui boucle indéfiniment, consommant une queue ou traitant des tâches en background ?
La bonne nouvelle : Blackfire peut fonctionner dans ce contexte. La moins bonne : on ne peut pas déclencher le profiling au moment où ça nous arrange... sauf si on utilise les signaux POSIX.</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>
Cet article s'appuie sur les signaux POSIX. Si vous n'êtes pas familier avec
ce concept, je vous invite à lire d'abord <a href="https://jolicode.com/blog/les-signaux-posix-et-php">Les signaux POSIX et PHP</a>
qui pose les bases nécessaires.</p>
        </div>
</div>

<h2>Le problème</h2>
<p>Un consumer PHP ressemble souvent à ça :</p>
<pre class="syntax-0" tabindex="0"><code><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-2">    $message </span><span class="syntax-4">=</span><span class="syntax-2"> $queue</span><span class="syntax-4">-></span><span class="syntax-8">consume</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">$message) {</span></span>
<span class="line"><span class="syntax-9">        sleep</span><span class="syntax-2">(</span><span class="syntax-3">1</span><span class="syntax-2">);</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>
<span class="line"></span>
<span class="line"><span class="syntax-11">    $this</span><span class="syntax-4">-></span><span class="syntax-8">process</span><span class="syntax-2">($message);</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Il tourne en permanence. On ne contrôle pas <em>quand</em> l'itération commence ni quand elle finit. Déclencher Blackfire depuis l'extérieur sans interrompre le traitement en cours, c'est précisément le défi.</p>
<p>La solution : envoyer un signal UNIX au processus pour lui dire &quot;commence à profiler&quot; puis un second signal pour lui dire &quot;arrête-toi et envoie le profil&quot;.</p>
<h2>Installation de Blackfire</h2>
<p>L'installation de Blackfire se fait via Composer :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-8">composer</span><span class="syntax-1"> require</span><span class="syntax-1"> blackfire/php-sdk</span></span></code></pre>
<h2><code>SignalableCommandInterface</code> : gérer les signaux dans une commande Symfony</h2>
<p>Depuis Symfony 5.2, le composant Console expose l'interface <code>SignalableCommandInterface</code>.
Elle permet à une commande de s'abonner à des signaux POSIX et de réagir proprement sans passer par <code>pcntl_signal()</code> manuellement.
L'interface oblige à implémenter deux méthodes :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Component\Console\Command\</span><span class="syntax-5">SignalableCommandInterface</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">MyWorkerCommand</span><span class="syntax-4"> extends</span><span> </span><span class="syntax-7">Command</span><span class="syntax-4"> implements</span><span> </span><span class="syntax-7">SignalableCommandInterface</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"> getSubscribedSignals</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-4">        return</span><span class="syntax-2"> [\</span><span class="syntax-3">SIGTERM</span><span class="syntax-2">, \</span><span class="syntax-3">SIGINT</span><span class="syntax-2">, \</span><span class="syntax-3">SIGUSR2</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">    public</span><span class="syntax-5"> function</span><span class="syntax-8"> handleSignal</span><span class="syntax-2">(</span><span class="syntax-4">int</span><span class="syntax-2"> $signal, </span><span class="syntax-4">int</span><span class="syntax-2">|</span><span class="syntax-4">false</span><span class="syntax-2"> $previousExitCode </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">)</span><span class="syntax-4">:</span><span class="syntax-4"> int</span><span class="syntax-2">|</span><span class="syntax-4">false</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-10">        // ...</span></span>
<span class="line"><span class="syntax-4">        return</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 class="syntax-2">}</span></span></code></pre>
<h3><code>getSubscribedSignals()</code></h3>
<p>Cette méthode retourne la liste des signaux que la commande veut intercepter. Les signaux sont des constantes entières définies par le système (voir <code>kill -l</code> pour la liste complète). Les plus courants :</p>
<table>
<thead>
<tr>
<th>Signal</th>
<th>Valeur</th>
<th>Usage habituel</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>SIGTERM</code></td>
<td>15</td>
<td>Arrêt demandé (e.g. <code>kill &lt;pid&gt;</code>)</td>
</tr>
<tr>
<td><code>SIGINT</code></td>
<td>2</td>
<td>Interruption clavier (<code>Ctrl+C</code>)</td>
</tr>
<tr>
<td><code>SIGUSR1</code></td>
<td>10</td>
<td>Signal utilisateur libre n°1</td>
</tr>
<tr>
<td><code>SIGUSR2</code></td>
<td>12</td>
<td>Signal utilisateur libre n°2</td>
</tr>
</tbody>
</table>
<h3><code>handleSignal()</code> : décrypter la signature</h3>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">public</span><span class="syntax-5"> function</span><span class="syntax-8"> handleSignal</span><span class="syntax-2">(</span><span class="syntax-4">int</span><span class="syntax-2"> $signal, </span><span class="syntax-4">int</span><span class="syntax-2">|</span><span class="syntax-4">false</span><span class="syntax-2"> $previousExitCode </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">)</span><span class="syntax-4">:</span><span class="syntax-4"> int</span><span class="syntax-2">|</span><span class="syntax-4">false</span></span></code></pre>
<p>Trois éléments méritent attention :</p>
<ul>
<li><strong><code>int $signal</code></strong> : Le numéro du signal reçu. C'est lui qu'on inspecte avec <code>match</code> ou <code>in_array</code> pour savoir quoi faire ;</li>
<li><strong><code>int|false $previousExitCode = 0</code></strong> : Code de sortie renvoyé par le signal handler précédent, ou <code>false</code> si c'est le premier signal traité dans cette invocation. Utile si plusieurs signal handlers s'accumulent ;</li>
<li><strong>Valeur de retour <code>int|false</code></strong> : C'est là que ça devient intéressant :
<ul>
<li>retourner <code>false</code> signifie &quot;je ne veux pas quitter, laisse le process continuer&quot; ;</li>
<li>retourner un entier (typiquement <code>0</code> ou un code d'erreur) signifie &quot;termine le process avec ce code de sortie&quot;.</li>
</ul>
</li>
</ul>
<p>Autrement dit, pour un consumer qui doit continuer à tourner après avoir traité le signal (comme démarrer ou stopper un profil Blackfire), on retourne <code>false</code>. Pour un signal d'arrêt (<code>SIGTERM</code>, <code>SIGINT</code>), on peut soit retourner un code de sortie, soit armer un flag <code>shouldStop</code> et retourner <code>false</code> pour finir proprement l'itération en cours.</p>
<h2>Intégrer Blackfire via <code>SIGUSR2</code></h2>
<p>L'idée : on utilise <code>SIGUSR2</code> comme interrupteur. Premier signal → on démarre la probe Blackfire. Second signal → on l'arrête et on affiche le profil.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Blackfire\</span><span class="syntax-5">Client</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Blackfire\</span><span class="syntax-5">ClientConfiguration</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Blackfire\</span><span class="syntax-5">Probe</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">ResourceAnalyserCommand</span><span class="syntax-4"> extends</span><span> </span><span class="syntax-7">Command</span><span class="syntax-4"> implements</span><span> </span><span class="syntax-7">SignalableCommandInterface</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    private</span><span class="syntax-4"> bool</span><span class="syntax-2"> $shouldStop </span><span class="syntax-4">=</span><span class="syntax-3"> false</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-8"> getSubscribedSignals</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">        $signals </span><span class="syntax-4">=</span><span class="syntax-2"> [\</span><span class="syntax-3">SIGTERM</span><span class="syntax-2">, \</span><span class="syntax-3">SIGINT</span><span class="syntax-2">];</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10">        // On n'ajoute SIGUSR2 que si la lib Blackfire est disponible</span></span>
<span class="line"><span class="syntax-4">        if</span><span class="syntax-2"> (</span><span class="syntax-9">class_exists</span><span class="syntax-2">(</span><span class="syntax-5">Client</span><span class="syntax-4">::class</span><span class="syntax-2">)) {</span></span>
<span class="line"><span class="syntax-2">            $signals[] </span><span class="syntax-4">=</span><span class="syntax-2"> \</span><span class="syntax-3">SIGUSR2</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"> $signals;</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-8"> handleSignal</span><span class="syntax-2">(</span><span class="syntax-4">int</span><span class="syntax-2"> $signal, </span><span class="syntax-4">int</span><span class="syntax-2">|</span><span class="syntax-4">false</span><span class="syntax-2"> $previousExitCode </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">)</span><span class="syntax-4">:</span><span class="syntax-4"> int</span><span class="syntax-2">|</span><span class="syntax-4">false</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-4">        if</span><span class="syntax-2"> (\</span><span class="syntax-9">in_array</span><span class="syntax-2">($signal, [\</span><span class="syntax-3">SIGTERM</span><span class="syntax-2">, \</span><span class="syntax-3">SIGINT</span><span class="syntax-2">])) {</span></span>
<span class="line"><span class="syntax-2">            $signalName </span><span class="syntax-4">=</span><span class="syntax-5"> SignalMap</span><span class="syntax-4">::</span><span class="syntax-8">getSignalName</span><span class="syntax-2">($signal);</span></span>
<span class="line"><span class="syntax-11">            $this</span><span class="syntax-4">-></span><span class="syntax-8">stop</span><span class="syntax-2">(</span><span class="syntax-1">"Signal {</span><span class="syntax-2">$signalName</span><span class="syntax-1">} received."</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">        if</span><span class="syntax-2"> (\</span><span class="syntax-3">SIGUSR2</span><span class="syntax-4"> ===</span><span class="syntax-2"> $signal) {</span></span>
<span class="line"><span class="syntax-2">            $client </span><span class="syntax-4">=</span><span class="syntax-4"> new</span><span class="syntax-5"> Client</span><span class="syntax-2">(</span><span class="syntax-4">new</span><span class="syntax-5"> ClientConfiguration</span><span class="syntax-2">(</span></span>
<span class="line"><span class="syntax-2">                $_SERVER[</span><span class="syntax-1">'BLACKFIRE_CLIENT_ID'</span><span class="syntax-2">],</span></span>
<span class="line"><span class="syntax-2">                $_SERVER[</span><span class="syntax-1">'BLACKFIRE_CLIENT_TOKEN'</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">            static</span><span class="syntax-2"> $probe </span><span class="syntax-4">=</span><span class="syntax-3"> null</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-3">null</span><span class="syntax-4"> ===</span><span class="syntax-2"> $probe) {</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">notice</span><span class="syntax-2">(</span><span class="syntax-1">'Blackfire profile started.'</span><span class="syntax-2">);</span></span>
<span class="line"><span class="syntax-2">                $probe </span><span class="syntax-4">=</span><span class="syntax-2"> $client</span><span class="syntax-4">-></span><span class="syntax-8">createProbe</span><span class="syntax-2">();</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">                $profile </span><span class="syntax-4">=</span><span class="syntax-2"> $client</span><span class="syntax-4">-></span><span class="syntax-8">endProbe</span><span class="syntax-2">($probe);</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">notice</span><span class="syntax-2">(</span><span class="syntax-1">'Blackfire profile finished.'</span><span class="syntax-2">, [</span></span>
<span class="line"><span class="syntax-1">                    'url'</span><span class="syntax-4"> =></span><span class="syntax-2"> $profile</span><span class="syntax-4">-></span><span class="syntax-8">getUrl</span><span class="syntax-2">(),</span></span>
<span class="line"><span class="syntax-2">                ]);</span></span>
<span class="line"><span class="syntax-2">                $probe </span><span class="syntax-4">=</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 class="syntax-2">        }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">        return</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 class="syntax-2">}</span></span></code></pre>
<p>Quelques points notables :</p>
<ul>
<li><strong>La variable <code>$probe</code> est <code>static</code></strong> : elle survit entre les appels à <code>handleSignal()</code>. Sans ça, le probe serait perdu entre le premier et le second signal ;</li>
<li><strong><code>class_exists(Client::class)</code></strong> : le package <code>blackfire/php-sdk</code> n'est pas forcément installé en production. Cette garde permet d'utiliser <code>SIGUSR2</code> uniquement quand Blackfire est disponible, sans planter si ce n'est pas le cas ;</li>
<li><strong>On retourne <code>false</code></strong> : le worker ne doit pas s'arrêter après un signal <code>SIGUSR2</code>. On continue la boucle normalement pendant que Blackfire collecte les données.</li>
</ul>
<h3>Utilisation concrète</h3>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># Trouver le PID du worker</span></span>
<span class="line"><span class="syntax-8">ps</span><span class="syntax-1"> aux</span><span class="syntax-4"> |</span><span class="syntax-8"> grep</span><span class="syntax-1"> redirectionio:crawler:analyze-resources</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10"># Premier signal : démarrer le profiling</span></span>
<span class="line"><span class="syntax-9">kill</span><span class="syntax-3"> -USR2</span><span class="syntax-4"> &#x3C;</span><span class="syntax-1">pi</span><span class="syntax-2">d</span><span class="syntax-4">></span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10"># Laisser tourner quelques itérations...</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-10"># Second signal : arrêter le profiling et envoyer le profil</span></span>
<span class="line"><span class="syntax-9">kill</span><span class="syntax-3"> -USR2</span><span class="syntax-4"> &#x3C;</span><span class="syntax-1">pi</span><span class="syntax-2">d</span><span class="syntax-4">></span></span></code></pre>
<p>L'URL du profil apparaît dans les logs du worker. On peut ensuite l'ouvrir dans l'interface Blackfire pour analyser les flamegraphs, les appels de fonctions, la mémoire, etc. Exactement comme pour une requête HTTP classique.</p>
<p>Si on ne veut pas ouvrir un deuxième terminal, nous pouvons passer le worker courant en mode &quot;background&quot; avec <code>Ctrl+Z</code> puis <code>bg</code>, et  envoyer les signaux depuis le même terminal.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-8">$</span><span class="syntax-1"> bin/console</span><span class="syntax-1"> redirection:crawler:analyze-resources</span><span class="syntax-3"> -vv</span></span>
<span class="line"><span class="syntax-8">15:00:28</span><span class="syntax-1"> NOTICE</span><span class="syntax-2">    [crawler] Crawl analyzer started.</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-8">^Z</span><span class="syntax-10">      #### CTRL+Z au clavier</span></span>
<span class="line"><span class="syntax-2">[1]+  Stopped bin/console redirection:crawler:analyze-resources -vv</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-8">$</span><span class="syntax-1"> bg</span><span class="syntax-10">    #### On passe le process en background</span></span>
<span class="line"><span class="syntax-2">[1]+ bin/console redirection:crawler:analyze-resources -vv &#x26;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-8">$</span><span class="syntax-1"> kill</span><span class="syntax-3"> -SIGUSR2</span><span class="syntax-1"> %1</span><span class="syntax-10">      #### On envoi le signal SIGUSR2 au premier processus en arrière-plan</span></span>
<span class="line"><span class="syntax-8">15:00:50</span><span class="syntax-1"> NOTICE</span><span class="syntax-2">    [crawler] Profile Blackfire started.</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-8">$</span><span class="syntax-1"> kill</span><span class="syntax-3"> -SIGUSR2</span><span class="syntax-1"> %1</span><span class="syntax-10">      #### On envoi le signal SIGUSR2 au premier processus en arrière-plan</span></span>
<span class="line"><span class="syntax-8">15:01:00</span><span class="syntax-1"> NOTICE</span><span class="syntax-2">    [crawler] Profile Blackfire finished. [</span><span class="syntax-1">"url"</span><span class="syntax-4"> =></span><span class="syntax-1"> "https://blackfire.io/XXXXXXXX/graph"</span><span class="syntax-2">]</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-8">$</span><span class="syntax-1"> fg</span><span class="syntax-10">    #### On repasse le process en avant-plan</span></span>
<span class="line"><span class="syntax-2">[1]+ bin/console redirection:crawler:analyze-resources -vv</span></span>
<span class="line"><span class="syntax-10">### ....</span></span></code></pre>
<h2>Aparté : gérer l'arrêt proprement avec <code>shouldStop</code></h2>
<p>Un consumer ne peut pas s'arrêter n'importe quand. Si un message est en cours de traitement au moment où <code>SIGTERM</code> arrive (déploiement, restart Kubernetes...), couper brutalement le process risque de laisser des données dans un état incohérent.</p>
<p>Le pattern classique : un flag booléen qu'on arme à la réception du signal, qu'on consulte entre chaque itération.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-5">class</span><span> </span><span class="syntax-6">ResourceAnalyserCommand</span><span class="syntax-4"> extends</span><span> </span><span class="syntax-7">Command</span><span class="syntax-4"> implements</span><span> </span><span class="syntax-7">SignalableCommandInterface</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-4">    private</span><span class="syntax-4"> bool</span><span class="syntax-2"> $shouldStop </span><span class="syntax-4">=</span><span class="syntax-3"> false</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    protected</span><span class="syntax-5"> function</span><span class="syntax-8"> execute</span><span class="syntax-2">(</span><span class="syntax-5">InputInterface</span><span class="syntax-2"> $input, </span><span class="syntax-5">OutputInterface</span><span class="syntax-2"> $output)</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">        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-10">            // On vérifie le flag en début d'itération, pas pendant</span></span>
<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">shouldStop) {</span></span>
<span class="line"><span class="syntax-4">                return</span><span class="syntax-5"> Command</span><span class="syntax-4">::</span><span class="syntax-3">SUCCESS</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">            $message </span><span class="syntax-4">=</span><span class="syntax-11"> $this</span><span class="syntax-4">-></span><span class="syntax-2">queue</span><span class="syntax-4">-></span><span class="syntax-8">consume</span><span class="syntax-2">();</span></span>
<span class="line"><span class="syntax-4">            if</span><span class="syntax-2"> (</span><span class="syntax-4">!</span><span class="syntax-2">$message) {</span></span>
<span class="line"><span class="syntax-9">                sleep</span><span class="syntax-2">(</span><span class="syntax-3">1</span><span class="syntax-2">);</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>
<span class="line"></span>
<span class="line"><span class="syntax-10">            // Traitement atomique : on ne sera pas interrompu ici</span></span>
<span class="line"><span class="syntax-11">            $this</span><span class="syntax-4">-></span><span class="syntax-8">process</span><span class="syntax-2">($message);</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-8"> handleSignal</span><span class="syntax-2">(</span><span class="syntax-4">int</span><span class="syntax-2"> $signal, </span><span class="syntax-4">int</span><span class="syntax-2">|</span><span class="syntax-4">false</span><span class="syntax-2"> $previousExitCode </span><span class="syntax-4">=</span><span class="syntax-3"> 0</span><span class="syntax-2">)</span><span class="syntax-4">:</span><span class="syntax-4"> int</span><span class="syntax-2">|</span><span class="syntax-4">false</span></span>
<span class="line"><span class="syntax-2">    {</span></span>
<span class="line"><span class="syntax-4">        if</span><span class="syntax-2"> (\</span><span class="syntax-9">in_array</span><span class="syntax-2">($signal, [\</span><span class="syntax-3">SIGTERM</span><span class="syntax-2">, \</span><span class="syntax-3">SIGINT</span><span class="syntax-2">])) {</span></span>
<span class="line"><span class="syntax-11">            $this</span><span class="syntax-4">-></span><span class="syntax-8">stop</span><span class="syntax-2">(</span><span class="syntax-1">'Signal received.'</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-3"> false</span><span class="syntax-2">;  </span><span class="syntax-10">// On ne quitte pas immédiatement !</span></span>
<span class="line"><span class="syntax-2">    }</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">    private</span><span class="syntax-5"> function</span><span class="syntax-8"> stop</span><span class="syntax-2">(</span><span class="syntax-4">string</span><span class="syntax-2"> $reason)</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-11">        $this</span><span class="syntax-4">-></span><span class="syntax-2">logger</span><span class="syntax-4">-></span><span class="syntax-8">notice</span><span class="syntax-2">(</span><span class="syntax-1">"Worker will stop after current task. {</span><span class="syntax-2">$reason</span><span class="syntax-1">}"</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">shouldStop </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>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>L'astuce est dans le <code>return false</code> de <code>handleSignal()</code> : on ne quitte pas immédiatement.
On arme juste le flag, et on laisse l'itération en cours se terminer normalement. Le check <code>if ($this-&gt;shouldStop)</code> en tête de boucle assure qu'on sort proprement entre deux tâches.</p>
<p>Cette approche est particulièrement importante dans des contextes où le traitement a des effets de bord (écriture en base, appel à une API externe, mise à jour d'un état distribué) et où une interruption au milieu laisserait des données incohérentes.</p>
<h2>Conclusion</h2>
<p>Profiler un consumer avec Blackfire se résume à trois ingrédients :</p>
<ol>
<li>Implémenter <code>SignalableCommandInterface</code> pour intercepter <code>SIGUSR2</code> proprement ;</li>
<li>Utiliser une variable <code>static</code> dans <code>handleSignal()</code> pour alterner entre &quot;start probe&quot; et &quot;end probe&quot; d'un signal à l'autre ;</li>
<li>Retourner <code>false</code> pour que le worker continue à tourner pendant et après le profiling.</li>
</ol>
<p>Le résultat : on peut déclencher un profil Blackfire sur un consumer en production (ou en staging) sans le redémarrer, sans modifier le code, et sans interrompre le traitement en cours. Un <code>kill -USR2 &lt;pid&gt;</code> suffit.</p>
<p>Le SDK Blackfire fournit ensuite l'URL du profil, qu'on peut analyser dans l'interface Blackfire comme pour n'importe quelle requête HTTP.</p>
<p>Enfin, le SDK propose d'autres fonctionnalités avancées, comme la possibilité de créer des probes avec des paramètres spécifiques, de gérer des scénarios complexes, ou d'intégrer le profiling dans des pipelines CI/CD.
Pour plus de détails, consultez la <a rel="nofollow noopener noreferrer" href="https://docs.blackfire.io/php/integrations/sdk">documentation officielle de Blackfire</a>.</p>]]></description></item><item><title>Am&#xE9;liorer l&#x2019;accessibilit&#xE9; d&#x2019;un site existant : retour d&#x2019;exp&#xE9;rience chez JoliCode</title><link>https://jolicode.com/blog/ameliorer-l-accessibilite-d-un-site-existant-retour-d-experience-chez-jolicode</link><author>JoliCode Team</author><date>Wed, 01 Jul 2026 09:42:00 +0200</date><description><![CDATA[<p>Malgré des avancées réglementaires importantes, l’accessibilité numérique reste largement sous-estimée dans de nombreux projets web. Depuis le 28 juin 2025, l’European Accessibility Act (EAA) étend les obligations d’accessibilité à de nombreux services numériques privés. Pourtant, la réalité est encore loin des objectifs fixés : selon <a rel="nofollow noopener noreferrer" href="https://observatoire-access-num.aveuglesdefrance.org/">l’Observatoire du respect des obligations d’accessibilité numérique</a>, seuls 44 sites sur 7 560 contrôlés déclarent être totalement conformes au référentiel général d’amélioration de l’accessibilité (RGAA), soit 0,58 % de l’échantillon analysé.</p>
<p>Atteindre un bon niveau d’accessibilité demande généralement un travail de fond sur la conception, le contenu et les développements du site. Cependant, certaines améliorations simples, peu coûteuses et rapides à mettre en œuvre permettent déjà d'offrir une expérience plus inclusive à l'ensemble de vos visiteurs.</p>
<p>Dans cet article, je partage les premières actions que j’ai mises en place sur le site de JoliCode. Ces améliorations ont permis de corriger plusieurs problèmes courants et constituent une première étape vers une expérience plus accessible pour toutes et tous.</p>
<h2>L’état des lieux</h2>
<p>La première étape consiste à réaliser ce qu’on appelle un audit d’accessibilité. Il permet de tester l’ensemble des 106 critères constitutifs du RGAA. Pour cela, l’outil <a rel="nofollow noopener noreferrer" href="https://ara.numerique.gouv.fr/">ARA</a> développé par la direction interministérielle du numérique (DINUM) propose une interface en ligne et la génération de rapport et de déclaration de conformité.</p>
<p>Pour ma part, armée de <a rel="nofollow noopener noreferrer" href="https://accessibilite.numerique.gouv.fr/methode/criteres-et-tests/">la liste des critères</a>, j’ai passé en revue un échantillon de pages représentatif : la page d’accueil, la page contact, la page blog et une page article. L'objectif était également de mieux connaître les critères du RGAA et les différentes méthodes permettant de les évaluer. La navigation au clavier, l'utilisation d'un lecteur d'écran et quelques extensions de navigateur m'ont permis d'identifier rapidement plusieurs problèmes d'accessibilité. Même si l’accessibilité est prise en compte dès le début de nos projets, les mises à jour et maintenances successives, ainsi que les diverses contributions, ont introduit au fil du temps des erreurs fréquentes et faciles à corriger.</p>
<p>Pour structurer les corrections issues de cet audit, j’ai commencé par regrouper les problèmes identifiés dans un tableau de suivi. L’objectif était de relier chaque point à son critère RGAA et de pouvoir suivre facilement les corrections à mettre en place sur le site. Le format du tableau était le suivant :</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>Correction à faire</th>
<th>Critère correspondant</th>
<th>Statut</th>
<th>Images</th>
</tr>
</thead>
<tbody>
<tr>
<td>&quot;Navigation&quot; ou &quot;Contrastes&quot;</td>
<td>Description de la correction à appliquer</td>
<td>Lien vers le critère RGAA correspondant</td>
<td>✅, 🔜 ou ❌ avec un lien vers la pull request si elle existe</td>
<td>Capture d'écran du problème constaté</td>
</tr>
</tbody>
</table>
<h2>Améliorer la lisibilité des contenus</h2>
<p>Parmi les erreurs les plus fréquentes, on retrouve souvent des problèmes de contrastes insuffisants. C’est l’un des éléments qui doit être anticipé dès la phase de conception d’un site. Il existe pourtant des <a rel="nofollow noopener noreferrer" href="https://webaim.org/resources/contrastchecker/">outils simples en ligne</a> (et même des <a rel="nofollow noopener noreferrer" href="https://www.figma.com/community/accessibility/contrast?resource_type=plugins&amp;editor_type=all&amp;price=all&amp;sort_by=all_time&amp;creators=all">plugins Figma</a>) permettant de vérifier le contraste entre une couleur d’arrière-plan et d’avant-plan. Sur le site de JoliCode la couleur rose ainsi qu’un niveau de gris, avaient un contraste insuffisant (sur fond blanc et noir pour le rose, sur fond blanc pour le gris). J’ai utilisé <a rel="nofollow noopener noreferrer" href="https://contrast-finder.tanaguru.com/">l’outil de Tanaguru</a> permettant de trouver des couleurs similaires respectant les critères de contraste. L’usage du rose a été limité au fond blanc, les liens sur section sombres s’affichant désormais en jaune. Cette modification n'a pas eu d’impact sur l'identité visuelle du site tout en améliorant la lisibilité des contenus.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/a11y-jolisite/before-after-secondary.png" data-original-width="1561" data-original-height="690"><source type="image/webp" srcset="/media/cache/content-webp/2026/a11y-jolisite/before-after-secondary.ae10d291.webp" /><source type="image/png" srcset="/media/cache/content/2026/a11y-jolisite/before-after-secondary.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1561 / 690)" src="https://jolicode.com//media/cache/content/2026/a11y-jolisite/before-after-secondary.png" alt="Aperçu de la couleur de texte avant et après modification, la couleur a été légèrement assombrie sans que ça change drastiquement le rendu" /></picture></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><br />
<strong>Critère 3.2</strong> : Dans chaque page web, le contraste entre la couleur du texte et la couleur de son arrière-plan est-il suffisamment élevé ?</p>
        </div>
</div>

<h2>Faciliter la navigation</h2>
<h3>Corriger la navigation principale</h3>
<p>Le menu de navigation principal comportait plusieurs problèmes :</p>
<ul>
<li>Il n’utilisait pas les bons attributs <a rel="nofollow noopener noreferrer" href="https://www.w3.org/WAI/standards-guidelines/aria/">ARIA</a></li>
<li>Il n’était pas totalement utilisable au clavier</li>
<li>Le menu actif était indiqué uniquement par un changement de couleur</li>
</ul>
<p>Pour corriger la structure du menu, je me suis basée sur le pattern “<a rel="nofollow noopener noreferrer" href="https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/">Disclosure Navigation Menu</a>” du guide de la Web Accessibility Initiative (WAI). Pour ne pas toucher au design mais permettre l’utilisation des sous-menus au clavier, un bouton s’affiche désormais lors de la tabulation et permet d’y accéder. La barre jaune initialement affichée uniquement au survol permet désormais d'indiquer le menu actif, permettant une meilleure visibilité aux personnes qui distinguent difficilement certaines couleurs.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/a11y-jolisite/btn-submenu.png" data-original-width="2468" data-original-height="300"><source type="image/webp" srcset="/media/cache/content-webp/2026/a11y-jolisite/btn-submenu.f66c7751.webp" /><source type="image/png" srcset="/media/cache/content/2026/a11y-jolisite/btn-submenu.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(2468 / 300)" src="https://jolicode.com//media/cache/content/2026/a11y-jolisite/btn-submenu.png" alt="" /></picture></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><br />
<strong>Critère 3.1</strong> : Dans chaque page web, l’information ne doit pas être donnée uniquement par la couleur. Cette règle est-elle respectée ?</p>
<p><strong>Critère 10.14</strong> : Dans chaque page web, les contenus additionnels apparaissant via les styles CSS uniquement peuvent-ils être rendus visibles au clavier et par tout dispositif de pointage ?</p>
        </div>
</div>

<h3>Ajouter un lien d’évitement</h3>
<p>S’il est important que le menu de navigation soit accessible, il faut également permettre à l’utilisateur d’accéder facilement au contenu du site, sans forcément avoir à parcourir tout le menu. Ainsi, j’ai ajouté un lien d’accès rapide accessible via la touche tabulation dès l’arrivée sur le site. Il permet aux utilisateurs naviguant au clavier ou avec un lecteur d'écran d’accéder directement au contenu du site.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/a11y-jolisite/skip-link.png" data-original-width="2476" data-original-height="268"><source type="image/webp" srcset="/media/cache/content-webp/2026/a11y-jolisite/skip-link.382b6b38.webp" /><source type="image/png" srcset="/media/cache/content/2026/a11y-jolisite/skip-link.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(2476 / 268)" src="https://jolicode.com//media/cache/content/2026/a11y-jolisite/skip-link.png" alt="" /></picture></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><br />
<strong>Critère 12.7</strong> : Dans chaque page web, un lien d’évitement ou d’accès rapide à la zone de contenu principal est-il présent ?</p>
        </div>
</div>

<h2>Structurer l’information</h2>
<p>L’utilisation de titres au sein d’une page web est essentielle pour structurer l’information. Elle rend la lecture plus agréable, permet de mettre des mots clés en avant d’un point de vue SEO et certains utilisateurs et utilisatrices s’en servent pour naviguer. Sur ce dernier point il est donc important d’avoir une hiérarchie de titres cohérente : éviter les sauts de titre (passer d’un h2 à un h4 par exemple) et surtout un ordre incohérent (passer d’un h1 à un h3 puis un h2). Pour cela je vous conseille l’extension <a rel="nofollow noopener noreferrer" href="https://chromewebstore.google.com/detail/headingsmap/flbjommegcjonpdmenkdiocclhjacmbi">headings map</a> qui permet de visualiser simplement une erreur dans la hiérarchie de vos titres.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/a11y-jolisite/headings-map-before-after.png" data-original-width="1440" data-original-height="714"><source type="image/webp" srcset="/media/cache/content-webp/2026/a11y-jolisite/headings-map-before-after.630baf51.webp" /><source type="image/png" srcset="/media/cache/content/2026/a11y-jolisite/headings-map-before-after.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1440 / 714)" src="https://jolicode.com//media/cache/content/2026/a11y-jolisite/headings-map-before-after.png" alt="La page contact comportait un titre de niveau 1 &quot;Contact&quot; puis un titre de niveau 4 &quot;Envoyez-nous un message&quot; suivi d'un titre de niveau 2 &quot;Où nous retrouver ?&quot;. Le titre de niveau 4 est désormais un titre de niveau 2." /></picture></p>
<p>Sur le site de JoliCode, un titre avait été balisé de manière à prendre un certain style mais l’ordre n’était plus cohérent. Le balisage a été changé et le style géré via une classe CSS.</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><br />
<strong>Critère 9.1</strong> : Dans chaque page web, l’information est-elle structurée par l’utilisation appropriée de titres ?</p>
        </div>
</div>

<h2>Donner du sens aux images</h2>
<p>Vient le sujet majeur de l’accessibilité des images et son fameux attribut <code>alt</code>. On en a tellement entendu parler que certains contributeurs se mettent désormais à le remplir, même quand ce n’est pas nécessaire.</p>
<p>Voici ce qu’il faut retenir :</p>
<ul>
<li>une image qui n’apporte pas d’information supplémentaire <strong>doit avoir un attribut alt vide</strong>. Cela signifie que même si l’image est porteuse d’information, si cette information est présente dans le texte à côté de l’image, il est inutile de l’indiquer dans l’attribut alt. Le lecteur d’écran risquerait de lire deux fois la même information, générant du bruit inutile. Le mieux est l’ennemi du bien.</li>
<li>En revanche, si l'image est essentielle à la compréhension, il faut remplir l’attribut alt pour la décrire.</li>
</ul>
<p>Demandez-vous simplement : si on retire l’image, est-ce que l’information reste compréhensible dans son entièreté ? Ou au contraire est-ce que l’on perd de l’information ? Si retirer l’image ne change rien alors elle n’est pas porteuse de sens et l’attribut alt doit être présent, mais vide.</p>
<p>En partant de ce constat, plusieurs attributs alt ont été corrigés, même si, nombre d’entre eux étant contribués dans les pages article, il en reste sûrement à améliorer.</p>
<p>Les SVG méritent également une attention particulière. Pour les SVG en ligne, il faut là aussi, dissocier ceux qui sont porteurs d’information et ceux qui ne le sont pas. Dans le premier cas, il est nécessaire de fournir une alternative textuelle directement dans le SVG, par exemple à l'aide d'un élément <code>&lt;title&gt;</code>.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">&#x3C;</span><span class="syntax-4">svg</span><span class="syntax-8"> role</span><span class="syntax-2">=</span><span class="syntax-1">"img"</span><span class="syntax-2">></span></span>
<span class="line"><span class="syntax-2">  &#x3C;</span><span class="syntax-4">title</span><span class="syntax-2">>texte de description&#x3C;/</span><span class="syntax-4">title</span><span class="syntax-2">></span></span>
<span class="line"><span class="syntax-2">  ...</span></span>
<span class="line"><span class="syntax-2">&#x3C;/</span><span class="syntax-4">svg</span><span class="syntax-2">></span></span></code></pre>
<p>Dans le deuxième cas, l’attribut <code>aria-hidden=&quot;true&quot;</code> doit être ajouté à la balise.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">&#x3C;</span><span class="syntax-4">svg</span><span class="syntax-8"> aria-hidden</span><span class="syntax-2">=</span><span class="syntax-1">"true"</span><span class="syntax-2">></span></span>
<span class="line"><span class="syntax-2">…</span></span>
<span class="line"><span class="syntax-2">&#x3C;/</span><span class="syntax-4">svg</span><span class="syntax-2">></span></span></code></pre>

<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><br />
L’attribut <code>focusable=&quot;false&quot;</code> n’est plus nécessaire sur les SVG inline non porteurs de sens à moins que vous ne souhaitiez supporter les navigateurs Internet Explorer et Edge avant 2020 (avant le passage à Chromium).</p>
        </div>
</div>


<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><br />
<strong>Critère 4.8</strong> : Chaque média non temporel a-t-il, si nécessaire, une alternative ?</p>
<p><strong>Critère 4.9</strong> : Pour chaque média non temporel ayant une alternative, cette alternative est-elle pertinente ?</p>
        </div>
</div>

<h2>Rendre les formulaires plus explicites</h2>
<p>Lier un champ de formulaire à un libellé est globalement maîtrisé. Mais sur le site de JoliCode, il y a un cas que nous n’avions pas forcément anticipé. Sur la page blog, le formulaire de recherche possède un champ avec un libellé non visible.</p>
<p><picture><source type="image/webp" srcset="/media/cache/content-webp/2026/a11y-jolisite/search-form.17a5de1c.webp" /><source type="image/png" srcset="/media/cache/content/2026/a11y-jolisite/search-form.png" /><img loading="lazy" decoding="async" style="width: 864px; ; aspect-ratio: calc(864 / 156)" src="https://jolicode.com//media/cache/content/2026/a11y-jolisite/search-form.png" alt="Le formulaire est constitué d'un champ avec une icône loupe et un placeholder &quot;Saisissez votre recherche&quot; ainsi qu'un bouton &quot;Rechercher&quot;" /></picture></p>
<p>Si un label est bien présent dans le code et accessible aux technologies d’assistance grâce à une classe <code>sr-only</code>, il ne satisfait pas le critère 11.1.3 du RGAA :</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><br />
Chaque champ de formulaire ayant une étiquette dont le contenu n’est pas visible ou à proximité (masqué, <code>aria-label</code>) ou qui n’est pas accolé au champ (<code>aria-labelledby</code>), vérifie-t-il une de ses conditions ?</p>
<ul>
<li>Le champ de formulaire possède un attribut <code>title</code> dont le contenu permet de comprendre la nature de la saisie attendue ;</li>
<li>Le champ de formulaire est accompagné d’un passage de texte accolé au champ qui devient visible à la prise de focus permettant de comprendre la nature de la saisie attendue ;</li>
<li>Le champ de formulaire est accompagné d’un passage de texte visible accolé au champ permettant de comprendre la nature de la saisie attendue.</li>
</ul>
        </div>
</div>

<p>Même si le champ possède un placeholder, celui-ci disparaissant au focus, le référentiel demande qu’un autre texte visible indique la fonction du champ de formulaire. Afin d’éviter un changement graphique, j’ai ajouté un attribut title pour répondre au critère.</p>
<h2>Enseignements et perspectives</h2>
<p>Ces différentes corrections représentent finalement peu d’efforts et de lignes de code pour un gain non négligeable pour de nombreuses personnes. Elles correspondent à des erreurs parmi les plus fréquemment rencontrées :</p>
<ul>
<li>le respect des contrastes de couleurs,</li>
<li>la bonne gestion des alternatives textuelles des images et des SVG,</li>
<li>une navigation entièrement utilisable au clavier,</li>
<li>l’ajout d’un lien d’évitement,</li>
<li>et une meilleure explicitation des formulaires.</li>
</ul>
<p>Pour autant, ce travail ne suffit pas à rendre un site conforme au RGAA. L’accessibilité reste un sujet transversal qui concerne aussi bien la conception, le design, le développement que la production de contenu. Ces premières améliorations constituent donc moins une finalité qu’un point de départ vers une démarche d’amélioration continue.</p>]]></description></item><item><title>Domain Driven Design: l'essentiel est avant le code</title><link>https://www.jdecool.fr/blog/2026/06/27/domain-driven-design-l-essentiel-est-avant-le-code.html</link><author/><date>Sat, 27 Jun 2026 00:00:00 +0200</date><description><![CDATA[<p>Pour beaucoup de développeurs, voir un dossier “Domain” dans le code source d’un projet, visant à isoler la partie métier, et avoir des objets riches avec un nommage cohérent, c’est faire du DDD (Domain Driven Design). C’est certes un bon début, mais réduire le Domain Driven Design à une structure de dossier, c’est passer à côté de l’essentiel.</p>

<!--more-->

<p>Le DDD, ce n’est pas qu’une affaire de découpage et d’organisation technique. C’est avant tout une approche stratégique. Identifier les différents sous-domaines, comprendre lesquels portent réellement la valeur de l’entreprise, construire un langage commun entre développeurs et experts métier. Tout cela se passe bien avant d’écrire la moindre ligne de code.</p>

<p>Les patterns tactiques ne sont que la partie émergée de l’iceberg. La vraie force du DDD réside dans le travail en amont: aligner le modèle de code sur la réalité du métier et faire en sorte que tout le monde parle le même langage.</p>

<p>Je n’ai que trop rarement vu des équipes embrasser les patterns stratégiques du DDD: définition et mise en place du langage commun (Ubiquitous Language), atelier de “context mapping” (cartographie des domaines et sous-domaines) ou d’“event storming” (modélisation d’un domaine métier en identifiant les événements clés). Au-delà de la technique, c’est pour moi ce qui fait la valeur de la conception pilotée par le domaine.</p>

<p>Parce que ranger son code dans un dossier “Domain” sans faire un travail de fond sur le métier, c’est confondre la technique avec la méthode.</p>
]]></description></item><item><title>UX Days 2026 : Design, IA et conscience, ce que cette journ&#xE9;e nous a appris sur notre m&#xE9;tier</title><link>https://jolicode.com/blog/ux-days-2026-design-ia-et-conscience-ce-que-cette-journee-nous-a-appris-sur-notre-metier</link><author>JoliCode Team</author><date>Wed, 24 Jun 2026 11:42:00 +0200</date><description><![CDATA[<p>L'intelligence artificielle redessine nos métiers. C'est le constat que partagent aujourd'hui designers, développeurs et product managers. Les UX Days 2026 n'ont pas échappé à cette réalité. Parmi les nombreuses conférences de la journée, six nous ont particulièrement marqués et partageaient un fil rouge : <strong>comment concevoir avec conscience à l'ère de l'IA ?</strong></p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/ux-days-2026/uxdays01.png" data-original-width="1920" data-original-height="980"><source type="image/webp" srcset="/media/cache/content-webp/2026/ux-days-2026/uxdays01.831d22fb.webp" /><source type="image/png" srcset="/media/cache/content/2026/ux-days-2026/uxdays01.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1920 / 980)" src="https://jolicode.com//media/cache/content/2026/ux-days-2026/uxdays01.png" alt="Alt text" /></picture></p>
<h2><strong>Keynote d'ouverture avec Pablo Ruiz-Múzquiz : L'avenir du design UX est-il l’open source ?</strong></h2>
<p>La matinée s'est ouverte avec Pablo Ruiz-Múzquiz, cofondateur et CEO de Penpot, une plateforme de design UX open source. Physicien de formation, il cofonde en 2011 Kaleidos, une entreprise avec une équipe de designers et développeurs. Pablo porte une conviction forte : les outils que nous utilisons ne sont pas neutres.</p>
<p>Il part d'un constat simple : quand on parle de &quot;l'avenir du design UX&quot;, on ne parle pas tous de la même chose. Certains pensent à qui va concevoir, d'autres aux outils utilisés,  et d'autres encore à pour qui on conçoit. Pour lui, c'est avant tout une question de capacité à résoudre de vrais problèmes avec les contraintes du réel.</p>
<p>Si le matériel reste la contrainte principale de nos usages, il n'évoluera pas de sitôt (smartphones, laptops, claviers, souris, sans évolution imminente à l'horizon). C'est donc du côté du software que viendra le changement, notamment via les LLM. Deux conséquences concrètes : des expériences de plus en plus personnalisées pour chaque utilisateur, et l'émergence d'agents IA qui utilisent nos interfaces exactement comme nous le ferions. Ce dernier point soulève une question nouvelle pour les designers : comment concevoir une interface qui fonctionne bien à la fois pour un humain et pour un agent IA ? C'est ce que Pablo appelle la frontière entre UX et AX et cette frontière va progressivement devenir flou.</p>
<p>Avant de parler de Penpot, Pablo s'arrête sur ce qu'est vraiment l'open source.  Ce n'est pas une collection de logiciels gratuits, c'est un mécanisme juridique qui redistribue des libertés. Il produit trois choses concrètes : de la confiance car le code est auditable, une collaboration distribuée à l'échelle mondiale, et une souveraineté numérique difficile à abandonner une fois acquise.</p>
<p>Pour illustrer ces convictions, Pablo détaille les cinq choix structurants de Penpot :</p>
<ol>
<li><strong>Être open source pour ne pas laisser le marché aux seuls acteurs dominants</strong>, même si cela implique une gouvernance plus complexe des communautés ;</li>
<li><strong>Adopter un design basé sur les standards CSS</strong> (Flexbox, Grid, tokens natifs) pour créer une correspondance directe avec le code ;</li>
<li><strong>Proposer une interface familière aux outils existants</strong> pour réduire la courbe d'apprentissage ;</li>
<li><strong>Parier sur les standards ouverts</strong>, même quand ça coûte des mois de refonte ;</li>
<li><strong>Faire de Penpot un outil autant pour les designers que pour les développeurs</strong>, ces derniers y sont aujourd'hui deux fois plus nombreux.</li>
</ol>
<p>Sa question de clôture est simple : <strong>quels seront vos choix de design ?</strong></p>
<p>Chez JoliCode, l'<a href="https://jolicode.com/nos-metiers/contributions">open source est au cœur de notre travail et des outils que nous mettons en place</a>. Pablo n'a fait que nous donner de bons arguments pour continuer.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/ux-days-2026/uxdays02.png" data-original-width="1920" data-original-height="980"><source type="image/webp" srcset="/media/cache/content-webp/2026/ux-days-2026/uxdays02.14b53520.webp" /><source type="image/png" srcset="/media/cache/content/2026/ux-days-2026/uxdays02.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1920 / 980)" src="https://jolicode.com//media/cache/content/2026/ux-days-2026/uxdays02.png" alt="Alt text" /></picture></p>
<h2><strong>Matthieu Froidure : Comment l'IA peut compléter (sans remplacer) une démarche d'accessibilité numérique ?</strong></h2>
<p>Matthieu Froidure est aveugle depuis quinze ans et expert en accessibilité chez Urbilog. Il arrive sur scène et avant tout, il veut mettre la salle dans sa peau.</p>
<p>Il demande au public de se lever, de fermer les yeux, et de serrer la main du voisin le plus proche. &quot;Vous venez de perdre 70% de votre information quotidienne.&quot; L'expérience est assez troublante.</p>
<p>Avant d'aborder l'IA, Matthieu pose le cadre légal sur l’accessibilité. En France, l'accessibilité numérique est une obligation depuis 2005 pour le secteur public, étendue aux grandes entreprises en 2016. Mais c'est en juin 2025 que tout change avec l'European Accessibility Act : toute entreprise avec plus de 2 millions d'euros de chiffre d'affaires ou 10 salariés ayant une relation directe avec des clients est désormais concernée. C’est un enjeu important.</p>
<p>Ce cadre posé, il entre dans le vif du sujet : respecter les 106 critères du RGAA ne garantit pas qu'une interface soit utilisable. Il diffuse deux versions sonores d'un même tableau INSEE, une d'abord inaccessible, puis une version conforme. Dans les deux cas, comprendre les données demande un effort cognitif considérable. Un site peut être accessible sur le papier mais incompréhensible en conditions réelles.</p>
<p>C'est là que l'IA entre en jeu et Matthieu en dresse un portrait nuancé. L’IA peut être utile pour réexprimer une information complexe, la délivrer à l'oral et répondre à des questions précises (par exemple pour lire un tableau de garanties mutuelles, adapter le contenu en Facile à Lire et à Comprendre pour les personnes déficientes intellectuelles). Il en a lui-même fait l'expérience avec Claude pour installer un logiciel via son lecteur d'écran NVDA, et globalement ça a marché.</p>
<p>Mais l'IA a trois biais qu'il faut garder en tête :</p>
<ul>
<li><strong>Le biais d'information</strong> : elle peut donner des réponses fausses avec la même conviction que des réponses exactes ;</li>
<li><strong>La peur du silence</strong> : elle préfère inventer plutôt que se taire ;</li>
<li><strong>Le biais de complaisance</strong> : elle ne contredira jamais l'utilisateur même s'il a tort.</li>
</ul>
<p>Matthieu en a fait les frais lui-même en demandant à une IA un itinéraire de transport à Paris et il s'est retrouvé à errer une demi-heure dans la mauvaise direction.</p>
<p>Matthieu appuie donc sur l’importance de tester nos interfaces avec des extreme users. L'IA n'a pas les données pour couvrir ces cas extrêmes mais ce sont précisément ces cas qui une fois résolus bénéficient à tous. La télécommande a été conçue pour les personnes à mobilité réduite. Le SMS pour les sourds.</p>
<p>Sa conclusion est simple : <strong>concevoir pour les extrêmes, c'est concevoir mieux pour tout le monde</strong>.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/ux-days-2026/uxdays03.png" data-original-width="1920" data-original-height="980"><source type="image/webp" srcset="/media/cache/content-webp/2026/ux-days-2026/uxdays03.99d23869.webp" /><source type="image/png" srcset="/media/cache/content/2026/ux-days-2026/uxdays03.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1920 / 980)" src="https://jolicode.com//media/cache/content/2026/ux-days-2026/uxdays03.png" alt="Alt text" /></picture></p>
<h2><strong>Antoine Pezé, Les choix qu’on ne faisait pas : quand un designer reprend le pouvoir sur le code</strong></h2>
<p>Antoine Pezé est UX designer et entrepreneur depuis une quinzaine d'années, ancien coach UX chez Leroy Merlin. En octobre 2025, il se lance seul dans le développement d'une application mobile autour du jeu de cartes Riftbound, sans savoir coder et avec Claude Code comme seul partenaire.</p>
<p>Il en tire quatre enseignements qu'il n'aurait jamais vus sans avoir été confronté au code.</p>
<p><strong>Premier enseignement</strong> : tester en conditions réelles révèle des choix de design invisibles. En voulant afficher 50 000 tournois d'un coup, l'application plantait. La solution n'était pas technique, c'était un choix de design qu'il n'avait pas fait au préalable. Airbnb n'affiche que 270 résultats par recherche, pas plus. La performance est une dimension UX qu'on délègue habituellement au développeur et en travaillant seul directement dans le code, Antoine a dû se confronter à de nouvelles problématiques.</p>
<p><strong>Deuxième enseignement</strong> : réduire la boucle de feedback rend les maquettes facultatives. Après quelques semaines, Antoine réalise qu'il n'utilise plus Figma depuis six mois. Il décrit son intention, Claude propose un design, et il itère directement depuis le code. Le code est devenu un support d'itération qui remplace le sketch et ça change tout à sa manière de concevoir.</p>
<p><strong>Troisième enseignement</strong> : discuter avec une IA pousse à beaucoup mieux spécifier. En lui demandant de designer une page et de remonter les cas limites, Antoine se retrouve face à une dizaine de questions précises sur la densité de navigation, l'intégration dans l'onboarding, ou encore la terminologie. Des questions qu'il n'aurait pas forcément formulées seul.</p>
<p><strong>Quatrième enseignement</strong> : mettre du code en production efface les frontières entre design et développement. Antoine avoue lui-même avoir du mal à définir son activité car il ne sait pas lire une ligne de code, mais ce qu'il fait se rapproche davantage d'un job de développeur. Il a réussi à mettre une application en production sur iPhone et Android, intégrer de l'OCR pour scanner des cartes, récupérer et analyser des données satellites pour détecter des coupes sauvages en France. Des choses qu'il pensait impossibles il y a encore six mois. Et il pose la question symétrique : qu'est-ce qui empêche un développeur de designer, maintenant que les maquettes peuvent être générées par l'IA ?</p>
<p>Antoine conclut sur une note réaliste. Claude Code fonctionne très bien sur des projets jeunes, des prototypes et des contextes où l'enjeu est d'explorer. Ce qu'il retient surtout, c'est le sentiment de pouvoir enfin explorer sans limite et seul. Mais cette liberté a ses limites dès qu'on entre dans le monde de l'entreprise. Sur des projets legacy avec des années de règles métier, c'est une autre affaire. Antoine affirme que la vraie barrière qui résiste n'est pas la production, c'est la prise de décision. Selon une étude du MIT, 95% des projets IA en entreprise en 2025 n'ont pas généré de valeur, non pas parce que les modèles étaient mauvais, mais parce que les organisations ne savaient pas se mettre d'accord sur ce qu'il fallait construire. L'IA peut produire plus vite, encore faut-il savoir quoi lui demander.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/ux-days-2026/uxdays04.png" data-original-width="1920" data-original-height="980"><source type="image/webp" srcset="/media/cache/content-webp/2026/ux-days-2026/uxdays04.8df9db92.webp" /><source type="image/png" srcset="/media/cache/content/2026/ux-days-2026/uxdays04.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1920 / 980)" src="https://jolicode.com//media/cache/content/2026/ux-days-2026/uxdays04.png" alt="Alt text" /></picture></p>
<h2><strong>Anna E. Cook : L'accessibilité comme infrastructure à l'ère de l'IA</strong></h2>
<p>Anna E. Cook, product et systems designer spécialisée en accessibilité et design inclusif, ouvre l'après-midi avec un message clair : l'IA ne rendra pas vos interfaces accessibles à votre place. Elle le démontre en déconstruisant quatre mythes.</p>
<p><strong>Premier mythe</strong> : l'IA rendrait enfin nos interfaces vraiment adaptatives. Faux. Les utilisateurs ont toujours zoomé, changé la taille de leur texte, modifié leurs paramètres. La variabilité était déjà là avant l’IA.</p>
<p><strong>Deuxième mythe</strong> : le diagnostic médical comme stratégie de design. Nielsen, figure incontournable de l'UX, a proposé de personnaliser une interface en fonction du diagnostic d'un utilisateur. Anna s'y oppose car le diagnostic arrive souvent longtemps après que le besoin s'est manifesté. Elle en sait quelque chose car pendant des mois sa vision s'est dégradée sans qu'elle ait encore de diagnostic. Ce dont elle avait besoin : zoomer et agrandir le texte, cela ne nécessitait donc aucun étiquetage médical. Deux personnes avec le même diagnostic peuvent avoir des besoins très différents et inversement. Ce qui compte c'est la variabilité des besoins, pas le diagnostic.</p>
<p><strong>Troisième mythe</strong> : plus une interface est personnalisée, meilleure elle est. Anna nuance. Une interface qui change constamment pour s'adapter à vous fragilise vos repères, pourtant essentiels. Imaginez vous connecter à votre application bancaire et ne plus reconnaître rien : les menus ont bougé, les couleurs ont changé. Vous êtes perdu. La bonne approche est celle de GitHub : tous les liens sont soulignés par défaut pour ceux qui en ont besoin, mais chacun peut désactiver cette option. En résumé, d'abord garantir les besoins essentiels et ensuite laisser la liberté d'adapter.</p>
<p><strong>Quatrième mythe</strong> : l'IA peut réparer une structure d'accessibilité cassée. C’est faux. Les modèles ont été entraînés sur un web massivement inaccessible et reproduisent ce qu'ils ont appris. Un collègue d'Anna chez Microsoft évalue quotidiennement les LLM sur leur capacité à produire du code conforme WCAG. Résultat : au mieux 30% de réussite par défaut. Les données WebAIM 2026 confirment cette tendance : 95,9% des pages d'accueil présentent des erreurs d'accessibilité de base, avec une hausse de 10% des erreurs par page. Une tendance attribuée en partie à la généralisation de l'IA dans la production de code.</p>
<p>En réponse à ses mythes, Anna propose six questions à se poser avant d'intégrer l'IA dans un système : quels sont nos biais de design ? Quels éléments ne doivent jamais changer ? Qui est responsable des sorties de l'IA ? Quelle est l'expérience par défaut sans IA ? Quels signaux les utilisateurs envoient-ils déjà via leurs paramètres OS et navigateur ? Et surtout, est-ce que l'IA résout réellement un problème ici ?</p>
<p>Anna n'est pas contre l’IA, elle est pour <strong>l’utiliser avec intention</strong>. Elle le rappelle clairement : l'IA a des coûts réels, financiers, opérationnels, environnementaux et n'est pas la bonne réponse à chaque problème.</p>
<p>Elle termine sur un message clair pour les designers : <strong>&quot;Tous les produits n'ont pas besoin de l'IA. Mais chaque produit a besoin de systèmes accessibles et bien conçus.”</strong></p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/ux-days-2026/uxdays05.png" data-original-width="1920" data-original-height="980"><source type="image/webp" srcset="/media/cache/content-webp/2026/ux-days-2026/uxdays05.009ed6db.webp" /><source type="image/png" srcset="/media/cache/content/2026/ux-days-2026/uxdays05.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1920 / 980)" src="https://jolicode.com//media/cache/content/2026/ux-days-2026/uxdays05.png" alt="Alt text" /></picture></p>
<h2><strong>Bastien Hughes : Designer, l’IA va nous rendre idiots… ou meilleurs</strong></h2>
<p>Bastien Hughes, Head of Design chez Bouygues Telecom, pose d'emblée une question volontairement provocatrice : l'IA va-t-elle nous rendre idiots ou meilleurs ? Sa réponse : les deux, cela dépend de comment on l'utilise.</p>
<p>Pour introduire son propos, Bastien remonte soixante-treize ans en arrière. En 1953, Albert Ducrocq, ingénieur français et créateur du premier robot français, publiait L'Ère des robots. Sa mise en garde est étonnamment actuelle : &quot;Il doit y avoir un juste équilibre entre la part humaine et la vie artificielle.&quot; D’autres penseurs feront écho à cette inquiétude dans les décennies suivantes.</p>
<p>Ces mises en garde sont aujourd'hui confirmées par la science. Pourquoi avons-nous cette tendance naturelle à déléguer ? Parce que le cerveau, qui représente 2% de notre poids mais consomme 20% de notre énergie, cherche naturellement à économiser ses ressources. Le GPS a affaibli notre sens de l'orientation. Le correcteur orthographique a baissé notre vigilance. Aujourd'hui, un quart des utilisateurs français d'IA déclarent déjà ne plus être capables d'effectuer certaines tâches sans elle.</p>
<p>Trois facteurs aggravent cette tendance. D'abord, le design des IA elles-mêmes est conçu pour capter notre attention et contourner notre esprit critique. Ensuite, la pression des entreprises à mettre de l'IA partout, souvent sans se demander si elle résout un vrai problème. Enfin, l'argument économique : réduire les coûts, augmenter la productivité. Résultat, comme le résume Maslow : &quot;Quand le seul outil dont vous disposez est un marteau, tout ressemble à un clou.&quot;</p>
<p>Pourtant, quand l'IA répond à un vrai besoin, Bastien le confirme : ça fonctionne. Le Bon Coin en est un bon exemple. En générant automatiquement les descriptions d'annonces à partir d'une photo et d'un titre, ils ont résolu un vrai problème : rédiger dix annonces, c'est long et fastidieux pour beaucoup d'utilisateurs.</p>
<p>C'est dans cet esprit que Bastien propose un cadre : <strong>le Nutri-Score de l'IA</strong>. En bas de l'échelle (E), l'usage le plus néfaste : je demande, l'IA exécute et je valide sans réfléchir. En haut (A), l'usage le plus bénéfique : je structure ma pensée et je sollicite l'IA pour me challenger, elle me pose des questions qui m'obligent à raisonner. C'est ce qu'il appelle le &quot;ping-pong de la pensée”.</p>
<p>L'IA permet aussi de tester ses idées plus tôt dans le processus. Prototyper rapidement, itérer avec des personas IA, tester avant de présenter.</p>
<p>Bastien conclut : <strong>l'IA nous rendra idiots ou meilleurs selon l'usage qu'on en fait.</strong></p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/ux-days-2026/uxdays06.png" data-original-width="1920" data-original-height="980"><source type="image/webp" srcset="/media/cache/content-webp/2026/ux-days-2026/uxdays06.9736c100.webp" /><source type="image/png" srcset="/media/cache/content/2026/ux-days-2026/uxdays06.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1920 / 980)" src="https://jolicode.com//media/cache/content/2026/ux-days-2026/uxdays06.png" alt="Alt text" /></picture></p>
<h2><strong>Marine Lochet : Fiction et design : concevoir depuis ce qui dérange</strong></h2>
<p>Marine Lochet, designer et auteure, est la dernière conférencière que nous souhaitons vous présenter et son intervention mérite qu'on s'y attarde. Elle démarre par trois récits, deux fictifs et un réel, apportant une atmosphère particulière dans la salle.</p>
<p><strong>Le premier, Never Let Me Go de Kazuo Ishiguro</strong> : des enfants élevés dans un pensionnat idyllique, destinés à donner leurs organes. Tout a été si bien lissé qu'ils acceptent leur propre fin comme une formalité.</p>
<p><strong>Le deuxième, BioShock</strong> : un personnage qui n'a jamais pris une seule décision, sa liberté n'était qu'un scénario écrit par quelqu'un d'autre.</p>
<p><strong>Le troisième, réel celui-là : le scroll infini, inventé par Aza Raskin pour fluidifier la navigation</strong>. Quelques années plus tard, il réalise qu'il a créé un mécanisme de capture de l'attention humaine.</p>
<p>Trois récits avec une même mécanique : le design comme machine à lisser le réel jusqu'à rendre la résistance impossible. Ce mécanisme, Marine le retrouve au cœur même de notre pratique, dans un principe que tous les designers connaissent : &quot;Don't Make Me Think&quot; de Steve Krug. L'idée : une interface doit être si intuitive que l'utilisateur n'a jamais à réfléchir pour l'utiliser. On l'applique désormais aux formulaires de résiliation, aux paramètres de consentement, aux confirmations de paiement, là où la friction peut protéger l’utilisateur. Car face à une décision importante, un moment de résistance force la délibération consciente.</p>
<p>On a supprimé la charge cognitive inutile, mais on a supprimé avec elle la capacité à vraiment choisir.</p>
<p>Marine applique ce même regard à notre vocabulaire de designer. Dire &quot;utilisateur&quot; plutôt que &quot;personne&quot;, ces mots décrivent les humains du point de vue du système et rendent certaines questions morales impossibles à formuler. Optimiser un tunnel de conversion, c'est une compétence technique. Décider ce qu'on est prêt à faire subir à une personne pour l'atteindre, c'est une responsabilité.</p>
<p>Pour illustrer concrètement cela, elle distingue deux types de friction. Les frictions négatives qui manipulent ou épuisent l'utilisateur contre son intérêt :</p>
<ul>
<li><strong>Dark pattern</strong> : trompe activement (écrire &quot;renoncer à vos avantages&quot; plutôt que &quot;se désabonner&quot;).</li>
<li><strong>Sludge</strong> : épuise délibérément (un formulaire de résiliation en douze étapes).</li>
</ul>
<p>Et les frictions positives qui orientent ou éclairent dans son intérêt :</p>
<ul>
<li><strong>Nudge</strong> : oriente sans contraindre (arrondir automatiquement ses achats pour alimenter un livret d'épargne).</li>
<li><strong>Boost</strong> : force la conscience (demander à l'utilisateur de taper le mot &quot;supprimer&quot; plutôt que de simplement cliquer sur valider).</li>
</ul>
<p>L'enjeu, Marine le formule simplement : <strong>remplacer la fluidité anesthésiante par une friction émancipatrice</strong>.</p>
<h2><strong>Ce que nous retenons de cette journée</strong></h2>
<p>Six conférences avec une conviction partagée : l'IA est un outil et non une solution. Ce qui fait la valeur du designer aujourd'hui est sa capacité à concevoir avec intention avec une vraie conscience de l'impact de ses choix.</p>]]></description></item><item><title>Comit&#xE9; de s&#xE9;lection du Forum PHP 2026 : ses membres, son processus, son verdict !</title><link>https://afup.org/news/1260-comite-selection-forumphp2026</link><author/><date>Wed, 24 Jun 2026 06:10:00 +0200</date><description><![CDATA[<h2>Qui est membre du comité de sélection 2026 ?</h2>
<p>Le comité de sélection est en charge d’élaborer le programme du Forum PHP. Il est constitué de membres du pôle conférences de l’AFUP, membres qui sont également impliqués au sein de l’organisation logistique du Forum PHP et pour certain·e·s, membres du bureau de l’AFUP. <br>
Voici la constitution du comité 2026 : Olivier ALLAIN (<a href="https://www.synolia.com">Synolia</a>), Mickaël AUGER (Ministère des Armées), Benjamin CLAY (<a href="https://jolicode.com/">JoliCode</a>), Florian FERRIERE (<a href="https://bedrockstreaming.com/">Bedrock Streaming</a>), Adrien GALLOU (CODE SQUARE), Victor GREBOT (<a href="https://www.ccmbenchmark.com/">CCM Benchmark Group</a>), Nina IACOPONELLI (<a href="https://www.wimova.com/fr">Wimova</a>), Julien LARY (<a href="http://Les-Tilleuls.coop">Les-Tilleuls.coop</a>), Maud REMORIQUET (<a href="https://52-entertainment.com/">52 Entertainment</a>) et Sébastien ROGIER (<a href="https://semji.com">Semji</a>). Amélie DEFFRENNES est également présente aux réunions de sélection mais ne vote pas. </p>
<h2>Comment le comité de sélection procède-t-il ?</h2>
<h3>Un nombre de créneaux à remplir</h3>
<p>Pour l’édition 2026, 31 slots de conférences et 2 slots d’ateliers sont disponibles. 8 de ces slots sont d’ores et déjà pourvus, via les invitations à certains speakers envoyées en parallèle du CFP : en effet, afin de nous assurer que certaines thématiques seront au programme, nous invitons des conférenciers et conférencières expert·e·s de ces sujets pour venir vous en parler. Pour vous proposer toujours le meilleur, nous leur avons réservé 8 créneaux cette année. 25 créneaux restent alors disponibles. <br></p>
<h3>Un processus de sélection à suivre</h3>
<p>Le <a href="https://event.afup.org/processus-de-selection-eventsafup/">processus de sélection de l'AFUP</a> guide la sélection. Dès la réception des soumissions, le nom du·de la conférencier·e et son entreprise sont masqués, pour se concentrer uniquement sur la qualité et la pertinence du sujet. À la fin de la période des votes du public, le comité entre en piste ! Chaque membre lit l'ensemble des abstracts et attribue une note à chaque sujet. À l'issue de cette phase, un premier classement émerge grâce au principe du jugement majoritaire.<br>
S'ensuit une longue réunion où l'équipe confronte ses appréciations, les votes des internautes et le besoin de variété dans les thématiques pour dégager une shortlist (une quarantaine de talks pour le Forum PHP). C'est seulement à ce moment que les identités des conférencier·e·s sont révélées. Si la conférence d’un·e collègue d’un des membres du comité est discutée, le membre reste en retrait et ne donne pas son avis. <br>
La sélection finale est alors affinée selon plusieurs critères : exclusivité du sujet, existence d'une captation déjà disponible en ligne, diversité des entreprises représentées, cohérence du programme ou durée adaptée au traitement d'un thème. <br></p>
<h2>Et ensuite ?</h2>
<p>Dans les jours suivant la réunion, tous et toutes les soumissionnaires sont notifié·e·s par email : les sélectionné·e·s reçoivent une confirmation, et les non-retenu·e·s un coupon de réduction pour assister à l'événement en tant que participant·e·s. Une liste de remplaçant·e·s est également constituée, pour pallier d'éventuels désistements.</p>
<p><strong>Merci aux 104 conférencier·e·s qui ont répondu à notre appel cette année ! Vous serez contacté·e·s sous peu par notre équipe. Et pour le grand public, rendez-vous le 7 juillet pour découvrir le programme du <a href="https://event.afup.org">Forum PHP 2026</a> : vous ne voudrez pas manquer ça, prenez d’ores et déjà vos places pour les 8-9 octobre à Disneyland Paris !</strong></p>]]></description></item><item><title>L'IA pousse &#xE0; faire soi-m&#xEA;me, au d&#xE9;triment de l'&#xE9;quipe</title><link>https://www.jdecool.fr/blog/2026/06/17/l-ia-pousse-a-faire-soi-meme-au-detriment-de-l-equipe.html</link><author/><date>Wed, 17 Jun 2026 00:00:00 +0200</date><description><![CDATA[<p>Je commence à voir de plus en plus d’équipes où le lead tech est devenu plus rapide à implémenter une fonctionnalité via l’IA qu’à l’expliquer, la déléguer et attendre que quelqu’un la prenne en charge. Pourquoi cadrer un ticket, répondre aux questions et relire du code quand on peut obtenir le résultat directement en utilisant une IA ?</p>

<!--more-->

<p>C’est un pattern organisationnel que je vois de plus en plus souvent. C’est un problème mis en avant avec l’IA, mais qui était déjà présent bien avant: une communication mal huilée.</p>

<p>Le problème de ce fonctionnement est que l’on délègue moins à l’équipe, qui perd alors en compétence. Et moins elle monte en compétence, plus la tentation de faire le travail soi-même est grande.</p>

<p>C’est exactement pour cette raison qu’avec l’avènement de l’IA, il devient essentiel de faire évoluer les métiers, et particulièrement celui de développeur. Il n’est aujourd’hui plus possible d’avoir des développeurs dont la seule responsabilité est d’écrire du code. Ils doivent prendre part au processus de définition des besoins et de conception.</p>

<p>Le rôle du développeur ne s’est jamais limité à l’écriture du code. Sa vraie valeur est de comprendre le problème à résoudre et de proposer les solutions adéquates pour y répondre. C’est une compétence d’autant plus indispensable, quand l’écriture de code est plus que jamais facilitée par les outils qui sont aujourd’hui à notre portée.</p>
]]></description></item><item><title>Comprendre (enfin) les TTY et PTY avec le composant Process de Symfony</title><link>https://jolicode.com/blog/comprendre-enfin-les-tty-et-pty-avec-le-composant-process-de-symfony</link><author>JoliCode Team</author><date>Mon, 15 Jun 2026 10:42:00 +0200</date><description><![CDATA[<p>Vous est-il déjà arrivé de lancer une commande (<code>composer</code>, <code>rsync</code> ou une commande Symfony) directement dans votre terminal pour y admirer de jolies barres de progression colorées, mais de constater que cette même commande, une fois exécutée via un script PHP, perdait soudainement tout son formatage ?</p>
<p>C’est un grand classique lorsque l'on utilise le composant <code>Process</code> de Symfony. Pour comprendre l'origine de ce comportement (et surtout comment y remédier), il faut plonger un instant dans la façon dont Linux gère les flux et les terminaux. Rassurez-vous, c'est plus simple qu'il n'y paraît.</p>
<h2>Retour aux bases : les flux standards</h2>
<p>Sous Linux, chaque processus dispose par défaut de trois flux standards, identifiés par des descripteurs de fichiers (<em>file descriptors</em>) :</p>
<ul>
<li><strong>0 : STDIN</strong> (l'entrée standard)</li>
<li><strong>1 : STDOUT</strong> (la sortie standard)</li>
<li><strong>2 : STDERR</strong> (la sortie d'erreur standard)</li>
</ul>
<p>Ce qu'il faut retenir, c'est que ces flux se comportent comme de simples tuyaux. Ce qui se trouve au bout du tuyau détermine le comportement du programme. Généralement, on rencontre trois scénarios :</p>
<ol>
<li><strong>Un fichier :</strong> Par exemple, lorsque vous redirigez une sortie avec <code>ls &gt; file.txt</code> ;</li>
<li><strong>Un <abbr title="Pseudo TeletYpe">PTY</abbr> / <abbr title="TeleTYpewriter">TTY</abbr> (Terminal) :</strong> Lorsque vous exécutez la commande directement devant votre écran ;</li>
<li><strong>Un pipe (tube) :</strong> Lorsque vous enchaînez des commandes (<code>ls | grep php</code>) ou que vous lancez un sous-processus de manière programmatique.</li>
</ol>
<h2>Le &quot;problème&quot; de l'exécution programmatique</h2>
<p>Par défaut, quand vous utilisez le composant <code>Process</code> pour lancer une commande, Symfony utilise des <strong>pipes</strong> (notre troisième scénario).</p>
<p>Le programme exécuté (prenons Composer) est intelligent : il analyse ce qui se trouve au bout du tuyau de sa sortie standard (STDOUT). S'il détecte un <em>pipe</em> au lieu d'un terminal, il en déduit qu'il est exécuté par une machine ou un script. Pour éviter de polluer d'éventuels fichiers de logs avec des caractères invisibles (les fameux codes ANSI qui génèrent les couleurs) ou des barres de progression illisibles, il bascule automatiquement en mode &quot;texte brut&quot;.</p>
<p>C'est pour cette raison exacte que vos couleurs disparaissent.</p>
<h2>La méthode <code>setTty(true)</code> : le lien direct</h2>
<p>Le composant Process propose une première solution avec la méthode <code>setTty(true)</code>.</p>
<p>En l'activant, vous branchez <em>directement</em> les flux de votre sous-processus sur le vrai terminal de votre système (celui depuis lequel vous avez lancé votre script PHP).</p>
<p><strong>La conséquence :</strong> Les couleurs et les animations sont de retour. La commande s'affiche exactement comme si vous l'aviez tapée vous-même.
<strong>La limite :</strong> Puisque le flux est branché directement sur votre écran, votre script PHP n'y a plus accès. La synchronisation automatique fait que l'affichage est immédiat, mais il devient impossible de capturer la sortie avec un <code>$process-&gt;getOutput()</code> pour l'inspecter.</p>
<h2>La magie de <code>setPty(true)</code> : l'illusion parfaite</h2>
<p>C'est ici qu'interviennent les Pseudo-Terminaux (PTY).</p>
<p>Lorsque vous utilisez <code>setPty(true)</code>, vous demandez au système de créer un terminal émulé de toutes pièces. Un PTY fonctionne comme un <strong>duo composé d'un contrôleur et d'un terminal virtuel</strong> :</p>
<ol>
<li>Le script PHP (via Symfony Process) agit comme le <strong>contrôleur</strong>.</li>
<li>Le processus enfant (votre commande) est branché sur le <strong>terminal virtuel</strong> émulé (qui prend la forme d’un fichier dynamique, souvent <code>/dev/pts/X</code>).</li>
</ol>
<p>Pour le processus enfant, l'illusion est totale. Il détecte bien un terminal au bout de son tuyau et génère donc ses couleurs et ses barres de progression interactives.</p>
<p>Côté PHP, en tant que &quot;contrôleur&quot; du PTY, la donne change : l'envoi vers l'écran n'est plus automatique (il agit comme un <em>buffer</em>). C'est à vous de lire ce qui sort du terminal virtuel. Vous retrouvez ainsi le meilleur des deux mondes : vous forcez le programme à conserver son affichage riche, tout en gardant la capacité d'intercepter et de manipuler le flux sortant directement dans votre code PHP.</p>
<h2>Démo time !</h2>
<p>On commence avec une commande Symfony qui affiche, si l'exécuteur le permet, des choses en couleur :</p>
<pre><code>#!/usr/bin/env php
&lt;?php
require __DIR__.'/vendor/autoload.php';

use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\SingleCommandApplication;

new SingleCommandApplication()
    -&gt;setCode(function (OutputInterface $output): int {
        $output-&gt;writeln('&lt;info&gt;Hello World!&lt;/info&gt;');
        $output-&gt;writeln('&lt;comment&gt;This is a single command application.&lt;/comment&gt;');
        $output-&gt;writeln('&lt;error&gt;Goodbye!&lt;/error&gt;');

        return 0;
    })
    -&gt;run();
</code></pre>
<p>Ensuite, nous exécutons cette commande Symfony, avec le composant Process. En fonction des arguments, nous activons ou non PTY ou TTY :</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">require</span><span class="syntax-3"> __DIR__</span><span class="syntax-4">.</span><span class="syntax-1">'/vendor/autoload.php'</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">$process </span><span class="syntax-4">=</span><span class="syntax-4"> new</span><span class="syntax-2"> Symfony\Component\Process\</span><span class="syntax-5">Process</span><span class="syntax-2">([</span><span class="syntax-3">__DIR__</span><span class="syntax-4"> .</span><span class="syntax-1"> '/console.php'</span><span class="syntax-2">]);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">$process</span><span class="syntax-4">-></span><span class="syntax-8">setTty</span><span class="syntax-2">(($argv[</span><span class="syntax-3">1</span><span class="syntax-2">] </span><span class="syntax-4">??</span><span class="syntax-1"> ''</span><span class="syntax-2">) </span><span class="syntax-4">===</span><span class="syntax-1"> 'tty'</span><span class="syntax-2">);</span></span>
<span class="line"><span class="syntax-2">$process</span><span class="syntax-4">-></span><span class="syntax-8">setPty</span><span class="syntax-2">(($argv[</span><span class="syntax-3">1</span><span class="syntax-2">] </span><span class="syntax-4">??</span><span class="syntax-1"> ''</span><span class="syntax-2">) </span><span class="syntax-4">===</span><span class="syntax-1"> 'pty'</span><span class="syntax-2">);</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">$process</span><span class="syntax-4">-></span><span class="syntax-8">mustRun</span><span class="syntax-2">();</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-9">echo</span><span class="syntax-1"> "</span><span class="syntax-3">\n</span><span class="syntax-1">Output captured:</span><span class="syntax-3">\n</span><span class="syntax-1">"</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-8">dump</span><span class="syntax-2">($process</span><span class="syntax-4">-></span><span class="syntax-8">getOutput</span><span class="syntax-2">());</span></span></code></pre>
<p>Et voici le résultat :</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/tty/tty-pty-none.png" data-original-width="1190" data-original-height="655"><source type="image/webp" srcset="/media/cache/content-webp/2026/tty/tty-pty-none.3626426a.webp" /><source type="image/png" srcset="/media/cache/content/2026/tty/tty-pty-none.png" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1190 / 655)" src="https://jolicode.com//media/cache/content/2026/tty/tty-pty-none.png" alt="Alt text" /></picture></p>
<h2>En résumé</h2>
<p>Si vous construisez des outils en ligne de commande ou des <em>workers</em> asynchrones en PHP :</p>
<ul>
<li><strong>Par défaut (Pipes) :</strong> À privilégier pour les tâches de fond où la sortie doit être parsée ou logguée proprement, sans caractères d'échappement ;</li>
<li><strong><code>setTty(true)</code> :</strong> Idéal si vous voulez simplement déléguer l'affichage et l'interactivité à l'utilisateur, sans avoir besoin d'analyser la sortie côté PHP ;</li>
<li><strong><code>setPty(true)</code> :</strong> La solution de choix pour forcer un affichage riche (couleurs, animations) tout en conservant le contrôle du flux sortant dans votre script.</li>
</ul>]]></description></item><item><title>Comment utiliser les attributs PHP sur un contr&#xF4;leur Symfony ?</title><link>https://jolicode.com/blog/comment-utiliser-les-attributs-php-sur-un-controleur-symfony</link><author>JoliCode Team</author><date>Wed, 10 Jun 2026 14:42:00 +0200</date><description><![CDATA[<p>PHP 8.0 a introduit les attributs, et c'est une excellente fonctionnalité ! Le code devient plus lisible, plus simple à écrire, et bénéficie pleinement de la coloration syntaxique et du <em>linting</em> de notre IDE.</p>
<p>Symfony a très vite adopté cette nouveauté. On les utilise aujourd'hui partout : pour configurer les routes, ajouter des contraintes de validation ou déclarer des <em>listeners</em>.</p>
<p>Dans cet article, nous allons voir <strong>comment créer et ajouter un attribut PHP personnalisé</strong> sur un contrôleur pour lui injecter un comportement automatique, comme du logging.</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>
Nous avions déjà parlé des attributs <a href="https://jolicode.com/blog/rate-limit-your-symfony-apis">en 2021 pour limiter le débit de vos API (Rate Limit)</a>. Cependant, Symfony a bien évolué depuis, et leur utilisation est devenue encore plus simple.</p>
        </div>
</div>

<h2>1. Créer un attribut</h2>
<p>La première étape consiste à créer une classe PHP classique. Pour indiquer qu'elle servira d'attribut, on lui ajoute elle-même l'attribut natif <code>#[\Attribute]</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\AuditLog</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Psr\Log\</span><span class="syntax-5">LogLevel</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">#[\Attribute(</span><span class="syntax-5">\Attribute</span><span class="syntax-4">::</span><span class="syntax-3">TARGET_METHOD</span><span class="syntax-4"> |</span><span class="syntax-5"> \Attribute</span><span class="syntax-4">::</span><span class="syntax-3">TARGET_CLASS</span><span class="syntax-2">)]</span></span>
<span class="line"><span class="syntax-5">class</span><span> </span><span class="syntax-6">Loggable</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-4">        public</span><span class="syntax-4"> string</span><span class="syntax-2"> $level </span><span class="syntax-4">=</span><span class="syntax-5"> LogLevel</span><span class="syntax-4">::</span><span class="syntax-3">INFO</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>Les paramètres du constructeur sont optionnels. Ils permettent de passer des options au moment où on utilise l'attribut. Ici, notre propriété <code>$level</code> sert à définir le niveau de log souhaité.</p>
<h2>2. Utiliser l'attribut sur un contrôleur</h2>
<p>Une fois notre classe créée, nous pouvons l'appliquer directement sur un contrôleur, soit sur la classe entière, soit sur une méthode (action) spécifique :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">namespace</span><span> </span><span class="syntax-6">App\Controller</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\AuditLog\</span><span class="syntax-5">Loggable</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Psr\Log\</span><span class="syntax-5">LogLevel</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Bridge\Twig\Attribute\</span><span class="syntax-5">Template</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Bundle\FrameworkBundle\Controller\</span><span class="syntax-5">AbstractController</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Component\Routing\Attribute\</span><span class="syntax-5">Route</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-2">#[Loggable()]</span></span>
<span class="line"><span class="syntax-4">final</span><span class="syntax-5"> class</span><span> </span><span class="syntax-6">HomepageController</span><span class="syntax-4"> extends</span><span> </span><span class="syntax-7">AbstractController</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-2">    #[Loggable(</span><span class="syntax-5">LogLevel</span><span class="syntax-4">::</span><span class="syntax-3">CRITICAL</span><span class="syntax-2">)]</span></span>
<span class="line"><span class="syntax-2">    #[Route(</span><span class="syntax-1">'/'</span><span class="syntax-2">, name: </span><span class="syntax-1">'app_homepage'</span><span class="syntax-2">)]</span></span>
<span class="line"><span class="syntax-2">    #[Template(</span><span class="syntax-1">'homepage/index.html.twig'</span><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"> index</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">    }</span></span>
<span class="line"><span class="syntax-2">}</span></span></code></pre>
<p>Ici, la méthode <code>index</code> hérite de la configuration par défaut de la classe, et ajoute un nouveau niveau de log plus critique (<code>CRITICAL</code>).</p>
<h2>3. Créer le listener pour activer le comportement</h2>
<p>Pour que notre attribut serve à quelque chose, il faut intercepter l'appel du contrôleur. Symfony déclenche l'événement <code>ControllerEvent</code> juste avant d'exécuter l'action d'un contrôleur. C'est le moment idéal pour vérifier la présence de notre attribut.</p>
<p>Selon votre version de Symfony, l'implémentation est devenue de plus en plus simple.</p>
<h3>Avec Symfony 6.2+</h3>
<p>On utilise la méthode <code>getAttributes()</code> de l'événement pour récupérer notre attribut et appliquer notre logique :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">namespace</span><span> </span><span class="syntax-6">App\AuditLog</span><span class="syntax-2">;</span></span>
<span class="line"></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Psr\Log\</span><span class="syntax-5">LoggerInterface</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Psr\Log\</span><span class="syntax-5">NullLogger</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 class="syntax-4">use</span><span class="syntax-2"> Symfony\Component\HttpKernel\Event\</span><span class="syntax-5">ControllerEvent</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">LoggerListener</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-4">        private</span><span class="syntax-4"> readonly</span><span class="syntax-5"> LoggerInterface</span><span class="syntax-2"> $logger </span><span class="syntax-4">=</span><span class="syntax-4"> new</span><span class="syntax-5"> NullLogger</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-2">    #[AsEventListener(priority: </span><span class="syntax-4">-</span><span class="syntax-3">100</span><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"> logSymfony62</span><span class="syntax-2">(</span><span class="syntax-5">ControllerEvent</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-4">        foreach</span><span class="syntax-2"> ($event</span><span class="syntax-4">-></span><span class="syntax-8">getAttributes</span><span class="syntax-2">()[</span><span class="syntax-5">Loggable</span><span class="syntax-4">::class</span><span class="syntax-2">] </span><span class="syntax-4">??</span><span class="syntax-2"> [] </span><span class="syntax-4">as</span><span class="syntax-2"> $attribute) {</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">log</span><span class="syntax-2">($attribute</span><span class="syntax-4">-></span><span class="syntax-2">level, </span><span class="syntax-1">'Controller is loggable'</span><span class="syntax-2">, [</span></span>
<span class="line"><span class="syntax-1">                'controller'</span><span class="syntax-4"> =></span><span class="syntax-2"> $event</span><span class="syntax-4">-></span><span class="syntax-8">getController</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>
<h3>Avec Symfony 8.1+</h3>
<p>Symfony 8.1 a introduit des événements spécifiques aux attributs. Plus besoin de boucler manuellement, l'événement contient directement l'attribut ciblé :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Component\HttpKernel\Event\</span><span class="syntax-5">ControllerAttributeEvent</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Component\HttpKernel\</span><span class="syntax-5">KernelEvents</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">LoggerListener</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-2">    #[AsEventListener(</span><span class="syntax-5">KernelEvents</span><span class="syntax-4">::</span><span class="syntax-3">CONTROLLER</span><span class="syntax-4"> .</span><span class="syntax-1"> '.'</span><span class="syntax-4"> .</span><span class="syntax-5"> Loggable</span><span class="syntax-4">::class</span><span class="syntax-2">, priority: </span><span class="syntax-4">-</span><span class="syntax-3">100</span><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"> logSymfony81</span><span class="syntax-2">(</span><span class="syntax-5">ControllerAttributeEvent</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-11">        $this</span><span class="syntax-4">-></span><span class="syntax-2">logger</span><span class="syntax-4">-></span><span class="syntax-8">log</span><span class="syntax-2">($event</span><span class="syntax-4">-></span><span class="syntax-2">attribute</span><span class="syntax-4">-></span><span class="syntax-2">level, </span><span class="syntax-1">'Controller is loggable'</span><span class="syntax-2">, [</span></span>
<span class="line"><span class="syntax-1">            'controller'</span><span class="syntax-4"> =></span><span class="syntax-2"> $event</span><span class="syntax-4">-></span><span class="syntax-2">kernelEvent</span><span class="syntax-4">-></span><span class="syntax-8">getController</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>
<h3>Avec Symfony 8.2+</h3>
<p>La version 8.2 simplifira encore la syntaxe grâce à un attribut dédié (<code>AsControllerAttributeListener</code>) qui cible directement notre classe :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Component\HttpKernel\Attribute\</span><span class="syntax-5">AsControllerAttributeListener</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Component\HttpKernel\Event\</span><span class="syntax-5">ControllerAttributeEvent</span><span class="syntax-2">;</span></span>
<span class="line"><span class="syntax-4">use</span><span class="syntax-2"> Symfony\Component\HttpKernel\Event\</span><span class="syntax-5">ControllerEvent</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">LoggerListener</span></span>
<span class="line"><span class="syntax-2">{</span></span>
<span class="line"><span class="syntax-2">    #[AsControllerAttributeListener(</span><span class="syntax-5">ControllerEvent</span><span class="syntax-4">::class</span><span class="syntax-2">, </span><span class="syntax-5">Loggable</span><span class="syntax-4">::class</span><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"> logSymfony82</span><span class="syntax-2">(</span><span class="syntax-5">ControllerAttributeEvent</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-11">        $this</span><span class="syntax-4">-></span><span class="syntax-2">logger</span><span class="syntax-4">-></span><span class="syntax-8">log</span><span class="syntax-2">($event</span><span class="syntax-4">-></span><span class="syntax-2">attribute</span><span class="syntax-4">-></span><span class="syntax-2">level, </span><span class="syntax-1">'Controller is loggable'</span><span class="syntax-2">, [</span></span>
<span class="line"><span class="syntax-1">            'controller'</span><span class="syntax-4"> =></span><span class="syntax-2"> $event</span><span class="syntax-4">-></span><span class="syntax-2">kernelEvent</span><span class="syntax-4">-></span><span class="syntax-8">getController</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>
<h2>Conclusion</h2>
<p>Les attributs apportent une grande souplesse à l'écosystème PHP, et Symfony propose des outils parfaits pour les exploiter.</p>
<p>Grâce à eux, vous pouvez ajouter des comportements à vos contrôleurs de manière propre et déclarative, sans polluer vos méthodes avec du code répétitif.</p>
<p>Bien sûr, Symfony intègre déjà nativement des attributs puissants pour gérer la sécurité (<code>#[IsGranted]</code>), le cache (<code>#[Cache]</code>), ou encore le rate limiting (<code>#[RateLimit]</code>). Mais créer vos propres attributs ouvre la porte à des cas d'usage métiers très intéressants :</p>
<ul>
<li><strong>Le Feature Flipping (<code>#[FeatureFlag('new-dashboard')]</code>)</strong> : pour activer ou désactiver l'accès à une route selon le déploiement progressif d'une fonctionnalité ;</li>
<li><strong>La télémétrie et l'observabilité (<code>#[TrackActivity('checkout')]</code>)</strong> : pour envoyer des statistiques précises à des outils tiers comme OpenTelemetry ou Plausible dès qu'un utilisateur visite une page clé ;</li>
<li><strong>La transformation de réponse (<code>#[Serialize]</code>)</strong> : pour intercepter le retour de vos contrôleurs et le formater automatiquement selon un standard précis (comme le format JSON d'une API spécifique).</li>
</ul>
<p>Les possibilités n'ont de limite que votre imagination !</p>]]></description></item><item><title>Am&#xE9;liorer la maintenance de vos workflows GitHub</title><link>https://jolicode.com/blog/ameliorer-la-maintenance-de-vos-workflows-github</link><author>JoliCode Team</author><date>Tue, 09 Jun 2026 11:42:00 +0200</date><description><![CDATA[<p>Les failles de sécurité liées à la <em>supply chain</em> sont de plus en plus courantes. En tant que mainteneur d'un projet open source populaire, l'enjeu est de taille. Votre code est déployé sur de nombreux serveurs de production et postes de développeurs. Il est donc crucial de garder vos pipelines CI à jour et sécurisés.</p>
<p>Le projet open source qui nous importe aujourd'hui est Castor 🦫, que nous vous invitons <a href="https://jolicode.com/blog/le-task-runner-castor-est-maintenant-disponible-en-version-1">à découvrir</a>.</p>
<p>Pour nous aider dans cette tâche, nous avons choisi <a rel="nofollow noopener noreferrer" href="https://github.com/zizmorcore/zizmor">zizmor</a>. C'est un outil d'analyse statique de code. Il détecte les vulnérabilités dans vos fichiers de configuration de CI et propose souvent des correctifs. Pour faire simple, c'est l'équivalent de PHPStan, mais pour vos workflows.</p>
<p>Une fois installé, une simple exécution (<code>zizmor .</code>) permet de détecter les erreurs de configuration et de les corriger rapidement. Nous avons ainsi amélioré notre sécurité et facilité la maintenance à long terme.</p>
<p>Voici un exemple de rapport généré par l'outil :</p>
<pre><code>error[unpinned-uses]: unpinned action reference
  --&gt; ./.github/actions/cache/action.yaml:48:13
   |
48 |       uses: actions/cache@v5
   |             ^^^^^^^^^^^^^^^^ action is not pinned to a hash (required by blanket policy)
   |
   = note: audit confidence → High
   = note: this finding has an auto-fix
   = help: audit documentation → https://docs.zizmor.sh/audits/#unpinned-uses

help[artipacked]: credential persistence through GitHub Actions artifacts
  --&gt; ./.github/workflows/artifacts.yml:16:9
   |
16 |         - name: Checkout
   |  _________^
17 | |         uses: actions/checkout@v6
   | |_________________________________^ does not set persist-credentials: false
   |
   = note: audit confidence → Low
   = note: this finding has an auto-fix
   = help: audit documentation → https://docs.zizmor.sh/audits/#artipacked

error[github-env]: dangerous use of environment file
  --&gt; ./.github/actions/cache/action.yaml:14:7
   |
14 | /       run: |
15 | |         set -e
16 | |
17 | |         # Should be the same command as the one in tools/static/castor.php
...  |
44 | |         echo cache_dirname_test=$cache_dirname_test &gt;&gt; $GITHUB_ENV
45 | |         echo cache_key_test=$cache_key_test &gt;&gt; $GITHUB_ENV
   | |__________________________________________________________^ write to GITHUB_ENV may allow code execution
   |
   = note: audit confidence → Low
   = help: audit documentation → https://docs.zizmor.sh/audits/#github-env

error[template-injection]: code injection via template expansion
  --&gt; ./.github/actions/install/action.yaml:28:108
   |
28 |       run: composer install --prefer-dist --no-progress --optimize-autoloader --classmap-authoritative ${{ inputs.composer-flags }}
   |       --- this run block                                                                                   ^^^^^^^^^^^^^^^^^^^^^ may expand into attacker-controllable code
   |
   = note: audit confidence → High
   = note: this finding has an auto-fix
   = help: audit documentation → https://docs.zizmor.sh/audits/#template-injection
</code></pre>
<p>Un grand nombre d'erreurs peuvent être corrigées automatiquement :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-8">zizmor</span><span class="syntax-1"> .</span><span class="syntax-3"> --fix=all</span></span></code></pre>
<p>Cependant, pour trouver le hash de commit correspondant à un tag, l'outil a besoin d'un token GitHub avec les bonnes permissions. Il convient donc de le relancer avec la variable configurée :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">GITHUB_TOKEN</span><span class="syntax-4">=</span><span class="syntax-1">ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX</span><span class="syntax-8"> zizmor</span><span class="syntax-3"> --fix=all</span></span></code></pre>
<p>Ou, si vous utilisez la CLI <code>gh</code> au quotidien :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">GITHUB_TOKEN</span><span class="syntax-4">=</span><span class="syntax-1">`</span><span class="syntax-8">gh</span><span class="syntax-1"> auth token`</span><span class="syntax-8"> zizmor</span><span class="syntax-3"> --fix=all</span></span></code></pre>
<p>En revanche, zizmor ne gère pas les montées de version majeures des actions. Pour cela, nous utilisons deux autres méthodes.</p>
<p>En local, nous exécutons <a rel="nofollow noopener noreferrer" href="https://github.com/azat-io/actions-up">actions-up</a>. Cet outil détecte les actions utilisées et propose des mises à jour. C'est le <code>composer update</code> de vos workflows.</p>
<p>En CI, nous faisons confiance à <a rel="nofollow noopener noreferrer" href="https://dependabot.com/">Dependabot</a>. Il vérifie régulièrement vos dépendances et ouvre des Pull Requests automatiquement. Voici notre configuration :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">version</span><span class="syntax-2">: </span><span class="syntax-3">2</span></span>
<span class="line"><span class="syntax-4">updates</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-2">    - </span><span class="syntax-4">package-ecosystem</span><span class="syntax-2">: </span><span class="syntax-1">'github-actions'</span></span>
<span class="line"><span class="syntax-4">      directory</span><span class="syntax-2">: </span><span class="syntax-1">'/'</span></span>
<span class="line"><span class="syntax-4">      target-branch</span><span class="syntax-2">: </span><span class="syntax-1">'main'</span></span>
<span class="line"><span class="syntax-4">      schedule</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">          interval</span><span class="syntax-2">: </span><span class="syntax-1">'monthly'</span></span>
<span class="line"><span class="syntax-4">      groups</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">          github-actions</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">              patterns</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-2">                  - </span><span class="syntax-1">'*'</span></span>
<span class="line"><span class="syntax-4">      cooldown</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">          default-days</span><span class="syntax-2">: </span><span class="syntax-3">7</span></span></code></pre>
<p>En conclusion, nous vous recommandons vivement d'adopter zizmor. C'est un outil puissant, rapide à prendre en main, qui sécurise vos pipelines et vous fait gagner un temps précieux au quotidien.</p>]]></description></item><item><title>Satellites, Horloge &#xE0; air et cimeti&#xE8;re des IA</title><link>https://www.synolia.com/synolab/front-office/satellites-horloge-a-air-et-cimetiere-des-ia/</link><author>Estelle M.</author><date>Tue, 09 Jun 2026 09:30:15 +0200</date><description><![CDATA[<p><span style="font-weight: 400;">Découvrez notre veille technique du mois de mai entre satellites, horloge à air et cimetière des IA.</span></p>
<h2><b>Tout savoir sur les satellites</b></h2>
<p><span style="font-weight: 400;">Vous êtes-vous déjà demandé comment Internet arrivait jusqu’à vos téléphones ? Comment, même en pleine montagne, vous pouvez regarder vos vidéos préférées ? La réponse est simple ; les satellites.</span></p>
<p><span style="font-weight: 400;">Mais savez-vous comment fonctionne un satellite ? Quels en sont les types, et quels sont les grands acteurs qui envoient chaque année des petits vaisseaux tourner au-dessus de nos têtes ?</span><span style="font-weight: 400;"><br />
</span><span style="font-weight: 400;"> Beaucoup de questions auxquelles cet article de</span><a href="https://buttondown.com/LimitesNumeriques/archive/limites-numeriques-23/"> <span style="font-weight: 400;">buttondown.com</span></a><span style="font-weight: 400;"> répond ce mois-ci.</span></p>
<p><span style="font-weight: 400;">Composé avec l’aide de Tom Nico, de l’ARCEP* et du chercheur Irénée Régnauld, cet article s’exprime dans un discours extrêmement simple à comprendre et très plaisant à lire. Les informations mises en avant, comme les différents impacts de ces appareils et le fait que l’on a envoyé autant de satellites dans l’espace ces trois dernières années que depuis 1957, sont réellement enrichissantes.</span></p>
<p><span style="font-weight: 400;">Une bonne morale sur l’exploration spatiale, ses promesses de partage et une réalité bien plus contrastée…</span></p>
<p><span style="font-weight: 400;"> </span></p>
<p><span style="font-weight: 400;">* Autorité indépendante française en charge de la régulation des réseaux télécoms</span><b></b></p>
<h2><b>Quand l’air donne l’heure</b></h2>
<p><span style="font-weight: 400;">Depuis plusieurs décennies, l’électronique s’est emparée d’une part très importante de la vie moderne. Quand l’on regarde les outils précurseurs à nos horloges, nos appareils électroménagers, etc., on les juge d’archaïque, historique, même.</span></p>
<p><span style="font-weight: 400;">Mais s’il existait des alternatives à l’électronique ? C’est la question à laquelle répond cette</span><a href="https://www.youtube.com/watch?v=E1BLGpE5zH0"> <span style="font-weight: 400;">vidéo de soiboi soft</span></a><span style="font-weight: 400;">, qui nous présente ce mois-ci une horloge à quatre chiffres et qui fonctionne… à l’air.</span></p>
<p><span style="font-weight: 400;">Avec l’aide de la logique microfluidique, de membranes en silicone et de la pression de l’air, le créateur nous démontre une véritable ingénierie et une exploration fascinante des formes alternatives de calcul.</span></p>
<p><span style="font-weight: 400;">Ce qui rend cette vidéo particulièrement intéressante, au-delà du fait que le système est très satisfaisant à regarder, c’est qu’elle montre comment les concepts fondamentaux de l’informatique – comme la logique, la mémoire et le stockage des données – peuvent exister sans électronique.</span></p>
<p>&nbsp;</p>
<h2><b>Choisir l’enseignement à la haine </b></h2>
<p><span style="font-weight: 400;">Perdre son travail, ce n’est pas une mince affaire. Dans une situation pareille, il est compréhensible de laisser exploser sa colère, d’avoir un discours amer et de critiquer ses anciens employeurs.</span></p>
<p><span style="font-weight: 400;">Et si nous transformions cette colère en quelque chose de plus concret… de plus utile ? Voici l’histoire du licenciement de Vasilios Syrakis, racontée ce mois-ci dans</span><a href="https://www.financialexpress.com/trending/i-built-a-lot-of-things-laid-off-engineer-shares-38-minute-what-i-built-video-instead-of-a-rant/4243251/"> <span style="font-weight: 400;">cet article du financianexpress</span></a><span style="font-weight: 400;">.</span></p>
<p><span style="font-weight: 400;">Après son licenciement, Syrakis a choisi de publier une vidéo de 38 minutes pour expliquer les systèmes d’infrastructure qu’il a conçus pendant huit ans ; routage réseau, équilibrage de charge, plateformes cloud et outils permettant à des milliers de développeurs de déployer leurs services à grande échelle. L’objectif est simple ; documenter son travail et partager son expérience avec d’autres ingénieurs. Plutôt que de se concentrer sur la perte de son emploi, Syrakis a choisi de transformer son histoire de licenciement en une démonstration de savoir-faire et de transmission de connaissances.</span></p>
<h2><b>“Vous gaspillez 90% de vos tokens sans le savoir”</b></h2>
<p><span style="font-weight: 400;">Si vous utilisez des IAs de manière quotidienne et gratuite, vous avez sans doute été confronté au blocage journalier imposé par ces dernières.</span></p>
<p><span style="font-weight: 400;">Avec cette</span><a href="https://www.youtube.com/watch?v=Q3VqYvsFo84"> <span style="font-weight: 400;">vidéo de Shubham SHARMA</span></a><span style="font-weight: 400;">, vous serez désormais incollable sur l’utilisation des IAs et la compréhension de leur limite.</span></p>
<p><span style="font-weight: 400;">En bon instructeur, Shubham commence d’abord ses explications par la définition de ce que sont des « tokens » d’IA, pour ensuite faire la différence entre la limite de message et de token, et expliquer pourquoi les IA deviennent plus lentes, voire même moins intelligentes à l’utilisation.</span></p>
<p><span style="font-weight: 400;">Shubham surligne ensuite 9 pièges communs classés parmi 3 grandes catégories. Parmi ces pièges, on retiendra surtout que :</span></p>
<p><span style="font-weight: 400;">&#8211; Plus la conversation est longue, plus chaque message coûte cher (car l’IA relit tout l’historique),</span></p>
<p><span style="font-weight: 400;">&#8211; Les réponses verbeuses coûtent plus cher, et qu’il existe des outils, comme</span><a href="https://github.com/JuliusBrussee/caveman/blob/main/README.md"> <span style="font-weight: 400;">Caveman</span></a><span style="font-weight: 400;">, qui permettent de réduire ces longues réponses,</span></p>
<p><span style="font-weight: 400;">&#8211; Le choix du modèle est important (ne pas utiliser une Ferrari pour aller chercher du pain).</span></p>
<p><span style="font-weight: 400;">&#8211; L’utilisation de fichiers lourds, comme des images ou des PDFs, fait exploser le quota de token. Il est donc préférable d’utiliser du texte.</span></p>
<p><span style="font-weight: 400;">Et vous, connaissiez-vous ces pièges ?</span></p>
<h2><b>Le cimetière des IA </b></h2>
<p><a href="https://vibegraveyard.ai/"><span style="font-weight: 400;">Vibe Graveyard</span></a><span style="font-weight: 400;"> est un site qui recense des incidents, des échecs et des « oopsies » liés à l’utilisation excessive, voire imprudence, des intelligences artificielles dans le développement logiciel, les produits numériques, les médias, la santé et même le droit. Il se présente comme un cimetière de projets développés en grande partie par l’IA et avec peu de compréhension ou de validation humaine.</span></p>
<p><span style="font-weight: 400;">De la petite bourde aux erreurs catastrophiques, vous pourrez y lire des cas réels d’utilisation d’IA ayant parfois entraîné des erreurs juridiques, voire médicales. Un des cas récents concerne par exemple un problème de gestion de stock au sein des enseignes Starbucks, dont l’IA comptait et étiquetait souvent de manière erronée des boissons de base, notamment des types de lait similaires, et omettait parfois complètement certains produits.</span></p>
<p><span style="font-weight: 400;">L’objectif d’un tel site n’est pas d’offrir une analyse neutre de l’IA, mais de rappeler que l’automatisation ne remplace pas l’expertise technique, la validation et la maintenance logicielle. Une riche archive et un contrepoids très concret au discours enthousiaste entourant l’IA générative.</span></p>
<p>&nbsp;</p>
<h2><b>Pour aller plus loin</b></h2>
<p><span style="font-weight: 400;">Voici les autres liens que vous avez partagés ce mois-ci, bonne lecture !</span></p>
<ul>
<li><a href="https://actu.fr/societe/pourquoi-il-faut-donner-son-sang-juste-avant-les-ponts-du-mois-de-mai_60965603.html"><span style="font-weight: 400;">Pourquoi il faut donner son sang juste avant les ponts du mois de mai</span></a><span style="font-weight: 400;"> </span></li>
<li><a href="https://wiki.php.net/rfc/bound_erased_generic_types"><span style="font-weight: 400;">https://wiki.php.net/rfc/bound_erased_generic_types</span></a><span style="font-weight: 400;"> </span></li>
<li><a href="https://thehackernews.com/2026/05/windows-zero-days-expose-bitlocker.html?m=1"><span style="font-weight: 400;">Windows Zero-Days Expose BitLocker Bypasses And CTFMON Privilege Escalation</span></a><span style="font-weight: 400;"> </span></li>
<li><a href="https://grimlink.com/blog/2026/grimlink-com-finally-live/"><span style="font-weight: 400;">GrimLink.com Finally Live!</span></a><span style="font-weight: 400;"> </span></li>
<li><a href="https://learn.microsoft.com/en-us/windows/powertoys/"><span style="font-weight: 400;">Microsoft PowerToys &#8211; Windows Utilities for Power Users</span></a><span style="font-weight: 400;"> </span></li>
<li><a href="https://www.frandroid.com/marques/google/3105147_trafic-divise-par-trois-agents-qui-ne-cliquent-plus-la-google-i-o-2026-sonne-la-fin-dune-epoque"><span style="font-weight: 400;">Trafic divisé par trois, agents qui ne cliquent plus : la Google I/O 2026 sonne la fin d&rsquo;une époque</span></a><span style="font-weight: 400;"> </span></li>
<li><a href="https://www.frenchweb.fr/google-i-o-2026-google-veut-reconstruire-internet-autour-de-lia/461888"><span style="font-weight: 400;">Google I/O 2026 : Google veut reconstruire Internet autour de l’IA</span></a><span style="font-weight: 400;"> </span></li>
<li><a href="https://www.linkedin.com/news/story/la-guerre-affecte-62-des-pme-tpe-8875618/"><span style="font-weight: 400;">La guerre affecte 62% des PME-TPE | LinkedIn</span></a><span style="font-weight: 400;"> </span></li>
<li><a href="https://www.europesays.com/ai/28009/"><span style="font-weight: 400;">https://www.europesays.com/ai/28009/</span></a><span style="font-weight: 400;"> </span></li>
</ul>
<p>&nbsp;</p>
<p>Cet article <a href="https://www.synolia.com/synolab/front-office/satellites-horloge-a-air-et-cimetiere-des-ia/">Satellites, Horloge à air et cimetière des IA</a> est apparu en premier sur <a href="https://www.synolia.com">Synolia, agence e-commerce, CRM, Data, PIM/DAM, OMS</a>.</p>
]]></description></item><item><title>Contre-performance pour la campagne de collecte du barom&#xE8;tre des salaires en PHP 2026</title><link>https://afup.org/news/1259-contreperformance-barometre-salaires-2026</link><author/><date>Mon, 08 Jun 2026 20:37:00 +0200</date><description><![CDATA[<h3>Une participation en baisse</h3>
<p>La quatorzième édition du baromètre des salaires en PHP vient de clore sa campagne de récolte de données. 509 professionnel•le•s ont pris le temps de répondre à l'enquête cette année : qu'ils et elles en soient sincèrement remercié·e·s ! Ces 509 voix permettront, comme chaque année, de publier en fin d'année une photographie précise et utile des rémunérations et des conditions de travail dans l'écosystème PHP. <br>
Mais un constat s'impose déjà : il s'agit là de notre score le plus bas depuis 2019. Après 721 réponses en 2025, la baisse est significative, de près de 30%. </p>
<h3>De premières pistes d'explications</h3>
<p>Le marché de l'emploi tech en France traverse depuis deux ans une période difficile : gels de recrutement, restructurations, fermetures de structures, incertitude sur les trajectoires de carrière. Dans ce contexte, participer à une enquête collective suppose un certain sentiment d'appartenance à un écosystème dans lequel on se projette. Or quand les devs traversent une période d'instabilité professionnelle, ce type de démarche passe naturellement au second plan. <p><p>
Nous étudions également un autre phénomène avec la multiplication des enquêtes en ligne. Les devs sont sollicité·e·s de toutes parts. La multiplication des sondages, questionnaires et formulaires de feedback a progressivement érodé la disponibilité pour ce type de démarche.<p><p>
Cette contre-performance est un signal que nous prenons au sérieux en vue des prochaines éditions : sans avoir de solution à l'heure actuelle, nous allons réfléchir à des pistes pour rebooster cette enquête essentielle pour notre secteur. </p>
<h3>Le pôle baromètre passe à l'action !</h3>
<p>Le travail continue : les données collectées vont maintenant être analysées avec le soin et la rigueur qui ont fait la réputation du baromètre depuis quatorze ans. Les résultats seront publiés en fin d'année, comme toujours, au moment où ils sont le plus utiles : à l'approche des entretiens annuels, quand il s'agit d'engager des conversations concrètes sur la rémunération.<p><p>
<strong>Les résultats vous seront partagés cet automne</strong>, vous donnant ainsi les informations les plus fraîches en vue de ce moment important pour votre carrière.</p>
<h3>Merci à nos partenaires Human Coders et le Journal du Net</h3>
<p>Un grand merci à l'équipe de <a href="https://www.humancoders.com/">Human Coders</a>, centre de formation pour développeurs·euses, de nous avoirà nouveau accompagnés pour cette édition 2026. Merci également au <a href="https://www.journaldunet.com/">Journal du Net</a> pour son soutien.
<br><br>
<strong>Merci aux 509 personnes qui ont choisi de contribuer à ce bien commun. Leurs réponses éclaireront des centaines de professionnel·le·s du secteur : c'est pour ça que le baromètre existe, et c'est pour ça qu'il continuera ! Rendez-vous à l'automne pour la publication des résultats.</strong></p>]]></description></item></channel></rss>
