<?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>Dans les coulisses de mon dernier side project: l'infrastructure</title><link>https://www.jdecool.fr/blog/2026/10/27/dans-les-coulisses-de-mon-dernier-side-project-l-infrastructure.html</link><author/><date>Mon, 26 Oct 2026 23:00:00 +0000</date><description><![CDATA[<p><i>Je me suis lancé dans un <em>side project</em> pour gérer les rétrospectives de mon équipe, une application que je construis depuis plusieurs mois et dont j’ai décidé de raconter les coulisses de la création.</i></p>

<div class="noexcerpt-content">
<ul>
  <li><a href="/blog/2026/10/24/dans-les-coulisses-de-mon-dernier-side-project-l-architecture.html">1/X - L’architecture technique</a></li>
  <li><a href="/blog/2026/10/25/dans-les-coulisses-de-mon-dernier-side-project-le-harness.html">2/X - Le harness</a></li>
  <li><a href="/blog/2026/10/26/dans-les-coulisses-de-mon-dernier-side-project-les-tests-automatises.html">3/X - Les tests automatisés</a></li>
  <li><strong>4/X - L’infrastructure</strong> (cet article)</li>
</ul>
</div>

<p>Avant l’IA, travailler sur un projet Web ne présentait pas de difficulté particulière: il suffisait de lancer les serveurs de développement qui étaient généralement directement intégrés aux frameworks. Mais lorsque l’on souhaite travailler avec des agents en parallèle, sans que ces derniers ne se marchent dessus, cela devient plus complexe. Je vais dans ce billet expliquer les décisions qui ont été prises pour permettre de gérer cette infrastructure en réutilisant un maximum de briques entre le développement local et le déploiement du projet en production.</p>

<!--more-->

<p>Pour permettre à plusieurs agents de travailler simultanément sur des fonctionnalités différentes, il est nécessaire d’avoir plusieurs copies du projet dans des dossiers distincts. Pour cela, j’utilise sans surprise des <code>worktree</code> Git. Mais ce n’est qu’une partie du problème, car il faut ensuite que chaque <code>worktree</code> dispose de sa propre instance Web.</p>

<p>Afin d’y parvenir, j’ai conteneurisé le projet avec <a href="https://www.docker.com " target="_blank" rel="noopener noreferrer">Docker</a>. Chaque <code>worktree</code> dispose ainsi de ses propres conteneurs, identifiés par un slug et accessibles via une URL qui leur est dédiée: <code>https://&lt;slug&gt;.retro.localhost</code>. Les conteneurs sont orchestrés via <a href="https://docs.docker.com/compose/ " target="_blank" rel="noopener noreferrer">Docker Compose</a>.</p>

<p>Un <em>reverse proxy</em> (<a href="https://traefik.io/traefik " target="_blank" rel="noopener noreferrer">Traefik</a>) est utilisé pour router les URLs vers les conteneurs <a href="https://frankenphp.dev " target="_blank" rel="noopener noreferrer">FrankenPHP</a> qui font tourner l’application. Chaque instance ne démarre pas une <em>stack</em> complète: certains services tels que la base de données (PostgreSQL), Redis ou encore Mailpit sont mutualisés. Un script dédié <code>bin/worktree</code> m’aide à gérer les <code>worktree</code> et les services qui leur sont associés. Une <em>stack</em> d’observabilité basée sur <a href="https://opentelemetry.io " target="_blank" rel="noopener noreferrer">OpenTelemetry</a> existe en local, même si cette dernière n’est pas réellement utilisée en production.</p>

<p>Redis est optionnel, mais il est utilisé sur le serveur de production pour permettre de faire des déploiements sans interruption de service. Son rôle est de stocker les sessions PHP ainsi que des données de cache.</p>

<p>Le code source du projet est principalement hébergé sur GitHub (et répliqué sur des services français et européens). L’intégration continue repose sur GitHub Actions. Néanmoins, elle nécessite beaucoup de ressources et le quota autorisé est rapidement atteint. Je fais donc tourner des <em>runners</em> GitHub Actions sur un serveur dédié, entièrement géré et provisionné au travers d’Ansible. La configuration GitHub Actions du projet me permet de passer des <em>runners</em> auto-hébergés aux <em>runners</em> managés en modifiant une seule ligne.</p>

<p>Une fois que l’on a tout ça, on peut concevoir et développer le projet avec un environnement de CI qui assure la sécurité de ce que l’on souhaite livrer. À ce stade, il ne reste plus qu’à déployer le projet en production avec deux contraintes: réutiliser un maximum de briques mises en place pour le développement local et avoir une gestion simple et automatisée des certificats SSL en utilisant <a href="https://letsencrypt.org " target="_blank" rel="noopener noreferrer">Let’s Encrypt</a>.</p>

<p>Quand on a un projet <em>dockerisé</em>, on pense tout de suite à <a href="https://kubernetes.io " target="_blank" rel="noopener noreferrer">Kubernetes</a>. Mais c’est un écosystème que je connais très mal et au moment où je souhaite mettre l’outil à disposition, il faut également se rendre compte qu’il y a un besoin réel pour l’équipe dans laquelle je travaille. J’ai donc recherché des solutions autour de Docker Compose. Il en existe assez peu et la plupart des recherches ramènent à <a href="https://docs.docker.com/engine/swarm/ " target="_blank" rel="noopener noreferrer">Docker Swarm</a>. Pour moi, il s’agissait d’un vieil outil qui n’était plus réellement maintenu et qui n’était même plus mis en avant par les équipes de Docker.</p>

<p>C’est néanmoins la solution qui coche toutes les cases. Une configuration qui ressemble énormément à Docker Compose. Une utilisation simple, qui nécessite peu de ressources et ne pose aucun problème sur une instance mono-serveur. Et puis aucun arrêt du développement n’a été annoncé et <a href="https://github.com/moby/swarmkit " target="_blank" rel="noopener noreferrer">le projet continue de recevoir des commits</a>. Alors commençons simplement, et on verra avec le temps.</p>

<p>C’est donc un nœud Swarm qui fait actuellement tourner le projet, hébergé sur un VPS <a href="https://scaleway.fr " target="_blank" rel="noopener noreferrer">Scaleway</a>. Ce dernier est composé des services ci-dessous:</p>

<table>
  <thead>
    <tr>
      <th>Service</th>
      <th>Rôle</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>caddy</code></td>
      <td>Le point d’entrée du trafic HTTP.</td>
    </tr>
    <tr>
      <td><code>php</code></td>
      <td>Instance applicative tournant avec FrankenPHP (deux réplicas).</td>
    </tr>
    <tr>
      <td><code>worker</code></td>
      <td>Un worker de messages pour le traitement des événements asynchrones.</td>
    </tr>
    <tr>
      <td><code>database</code></td>
      <td>La base de données relationnelle (PostgreSQL).</td>
    </tr>
    <tr>
      <td><code>redis</code></td>
      <td>Cache et sessions.</td>
    </tr>
    <tr>
      <td><code>mercure</code></td>
      <td>Hub pour la gestion des communications temps réel.</td>
    </tr>
  </tbody>
</table>

<p>Vous noterez que seul le service PHP est répliqué sur la même machine. Cela permet d’assurer la continuité de service lors des déploiements, qui nécessitent le redémarrage du conteneur PHP. Il est également important que les migrations de base de données soient rétrocompatibles avec la version précédente du code, sans quoi on pourrait avoir des erreurs applicatives le temps de la mise à jour du code PHP.</p>

<p>Des endpoints de type <em>healthcheck</em> sont disponibles dans l’application et utilisés par Swarm lors des déploiements pour s’assurer que tout est démarré et fonctionnel.</p>

<p>Parmi les outils que je n’ai pas encore cités:</p>
<ul>
  <li>Le serveur, sa configuration et celle du projet sont entièrement gérés par Ansible,</li>
  <li>La gestion et le suivi des erreurs sont réalisés au travers de <a href="https://sentry.io " target="_blank" rel="noopener noreferrer">Sentry</a>,</li>
  <li>Les sauvegardes sont réalisées avec <a href="https://restic.net " target="_blank" rel="noopener noreferrer">Restic</a> et stockées, chiffrées côté client, sur un <em>Scaleway Object Storage</em>.</li>
</ul>
]]></description></item><item><title>Dans les coulisses de mon dernier side project: les tests automatis&#xE9;s</title><link>https://www.jdecool.fr/blog/2026/10/26/dans-les-coulisses-de-mon-dernier-side-project-les-tests-automatises.html</link><author/><date>Sun, 25 Oct 2026 23:00:00 +0000</date><description><![CDATA[<p><i>Je me suis lancé dans un <em>side project</em> pour gérer les rétrospectives de mon équipe, une application que je construis depuis plusieurs mois et dont j’ai décidé de raconter les coulisses de la création.</i></p>

<div class="noexcerpt-content">
<ul>
  <li><a href="/blog/2026/10/24/dans-les-coulisses-de-mon-dernier-side-project-l-architecture.html">1/X - L’architecture technique</a></li>
  <li><a href="/blog/2026/10/25/dans-les-coulisses-de-mon-dernier-side-project-le-harness.html">2/X - Le harness</a></li>
  <li><strong>3/X - Les tests automatisés</strong> (cet article)</li>
  <li><a href="/blog/2026/10/27/dans-les-coulisses-de-mon-dernier-side-project-l-infrastructure.html">4/X - L’infrastructure</a></li>
</ul>
</div>

<p>L’architecture et le cadre fourni à l’IA ne suffisent pas à garantir ce qui va être produit. C’est pour cela qu’en plus de tout ça, de nombreux outils de vérification et de tests, cette fois-ci déterministes, ont été mis en place.</p>

<!--more-->

<p>Si l’on devait comptabiliser le nombre de lignes de code par rapport au code de test ou qui vise à garantir le bon fonctionnement et l’alignement du code produit, la quantité de tests est supérieure à celle du code de production. Et c’est tout à fait explicable, puisque je mets moins les mains dans le code, je vais naturellement ajouter de multiples vérifications pour m’assurer que tout est OK.</p>

<p>Commençons par les outils classiques de qualité associés à l’écosystème PHP (et Javascript). On retrouve:</p>
<ul>
  <li>La validation et la normalisation des configurations Composer,</li>
  <li>Des <em>linters</em> de code: <a href="https://cs.symfony.com " target="_blank" rel="noopener noreferrer">PHP CS Fixer</a> pour PHP et <a href="https://eslint.org " target="_blank" rel="noopener noreferrer">ESLint</a> pour la partie Javascript,</li>
  <li>De l’analyse statique avec <a href="https://phpstan.org " target="_blank" rel="noopener noreferrer">PHPStan</a>,</li>
  <li>Des tests automatisés basés sur <a href="https://phpunit.de " target="_blank" rel="noopener noreferrer">PHPUnit</a>,</li>
  <li><a href="/blog/2025/05/11/evaluez-la-qualite-de-vos-tests-avec-les-tests-de-mutation.html">Des tests de mutation</a> avec <a href="https://infection.github.io " target="_blank" rel="noopener noreferrer">Infection</a>,</li>
  <li><a href="/blog/2025/04/15/un-projet-coherent-sur-le-long-terme-grace-aux-tests-d-architecture.html">Des tests d’architecture</a> avec <a href="https://www.phpat.dev " target="_blank" rel="noopener noreferrer">PHP Architecture Tester</a>,</li>
  <li>Une vérification de la documentation Open API.</li>
</ul>

<p>Pour donner quelques chiffres, voici la répartition des différents tests du projet:</p>

<table>
  <thead>
    <tr>
      <th>Catégorie</th>
      <th>% PHPUnit</th>
      <th>% tous tests</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Unitaire PHP</td>
      <td>63,6 %</td>
      <td>62,5 %</td>
    </tr>
    <tr>
      <td>Fonctionnel</td>
      <td>21,1 %</td>
      <td>20,7 %</td>
    </tr>
    <tr>
      <td>Intégration</td>
      <td>14,7 %</td>
      <td>14,4 %</td>
    </tr>
    <tr>
      <td>Architecture</td>
      <td>0,7 %</td>
      <td>0,7 %</td>
    </tr>
    <tr>
      <td>Unitaire JS</td>
      <td>-</td>
      <td>1,7 %</td>
    </tr>
    <tr>
      <td>E2E</td>
      <td>-</td>
      <td>0,05 %</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>100 %</strong></td>
      <td><strong>100 %</strong></td>
    </tr>
  </tbody>
</table>

<p>En plus de ces derniers, de nouveaux contrôles plus spécifiques ont été ajoutés au fil des divers développements et évolutions. On retrouve donc des scripts dédiés au projet tels que:</p>
<ul>
  <li>Une vérification du <code>CHANGELOG.md</code> qui liste toutes les modifications et permet de générer les notes de publication lors des déploiements,</li>
  <li>Des vérifications liées aux thèmes et aux contrastes du projet dans les thèmes clair et sombre,</li>
  <li>Une analyse des dépendances du projet (en termes de sécurité, mais également de pertinence entre les dépendances de dev et de prod),</li>
  <li>Des contrôles sur la documentation et notamment la génération des tables des matières,</li>
  <li>Des contrôles sur les traductions (présence des traductions dans toutes les langues disponibles).</li>
</ul>

<p>Bien entendu, l’ensemble de ces tests est exécuté au sein d’un environnement d’intégration continue qui, en plus de l’exécution des différents contrôles, va vérifier les données de type couverture de code ainsi que les scores de mutation afin de s’assurer que les tests couvrent un maximum de code et soient pertinents.</p>
]]></description></item><item><title>Dans les coulisses de mon dernier side project: l'architecture technique</title><link>https://www.jdecool.fr/blog/2026/10/24/dans-les-coulisses-de-mon-dernier-side-project-l-architecture.html</link><author/><date>Fri, 23 Oct 2026 22:00:00 +0000</date><description><![CDATA[<p><i>Je me suis lancé dans un <em>side project</em> pour gérer les rétrospectives de mon équipe, une application que je construis depuis plusieurs mois et dont j’ai décidé de raconter les coulisses de la création.</i></p>

<div class="noexcerpt-content">
<ul>
  <li><strong>1/X - L’architecture technique</strong> (cet article)</li>
  <li><a href="/blog/2026/10/25/dans-les-coulisses-de-mon-dernier-side-project-le-harness.html">2/X - Le harness</a></li>
  <li><a href="/blog/2026/10/26/dans-les-coulisses-de-mon-dernier-side-project-les-tests-automatises.html">3/X - Les tests automatisés</a></li>
  <li><a href="/blog/2026/10/27/dans-les-coulisses-de-mon-dernier-side-project-l-infrastructure.html">4/X - L’infrastructure</a></li>
</ul>
</div>

<p>Des outils de rétrospective, il y en a énormément. Mais comme tout développeur, j’ai eu envie de créer le mien, celui qui correspond exactement à mes besoins (et bien plus). C’est également un excellent moyen pour apprendre, approfondir et/ou explorer des concepts techniques qui m’intéressent. D’un besoin d’équipe technique et de cette envie est né: <strong>Team Retrospective</strong>.</p>

<!--more-->

<p>Au départ, le besoin était simple: avoir un tableau pour gérer des rétrospectives: 5 colonnes, pour aborder 5 thématiques en équipe et faire le bilan de l’itération écoulée. Les fonctionnalités ont évolué au fil des rituels et des équipes qui l’ont adopté.</p>

<p>D’un point de vue technique, c’est un projet <a href="https://www.php.net " target="_blank" rel="noopener noreferrer">PHP</a> propulsé par le <em>framework</em> <a href="https://symfony.com " target="_blank" rel="noopener noreferrer">Symfony</a> et sa <em>stack</em> <a href="https://ux.symfony.com " target="_blank" rel="noopener noreferrer">Symfony UX</a>. Des technologies simples et maîtrisées que j’utilise dans mon quotidien professionnel. Team Retrospective c’est également 10 contextes bornés pour gérer les utilisateurs et les équipes, les rétrospectives, les actions et accords associés, des modules IA et diverses fonctionnalités additionnelles. S’ajoute à ces contextes un <a href="https://deviq.com/domain-driven-design/shared-kernel/ " target="_blank" rel="noopener noreferrer">Shared Kernel</a> pour gérer toutes les notions transverses.</p>

<p>Parmi les notions transverses, on retrouve de nombreux concepts et <em>patterns</em> techniques:</p>
<ul>
  <li>Divers bus de communication: <code>Command Bus</code>, <code>Query Bus</code> et <code>Event Bus</code>,</li>
  <li>Des modules de communication en temps réel basés sur le <a href="https://mercure.rocks " target="_blank" rel="noopener noreferrer">protocole Mercure</a>,</li>
  <li>Une gestion des <em>healthchecks</em> pour évaluer la santé de l’application,</li>
  <li>Ou des modèles de base communs à tous les modules pour gérer les entités ou l’identification de ces dernières.</li>
</ul>

<p>Chaque contexte métier est structuré en suivant les principes de la <a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html " target="_blank" rel="noopener noreferrer">Clean Architecture</a> avec le classique découpage en 4 couches: <code>Domain</code>, <code>Use Case</code>, <code>Infrastructure</code> et <code>Presentation</code>. Pour les besoins de communication entre modules, chaque contexte peut exposer une API publique qui sera matérialisée au sein d’une cinquième couche <code>Contracts</code>.</p>

<p>Le projet possède une documentation riche:</p>
<ul>
  <li>Une présentation succincte du projet dans un fichier (<code>README.md</code>),</li>
  <li>La présentation de la vision produit dans un fichier (<code>PRODUCT.md</code>),</li>
  <li>Un glossaire et une présentation des différents contextes métier (<code>CONTEXT.md</code>),</li>
  <li>Une liste de changements par version (<code>CHANGELOG.md</code>),</li>
  <li>Les instructions pour les agents de code (<code>AGENTS.md</code> et <code>CLAUDE.md</code>),</li>
  <li>Diverses documentations techniques dans un répertoire <code>docs</code> contenant également l’ensemble des décisions d’architecture du projet.</li>
</ul>

<p>Le projet est entièrement conteneurisé, aussi bien en développement qu’en production, et son infrastructure est gérée via de l’<em>Infrastructure as Code</em>.</p>

<p>Ce projet concentre beaucoup de concepts. Il s’agit de fondations que j’ai pu mettre en place en un temps record et avec un minimum d’effort grâce aux agents de code. Cette architecture pose un cadre clair, délimité et cohérent qui permet à l’IA de s’y retrouver facilement. Car si cette dernière écrit la majorité du code du projet, je conserve un droit de lecture et de modification et surtout, moi seul conçois l’architecture.</p>
]]></description></item><item><title>Failles critiques dans PHP : la communaut&#xE9; au rendez-vous</title><link>https://afup.org/news/1266-failles-critiques-dans-php-la-communaute-au-rendez-vous</link><author/><date>Thu, 22 Oct 2026 19:02:00 +0000</date><description><![CDATA[<p>Mais derrière ce travail colossal porté par une poignée de développeurs et développeuses pour proposer des correctifs et coordonner la communication, se cache le bénévolat de ces personnes pour garantir la sécurité de l'écosystème numérique mondial. À l'heure où nous alertons sur la crise des événements techniques via une <a href="https://lettreouverte.afup.org">lettre ouverte</a> portée par 78 signataires, il nous semble important de rappeler une fois encore que l'open source repose sur les épaules - et le temps et l'énergie - des communautés, qui ont besoin de lieux d'échanges pour se rencontrer, échanger, se challenger et faire grandir les idées.</p>
<h3 id="content-face-a-l-acceleration-ce-sont-les-communautes-qui-font-la-difference">Face à l'accélération, ce sont les communautés qui font la différence</h3>
<p>Avec l'expansion de l'IA, la découverte comme l'exploitation des vulnérabilités risquent de s'accélérer à une vitesse inédite. Face à cette course de vitesse, qu'est-ce qui fait la différence pour sécuriser nos infrastructures ? Les communautés.
Derrière chaque correctif déployé en urgence, il y a des développeuses et développeurs qui se connaissent, collaborent et réagissent en quelques heures. Ces réseaux de confiance se créent, se consolident et se maintiennent dans les événements techniques. Sans ces lieux de rencontre, ces liens se distendent, et avec eux la capacité à réagir ensemble.</p>
<h3 id="content-des-evenements-fragilises">Des événements fragilisés</h3>
<p>Les organisateurs d’événements techniques, les communautés et associations tech réunis au sein de la lettre ouverte alertent l’écosystème depuis des mois : ces lieux sont aujourd'hui en danger. Une enquête* menée auprès des signataires de la lettre ouverte pour la survie des événements techniques montre que :</p>
<ul>
<li><strong>73% des organisations qui font appel au sponsoring</strong>  voient leurs recettes baisser depuis 2024 ;</li>
<li><strong>la fréquentation recule de 20 %</strong> en médiane ;</li>
<li><strong>plus d'une organisation sur cinq a déjà réduit son format</strong> ou arrêté un événement ;</li>
<li>pour 2027, <strong>seul un tiers prévoit de maintenir son événement à l'identique</strong> ;</li>
<li><strong>près de 9 structures sur 10</strong> sont entièrement bénévoles, et <strong>3 des 4 structures salariées</strong>  - dont l'AFUP- déclarent des emplois menacés.</li>
</ul>
<h3 id="content-un-appel-aux-entreprises-qui-s-appuient-sur-php-et-l-open-source">Un appel aux entreprises qui s'appuient sur PHP et l’open source</h3>
<p>Les entreprises dont l'activité repose sur PHP et sur l'open source ont un intérêt direct à ce que ces communautés restent vivantes. Sponsorisez les événements techniques à hauteur de vos moyens, achetez des places pour vos équipes, idéalement tôt, et adhérer aux associations qui les portent.
<strong>Soutenir les événements tech, y participer, y contribuer, c'est aussi investir dans la capacité de l'écosystème numérique à réagir ensemble face aux crises comme celle que nous avons rencontré ce mercredi 21 octobre.</strong></p>
<p>*Chiffres issus d’une enquête envoyée aux 78 signataires, sur un échantillon de 33 organisations signataires ayant répondu à l’enquête. Résultats non représentatifs de l'ensemble de l'écosystème.</p>
]]></description></item><item><title>HubSpot Agent CLI : la r&#xE9;volution de l&#x2019;automatisation et de l&#x2019;IA au service du CRM</title><link>https://www.synolia.com/synolab/non-classe/hubspot-agent-cli/</link><author>Sylvain P.</author><date>Thu, 22 Oct 2026 06:44:15 +0000</date><description><![CDATA[<div class="color-insert theme-light" >
<h2>En résumé :</h2>
<p>&nbsp;</p>
<p>Cet article explore le nouvel outil <a href="https://www.synolia.com/solutions/hubspot-crm/">HubSpot</a> Agent CLI, une interface en ligne de commande conçue pour connecter les agents d&rsquo;IA (Claude, ChatGPT) et les équipes techniques aux données du CRM HubSpot. Nous analysons ses fonctionnalités, la gestion des permissions par les administrateurs et ses cas d&rsquo;usage concrets pour étendre le <a href="https://www.synolia.com/dxp/crm/">CRM</a> et optimiser le travail des responsables CRM et chefs de projet digital.</p>
</div>
<p><strong>L&rsquo;écosystème de la gestion de la relation client (CRM) franchit une nouvelle étape stratégique avec la convergence de l&rsquo;intelligence artificielle et du développement local. Récemment dévoilé par l&rsquo;éditeur, le HubSpot Agent CLI marque un tournant dans la manière d&rsquo;interagir avec votre base de données. Cet outil en ligne de commande (actuellement disponible en version Beta) permet aux agents d&rsquo;IA autonomes et aux équipes techniques de manipuler, analyser et automatiser les données directement depuis leur environnement de travail local.</strong></p>
<p><strong>Pour les entreprises soucieuses de maintenir des plateformes d&rsquo;expériences digitales fluides et performantes, cette innovation ouvre de nouvelles perspectives d&rsquo;extension et de productivité. Nous allons découvrir ensemble ce qu&rsquo;apporte concrètement cet agent CLI, comment il garantit une gouvernance stricte de vos données et en quoi il transforme le quotidien de vos responsables CRM et chefs de projets digitaux.</strong></p>
<h2>Comprendre le HubSpot Agent CLI : l&rsquo;interface de commande conçue pour l&rsquo;IA</h2>
<p>Le <b>HubSpot Agent CLI</b><span style="font-weight: 400;"> est une interface en ligne de commande développée sur mesure pour permettre aux agents IA de codage ou d&rsquo;assistance (tels que Claude Code, Claude App ou l&rsquo;application ChatGPT) et aux utilisateurs techniques d&rsquo;effectuer des opérations directement dans le CRM HubSpot.</span></p>
<p>Contrairement aux connecteurs API traditionnels ou à l&rsquo;interface graphique habituelle, l&rsquo;agent CLI s&rsquo;exécute dans un espace de travail local ou un terminal d&rsquo;agent IA. Grâce à une bibliothèque de compétences dédiées (<span style="font-weight: 400;">HubSpot Agent CLI Skills</span><span style="font-weight: 400;"> installables via npx skills add hubspot/agent-cli-skills), l&rsquo;agent IA comprend la structure du CRM et sait exécuter des commandes complexes pour interroger ou mettre à jour la plateforme.</span></p>
<pre><code class="language-bash">hubspot --help
hubspot objects list
hubspot auth login</code></pre>
<h3></h3>
<h3>Un niveau d&rsquo;accès étendu aux données CRM</h3>
<p>L&rsquo;outil permet de manipuler un périmètre très large de données au sein de votre compte HubSpot :</p>
<ul>
<li><b>Objets CRM standards et personnalisés :</b><span style="font-weight: 400;"> contacts, entreprises, transactions (</span><i><span style="font-weight: 400;">deals</span></i><span style="font-weight: 400;">), tickets et objets sur mesure.</span></li>
<li><b>Workflows et pipelines :</b><span style="font-weight: 400;"> interrogation et gestion des étapes de transactions ou de tickets.</span></li>
<li><b>Propriétés et métadonnées :</b><span style="font-weight: 400;"> lecture et mise à jour des champs et des structures de données.</span></li>
<li><b>Données de revenus (lecture seule) :</b><span style="font-weight: 400;"> devis, factures, abonnements, commandes et contrats.</span></li>
</ul>
<p>En associant le traitement en langage naturel des agents IA à la vitesse d&rsquo;exécution d&rsquo;une interface CLI, vos équipes peuvent désormais exécuter en quelques secondes des opérations de masse ou des analyses poussées qui nécessitaient auparavant plusieurs heures de travail manuel ou des scripts d&rsquo;intégration complexes.</p>
<h2>2. Gouvernance et sécurité : le rôle clé de l&rsquo;administrateur CRM</h2>
<p>L&rsquo;introduction d&rsquo;un outil permettant des modifications de données à grande échelle via une interface de commande exige une rigueur absolue en matière de sécurité et de gouvernance. HubSpot a structuré l&rsquo;Agent CLI afin que les administrateurs et super administrateurs conservent un contrôle total sur l&rsquo;accès et les actions exécutées.</p>
<h3>Contrôle des accès et permissions (App Governance)</h3>
<p>Par défaut, le HubSpot Agent CLI respecte scrupuleusement la gestion des droits d&rsquo;accès basée sur les rôles. Un utilisateur qui s&rsquo;authentifie via la commande hubspot auth login ne dispose que des périmètres et permissions attribués à son propre compte utilisateur HubSpot.</p>
<p>De plus, grâce aux fonctionnalités de gouvernance des applications, les super administrateurs peuvent valider ou restreindre l&rsquo;installation de l&rsquo;Agent CLI sur le compte entreprise. Si un utilisateur ne possède pas l&rsquo;autorisation d&rsquo;installer des intégrations, une demande d&rsquo;approbation est directement transmise aux administrateurs.</p>
<table>
<tbody>
<tr>
<td><b>Mode d&rsquo;authentification</b></td>
<td><b>Portée des permissions</b></td>
<td><b>Cas d&rsquo;usage recommandé</b></td>
</tr>
<tr>
<td><b>Mode Standard (OAuth Login)</b></td>
<td>Restreint aux droits de l&rsquo;utilisateur connecté avec traçabilité d&rsquo;audit.</td>
<td>Analyses de données, générations de rapports, nettoyages ponctuels.</td>
</tr>
<tr>
<td><b>Mode Admin (Service Key Token)</b></td>
<td>Droits globaux au niveau du compte via la variable HUBSPOT_ACCESS_TOKEN.</td>
<td>Automatisations avancées, opérations de suppression complexes ou modifications de schéma.</td>
</tr>
</tbody>
</table>
<h3>Les fonctionnalités de sécurité pour éviter les pertes de données</h3>
<p>Parce que les opérations d&rsquo;écriture (create, update, delete) modifient la base de données réelle, HubSpot inclut des garde-fous essentiels pour vos équipes :</p>
<ol>
<li><b>L&rsquo;option &#8211;dry-run :</b> elle permet de simuler une commande de modification ou de suppression. L&rsquo;agent IA ou le chef de projet peut ainsi visualiser le plan d&rsquo;action précis (le <i>digest</i>) avant d&rsquo;appliquer réellement les changements.</li>
<li><b>Utilisation recommandée des comptes Sandbox :</b> les équipes sont fortement encouragées à tester leurs requêtes et leurs prompts dans des environnements de test isolés avant tout déploiement en production.</li>
</ol>
<h2>3. Des cas d&rsquo;usage concrets pour chaque rôle métier</h2>
<p>L&rsquo;un des atouts majeurs du HubSpot Agent CLI réside dans sa capacité à traiter des tâches opérationnelles récurrentes et gourmandes en temps. Voici comment vos équipes métiers peuvent exploiter cette solution au quotidien.</p>
<h3>Pour le responsable CRM : qualité de donnée et analyses prédictives</h3>
<p>Le responsable CRM fait face quotidiennement à des enjeux d&rsquo;hygiène de la donnée et d&rsquo;aide au pilotage commercial. L&rsquo;Agent CLI devient un assistant puissant pour :</p>
<ul>
<li><b>Le nettoyage et la déduplication de la base :</b><span style="font-weight: 400;"> identifier automatiquement les tickets de support ou les fiches contacts en double pour le même client, fusionner le contexte des échanges et réacheminer la fiche vers le bon pipeline.</span></li>
<li><b>L&rsquo;analyse du pipeline commercial :</b><span style="font-weight: 400;"> interroger l&rsquo;IA pour repérer les transactions à forte valeur stagnantes depuis plus de 30 jours, résumer l&rsquo;historique des réunions et des e-mails, et générer un rapport quotidien des opportunités approchant de la date de clôture sans activité récente.</span></li>
<li><b>La prévention du churn :</b><span style="font-weight: 400;"> analyser l&rsquo;engagement des comptes clients, croiser les données de support avec les volumes de contrats et faire remonter proactivement les fiches présentant un risque de résiliation.</span></li>
</ul>
<h3>Pour le chef de projet digital : extension du CRM et automatisation</h3>
<p>Le chef de projet digital cherche continuellement à étendre les capacités du CRM et à connecter l&rsquo;outil aux processus métier de l&rsquo;entreprise. Grâce au chaining de commandes via les tuyaux Unix (<i><span style="font-weight: 400;">pipes</span></i><span style="font-weight: 400;">) et au format JSONL, il devient très simple de :</span></p>
<ul>
<li><b>Exécuter des mises à jour en masse :</b><span style="font-weight: 400;"> importer et mettre à jour des milliers de propriétés CRM à partir d&rsquo;un fichier .jsonl en une seule ligne de commande.</span></li>
<li><b>Prototyper des extensions de schéma :</b><span style="font-weight: 400;"> créer et tester rapidement des objets personnalisés ou des groupes de propriétés directement depuis l&rsquo;environnement de développement local.</span></li>
<li><b>Automatiser les flux entre outils :</b><span style="font-weight: 400;"> concevoir des scripts légers combinant le CLI avec d&rsquo;autres briques de l&rsquo;écosystème digital pour alimenter le CRM ou synchroniser des données transversales.</span></li>
</ul>
<h2>Bâtir une stratégie d&rsquo;extension CRM durable</h2>
<p>L&rsquo;arrivée du HubSpot Agent CLI illustre parfaitement la transformation des outils de relation client : l&rsquo;IA ne se contente plus de rédiger du contenu, elle devient un véritable agent d&rsquo;exécution capable de comprendre la structure de votre CRM et d&rsquo;exécuter des actions techniques en langage naturel.</p>
<p>Pour tirer le meilleur parti de ces nouveautés sans compromettre l&rsquo;intégrité de vos bases de données, une démarche structurée reste indispensable. Définir des règles d&rsquo;attribution des droits claire, former vos équipes aux bonnes pratiques du <i><span style="font-weight: 400;">dry-run</span></i><span style="font-weight: 400;"> et intégrer ces nouveaux flux dans une architecture globale d&rsquo;expérience client sont les clés d&rsquo;un projet réussi.</span></p>
<p>Nous espérons que cet article vous sera utile pour appréhender ces nouvelles possibilités et faire évoluer votre plateforme CRM vers plus d&rsquo;agilité et de performance.</p>
<h2>Besoin d&rsquo;accompagnement dans la transformation de votre CRM ?</h2>
<p>Vous souhaitez moderniser l&rsquo;architecture de votre CRM, explorer les possibilités offertes par l&rsquo;IA ou former vos équipes aux meilleures pratiques d&rsquo;administration HubSpot ? Nos experts vous accompagnent dans la co-conception de vos projets digitaux pour maximiser l&rsquo;efficacité de vos opérations.</p>
<p><b>Découvrez notre expertise en conseil et intégration CRM</b></p>
<div class="accordions wysiwyg-block-faq">
<details class="accordion faq-item">
<summary><span class="accordion-title">Le HubSpot Agent CLI est-il payant ?</span></summary>
<div class="accordion-content">Non, l&rsquo;outil en ligne de commande est accessible gratuitement en version Beta. Cependant, son utilisation nécessite un compte HubSpot actif ainsi que l&rsquo;utilisation d&rsquo;un agent IA compatible (dont l&rsquo;utilisation peut dépendre de votre propre abonnement auprès du fournisseur d&rsquo;IA comme OpenAI ou Anthropic).</div>
</details>
<details class="accordion faq-item">
<summary><span class="accordion-title">Quelle est la différence entre l&#039;Agent CLI et le HubSpot CLI traditionnel ?</span></summary>
<div class="accordion-content">Le HubSpot CLI traditionnel est principalement conçu pour les développeurs web qui créent des thèmes, des modules CMS ou des applications custom. Le HubSpot Agent CLI est quant à lui spécifiquement optimisé pour manipuler les données CRM (contacts, transactions, tickets) et communiquer nativement avec des agents IA grâce à une structure de commandes adaptée au langage naturel.</div>
</details>
<details class="accordion faq-item">
<summary><span class="accordion-title">Est-il risqué de laisser un agent IA modifier des données via l&#039;Agent CLI ?</span></summary>
<div class="accordion-content">Toute modification de données comporte des risques si elle n&rsquo;est pas encadrée. C&rsquo;est pourquoi HubSpot impose des mécanismes de contrôle stricts : respect des permissions utilisateur, validation préalable par un administrateur via l&rsquo;App Governance et disponibilité du flag &#8211;dry-run pour prévisualiser l&rsquo;ensemble des modifications avant leur exécution effective.</div>
</details>
</div>
<p>Cet article <a href="https://www.synolia.com/synolab/non-classe/hubspot-agent-cli/">HubSpot Agent CLI : la révolution de l&rsquo;automatisation et de l&rsquo;IA au service du CRM</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>Ce qu'il fallait voir au Forum PHP 2026</title><link>https://jolicode.com/blog/ce-qu-il-fallait-voir-au-forum-php-2026</link><author>JoliCode Team</author><date>Fri, 16 Oct 2026 08:42:00 +0000</date><description><![CDATA[<p>Comme chaque année, nous nous sommes rendus à la conférence phare de l'écosystème PHP en France : <strong>le Forum PHP de l'AFUP</strong>. En plus d'être sur scène pour parler de lucidité, de headers HTTP ou encore d'embbeding et de régression, nous étions aussi dans le public pour assister à de nombreux talks de qualité !</p>
<p>À travers cet article, nous allons passer en revue les plus grands enseignements de ces deux journées à Disneyland Paris.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/forum-php-2026/bastien.jpg" data-original-width="4080" data-original-height="1668"><source type="image/webp" srcset="/media/cache/content-webp/2026/forum-php-2026/bastien.f04ce6ed.webp" /><source type="image/jpeg" srcset="/media/cache/content/2026/forum-php-2026/bastien.jpg" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(4080 / 1668)" src="https://jolicode.com//media/cache/content/2026/forum-php-2026/bastien.jpg" alt="" /></picture></p>
<h2>Les nouveautés de Symfony 8.2</h2>
<p>Impossible de passer à côté : le backlog de Symfony a changé de tête ces trois derniers mois :</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/forum-php-2026/symfony8.jpg" data-original-width="2775" data-original-height="1761"><source type="image/webp" srcset="/media/cache/content-webp/2026/forum-php-2026/symfony8.57a0c1f5.webp" /><source type="image/jpeg" srcset="/media/cache/content/2026/forum-php-2026/symfony8.jpg" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(2775 / 1761)" src="https://jolicode.com//media/cache/content/2026/forum-php-2026/symfony8.jpg" alt="Le nombre de PR sur Symfony qui chute de 217 à 40 soudainement" /></picture></p>
<p>Avoir plus de 300 pull requests ouvertes, en attente de review, était un état normal et sain dans la vie du projet - mais depuis que Nicolas GREKAS a adopté le développement et la review agentique, c'est du passé ! Il n'y a plus que 19 PR ouvertes à l'heure actuelle. Toutes les autres n'ont pas simplement été fermées 👋 - mais précieusement relues, testées et enfin mergées ; pour certaines, des années après leur ouverture 🎉</p>
<p>Ce préambule annonce donc <strong>du lourd</strong> pour la version à venir de Symfony (8.2), qui va bénéficier de plus de 375 features (contre ~180 d'habitude). En termes de sécurité aussi, les choses ont pris de l'ampleur, avec plus de 200 rapports de sécurité traités (un mélange de vraies failles et de hardening : des améliorations de robustesse).</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>
Les failles de sécurité corrigées vont aussi être backportées dans la version 5.4 de Symfony, une très bonne chose pour les projets legacy.</p>
        </div>
</div>

<p>Nicolas utilisait déjà l'IA depuis 2025, mais juste en babysitting. Depuis août, il a crafté des skills très précis basés sur tout son historique de code review, de contribution et de traitement des sujets. <strong>Aujourd'hui, rien n'est fait sans intervention humaine</strong>, mais l'agent s'occupe de tout ce qui est évident : les rebases, les checks basiques, et une review profonde en se réappropriant tout le sujet de A à Z.</p>
<h3>Quelques fonctionnalités que nous avons hâte d'utiliser</h3>
<ul>
<li>Demandé depuis 2019, l'attribut <code>#[SerializedName()]</code> prend maintenant en charge un nom différent en fonction du groupe de sérialisation !</li>
<li>Un mode « sudo » pour forcer une re-saisie du mot de passe (ou autre) dans certains endroits très sensibles de votre application ;</li>
<li>Le support natif d'OpenID Connect pour gérer la connexion des utilisateurs via des providers d'authentification (Google, Apple...) ;</li>
<li>Le composant KeyManagement pour parler avec HashiCorp Vault, Azure Key Vault ou AWS KMS, et chiffrer les données de façon sécurisée !</li>
<li>Consommer des messages en parallèle avec un seul worker Messenger via <code>amphp/parallel</code>. Une seule connexion backend et N threads de traitement 💪</li>
<li>Le <code>BatchDispatcher</code> dans Messenger, capable d'envoyer plusieurs messages d'un coup dans la queue, plus rapidement que des ajouts unitaires (à l'exception d'AMQP) ;</li>
<li>Le <code>ChainStamp</code> pour créer des enchaînements de messages qui se traitent les uns après les autres ;</li>
<li>L'unique <code>APP_SECRET</code>, utilisé pour tout ce qui concerne les signatures, les cookies, les jetons... est désormais séparé par fonctionnalité, et il devient possible de le faire tourner (donc d'utiliser plusieurs secrets en même temps : le premier configuré signe, et tous valident) ;</li>
<li>Le <code>RecorderHttpClient</code> est enfin mergé. <a href="https://jolicode.com//blog/afup-day-2026-paris-retour-sur-une-journee-dans-l-ecosysteme-php#architecture-et-qualite-du-code">Nous l'avions déjà vu à l'Afup Day Paris</a> et nous avions hâte de pouvoir l'utiliser. Un attribut <code>#[UseRecord]</code> permet d'enregistrer le HAR du trafic réel, qui est ensuite rejoué dans nos tests ;</li>
<li>L'attribut <code>#[AsFormType]</code> sur un DTO qui crée le formulaire correspondant pour nous 🤯</li>
<li>Pareil avec <code>#[AsWorkflow]</code> : les transitions sont directement définies sur nos constantes. <a href="https://jolicode.com//qui-sommes-nous/equipe/gregoire-pineau">Bravo à Grégoire</a>, qui avait commencé cette feature en 2024, et l'a terminée avec Claude !</li>
</ul>
<p>Enfin, une nouvelle page d'exception a été intégrée, mais tout va bien, <a rel="nofollow noopener noreferrer" href="https://github.com/jolicode/GifExceptionBundle">notre GifExceptionBundle</a> supporte déjà le nouveau design, merci Loïck.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/forum-php-2026/symfony-8-2-exception-gif.gif" data-original-width="1067" data-original-height="644"><source type="image/webp" srcset="/media/cache/content-webp/2026/forum-php-2026/symfony-8-2-exception-gif.5ea3d5eb.webp" /><source type="image/gif" srcset="/media/cache/content/2026/forum-php-2026/symfony-8-2-exception-gif.gif" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(1067 / 644)" src="https://jolicode.com//media/cache/content/2026/forum-php-2026/symfony-8-2-exception-gif.gif" alt="Le nouveau design, amélioré avec un gif" /></picture></p>
<h2>Exposer son métier aux agents IA avec API Platform</h2>
<p>Antoine BLUCHET nous a fait une démo sympa d'un agent IA vocal qu’il a développé en side project pour son garage : gestion documentaire, wireframes 3D des voitures, estimation de devis auto, le tout semblait très complet !</p>
<p>Sous le capot, on retrouve évidemment plusieurs APIs comme l'accès au <abbr title="Système d'Immatriculation des Véhicules">SIV</abbr> pour récupérer les infos via la plaque d'immatriculation, mais aussi le vecteur principal de son assistant : le <a href="https://jolicode.com//blog/mcp-the-open-protocol-that-turns-llm-chatbots-into-intelligent-agents">MCP</a>.</p>
<p>Antoine nous explique que pour faire du MCP en PHP aujourd’hui, on a des librairies bien pratiques :</p>
<ul>
<li>le <a rel="nofollow noopener noreferrer" href="https://github.com/modelcontextprotocol/php-sdk">SDK PHP</a> de bas niveau qui permet de lancer un serveur avec transport, chaque outil mis en avant est une méthode en PHP ;</li>
<li>l’<a rel="nofollow noopener noreferrer" href="https://github.com/symfony/mcp-bundle">abstraction Symfony</a>, qui permet, entre autres, d’utiliser des attributs sur les méthodes et de configurer le transport (HTTP, stdio) ;</li>
<li>une <a rel="nofollow noopener noreferrer" href="https://github.com/api-platform/mcp">abstraction API Platform</a> avec des attributs au niveau des classes et une exposition plus simple des APIs sur le MCP.</li>
</ul>
<p>Nous nous sommes donc attardés sur ce que propose l'abstraction d'API Platform :</p>
<ul>
<li>de la <strong>validation</strong> avec des Asserts et des Constraints ;</li>
<li>du <strong>typage</strong>, y compris avec des choices ;</li>
<li>la réutilisation des <strong>processors</strong> existants ;</li>
<li>de la <strong>sécurité</strong>, avec le support des isGranted ;</li>
<li>du retour en <strong>markdown</strong> et le branchement de providers.</li>
</ul>
<p>La liste est longue, et l'idée derrière est simple : exposer un métier à un agent, ce n'est pas réécrire une API, c'est la rendre lisible pour lui.</p>
<p>Vient alors la vraie question, celle que nous retenons : comment parler à un agent sans lui noyer le contexte ? Antoine écarte les appels HTTP à l’API en direct. Faire des allers-retours avec l’API est assez bavard et gourmand en tokens. Il propose à la place d'exposer la documentation de l'API sur sa racine <code>/</code>, au format hypermédia, pour que l'agent s'y renseigne seul et aille droit au but, en un seul appel.</p>
<p>Reste le flux : PHP synchrone n'aime pas rester ouvert, ce qui tombe mal pour tenir une connexion avec un agent. La réponse avancée est <a rel="nofollow noopener noreferrer" href="https://mercure.rocks/">Mercure</a>.</p>
<p>Nous avons mis en place le [MCP fourni par API Platform sur redirection.io] (<a rel="nofollow noopener noreferrer" href="https://redirection.io/mcp">https://redirection.io/mcp</a>) avec succès et nous recommandons chaudement son utilisation.</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>
NDLR : la spec de MCP a évoluée : le protocol SSE a été déprécié en faveur d’un protocol HTTP stateless. La partie Mercure n’est plus strictement nécessaire.</p>
        </div>
</div>

<p>En pratique, Antoine fait cohabiter plusieurs modèles selon les besoins. Avec <a rel="nofollow noopener noreferrer" href="https://github.com/symfony/ai">Symfony AI</a>, on peut limiter certains tools à un modèle précis, par exemple limiter la recherche web à Gemini 2.5 qui le fait très bien et propose un free tier.</p>
<p>Un mot pour finir sur le testing, où rien n'est vraiment sûr. Tester les choix d'un agent est difficile et coûteux, et si les LLM Judges existent, Antoine les trouve fragiles : on ajoute du déterminisme sur du non-déterminisme. Bref, pas encore de solution solide.</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>
NDLR : nous abordons ce point lors de la formation <a href="https://jolicampus.com/formations/maitriser-ia-symfony">Symfony AI</a>, en montrant comment tester ses briques logiciels qui utilisent des IA.</p>
        </div>
</div>

<p>Est-ce que le futur sera fait de pleins de &quot;Jarvis&quot; ? Pas forcément, mais quoi qu’il en soit, c’est autour des APIs qu’il sera construit !</p>
<h2>PHP Défensif : Arrêtez de faire confiance, défendez votre code !</h2>
<p>Thomas DUTRION nous ouvre son talk avec une citation de Léonard de Vinci :</p>
<blockquote>
<p>L'expérience prouve que celui qui n'a jamais confiance en personne ne sera jamais déçu.</p>
</blockquote>
<p>Le code défensif, ce n’est pas une question de compétence, mais de posture : ne faire confiance à personne, même pas à soi-même.
L’idée directrice du code défensif, comme nous le présente Thomas, c’est de déplacer la responsabilité de l'humain vers la machine. On ne compte plus sur la discipline et la mémoire de chacun ; on écrit les règles noir sur blanc dans le projet pour que l'outillage les fasse respecter à notre place. Ce qui n'est pas explicite n'existe pas.</p>
<p>Côté pratique, on retient plusieurs vraies actions :</p>
<ul>
<li><strong>Poka-yoke</strong> : rendre l’erreur humaine impossible et très facilement détectable si elle arrive (typage, readonly, logging). Préférer la prévention, prévoir la détection ;</li>
<li><strong>un type par concept</strong> : unions, intersections, DNF, tant qu'à faire. Si une union dépasse deux types, c'est sûrement qu'il manque un Enum ou un DTO ;</li>
<li><strong>des types dédiés</strong> plutôt que des primitifs : on type les IDs, on passe par <code>brick/money</code> pour les prix au lieu de floats, par une classe Uri au lieu de concaténer des strings ;</li>
<li>on bannit les <code>@</code>, on passe aux comparaisons strictes, en gardant en tête que, dans le legacy, les loose comparisons sont parfois des features ;</li>
<li>on ne fait jamais confiance aux valeurs externes : une couche de sécurité et de cohérence, on vérifie puis on valide ;</li>
<li>et les petits réflexes qui comptent : des enums plutôt que des constantes, la config injectée plutôt qu'un fichier <code>.env</code> lu directement, des classes complètes dès leur création (logique dans le constructeur) et <code>final</code> pour éviter les changements de comportement abusifs, des conventions d'équipe sur le nommage (get ou find ?).</li>
</ul>
<p>Pour appliquer toutes ces règles, ce sont finalement des outils que nous connaissons déjà qui entrent en jeu : <a rel="nofollow noopener noreferrer" href="https://github.com/php-cs-fixer/php-cs-fixer">PHP CS Fixer</a> pour le style, <a rel="nofollow noopener noreferrer" href="https://phpstan.org/">PHPStan</a> pour le typage, <a rel="nofollow noopener noreferrer" href="https://github.com/deptrac/deptrac">Deptrac</a> pour les frontières d'architecture, <a rel="nofollow noopener noreferrer" href="https://github.com/cuyz/valinor">Valinor</a> pour valider les valeurs externes et les collections, et d'autres encore. Le tout est de les mettre en place sur nos projets et de les y tenir dans la durée.</p>
<p>Le fil rouge, en soi, c’est que moins on s’appuie sur l’humain, plus on s’appuie sur la machine. À l’ère des agents qui produisent du code en volume, les bonnes pratiques deviennent une nécessité et la machine devient essentielle pour ne pas faillir là où la discipline et la mémoire humaines fatiguent.</p>
<h2>Vos coding agents en mode YOLO… mais en toute sécurité</h2>
<p>Le mode YOLO, c'est ce qui permet à vos agents de pouvoir lancer toutes les commandes sans vous demander la permission. C'est donc... un peu dangereux 😜 Cependant, il a pour gros avantage de laisser l’agent travailler sans avoir à valider chacune de ses opérations. L’agent va plus vite, mais nous sommes moins en sécurité !</p>
<p>Guillaume LOURS travaille chez Docker et il nous a présenté leur solution : <a rel="nofollow noopener noreferrer" href="https://www.docker.com/products/docker-sandboxes/"><strong>Docker Sandboxes</strong></a>, avec une <a rel="nofollow noopener noreferrer" href="https://github.com/glours/afup-sbx-demo/blob/main/DEMO.md">démo basé sur Symfony Demo</a>.</p>
<p>L'idée est de pouvoir laisser votre agent faire ce qu'il veut mais dans une sandbox où :</p>
<ul>
<li>il n'aura pas accès à tout Internet, configurable locale ou par l'organisation ;</li>
<li>il ne verra pas vos secrets applicatifs, vos jetons, etc. ;</li>
<li>il n'aura accès qu'aux fichiers de votre projet.</li>
</ul>
<p>En interne, c'est comme du Docker-in-Docker : nos projets qui tournent actuellement déjà avec Docker sont cette fois démarrés dans le Docker Sandboxe, avec l'agent et tous les outils dont nous avons besoin.</p>
<p>Nous pouvons noter que Guillaume n’a pas parlé du mode &quot;auto&quot; de Claude. Ce mode se situe entre le mode &quot;manual&quot; et le mode &quot;yolo&quot;. Il classifie chaque opération, et ne demande pas de validation tant que l’opération est jugée non dangereuse. Est-ce que ce mode permet d’éviter la complexité de faire tourner son agent et sa stack dans une micro VM ?</p>
<p>C'est plutôt un outil à destination des grands groupes qui ont besoin de contrôler ou limiter les capacités des agents de leurs collaborateurs - à retenir !</p>
<h2>La lucidité comme architecture</h2>
<p><a href="https://jolicode.com//qui-sommes-nous/equipe/bastien-jaillot">Bastien JAILLOT</a> a présenté un retour d'expérience à deux voix avec Joël GOMEZ CABALLE, lead dev chez Aropixel. Le point de départ est une billetterie en ligne lancée en 2014 par un développeur seul, qui apprend Symfony 2 en même temps. Le succès arrive vite : rachat, 150 sites en production, plusieurs millions d'euros par mois.</p>
<p>Avec le succès, Joël a voulu une architecture « sérieuse » : microservices, DDD, interfaces partout. Chaque choix avait une bonne raison. Mais ajouter un champ téléphone imposait de traverser un DTO, un transformer, un handler, un repository et un event listener. Le code était réparti sur quatre dépôts. Une développeuse junior devait publier une version de la librairie front, sans tests ni review, juste pour pouvoir avancer. Et on ne déployait qu'une fois tous les quinze jours : le lundi après-midi, plus personne ne committait.</p>
<p>Le talk prend le temps de nommer ce qui bloquait vraiment, et ce n'est pas la technique. À l'écran, les affects  s'affichent comme des lignes de log : la confusion et le déni en <code>[WARN]</code>, la honte en <code>[ERROR]</code>, la peur en <code>[FATAL]</code>.</p>
<p>Appelé pour un classique « le site est lent » (nous intervenons régulièrement dans ce genre de contexte), Bastien commence par un constat sans affect : un wrapper d'API n'a aucune raison de tomber avant l'API qu'il enveloppe. Varnish et le user context hash absorbent le trafic qui ne rapporte rien. Mais le vrai chantier est ailleurs : rendre les petits changements faciles avant de simplifier toute l'architecture.</p>
<ul>
<li>un monorepo, fait en une heure ;</li>
<li>un environnement Docker lancé avec <a rel="nofollow noopener noreferrer" href="https://castor.jolicode.com/">Castor</a> ;</li>
<li>Datadog, que Joël a rebaptisé « Data-drogue » 😂 ;</li>
<li>du trunk-based development, avec dix déploiements par jour pour une équipe de trois devs ;</li>
<li>des feature flags activés sur un shop, puis dix, puis tous.</li>
</ul>
<p>C'est seulement ensuite que Mercure, Memcache, Elasticsearch et toute une API intermédiaire ont pu disparaître, sans feature freeze.</p>
<p>Le dernier chapitre parle des agents IA. Tout ce qui avait été mis en place leur profite à plein. Mais l'enthousiasme de ceux qui s'éclatent avec eux peut, une seconde fois, empêcher de voir ceux qu'on laisse sur le bord du chemin. La conclusion : la lucidité ne se décrète pas, elle se construit, comme une architecture. Le talk se termine sur une question à la salle : et chez vous, qui donne l'alerte ?</p>
<p>Les slides et leurs notes sont <a rel="nofollow noopener noreferrer" href="https://aropixel.github.io/conference-lucidite-2026/">en ligne</a>.</p>
<h2>Avant que PHP n'exécute ton premier echo : ce que la couche réseau coûte vraiment</h2>
<p>Dans son talk, Nicolas PERUSSEL est parti d’un constat. FrankenPHP est arrivé en prod chez beaucoup de monde, et il active par défaut HTTP/3, 0-RTT, Early Hints et les certificats automatiques. Or on ne sait pas toujours ce que ces fonctionnalités font vraiment. L’idée est de suivre ce qui se passe quand un client se connecte, avant même que PHP n’entre en jeu. Ça vaut aussi pour Nginx et Apache.</p>
<p>Le talk commence par un rappel des bases en présentant le modèle <a rel="nofollow noopener noreferrer" href="https://fr.wikipedia.org/wiki/Mod%C3%A8le_OSI">OSI (Open Systems Interconnection)</a> pour aider à situer chaque couche du réseau. Vient ensuite le three-way handshake TCP : avant d’échanger la moindre donnée, client et serveur font un aller-retour complet pour établir la connexion.</p>
<p>Nicolas a ensuite évoqué la fameuse notion de RTT (Round Trip Time), rien à voir avec vos congés 🙂. Elle exprime combien coûte un dialogue avec une autre machine sur le réseau. Chaque étape de la connexion se compte en RTT, et c’est ce qu’on cherche à réduire.</p>
<p>Le chiffrement TLS ajoute ses propres RTT par-dessus TCP. TLS 1.2 en coûte 2, TLS 1.3 n’en coûte plus qu’1. Passer en 1.3 est donc déjà un gain direct sur chaque nouvelle connexion. Le RTT est généralement très variable sur mobile comparé à la fibre. Économiser quelques RTT a donc un impact bien plus visible pour les utilisateurs mobiles que pour ceux sur une connexion fixe.</p>
<p>Nous avons ensuite parlé de HTTP/3 et QUIC qui remplace TCP par UDP et englobe TLS 1.3. La connexion et le chiffrement se font en 1 seul RTT au total, contre 2 avec TCP + TLS 1.3. QUIC est implémenté en userspace et non dans le noyau. C’est donc souvent un peu plus gourmand en CPU, avec un p90 légèrement plus élevé. En contrepartie, une perte de paquet n’immobilise plus tous les flux. Avec TCP, un seul paquet perdu bloquait tout le monde. C’est aussi très pratique pour la reprise de connexion lors des changements d’antenne 5G : la connexion survit au changement de réseau au lieu de devoir tout renégocier.</p>
<p>Grâce au 0-RTT, si un client se reconnecte à un serveur déjà connu, il peut envoyer sa requête dès le premier paquet, sans attendre la fin du handshake. Cependant, le 0-RTT expose aux replay attacks : une requête peut être rejouée par un attaquant. Il faut donc le limiter aux routes idempotentes (GET) et ne pas l’accepter sur un POST qui modifie des données.</p>
<p>Avec sa conférence, Nicolas nous a non seulement permis de comprendre ce qu'il se passait dans le réseau lorsque l'on se connecte à un serveur, mais aussi nous a donné plusieurs pistes pour optimiser nos infrastructures. Car même si les réglages par défaut des logiciels aident beaucoup, certains paramètres doivent tout de même être faits à la main : restreindre le 0-RTT, s’assurer que l’infra laisse passer QUIC, activer l’OCSP stapling, etc.</p>
<h2>L’Ecosystem Security Team de la PHP Foundation de l’intérieur</h2>
<p>Alexandre DAUBOIS est Core Security Engineer à la PHP Foundation. Son rôle est de relire, de tester et de vérifier les différentes failles qui sont découvertes et signalées à la fondation.</p>
<p>Le monde de la sécurité a beaucoup changé cette année. L'avènement des agents a provoqué un afflux massif de rapports, à tel point que les programmes de bug bounty s'arrêtent : ils ne peuvent plus suivre ! Curl, Google, Internet Bug Bounty (IBB)… sont suspendus sous le poids des propositions.</p>
<p>Le goulot d'étranglement, c'est l'humain, car chaque notification doit être vérifiée. Beaucoup de mainteneurs font des burnouts ou n'arrivent pas à suivre. La solution pour ces mainteneurs qui n'ont pas toute une core team, c'est <a rel="nofollow noopener noreferrer" href="https://thephp.foundation/blog/2026/05/18/announcing-ecosystem-security-team/">l'Ecosystem Security Team de la fondation</a>, dont Alexandre fait partie.</p>
<p>Un des outils qu'ils utilisent est <a rel="nofollow noopener noreferrer" href="https://github.com/alpha-omega-security/scrutineer">scrutineer</a> d'Alpha-Omega. C'est un outil pour scanner les projets open source à la recherche de failles, via des agents LLM et des skills, et gérer le processus de divulgation proprement, sans doublons et sans les tartines de texte que les LLM adorent nous faire lire.</p>
<p>Leur premier job avec l'outil a été de scanner les 300 projets les mieux classés en nombre de téléchargements sur Packagist. En un mois, ils ont contacté 35 mainteneurs avec des <strong>rapports bien structurés, prouvés et qualitatifs</strong>, provoquant plus de 100 corrections !</p>
<p>Lors du scan d'API Platform, une faille a été trouvée. Il a fallu vérifier humainement, car c'est une lourde responsabilité de déclarer une faille : on demande un ID unique mondial, c'est propagé partout, les distributions Linux doivent se mettre à jour, les scanners de dépendances signalent la version concernée… CVE-2026-54164 est né, validé par des humains. On ne peut pas tout automatiser. L'humain voit tout : les conditions que le LLM n'a pas vues, le contexte, la véracité. Merci Alexandre 💪</p>
<h2>Les lighting-talks ⚡ !</h2>
<p>Bravo à <a href="https://jolicode.com//qui-sommes-nous/equipe/baptiste-leduc">Baptiste LEDUC</a>, qui a présenté PABLO, son orchestrateur maison d’agents et de tâches (vous pouvez en lire plus <a href="https://jolicode.com//blog/d-un-ade-a-un-orchestrator-la-construction-de-pablo">dans sa série d’articles ici-même</a>).</p>
<p><a href="https://jolicode.com//qui-sommes-nous/equipe/xavier-lacot">Xavier LACOT</a> a présenté, pour sa part, une petite liste d’en-têtes HTTP méconnus. Nous y avons, par exemple, découvert les User-Agent Client Hints, avec <code>Sec-CH-UA</code>, ou <code>Sec-GPC</code>, qui remplace le <code>Do Not Track</code>. Et un clin d’œil à <a rel="nofollow noopener noreferrer" href="https://redirection.io/">redirection.io</a> avec le bien ancien en-tête <code>Refresh: &lt;time&gt;; url=&lt;url&gt;</code>, qu’on vous recommande de ne jamais utiliser 😀</p>
<p>Nous avons aussi découvert <a rel="nofollow noopener noreferrer" href="https://nektosact.com/">nektos/act</a> par l’intermédiaire d’Estelle GAITS, pour exécuter nos GitHub Actions en local 🙏 Outil particulièrement pertinent à mettre en place à un moment où ce sont les agents qui écrivent nos actions !</p>
<p>Enfin, citons <a href="https://jolicode.com//qui-sommes-nous/equipe/gregoire-pineau">Grégoire PINEAU</a>, qui a expliqué son clone du site bien connu « De Gauche ou de Droite ? », mais qui tourne à 100 % dans le navigateur avec un petit modèle d’IA embarqué. <a rel="nofollow noopener noreferrer" href="https://lyrixx.github.io/gauche-ou-droite/media/slides/">Les slides expliquent la démarche, ce qui a fonctionné et ce qui a raté</a> - à lire !</p>
<h2>Souveraineté numérique à l'ére de l'IA</h2>
<p>Avec Quentin ADAM, CEO de <a rel="nofollow noopener noreferrer" href="https://www.clever.cloud/fr/">Clever Cloud</a>, pas de langue de bois. Dès les premières minutes, on comprend que le propos sera tranché, et il l'assume pleinement. Il commence par la révolution industrielle : le moteur a remplacé les muscles. Avec un tracteur de 400 chevaux, ce sont 400 chevaux et une centaine de personnes pour s'en occuper qui sont remplacés par seulement une personne pour conduire et une personne pour l'entretien. Puis l'ordinateur a commencé à remplacer les cerveaux.</p>
<p>Selon lui, l'Europe est en train de rater cette nouvelle révolution. Il en donne plusieurs raisons : une gouvernance trop centralisée, une politique qui préserve les emplois des vieilles entreprises au lieu de laisser émerger les jeunes, et une fiscalité qui marche sur la tête (8 % pour les GAFAM, 18 % pour nos grands groupes, 23 % pour nos PME). Les chiffres font mal : sur 100 € dépensés en informatique en Europe, 83 € partent aux États-Unis. Et le <a rel="nofollow noopener noreferrer" href="https://fr.wikipedia.org/wiki/CLOUD_Act">Cloud Act</a> donne en prime aux autorités américaines un accès permanent à nos données.</p>
<p>Son message tient en une phrase, qu'il répète sans détour : diversifiez vos achats, achetez local, et écrivez à vos député·es, parce qu'à Bruxelles, en face d'un lobbyiste comme Clever Cloud, il y en a cent payés par les géants américains. Il pousse la logique jusqu'à sa « clause de souveraineté finale » : si Clever Cloud est racheté par un acteur étranger, ses clients obtiennent une licence perpétuelle, ce qui enlève tout intérêt au rachat.</p>
<p>Le seul adversaire qu'il n'a pas réussi à faire plier ce matin-là, c'est le vidéoprojecteur. À chaque GIF, l'écran se figeait et il devait débrancher puis rebrancher le câble HDMI. Cloud souverain ou GAFAM, tout le monde a ses limites, surtout face à la connectique 🔌.</p>
<h2>Speed is a byproduct of trust</h2>
<p>D’après Sebastian BERGMANN, travailler avec un agent, c’est un peu comme jouer à un jeu d’aventure textuel, sauf que la grande majorité des commandes se résume à  &quot;Accept all&quot;. Nous finissons par nous y perdre, jusqu'à devoir repartir de la base. Ce talk parle justement des filets de sécurité qui nous permettent de faire confiance à la vitesse des agents qui produisent du code en masse.</p>
<p>La vraie question d’ailleurs, n'est pas de savoir si nous faisons confiance à l'agent, mais si nous faisons confiance à notre pipeline pour garantir que ce qu'il génère est valide.</p>
<p>Sebastian nous présente deux façons de se planter :</p>
<ul>
<li><strong>Recklessness</strong> (témérité) : de la vitesse sans contrôle. La dette s'accumule plus vite, les erreurs sont les mêmes qu'à la main, elles arrivent simplement plus vite ;</li>
<li><strong>Stagnation</strong> : du contrôle sans vitesse. Le marché nous largue, et la review devient un goulot d'étranglement.</li>
</ul>
<p>Les deux mènent dans le mur, mais le message central est ailleurs : les équipes ne vont pas plus vite malgré leur quality control, elles vont plus vite parce qu'elles ont confiance en leur quality control. La confiance n'est pas un sentiment, c'est un track record, une suite de résultats observés et reconnus.</p>
<p>Dans un exemple concret, voici ce que nous pouvons mettre en place :</p>
<blockquote>
<p>Un domaine isolé dans quelques fichiers PHP qui ne dépendent de rien + un dossier &quot;Application&quot; où l'agent travaille en le respectant sans jamais y toucher.</p>
</blockquote>
<p>L'idée : isoler le métier dans quelques fichiers PHP qui ne dépendent de rien, pour que le cœur reste stable et lisible, et laisser l'agent travailler dans un dossier dédié en le respectant sans jamais y toucher. Il gagne un terrain de jeu où produire vite, et une frontière qu'il ne franchit pas.</p>
<p>Le filet tient dans des outils connus (PHP CS Fixer, PHPStan niveau 10, Deptrac, PHPUnit, mutation testing, une CI qui bloque le merge), avec quatre réflexes :</p>
<ol>
<li>Observer le filet attraper des choses ;</li>
<li>Transformer les tests en specs ;</li>
<li>Inverser le rapport : c’est nous qui spécifions, et c’est l’agent qui implémente ;</li>
<li>Puis durcir notre gate.</li>
</ol>
<p>Et évidemment, écrire les règles en dur dans l'outillage, sinon nous ne les rattrapons jamais.</p>
<p>Côté tests, Sebastian conseille de nommer les tests comme des phrases de ce qui est attendu, et de préférer le TDD (un test venu du code n'est qu'un log). De plus, le coverage ne suffit pas : d'où le mutation testing, où nous cassons le code exprès pour voir si les tests réagissent.</p>
<p>Reste la &quot;Definition of Done&quot;, qu’il faut définir, sinon l'agent s'en choisit une tout seul, avec assurance et souvent à tort : plutôt que de dire &quot;écris une classe pour faire X&quot;, demandons &quot;fais passer ce test sans le modifier&quot;, en lui interdisant de toucher aux tests.</p>
<p>L'humain spécifie le comportement et l'architecture une fois, l'agent implémente jusqu'au vert, l'humain relit.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/forum-php-2026/Sebastian_DoD.jpg" data-original-width="4000" data-original-height="2252"><source type="image/webp" srcset="/media/cache/content-webp/2026/forum-php-2026/Sebastian_DoD.3751d90c.webp" /><source type="image/jpeg" srcset="/media/cache/content/2026/forum-php-2026/Sebastian_DoD.jpg" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(4000 / 2252)" src="https://jolicode.com//media/cache/content/2026/forum-php-2026/Sebastian_DoD.jpg" alt="4 étapes de la Definition of Done" /></picture></p>
<p>En résumé, <strong>nous ne faisons pas confiance à l'agent, mais au filet construit autour de lui</strong>.
Les tests sont nos specs, la CI est l'arbitre, l'agent est un outil et pas un collègue. C'est ce track record, et non un sentiment, qui rend la vitesse tenable et qui transforme la fameuse commande « Accept all » en décision.</p>
<p>La vitesse, au bout du compte, n'est qu'un sous-produit de la confiance. ⚡</p>
<h2>L'expérience agentique, ou comment tirer le maximum des IAs sur votre code</h2>
<p>François ZANINOTTO nous a présenté une série de bonnes pratiques pour coder avec des agents. En voici quelques-unes qui nous semblent intéressantes :</p>
<ul>
<li>Donner le contexte métier : le décrire dans <code>AGENTS.md</code>, dans les commentaires du code... Si la documentation est ailleurs, connecter un MCP (coucou Jira / Confluence / Notion...) ;</li>
<li>Les agents ne peuvent pas mettre tout le code dans le contexte. Ils font des <code>grep</code> en permanence. Il faut donc rendre le code facile à trouver. Utiliser des DTO, du LSP, documenter les synonymes, éviter les noms dupliqués ;</li>
<li>Il faut être concis, car le contexte de l'agent est limité. Des fichiers courts, des logs peu verbeux, pas de commentaires inutiles, pas de code mort, du Markdown plutôt que du HTML/PDF/DOCX...</li>
<li>Les modèles ont été entraînés sur du vieux code. Il est donc préférable de choisir des boring tech !</li>
<li>Aider l'agent à trouver la documentation de vos outils sans parser le Web : ajouter le <a rel="nofollow noopener noreferrer" href="https://context7.com/">MCP Context7</a> ou fournir la documentation directement dans les vendors ;</li>
<li>Renforcer les garde-fous avant que le code arrive dans la CI ; la boucle agentique doit être capable de tout tester / valider avant d'envoyer le code ;</li>
<li>Garder la revue de code 💯 ;</li>
<li>Améliorer le monitoring de production et les procédures de rollback en cas de soucis ;</li>
<li>Entretenir la mémoire : mettre à jour la documentation en fin de session, faire des skills, nettoyer le harnais agentique.</li>
</ul>
<p>Pour finir, François confirme ce que nous avons aussi constaté : écrire des tonnes de skills n'est pas nécessaire quand le code de votre projet est déjà clair / bien fait 👍</p>
<h2>La review qu'on aurait dû faire depuis toujours : reprendre sa place de reviewer humain à l'ère des agents IA</h2>
<p>Gaël CRISPYN délègue maintenant la première review à des agents et il a profité de ce talk pour nous présenter ses bonnes pratiques.</p>
<p>Bien sûr, cela passe par un Skill (de notre côté, nous voyons souvent passer <a rel="nofollow noopener noreferrer" href="https://github.com/mattpocock/skills/blob/main/skills/engineering/code-review/SKILL.md">celui-ci</a>) et par des descriptions de PR complètes. Une information que nous trouvons très intéressante, c’est de forcer le développeur à préciser dans chaque PR deux choses :</p>
<ul>
<li>ce qu’il a vérifié ;</li>
<li>ce qu’il n’a pas vérifié !</li>
</ul>
<p>Ce deuxième point est critique et l’agent pourra focus son étude dessus.</p>
<p>Tout ce qui concerne les coding standards, l’indentation et la cosmétique doit être ignoré par l’agent. L’agent doit aider l’humain à faire sa review, en mettant le doigt sur les points faibles et en questionnant tout ce qui n’est pas carré. Pour que ça fonctionne, il faut donner à l’agent le contexte, les spécifications, l’intention et des PR de taille raisonnable.</p>
<p>Un dernier point d’attention : quand l’agent dit « C’est OK, tout va bien », <strong>ce n’est pas OK</strong> ! L’humain doit toujours valider.</p>
<h2>L’IA dans l’image : La mort du réel ou le moment qu’on attendait toutes et tous ?</h2>
<p>La conférence s'est terminée avec Niels ACKERMANN, photographe de presse depuis plus de 20 ans, qui nous a parlé de la transformation de son métier face à l'IA générative. Bien que les générateurs d'images intègrent des filigranes, ils ne sont pas fiables et détecter une vraie photo d'une fausse devient impossible avec les derniers modèles.</p>
<p>Pourtant aujourd'hui, les grandes entreprises de l'IA ne font pas leurs publicités avec l'IA. L'IA ne sait pas innover. Tout ce qu'elles montrent est basé sur des choses qui ont déjà été faites. Donc pour les photos les plus évidentes / consensuelles, les IA remplacent le photographe, et il ne reste que l'innovant et l'inattendu.</p>
<p>Là où la vérité est importante (les médias !), le vrai, le réel, restent aussi importants. Mais pour autant, utiliser l'IA générative pour corriger une photo, l'améliorer ou la recomposer ne devrait pas être un problème tant que c'est dit.</p>
<p>Il a donc créé <a rel="nofollow noopener noreferrer" href="https://daliascale.org/">DALIA Scale</a>, un moyen simple de noter un travail, sans jugement, en fonction de l'utilisation de l'IA générative dans son processus de création. Avec des questions simples (« Qui a l'idée ? », « Qui exécute ? » et « Qui valide ? »), nous pouvons obtenir une note.</p>
<ul>
<li>D0 : tout est fait à la main ;</li>
<li>D5 : l'IA est autonome, elle a tout fait.</li>
</ul>
<p>Il pousse ses clients à l'utiliser dans leurs appels d'offres, pour demander un « maximum Dalia 2 », par exemple. Cet article est D1 (les agents ne vont pas encore en conférence à notre place !).</p>
<h2>Le mot du président de l'AFUP</h2>
<p><a href="https://jolicode.com//qui-sommes-nous/equipe/benjamin-clay">Benjamin</a> : pour cette année 2026, je suis président de l'AFUP, l'association organisatrice du Forum PHP.</p>
<p>L'évènement existe et rassemble la communauté depuis plus de 25 ans maintenant et est organisé en grande partie par des bénévoles sur leur temps libre : négociation avec la salle, le traiteur, rédaction du CFP et sélection des conférences, gestion du planning et des venues des conférenciers et conférencières, billetterie, beaucoup de coordination logistique, et vous l'avez sûrement vu, beaucoup de pas parcourus pendant un jour de préparation et deux jours de conférences.</p>
<p>Merci à tous les bénévoles et à Amélie pour tout ce travail 💪</p>
<p>À cause des budgets plus serrés que d'habitude ainsi que du profond changement que notre secteur connaît, cette année a été compliquée, et l'AFUP a publié une <a rel="nofollow noopener noreferrer" href="https://lettreouverte.afup.org/">lettre ouverte</a> pour en parler.</p>
<p>Cependant, cette édition a été une vraie réussite :</p>
<ul>
<li>les quelques conférences auxquelles j'ai pu assister m'ont appris des choses et m'ont donné envie de tester tout ça dès mon retour, d'autres m'ont aidé à prendre du recul sur différents sujets : des projets, du code, de l'organisation ;</li>
<li>j'ai pu retrouver des bénévoles, mais aussi en croiser de nouveaux (qui eux, ont pu découvrir la quantité d'énergie nécessaire pour tenir 3 jours !) ;</li>
<li>j'ai discuté avec des conférenciers et des conférencières, des responsables d'antennes de l'AFUP qui organisent des meetups régulièrement près de chez vous, des sponsors dont le soutien est indispensable au fonctionnement de l'association, des confrères et consœurs avec qui on a partagé des bières, et évidemment de nombreux visiteurs.</li>
</ul>
<p>L'AFUP travaille maintenant pour s'adapter également, et nous nous revoyons aux prochains événements AFUP en 2027 🎉</p>
<h2>En conclusion 🐘</h2>
<p>Merci à l'AFUP pour ces deux jours riches en échanges, en discussions et en IA ! C'était clairement le sujet de l'année, et peu de gens dans la salle lèvent encore la main quand on demande &quot;qui code sans IA&quot;. Notre métier change très vite et vivre de telles conférences nous aide à nous orienter dans ce paysage mouvant.</p>
<p><picture class="js-dialog-target" data-original-url="/media/original/2026/forum-php-2026/stand.jpg" data-original-width="3344" data-original-height="2184"><source type="image/webp" srcset="/media/cache/content-webp/2026/forum-php-2026/stand.556e3b8d.webp" /><source type="image/jpeg" srcset="/media/cache/content/2026/forum-php-2026/stand.jpg" /><img loading="lazy" decoding="async" style="width: 996px; ; aspect-ratio: calc(3344 / 2184)" src="https://jolicode.com//media/cache/content/2026/forum-php-2026/stand.jpg" alt="Notre stand avec Baptiste" /></picture></p>
<p>Comme dirait un bénévole à la fin d'un talk : On a plus le temps pour les questions ! Merci pour votre attention !</p>
<p>Et à l'année prochaine 🙏</p>]]></description></item><item><title>JSON Streaming et AutoMapper 10.3</title><link>https://jolicode.com/blog/json-streaming-et-automapper-10-3</link><author>JoliCode Team</author><date>Thu, 15 Oct 2026 10:42:00 +0000</date><description><![CDATA[<p>Symfony propose depuis peu le <a rel="nofollow noopener noreferrer" href="https://symfony.com/doc/current/serializer/streaming_json.html">composant <code>symfony/json-streamer</code></a>, permettant d’encoder et décoder du JSON sous forme de flux. Le but : traiter de très gros volumes sans charger l’intégralité du document en mémoire.</p>
<p>Dans <a rel="nofollow noopener noreferrer" href="https://automapper.jolicode.com/latest/">AutoMapper</a>, notre solution pour accélérer tous vos mappings de données dans vos projets (sérialisation, object mapping, etc.), nous supportons déjà 2 composants de Symfony en proposant une implémentation alternative :</p>
<ul>
<li><a rel="nofollow noopener noreferrer" href="https://symfony.com/doc/current/object_mapper.html">symfony/object-mapper</a> : Faire un mapping entre 2 objets</li>
<li><a rel="nofollow noopener noreferrer" href="https://symfony.com/doc/current/serializer.html">symfony/serializer</a> : (Dé)sérialiser dans différents formats</li>
</ul>
<p>Ajouter la gestion des flux JSON de la même façon (être une implémentation de l’interface) nous a paru naturel, tous ces composants font exactement la même chose : passer de la donnée de A vers B avec une représentation ou un format légèrement différent.</p>
<p>Nous supportons déjà depuis quelque temps le fait d’avoir un mapping Lazy, a.k.a le mapping s’effectue uniquement au moment où on a besoin du champ (si jamais on ne touche à rien : rien n’est mappé). Le fait de pouvoir passer d’un objet à un flux json fut donc très facile à réaliser.</p>
<p>Mais en travaillant sur l’autre sens : lire un flux pour le transformer en objet, l’implémentation nous a paru beaucoup plus complexe, il fallait revoir toute la partie où on lit la donnée dans la source (le flux JSON).</p>
<h2>Lazy JSON</h2>
<p>AutoMapper possède déjà toute la connaissance nécessaire pour transformer un tableau vers un objet.</p>
<p>L’idée était donc de lui fournir quelque chose qui <strong>se comporte comme un tableau PHP, sans réellement décoder tout le JSON</strong>.</p>
<p>C’est cette idée qui a donné naissance à une nouvelle librairie.</p>
<h3><code>json-stream-polyfill</code> : une première implémentation en PHP</h3>
<p><a rel="nofollow noopener noreferrer" href="https://github.com/joelwurtz/php-json-stream-polyfill"><code>joelwurtz/json-stream-polyfill</code></a> est la première implémentation, entièrement écrite en PHP.</p>
<p>L’objectif était d’avoir une API aussi simple que <code>json_decode()</code> :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$data </span><span class="syntax-4">=</span><span class="syntax-8"> json_stream_decode</span><span class="syntax-2">($json);</span></span></code></pre>
<p>avec une différence importante : on peut également lui passer directement un flux.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$data </span><span class="syntax-4">=</span><span class="syntax-8"> json_stream_decode</span><span class="syntax-2">(</span></span>
<span class="line"><span class="syntax-9">    fopen</span><span class="syntax-2">(</span><span class="syntax-1">'huge.json'</span><span class="syntax-2">, </span><span class="syntax-1">'r'</span><span class="syntax-2">)</span></span>
<span class="line"><span class="syntax-2">);</span></span>
<span class="line"><span class="syntax-9">echo</span><span class="syntax-2"> $data[</span><span class="syntax-1">'user'</span><span class="syntax-2">][</span><span class="syntax-1">'name'</span><span class="syntax-2">];</span></span></code></pre>
<p>Le résultat est un <code>JsonStream\Document</code>, qui se comporte comme un tableau PHP classique grâce notamment à <code>ArrayAccess</code>, <code>IteratorAggregate</code> et <code>Countable</code>, mais dont les données sont résolues de manière lazy.</p>
<p>L’appel à <code>json_stream_decode()</code> est quasiment instantané. Le JSON est parsé au fur et à mesure des accès : demander <code>user.name</code> fait avancer le flux jusqu’à cette donnée et seules les valeurs nécessaires sont matérialisées en PHP. Un champ a au final trois états :</p>
<ul>
<li>Non lu : on ne l’a pas encore vu dans le flux</li>
<li>Bufferisé : on est passé dessus mais on garde toujours sa représentation en JSON (chaîne de caractères)</li>
<li>Parsé : le champ a été lu, parsé et transformé en valeur PHP</li>
</ul>
<p>Pour AutoMapper, c’est particulièrement intéressant : son code généré peut continuer à lire la donnée comme il le fait déjà, tandis que le parsing JSON reste complètement lazy.</p>
<p>Le polyfill introduit également <code>JSON_STREAM_TRANSIENT</code> :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$data </span><span class="syntax-4">=</span><span class="syntax-8"> json_stream_decode</span><span class="syntax-2">(</span></span>
<span class="line"><span class="syntax-2">    $stream,</span></span>
<span class="line"><span class="syntax-3">    JSON_STREAM_TRANSIENT</span></span>
<span class="line"><span class="syntax-2">);</span></span></code></pre>
<p>Dans ce mode, les données déjà parcourues peuvent être libérées. On peut donc lire un document arbitrairement gros avec une consommation mémoire stable.</p>
<p>La contrepartie est importante : <strong>la lecture devient forward-only</strong>. Une donnée déjà dépassée peut être définitivement perdue. Ce mode est donc surtout intéressant lorsque l’on parcourt tout le document dans son ordre naturel, typiquement une grosse collection JSON.</p>
<p>Cette première implémentation fonctionne bien et valide l’API, mais elle possède une limite assez évidente : <strong>scanner un énorme JSON en pur PHP reste lent</strong>, surtout lorsqu’on finit par lire l’intégralité du document.</p>
<p>Il fallait donc aller plus bas.</p>
<h3><code>json-stream</code> : la même chose, mais en extension PHP</h3>
<p><a rel="nofollow noopener noreferrer" href="https://github.com/joelwurtz/php-json-stream"><code>joelwurtz/json-stream</code></a> expose exactement la même API :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$data </span><span class="syntax-4">=</span><span class="syntax-8"> json_stream_decode</span><span class="syntax-2">($json);</span></span></code></pre>
<p>mais directement sous forme d’extension PHP, installable avec <a rel="nofollow noopener noreferrer" href="https://github.com/php/pie">pie</a> via <code>pie install joelwurtz/json-stream</code>.</p>
<p>L’intérêt n’est pas uniquement de déplacer le code en natif. L’extension utilise également <strong>SIMD</strong> pour scanner rapidement la structure du JSON, avec différentes implémentations suivant le CPU : AVX2, SSSE3 ou SSE2 sur x86-64, NEON/PMULL sur ARM64, ainsi qu’un fallback scalaire.</p>
<p>Le fonctionnement reste le même : le document est lazy et seules les valeurs effectivement demandées sont matérialisées.</p>
<p>C’est important parce que le parsing n’est finalement qu’une partie du coût d’un <code>json_decode()</code>. Une fois le JSON compris, PHP doit encore créer les <code>zval</code>, strings, arrays et HashTables correspondants.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-3">JSON</span><span class="syntax-3"> →</span><span class="syntax-3"> parsing</span><span class="syntax-3"> →</span><span class="syntax-3"> zval</span><span class="syntax-4"> /</span><span class="syntax-3"> arrays</span><span class="syntax-4"> /</span><span class="syntax-3"> strings</span><span class="syntax-3"> →</span><span class="syntax-3"> objets</span></span></code></pre>
<p>SIMD permet d’accélérer fortement la première étape, mais pas de faire disparaître les suivantes.</p>
<p>C’est pourquoi <code>json-stream</code> peut être plus rapide que <code>json_decode()</code> dans certains cas, notamment lorsque seule une partie du document est utilisée, sans pour autant être magiquement dix fois plus rapide lorsque tout doit être matérialisé.</p>
<p>Et comme le polyfill et l’extension partagent la même API, le code ne change pas selon l’environnement : le polyfill fonctionne partout et l’extension peut être installée lorsque les performances deviennent importantes.</p>
<h2>AutoMapper 10.3</h2>
<p>Tout cela nous ramène à AutoMapper.</p>
<p>Avec la 10.3, AutoMapper intègre le support de <code>symfony/json-streamer</code>. Le bundle peut remplacer l’implémentation de Symfony et utiliser le moteur de mapping généré d’AutoMapper.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-4">automapper</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">    json_streamer</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">true</span></span></code></pre>
<p>On continue ensuite à utiliser l’API JsonStreamer :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">$users </span><span class="syntax-4">=</span><span class="syntax-2"> $reader</span><span class="syntax-4">-></span><span class="syntax-8">read</span><span class="syntax-2">($stream, $type);</span></span></code></pre>
<p>Le mapping passe par AutoMapper et bénéficie de toute la configuration : <strong>ObjectMapper, Serializer et JsonStreamer utilisent désormais le même moteur de mapping</strong>.
Les attributs et fonctionnalités supportés par AutoMapper fonctionnent donc quelle que soit la porte d’entrée utilisée. Cela inclut également les attributs Symfony Serializer supportés par AutoMapper : il n’est plus nécessaire de maintenir une configuration ou avoir des objets différents simplement parce que l’on passe au streaming.</p>
<h2>Benchmarks</h2>
<p><picture><source type="image/webp" srcset="/media/cache/content-webp/2026/automapper/maths.fda27186.webp" /><source type="image/gif" srcset="/media/cache/content/2026/automapper/maths.gif" /><img loading="lazy" decoding="async" style="width: 560px; ; aspect-ratio: calc(560 / 240)" src="https://jolicode.com//media/cache/content/2026/automapper/maths.gif" alt="Alt text" /></picture></p>
<p>Pour la release de cette nouvelle version nous avons aussi mis en place des <a rel="nofollow noopener noreferrer" href="https://github.com/jolicode/automapper/tree/main/bench">benchmarks</a> que vous pouvez exécuter vous-même si besoin.</p>
<p>AutoMapper propose de nombreuses options pour optimiser le code généré. La désactivation des checks sur les attributs en est un bon exemple : ils ne servent que si vous faites du mapping dynamique, typiquement un appel d’API qui choisit lui-même les champs à retourner. Par souci de cohérence avec le comportement par défaut du serializer de Symfony, ces checks restent malgré tout activés par défaut, et les benchmarks permettent justement de mesurer ce qu’ils coûtent.</p>
<p>Plutôt que de dérouler tous les scénarios, voici les quatre comparaisons qui correspondent aux portes d’entrée de Symfony. Les trois premières lignes mappent un objet <code>Person</code> unique ; les trois dernières lisent ou écrivent une collection de 20 000 <code>Person</code>, en mode streamé des deux côtés (<code>STREAM</code> pour AutoMapper, <code>iterable</code> / <code>getIterator()</code> pour Symfony), chaque objet étant entièrement hydraté à la lecture.</p>
<table>
<thead>
<tr>
<th align="left">Scénario</th>
<th align="right">AutoMapper</th>
<th align="right">AutoMapper opti.</th>
<th align="right">Symfony</th>
<th align="right">Gain / optimisé</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left"><code>array</code> → objet — vs <strong>Serializer</strong></td>
<td align="right">4.77 µs</td>
<td align="right">1.96 µs</td>
<td align="right">78.92 µs</td>
<td align="right">16.5× / 40×</td>
</tr>
<tr>
<td align="left">objet → <code>array</code> — vs <strong>Serializer</strong></td>
<td align="right">5.24 µs</td>
<td align="right">1.71 µs</td>
<td align="right">39.53 µs</td>
<td align="right">7.5× / 23×</td>
</tr>
<tr>
<td align="left">objet → objet — vs <strong>ObjectMapper</strong></td>
<td align="right">3.07 µs</td>
<td align="right">1.01 µs</td>
<td align="right">27.52 µs</td>
<td align="right">9.0× / 27×</td>
</tr>
<tr>
<td align="left">JSON → objets — vs <strong>JsonStreamer</strong></td>
<td align="right">1.112 s</td>
<td align="right">990.3 ms</td>
<td align="right">2.447 s</td>
<td align="right">2.2× / 2.5×</td>
</tr>
<tr>
<td align="left">JSON → objets — vs <strong>JsonStreamer</strong>, avec l’extension</td>
<td align="right">152.6 ms</td>
<td align="right">87.3 ms</td>
<td align="right">2.496 s</td>
<td align="right">16.4× / 28.6×</td>
</tr>
<tr>
<td align="left">objets → JSON — vs <strong>JsonStreamer</strong></td>
<td align="right">169.5 ms</td>
<td align="right">95.1 ms</td>
<td align="right">68.1 ms</td>
<td align="right">0.40× / 0.72×</td>
</tr>
</tbody>
</table>
<p>L’écriture ne décode rien : elle est strictement insensible à l’extension, et c’est le seul scénario où Symfony reste devant — de 2.5× par défaut, de 1.4× une fois l’attribute checking désactivé. C’est logique : son writer compilé lit les champs directement sur l’objet, là où AutoMapper fait passer chaque élément par le pipeline de mapping complet — renommages, transformers, <code>#[MapTo]</code>, propriétés privées, discriminateurs… C’est le prix de la cohérence entre les trois portes d’entrée, et il nous paraît raisonnable.</p>
<p>La lecture, en revanche, passe de 1.112 s à 152.6 ms avec l’extension, soit <strong>7.3× de gain</strong> : c’est elle qui fait basculer le streamer d’AutoMapper de « à peine plus rapide » à « un ordre de grandeur plus rapide » que celui de Symfony. Dès que vous lisez du JSON avec le streamer, installez-la.</p>
<p>Côté mémoire, les deux streamers gardent un pic plat quelle que soit la taille de la collection — environ 10 MB pour Symfony, 21 MB pour AutoMapper avec l’extension en lecture, 7.2 MB pour les deux en écriture — c’est tout l’intérêt. Encore faut-il demander le bon type à Symfony : avec <code>Type::list(...)</code> au lieu de <code>Type::iterable(...)</code>, le reader matérialise tout et le pic monte à <strong>682 MB</strong> sur les mêmes 20 000 objets.</p>
<p>Enfin, la colonne « opti. (sans attribute checking) » parle d’elle-même : c’est de loin le levier le plus rentable côté AutoMapper, avec 2 à 3× sur les scénarios unitaires, 1.7× en lecture streamée et 1.8× en écriture streamée. Et c’est une fonctionnalité rarement utilisée.</p>
<p>La fonctionnalité est encore jeune : <strong>testez-la sur vos gros JSON et faites-nous vos retours !</strong></p>]]></description></item><item><title>Nouveaut&#xE9;s HubSpot apps et APIs : versioning, granular scopes et aides &#xE0; la migration pour s&#xE9;curiser votre SI</title><link>https://www.synolia.com/synolab/outils/nouveautes-hubspot-apps-et-apis-versioning-granular-scopes-et-aides-a-la-migration-pour-securiser-votre-si/</link><author>Manon R.</author><date>Thu, 15 Oct 2026 08:32:38 +0000</date><description><![CDATA[<div class="color-insert theme-light" >
<h2>En résumé :</h2>
<p>&nbsp;</p>
<p><b><span style="font-weight: 400;">Cet article détaille les évolutions majeures de la plateforme développeur HubSpot concernant la gouvernance des applications, la gestion des APIs et la sécurité. Il présente l&rsquo;introduction du versioning prédictif par date, le passage obligatoire aux granular scopes (principe du moindre privilège) et la mise à disposition d&rsquo;outils d&rsquo;aide à la migration (HubSpot CLI, onglet de migration dédié). Destiné aux DSI et chefs de projets digitaux, ce guide permet d&rsquo;anticiper la mise à jour des intégrations CRM et d&rsquo;assurer une gouvernance SI optimale.</span></b></div>
<p>&nbsp;</p>
<p><strong>L&rsquo;écosystème d&rsquo;intégrations de votre CRM est le cœur battant de votre SI. Avec les dernières évolutions de sa plateforme développeur, HubSpot opère un tournant majeur en matière d&rsquo;app governance, d&rsquo;architecture API et d&rsquo;habilitations. Entre l&rsquo;instauration d&rsquo;un versioning horodaté prédictif, la transition vers les granular scopes et le déploiement d&rsquo;outils d&rsquo;aide à la migration automatisés, les DSI et chefs de projets digitaux disposent de nouveaux leviers pour sécuriser leurs flux de données. Nous allons découvrir ensemble comment ces nouveautés renforcent la stabilité de votre SI et quelles démarches suivre pour faire évoluer vos applications en toute sérénité.</strong></p>
<h2>Une feuille de route connue pour l’évolution des APIs</h2>
<p><span style="font-weight: 400;">Pendant longtemps, le cycle de vie des APIs pouvait générer des incertitudes techniques au sein des équipes informatiques. Pour offrir une prédictibilité maximale aux DSI et aux responsables informatiques, HubSpot adopte désormais un modèle de versioning basé sur les dates. </span><span style="font-weight: 400;">Concrètement, les versions des APIs et de la plateforme (par exemple la version </span><span style="font-weight: 400;">2026.03</span><span style="font-weight: 400;">) atteignent un statut stable à une date fixe et connue. Après le lancement d’une version, seules des corrections de bugs peuvent intervenir, aucun changement majeur ne peut intervenir. </span><span style="font-weight: 400;">HubSpot instaure des fenêtres de support de 18 mois et regroupe les changements majeurs (</span><span style="font-weight: 400;">breaking changes</span><span style="font-weight: 400;">) à un rythme semestriel prévisible.</span></p>
<p><span style="font-weight: 400;">Pour une DSI, cette évolution change la donne :</span></p>
<ul>
<li style="font-weight: 400;" aria-level="1"><b>Prévision budgétaire et technique :</b><span style="font-weight: 400;"> Vous planifiez la maintenance applicative dans votre feuille de route sans subir des interruptions inopinées.</span></li>
<li style="font-weight: 400;" aria-level="1"><b>Fin des ruptures brutales :</b><span style="font-weight: 400;"> Les fenêtres de migration de 18 mois vous laissent le temps d&rsquo;auditer et de mettre à niveau vos connecteurs.</span></li>
<li style="font-weight: 400;" aria-level="1"><b>Standardisation des projets :</b><span style="font-weight: 400;"> Grâce au framework </span><span style="font-weight: 400;">Projects</span><span style="font-weight: 400;"> et au fichier de configuration hsproject.json, l&rsquo;ensemble des scripts et fonctions </span><span style="font-weight: 400;">serverless</span><span style="font-weight: 400;"> est versionné de manière explicite et rigoureuse.</span></li>
</ul>
<h2>Scopes granulaires et gouvernance : appliquer le principe du moindre privilège</h2>
<p><span style="font-weight: 400;">La sécurité des données et le respect de la conformité (RGPD) sont au centre des préoccupations des entreprises. Jusqu&rsquo;à présent, l&rsquo;installation d&rsquo;une application nécessitait souvent la validation de périmètres d&rsquo;accès très larges (comme un accès global au hubdb ou aux tickets).</span></p>
<p><span style="font-weight: 400;">HubSpot remplace ces périmètres génériques par des </span>granular scopes<span style="font-weight: 400;"> (ou périmètres granulaires) fondés sur le principe du moindre privilège. Par exemple, au lieu d&rsquo;autoriser un accès général au CRM, une application doit solliciter des autorisations précises telles que </span><span style="font-weight: 400;">hubdb.tables.read</span><span style="font-weight: 400;"> ou </span><span style="font-weight: 400;">crm.objects.tickets.write</span><span style="font-weight: 400;">.</span></p>
<p><span style="font-weight: 400;">Cette approche apporte des avantages concrets pour la gestion de vos projets :</span></p>
<ol>
<li style="font-weight: 400;" aria-level="1"><b>Contrôle d&rsquo;accès ciblé :</b><span style="font-weight: 400;"> Chaque intégration ne lit et ne modifie strictement que ce dont elle a besoin pour fonctionner.</span></li>
<li style="font-weight: 400;" aria-level="1"><b>Adoption facilitée et confiance :</b><span style="font-weight: 400;"> Les responsables métier et administrateurs valident les applications plus rapidement, sachant que le périmètre est restreint.</span></li>
<li style="font-weight: 400;" aria-level="1"><b>Traçabilité et responsabilité :</b><span style="font-weight: 400;"> Chaque application connectée est désormais rattachée à un propriétaire formel (</span><span style="font-weight: 400;">app owner</span><span style="font-weight: 400;">). Un journal d&rsquo;activité (</span><span style="font-weight: 400;">activity log</span><span style="font-weight: 400;">) permet de suivre les autorisations et prévient les ruptures de flux lors du départ d&rsquo;un collaborateur.</span></li>
</ol>
<div class="citation" gsap-visible>
<blockquote class="contenu"><p>“Le passage aux granular scopes et aux versions API basées sur les dates est une avancée majeure pour la gouvernance SI. Pour nos clients, cela signifie moins de risques de sécurité, une parfaite maîtrise des habilitations et la garantie de connecteurs pérennes entre HubSpot et leur SI. ”</p></blockquote>
<div class="meta"><span class="name">Sylvain Plançon</span>, <span class="function">Développeur CRM</span>, <span class="company">Synolia</span></div>
</div>
<p>&nbsp;</p>
<h2>Quelles aides à la migration pour faire évoluer vos applications ?</h2>
<p><span style="font-weight: 400;">Faire évoluer un écosystème applicatif existant peut sembler complexe. HubSpot a déployé des fonctionnalités et des outils de migration avancés pour guider les équipes techniques à chaque étape.</span></p>
<h3>1. Un onglet de migration centralisé dans votre portail</h3>
<p><span style="font-weight: 400;">Dans le portail développeur, un nouvel onglet dédié aux migrations vous donne une vision consolidée de votre dette technique. Vous identifiez immédiatement :</span></p>
<ul>
<li style="font-weight: 400;" aria-level="1"><span style="font-weight: 400;">Les applications et les clés de services utilisant encore des APIs historiques.</span></li>
<li style="font-weight: 400;" aria-level="1"><span style="font-weight: 400;">Les connecteurs devant passer sur le framework </span><span style="font-weight: 400;">Projects</span><span style="font-weight: 400;">.</span></li>
<li style="font-weight: 400;" aria-level="1"><span style="font-weight: 400;">L&rsquo;échéance exacte des fenêtres de support pour planifier vos interventions.</span></li>
</ul>
<h3>2. Migration automatisée via la HubSpot CLI</h3>
<p><span style="font-weight: 400;">Pour les développeurs et chefs de projets digitaux, le passage au framework moderne s&rsquo;effectue directement en ligne de commande via la </span><b>HubSpot CLI</b><span style="font-weight: 400;"> (version 7.6.0 ou supérieure). Deux commandes spécifiques permettent de vous aider à migrer vos apps legacy et les apps dans des anciennes versions du framework Projects. Les apps les moins complexes seront migrées sans aucune intervention de votre. Et côté backend de votre application aucune modification ne sera nécessaire car les clés d’authentification sont préservées sans interruption de service pour vos utilisateurs.</span></p>
<h3>3. L&rsquo;outil d&rsquo;évaluation automatique des scopes (Scope Migration Tool)</h3>
<p><span style="font-weight: 400;">Concernant la transition vers les </span><span style="font-weight: 400;">granular scopes</span><span style="font-weight: 400;">, HubSpot propose un outil d&rsquo;analyse automatisé. En observant l&rsquo;historique d&rsquo;appel de vos APIs en environnement réel, l&rsquo;outil suggère exactement les scopes granulaires nécessaires à votre application, évitant ainsi toute phase de re-paramétrage manuel fastidieux.</span></p>
<h2>L&rsquo;accompagnement Synolia pour la pérennité de votre plateforme CRM</h2>
<p><span style="font-weight: 400;">Gérer le cycle de vie des APIs, effectuer les montées de version et garantir la sécurité des flux entre HubSpot et le reste de votre système d&rsquo;information exige une méthodologie rigoureuse.</span></p>
<p><span style="font-weight: 400;">Spécialisée en data integration et en déploiement CRM, Synolia accompagne vos équipes à chaque étape :</span></p>
<ul>
<li style="font-weight: 400;" aria-level="1"><b>Audit applicatif :</b><span style="font-weight: 400;"> Analyse de votre parc d&rsquo;applications privées et publiques pour identifier les endpoints obsolètes.</span></li>
<li style="font-weight: 400;" aria-level="1"><b>Refonte d&rsquo;architecture :</b><span style="font-weight: 400;"> Migration de vos scripts vers le framework </span><span style="font-weight: 400;">Projects</span><span style="font-weight: 400;"> et sécurisation des accès par les scopes granulaires.</span></li>
<li style="font-weight: 400;" aria-level="1"><b>Gouvernance SI :</b><span style="font-weight: 400;"> Alignement des règles d&rsquo;habilitation avec la politique de sécurité globale de votre entreprise.</span></li>
</ul>
<p><span style="font-weight: 400;">Nous espérons que cet article vous sera utile pour anticiper ces évolutions. Si vous souhaitez sécuriser l&rsquo;architecture de vos applications ou faire évoluer votre plateforme CRM, les équipes de Synolia sont à votre écoute pour vous conseiller ! </span></p>
<p><a href="https://www.synolia.com/contact/" class="btn btn-primary" title="" target="_self"><span>Contactez-nous ! </span></a></p>
<h2>Foire aux questions (FAQ)</h2>
<p>&nbsp;</p>
<div class="accordions">
<details class="accordion" >
<summary>
        <span class="accordion-title">Quel est le délai accordé pour migrer vers les granular scopes ?</span><br />
    </summary>
<div class="accordion-content">HubSpot a ouvert une fenêtre de migration 6 mois pour permettre aux développeurs et administrateurs d&rsquo;adapter leurs applications. Il est recommandé de lancer l&rsquo;audit de vos scopes dès à présent depuis votre espace développeur afin d&rsquo;éviter tout blocage lors des prochaines échéances de maintenance.</div>
</details>
<details class="accordion" >
<summary>
        <span class="accordion-title">La migration vers le nouveau framework d'application nécessite-t-elle de reconnecter nos utilisateurs ? </span><br />
    </summary>
<div class="accordion-content">Non, la migration d&rsquo;une application via les commandes hs app migrate ou via l&rsquo;outil de gestion des scopes préserve les identifiants d&rsquo;authentification (clés OAuth, jetons) et les installations existantes. Vos utilisateurs conservent leur accès sans nécessiter de réautorisation manuelle.</div>
</details>
<details class="accordion" >
<summary>
        <span class="accordion-title">Comment vérifier si nos applications privées utilisent des APIs obsolètes ?</span><br />
    </summary>
<div class="accordion-content"> Vous pouvez consulter l&rsquo;onglet dédié aux migrations (Migrations) disponible dans votre portail développeur HubSpot. Cet espace répertorie automatiquement les appels d&rsquo;APIs exécutés par vos applications et signale les endpoints historiques (v1, v2, v3) devant être mis à jour vers les versions horodatées.</div>
</details>
</div>
<p>Cet article <a href="https://www.synolia.com/synolab/outils/nouveautes-hubspot-apps-et-apis-versioning-granular-scopes-et-aides-a-la-migration-pour-securiser-votre-si/">Nouveautés HubSpot apps et APIs : versioning, granular scopes et aides à la migration pour sécuriser votre SI</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>Les-Tilleuls.coop annonce le lancement de Tilia, son offre d&#x2019;accompagnement sur mesure en intelligence artificielle</title><link>https://les-tilleuls.coop/blog/decouvrir-tilia-expertise-intelligence-artificielle</link><author>C&#xE9;cile Hamerel</author><date>Wed, 07 Oct 2026 08:49:42 +0000</date><description><![CDATA[<div class="container pt-48 pb-12">
<p class="wp-block-paragraph">Depuis plus de 15 ans, nous construisons des logiciels et des architectures robustes : des API, des applications web, des infrastructures cloud. Nous avons créé et maintenons <a href="https://api-platform.com/" data-type="link" data-id="https://api-platform.com/" target="_blank" rel="noreferrer noopener">API Platform</a>, <a href="https://les-tilleuls.coop/technologies/frankenphp" data-type="link" data-id="les-tilleuls.coop/technologies/frankenphp">FrankenPHP</a>, <a href="https://mercure.rocks/" data-type="link" data-id="https://mercure.rocks/" target="_blank" rel="noreferrer noopener">Mercure</a>, et nous contribuons quotidiennement à PHP, Symfony, Caddy, <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>… Des outils libres que des dizaines milliers d’équipes utilisent chaque jour dans le monde entier. Le 17 septembre dernier, lors de la keynote d’ouverture de Kévin Dunglas à <a href="https://api-platform.com/fr/con/2026/review/" data-type="link" data-id="https://api-platform.com/fr/con/2026/review/" target="_blank" rel="noreferrer noopener">l’API Platform Con 2026</a>, nous avons annoncé <a href="https://tilia.coop/">le lancement de Tilia</a>, notre structure spécialisée dans l’intelligence artificielle.</p>

<figure class="wp-block-image aligncenter size-large"><a href="https://tilia.coop/"><img decoding="async" width="1024" height="341" src="https://les-tilleuls.coop/wp-content/uploads/2026/10/image-1024x341.png" alt="" class="wp-image-22024" srcset="https://les-tilleuls.coop/wp-content/uploads/2026/10/image-1024x341.png 1024w, https://les-tilleuls.coop/wp-content/uploads/2026/10/image-600x200.png 600w, https://les-tilleuls.coop/wp-content/uploads/2026/10/image-300x100.png 300w, https://les-tilleuls.coop/wp-content/uploads/2026/10/image-768x256.png 768w, https://les-tilleuls.coop/wp-content/uploads/2026/10/image-40x13.png 40w, https://les-tilleuls.coop/wp-content/uploads/2026/10/image-1200x400.png 1200w, https://les-tilleuls.coop/wp-content/uploads/2026/10/image.png 1500w" sizes="(max-width: 1024px) 100vw, 1024px" /></a></figure>

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

<p class="wp-block-paragraph">Presque toutes les organisations que nous avons accompagnées ces derniers mois ont testé l’IA générative : un assistant de rédaction, une preuve de concept, un prototype. Et souvent, l’expérience s’arrête là. L’outil ne communique ni avec le CRM, ni avec le groupware, ni avec l’ERP, ni avec l’outil de ticketing, ni avec la base documentaire&#8230; Les données sensibles ne peuvent pas être envoyées vers n’importe quel cloud tiers. Et les équipes hésitent, à juste titre, à confier leurs processus métier à une boîte noire qui ne fonctionne pas de manière déterministe. Le problème n’est pas l’IA, c’est son intégration, et l’intégration c’est notre expertise depuis toujours.</p>

<h2 class="wp-block-heading decorative-title">Connecter l&rsquo;IA à ce qui existe déjà, en gardant le contrôle</h2>

<p class="wp-block-paragraph">Tilia part des systèmes que vous faites déjà tourner. Nous connectons vos données et vos applications à des assistants IA en développant des serveurs MCP sur mesure, nous concevons des workflows où des agents prennent en charge les tâches répétitives, nous construisons vos <a href="https://les-tilleuls.coop/blog/introduction-a-larchitecture-rag" data-type="link" data-id="https://les-tilleuls.coop/blog/introduction-a-larchitecture-rag" target="_blank" rel="noreferrer noopener">RAG</a>, nous créeons des modèles personnalisés (fine tuning) ou entrainés sur mesure. Nous déployons et maintenons les architectures cloud souveraines qui vont avec, 24 heures sur 24. Et nous formons vos équipes, avec des <a href="https://tilia.coop/fr/formation/" data-type="link" data-id="https://tilia.coop/fr/formation/" target="_blank" rel="noreferrer noopener">formations adaptées à tous les niveaux</a>, dispensées par notre organisme certifié Qualiopi.</p>

<p class="wp-block-paragraph">À travers ces services, vous gardez le contrôle : un agent IA est un assistant très rapide, pas un décideur. Il pré-remplit, vous contrôlez. Cette rigueur évite les erreurs coûteuses et garantit une chose simple : que la technologie reste au service de vos métiers, et pas l&rsquo;inverse.</p>

<h2 class="wp-block-heading decorative-title">15 ans d’expertise web à votre service </h2>

<p class="wp-block-paragraph">L’IA en entreprise est d’abord une problématique d’ingénierie. Elle demande des modèles de données et des API propres, des systèmes robustes, des bonnes pratiques de sécurité, de l’observabilité. C’est ce que nous faisons <strong>depuis 15 ans</strong>, et nos contributions au cœur des logiciel qui font tourner le web le montrent publiquement. Nous sommes aussi une coopérative, détenue à 100 % par ses 80 salarié·es et certifiée B Corp, engagée pour un <a href="https://les-tilleuls.coop/eco-conception-et-rse" data-type="link" data-id="https://les-tilleuls.coop/eco-conception-et-rse" target="_blank" rel="noreferrer noopener">numérique responsable</a>. Nous n’avons pas d’actionnaires à satisfaire en vendant de l’IA partout. Nous avons des clients à servir, et une conviction : la technologie doit rester au service des personnes qui l’utilisent.</p>

<h2 class="wp-block-heading decorative-title">Découvrez Tilia</h2>

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

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

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

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

<h2>Le mot de la fin</h2>
<p>Si vous ne retenez qu'une chose de la 1.8 : le binaire que vous lancez est le nôtre, preuve à l'appui, et il se met à jour tout seul. Vos collègues sous Windows ont le même outil que tout le monde. En bonus, <code>CTRL+C</code> fait enfin ce que vous attendez devant un serveur.</p>
<p>La mise à jour tient désormais en une commande :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-8">castor</span><span class="syntax-1"> self-update</span></span></code></pre>
<p>La liste complète des changements est dans le <a rel="nofollow noopener noreferrer" href="https://github.com/jolicode/castor/blob/main/CHANGELOG.md">changelog</a>. Et si l'histoire de static-php-cli vous a donné envie de savoir comment sont construits les binaires Linux, macOS et Windows, dites-le-nous : ça pourrait être le prochain article 🦫</p>]]></description></item><item><title>Se former fait partie du travail</title><link>https://www.jdecool.fr/blog/2026/09/17/se-former-fait-partie-du-travail.html</link><author/><date>Wed, 16 Sep 2026 22:00:00 +0000</date><description><![CDATA[<p>Dans la lignée de ma publication précédente sur les side projects, il existe une autre injonction qui poursuit les développeurs: la veille technique. Et non, elle ne devrait pas reposer sur le temps personnel.</p>

<!--more-->

<p>C’est pourtant une attente implicite dans beaucoup d’équipes. Le développeur “passionné”, c’est celui qui lit des articles le soir, qui suit les nouveautés le week-end, qui teste le dernier framework pendant ses congés. Celui qui ne le fait pas décroche, et ce sera à lui de rattraper le retard.</p>

<p>C’est faux. Se former et comprendre les outils que l’on utilise fait partie intégrante du travail en entreprise. A ce titre, l’article L6321-1 du code du travail impose à l’employeur d’assurer l’adaptation des salariés à leur poste et de veiller au maintien de leur capacité à occuper leur emploi, notamment face aux évolutions technologiques. Difficile de trouver une formulation plus adaptée à notre métier, surtout en ce moment.</p>

<p>Concrètement, cela veut dire un créneau régulier et identifié. J’ai vu des équipes réserver quelques heures par semaine à la lecture, aux tests d’outils et au partage entre développeurs. C’est peu, et pourtant l’écart avec celles qui ne le font pas se voit dans la qualité des décisions techniques.</p>

<p>Que certains aiment lire le soir ne pose aucun problème. Ce qui en pose un, c’est quand cela devient la seule manière de rester à jour.</p>
]]></description></item><item><title>N+1 GraphQL dans NestJS : d&#xE9;tecter avec NestJS Profiler, corriger avec DataLoader</title><link>https://blog.eleven-labs.com/fr/diagnostiquer-n-plus-un-graphql-nestjs-profiler/</link><author/><date>Wed, 16 Sep 2026 00:00:00 +0000</date><description><![CDATA[<div><p>Un problème <strong>N+1</strong> dans une API GraphQL NestJS apparaît lorsqu'un résolveur de champ imbriqué déclenche une lecture supplémentaire pour chaque élément renvoyé. La query peut sembler rapide tout en exécutant beaucoup trop d'allers-retours vers PostgreSQL, MongoDB ou une API externe.</p>
<p>Tant que le jeu de données est petit et que les dépendances répondent vite, ce défaut reste invisible. Il devient coûteux dès que le catalogue, la latence réseau ou le trafic augmentent.</p>
<p>Dans ce tutoriel, nous allons détecter un N+1 GraphQL dans NestJS avec NestJS Profiler, puis le corriger avec des DataLoaders scopés à la requête, capables de grouper les clés demandées et de dédupliquer les lectures d'une même opération.</p>
<p>Sur l'application d'exemple, cette query interroge 3 systèmes différents. Elle passe de <strong>10 allers-retours à 3</strong> : une requête SQL, une requête MongoDB et un appel HTTP.</p>
<p><strong>En résumé :</strong></p>
<ul>
<li><strong>Symptôme</strong> : le nombre de requêtes SQL, MongoDB ou HTTP augmente avec le nombre d'éléments renvoyés par la query.</li>
<li><strong>Cause</strong> : chaque résolveur de champ imbriqué charge sa donnée individuellement, sans connaître les autres éléments de la liste.</li>
<li><strong>Solution</strong> : un DataLoader par source de données, scopé à la requête, qui batche et déduplique les clés demandées.</li>
<li><strong>Résultat</strong> : 10 allers-retours ramenés à 3, et un coût qui ne dépend plus du nombre de produits renvoyés.</li>
</ul>
<h2>Qu'est-ce qu'un problème N+1 dans une API GraphQL NestJS ?</h2>
<p>Un N+1 apparaît lorsqu'une query récupère une liste, puis qu'un résolveur de champ imbriqué déclenche une lecture supplémentaire pour chaque élément de cette liste.</p>
<p>Dans notre exemple, la query récupère des produits, les reviews associées à chaque produit, puis l'auteur de chaque review :</p>
<pre><div><code><span>1 requête  → liste des produits
</span>N requêtes → reviews de chaque produit
M requêtes → auteur de chaque review</code></div></pre>
<p>Avec 4 produits et 5 reviews, cela donne :</p>
<pre><div><code><span>1 + 4 + 5 = 10 allers-retours</span></code></div></pre>
<p>Le danger est que le code n'a pas besoin de changer pour que le problème grossisse. C'est la taille du résultat demandé par le client qui décide du nombre de lectures.</p>
<p>Avec 20 produits, notre scénario déclenche 26 allers-retours :</p>
<pre><div><code><span>1 requête SQL + 20 requêtes MongoDB + 5 appels HTTP</span></code></div></pre>
<p>Un N+1 n'est donc pas forcément une query lente sur un petit jeu de données. C'est surtout une complexité qui évolue avec le nombre d'éléments retournés.</p>
<p>Si vous débutez sur le sujet, <a href="/fr/graphql-kesako/">GraphQL, kesako ?</a> revient sur le fonctionnement des schémas et des résolveurs, et <a href="/fr/commencer-avec-apollojs/">la mise en place d'une API GraphQL avec Apollo</a> détaille la structuration d'un serveur Node.js.</p>
<h2>Le scénario GraphQL utilisé : PostgreSQL, MongoDB et API HTTP</h2>
<p>L'application d'exemple de NestJS Profiler simule un backend de marketplace découpé en contextes métier. La query de démonstration est la suivante :</p>
<pre><div><code><span>query</span><span> </span><span>products</span><span> </span><span>{</span><span>
</span><span>  </span><span>products</span><span> </span><span>{</span><span>
</span><span>    </span><span>id</span><span>
</span><span>    </span><span>name</span><span>
</span><span>    </span><span>description</span><span>
</span><span>    </span><span>inStock</span><span>
</span><span>    </span><span>price</span><span>
</span><span>    </span><span>createdAt</span><span>
</span><span>    </span><span>reviews</span><span> </span><span>{</span><span>
</span><span>      </span><span>id</span><span>
</span><span>      </span><span>author</span><span> </span><span>{</span><span>
</span><span>        </span><span>id</span><span>
</span><span>        </span><span>name</span><span>
</span><span>        </span><span>company</span><span>
</span><span>      </span><span>}</span><span>
</span><span>      </span><span>comment</span><span>
</span><span>      </span><span>rating</span><span>
</span><span>      </span><span>createdAt</span><span>
</span><span>    </span><span>}</span><span>
</span><span>  </span><span>}</span><span>
</span><span></span><span>}</span></code></div></pre>
<p>Elle traverse 3 sources de données, une par champ résolu :</p>

























<div><table><thead><tr><th>Champ GraphQL</th><th>Résolveur</th><th>Source de données</th></tr></thead><tbody><tr><td><code>products</code></td><td><code>ProductResolver</code></td><td>PostgreSQL, avec TypeORM ou MikroORM</td></tr><tr><td><code>Product.reviews</code></td><td><code>ProductReviewsResolver</code></td><td>MongoDB, avec Mongoose</td></tr><tr><td><code>Review.author</code></td><td><code>ReviewAuthorResolver</code></td><td>API HTTP externe d'annuaire utilisateurs</td></tr></tbody></table></div>
<pre><div><code><span>@</span><span>Resolver</span><span>(</span><span>(</span><span>)</span><span> </span><span>=&gt;</span><span> ProductType</span><span>)</span><span>
</span><span></span><span>export</span><span> </span><span>class</span><span> </span><span>ProductReviewsResolver</span><span> </span><span>{</span><span>
</span><span>  </span><span>constructor</span><span>(</span><span>private</span><span> </span><span>readonly</span><span> loader</span><span>:</span><span> ProductReviewsLoader</span><span>)</span><span> </span><span>{</span><span>}</span><span>
</span>
<span>  </span><span>@</span><span>ResolveField</span><span>(</span><span>(</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>[</span><span>ReviewType</span><span>]</span><span>)</span><span>
</span><span>  </span><span>reviews</span><span>(</span><span>@</span><span>Parent</span><span>(</span><span>)</span><span> product</span><span>:</span><span> ProductType</span><span>)</span><span>:</span><span> </span><span>Promise</span><span>&lt;</span><span>Review</span><span>[</span><span>]</span><span>&gt;</span><span> </span><span>{</span><span>
</span><span>    </span><span>return</span><span> </span><span>this</span><span>.</span><span>loader</span><span>.</span><span>load</span><span>(</span><span>String</span><span>(</span><span>product</span><span>.</span><span>id</span><span>)</span><span>)</span><span>;</span><span>
</span><span>  </span><span>}</span><span>
</span><span></span><span>}</span></code></div></pre>
<pre><div><code><span>@</span><span>Resolver</span><span>(</span><span>(</span><span>)</span><span> </span><span>=&gt;</span><span> ReviewType</span><span>)</span><span>
</span><span></span><span>export</span><span> </span><span>class</span><span> </span><span>ReviewAuthorResolver</span><span> </span><span>{</span><span>
</span><span>  </span><span>constructor</span><span>(</span><span>private</span><span> </span><span>readonly</span><span> loader</span><span>:</span><span> ReviewerLoader</span><span>)</span><span> </span><span>{</span><span>}</span><span>
</span>
<span>  </span><span>@</span><span>ResolveField</span><span>(</span><span>(</span><span>)</span><span> </span><span>=&gt;</span><span> ReviewAuthorType</span><span>,</span><span> </span><span>{</span><span> nullable</span><span>:</span><span> </span><span>true</span><span> </span><span>}</span><span>)</span><span>
</span><span>  </span><span>author</span><span>(</span><span>@</span><span>Parent</span><span>(</span><span>)</span><span> review</span><span>:</span><span> ReviewType</span><span>)</span><span>:</span><span> </span><span>Promise</span><span>&lt;</span><span>Reviewer </span><span>|</span><span> </span><span>null</span><span>&gt;</span><span> </span><span>{</span><span>
</span><span>    </span><span>return</span><span> </span><span>this</span><span>.</span><span>loader</span><span>.</span><span>load</span><span>(</span><span>review</span><span>.</span><span>authorId</span><span>)</span><span>;</span><span>
</span><span>  </span><span>}</span><span>
</span><span></span><span>}</span></code></div></pre>
<p>Chaque résolveur est correct pris isolément : il ne connaît que l'élément qu'il doit résoudre. C'est l'exécution combinée de l'arbre GraphQL qui transforme ces lectures individuelles en N+1.</p>
<p>Pour comprendre plus précisément comment NestJS exécute un résolveur et traverse les différentes couches de l'application, consultez <a href="/fr/nestjs-le-cycle-de-vie-dune-requete/">le cycle de vie d'une requête NestJS</a>.</p>
<h2>Reproduire le N+1 GraphQL localement</h2>
<p>Le dépôt contient l'application d'exemple complète. Pour lancer le scénario :</p>
<pre><div><code><span>git</span><span> clone https://github.com/eleven-labs/nest-profiler.git
</span><span></span><span>cd</span><span> nest-profiler
</span><span></span><span>pnpm</span><span> </span><span>install</span><span>
</span><span></span><span>docker</span><span> compose up -d
</span><span></span><span>cp</span><span> examples/api/.env.example examples/api/.env
</span><span></span><span>pnpm</span><span> example:dev</span></code></div></pre>
<p>Le fichier <code>.env.example</code> active les modules nécessaires au scénario :</p>
<pre><div><code><span>FEATURE_GRAPHQL</span><span>=</span><span>true
</span><span></span><span>FEATURE_MONGOOSE</span><span>=</span><span>true
</span><span></span><span>FEATURE_DATALOADER</span><span>=</span><span>false</span></code></div></pre>
<p>Le flag <code>FEATURE_DATALOADER=false</code> est volontaire : nous commencerons par observer le comportement non optimisé, puis nous le basculerons à <code>true</code>.</p>
<p>Une fois l'application démarrée :</p>
<ul>
<li><code>http://localhost:3000/graphql</code> ouvre Apollo Sandbox.</li>
<li><code>http://localhost:3000/_profiler</code> ouvre NestJS Profiler.</li>
<li><code>http://localhost:3000/api</code> ouvre Swagger pour explorer le reste de l'exemple.</li>
</ul>
<p>Envoyez la query <code>products</code> depuis Apollo Sandbox. Pour brancher le profiler sur votre propre application NestJS, la page <a href="https://nest-profiler.eleven-labs.com/docs/getting-started">Getting started</a> décrit l'installation complète.</p>
<h2>Comment détecter un N+1 GraphQL dans NestJS avec le profiler</h2>
<p>NestJS Profiler associe un token à chaque exécution. Après la query GraphQL, les headers de réponse permettent de retrouver directement le profil concerné :</p>
<pre><div><code><span>X-Debug-Token</span><span>:</span><span> </span><span>ebab37ec-3ace-4569-8890-8360ee9e0d3a</span><span>
</span><span></span><span>X-Debug-Token-Link</span><span>:</span><span> </span><span>/_profiler/ebab37ec-3ace-4569-8890-8360ee9e0d3a</span></code></div></pre>
<p>Ouvrez <code>X-Debug-Token-Link</code>, ou accédez à <code>/_profiler</code> puis filtrez les profils GraphQL.</p>
<p>NestJS Profiler rassemble les informations techniques d'une même exécution : opération GraphQL, trace d'exécution, requêtes SQL et MongoDB, appels HTTP sortants, logs et exceptions. Ici, l'important est de commencer par la <strong>trace d'exécution</strong>, puis de confirmer les répétitions dans les panneaux de détail. Ce qu'il collecte sur une opération GraphQL est décrit dans le package <a href="https://nest-profiler.eleven-labs.com/docs/packages/nest-profiler-graphql"><code>@eleven-labs/nest-profiler-graphql</code></a>.</p>
<p></p>
<h3>Lire l'execution trace d'une opération GraphQL</h3>
<p>L'onglet <strong>Performance</strong> affiche une <em>execution trace</em> qui remet les événements dans leur ordre d'exécution. Avec le filtre <strong>I/O only</strong>, elle ne conserve que les opérations qui sortent du processus : SQL, MongoDB, HTTP, cache et autres dépendances externes. L'ensemble des panneaux est décrit dans <a href="https://nest-profiler.eleven-labs.com/docs/profiler-ui">la documentation de l'interface du profiler</a>.</p>
<p>Dans le profil initial, la trace fait apparaître :</p>
<ul>
<li>1 requête SQL.</li>
<li>4 requêtes MongoDB <code>find</code>.</li>
<li>5 appels HTTP sortants.</li>
</ul>
<p></p>
<p>La répétition se voit immédiatement : 4 opérations MongoDB similaires, puis 5 appels vers l'annuaire externe. Les 2 étages se recouvrent partiellement, car chaque appel HTTP démarre dès que les reviews de son produit sont revenues, sans attendre les autres.</p>
<p>La trace permet aussi de distinguer 2 notions souvent confondues :</p>
<ul>
<li>Les appels HTTP partent en parallèle ; cela limite la latence visible localement.</li>
<li>Ils restent malgré tout trop nombreux ; ils consomment des connexions, du budget de rate limiting et des ressources chez la dépendance externe.</li>
</ul>
<h3>N+1 sur MongoDB : 4 lectures des reviews</h3>
<p>Le panneau <strong>Database</strong>, sous-onglet <strong>MongoDB</strong>, confirme que le résolveur <code>Product.reviews</code> déclenche une opération <code>find</code> par produit. Ces requêtes sont collectées par le package <a href="https://nest-profiler.eleven-labs.com/docs/packages/nest-profiler-mongoose"><code>@eleven-labs/nest-profiler-mongoose</code></a>.</p>
<p></p>
<p>Chaque ligne indique notamment :</p>
<ul>
<li>La collection interrogée.</li>
<li>Le type d'opération.</li>
<li>Le filtre.</li>
<li>La durée.</li>
<li>Le nombre de documents retournés.</li>
</ul>
<p>Une des requêtes de l'exemple ne retourne aucun avis. Elle reste pourtant un aller-retour complet vers MongoDB. Plus le catalogue grandit, plus ce coût augmente.</p>
<h3>N+1 sur une API externe : 5 appels HTTP vers les auteurs</h3>
<p>Le panneau <strong>HTTP Client</strong> montre les appels effectués par <code>Review.author</code>. Ces appels sont collectés par le package <a href="https://nest-profiler.eleven-labs.com/docs/packages/nest-profiler-http"><code>@eleven-labs/nest-profiler-http</code></a>.</p>
<p></p>
<p>Les 5 appels relèvent d'un problème de batching : l'API externe est interrogée individuellement pour chaque auteur.</p>
<p>Mais le profil révèle aussi un doublon : <code>/users/1</code> est demandé 2 fois pendant la même opération GraphQL. C'est un problème de déduplication.</p>
<p>Ces 2 problèmes appellent une même solution dans notre cas : un DataLoader scopé à la requête, capable de grouper les clés et de mémoriser celles déjà demandées.</p>
<h2>Corriger un N+1 GraphQL NestJS avec DataLoader</h2>
<p><a href="https://github.com/graphql/dataloader" target="_blank">DataLoader</a> collecte les clés demandées pendant la même phase de résolution GraphQL, puis appelle une fonction de batch avec l'ensemble de ces clés.</p>
<p>Il apporte 2 bénéfices dans notre scénario :</p>
<ul>
<li>Le <strong>batching</strong> : plusieurs produits ou auteurs sont chargés dans une seule requête.</li>
<li>La <strong>déduplication</strong> : une même clé demandée 2 fois n'est chargée qu'une fois pendant la même opération GraphQL.</li>
</ul>
<h3>Avant : un loader direct</h3>
<p>La stratégie initiale appelle le service à chaque résolution :</p>
<pre><div><code><span>@</span><span>Injectable</span><span>(</span><span>)</span><span>
</span><span></span><span>export</span><span> </span><span>class</span><span> </span><span>DirectProductReviewsLoader</span><span> </span><span>implements</span><span> </span><span>ProductReviewsLoader</span><span> </span><span>{</span><span>
</span><span>  </span><span>constructor</span><span>(</span><span>private</span><span> </span><span>readonly</span><span> reviews</span><span>:</span><span> ReviewService</span><span>)</span><span> </span><span>{</span><span>}</span><span>
</span>
<span>  </span><span>load</span><span>(</span><span>productId</span><span>:</span><span> </span><span>string</span><span>)</span><span>:</span><span> </span><span>Promise</span><span>&lt;</span><span>Review</span><span>[</span><span>]</span><span>&gt;</span><span> </span><span>{</span><span>
</span><span>    </span><span>return</span><span> </span><span>this</span><span>.</span><span>reviews</span><span>.</span><span>findByProduct</span><span>(</span><span>productId</span><span>)</span><span>;</span><span>
</span><span>  </span><span>}</span><span>
</span><span></span><span>}</span></code></div></pre>
<p>Chaque produit entraîne donc son propre <code>find({ productId })</code>.</p>
<h3>Après : un DataLoader NestJS pour batcher les relations</h3>
<p>La version DataLoader collecte les identifiants de produits et appelle une unique méthode <code>findByProducts()</code> qui construit un filtre MongoDB avec <code>$in</code>.</p>
<pre><div><code><span>@</span><span>Injectable</span><span>(</span><span>{</span><span> scope</span><span>:</span><span> Scope</span><span>.</span><span>REQUEST</span><span> </span><span>}</span><span>)</span><span>
</span><span></span><span>export</span><span> </span><span>class</span><span> </span><span>DataLoaderProductReviewsLoader</span><span> </span><span>implements</span><span> </span><span>ProductReviewsLoader</span><span> </span><span>{</span><span>
</span><span>  </span><span>private</span><span> </span><span>readonly</span><span> loader</span><span>:</span><span> DataLoader</span><span>&lt;</span><span>string</span><span>,</span><span> Review</span><span>[</span><span>]</span><span>&gt;</span><span>;</span><span>
</span>
<span>  </span><span>constructor</span><span>(</span><span>reviews</span><span>:</span><span> ReviewService</span><span>)</span><span> </span><span>{</span><span>
</span><span>    </span><span>this</span><span>.</span><span>loader </span><span>=</span><span> </span><span>new</span><span> </span><span>DataLoader</span><span>&lt;</span><span>string</span><span>,</span><span> Review</span><span>[</span><span>]</span><span>&gt;</span><span>(</span><span>async</span><span> </span><span>(</span><span>productIds</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span><span>
</span><span>      </span><span>const</span><span> found </span><span>=</span><span> </span><span>await</span><span> reviews</span><span>.</span><span>findByProducts</span><span>(</span><span>productIds</span><span>)</span><span>;</span><span>
</span><span>      </span><span>const</span><span> byProduct </span><span>=</span><span> </span><span>new</span><span> </span><span>Map</span><span>&lt;</span><span>string</span><span>,</span><span> Review</span><span>[</span><span>]</span><span>&gt;</span><span>(</span><span>)</span><span>;</span><span>
</span>
<span>      </span><span>for</span><span> </span><span>(</span><span>const</span><span> review </span><span>of</span><span> found</span><span>)</span><span> </span><span>{</span><span>
</span><span>        </span><span>const</span><span> current </span><span>=</span><span> byProduct</span><span>.</span><span>get</span><span>(</span><span>review</span><span>.</span><span>productId</span><span>)</span><span>;</span><span>
</span>
<span>        </span><span>if</span><span> </span><span>(</span><span>current</span><span>)</span><span> </span><span>{</span><span>
</span><span>          current</span><span>.</span><span>push</span><span>(</span><span>review</span><span>)</span><span>;</span><span>
</span><span>        </span><span>}</span><span> </span><span>else</span><span> </span><span>{</span><span>
</span><span>          byProduct</span><span>.</span><span>set</span><span>(</span><span>review</span><span>.</span><span>productId</span><span>,</span><span> </span><span>[</span><span>review</span><span>]</span><span>)</span><span>;</span><span>
</span><span>        </span><span>}</span><span>
</span><span>      </span><span>}</span><span>
</span>
<span>      </span><span>return</span><span> productIds</span><span>.</span><span>map</span><span>(</span><span>(</span><span>productId</span><span>)</span><span> </span><span>=&gt;</span><span> byProduct</span><span>.</span><span>get</span><span>(</span><span>productId</span><span>)</span><span> </span><span>??</span><span> </span><span>[</span><span>]</span><span>)</span><span>;</span><span>
</span><span>    </span><span>}</span><span>)</span><span>;</span><span>
</span><span>  </span><span>}</span><span>
</span>
<span>  </span><span>load</span><span>(</span><span>productId</span><span>:</span><span> </span><span>string</span><span>)</span><span>:</span><span> </span><span>Promise</span><span>&lt;</span><span>Review</span><span>[</span><span>]</span><span>&gt;</span><span> </span><span>{</span><span>
</span><span>    </span><span>return</span><span> </span><span>this</span><span>.</span><span>loader</span><span>.</span><span>load</span><span>(</span><span>productId</span><span>)</span><span>;</span><span>
</span><span>  </span><span>}</span><span>
</span><span></span><span>}</span></code></div></pre>
<div><p>Le contrat de DataLoader</p>
<p>Le tableau retourné doit garder le même ordre que les clés demandées : une position du tableau correspond à une clé donnée, même lorsqu'aucun résultat n'est trouvé. C'est la source d'erreur la plus fréquente lors de l'écriture d'une fonction de batch.</p>
</div>
<h3>Dédupliquer les appels HTTP avec un second DataLoader</h3>
<p>Batcher les reviews est aussi ce qui permet de batcher les auteurs : quand toutes les reviews sont disponibles dans la même phase de résolution, les identifiants d'auteurs peuvent être regroupés par un second DataLoader.</p>
<pre><div><code><span>Sans DataLoader :
</span>products → reviews(product 1) → authors(product 1)
         → reviews(product 2) → authors(product 2)

Avec DataLoader :
products → reviews(product 1, 2, 3, 4)
         → authors(1, 2, 3, 4)</code></div></pre>
<p>Voici une implémentation type pour ce second loader :</p>
<pre><div><code><span>@</span><span>Injectable</span><span>(</span><span>{</span><span> scope</span><span>:</span><span> Scope</span><span>.</span><span>REQUEST</span><span> </span><span>}</span><span>)</span><span>
</span><span></span><span>export</span><span> </span><span>class</span><span> </span><span>DataLoaderReviewerLoader</span><span> </span><span>implements</span><span> </span><span>ReviewerLoader</span><span> </span><span>{</span><span>
</span><span>  </span><span>private</span><span> </span><span>readonly</span><span> loader</span><span>:</span><span> DataLoader</span><span>&lt;</span><span>number</span><span>,</span><span> Reviewer </span><span>|</span><span> </span><span>null</span><span>&gt;</span><span>;</span><span>
</span>
<span>  </span><span>constructor</span><span>(</span><span>private</span><span> </span><span>readonly</span><span> reviewers</span><span>:</span><span> ReviewerService</span><span>)</span><span> </span><span>{</span><span>
</span><span>    </span><span>this</span><span>.</span><span>loader </span><span>=</span><span> </span><span>new</span><span> </span><span>DataLoader</span><span>&lt;</span><span>number</span><span>,</span><span> Reviewer </span><span>|</span><span> </span><span>null</span><span>&gt;</span><span>(</span><span>async</span><span> </span><span>(</span><span>authorIds</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span><span>
</span><span>      </span><span>const</span><span> authors </span><span>=</span><span> </span><span>await</span><span> </span><span>this</span><span>.</span><span>reviewers</span><span>.</span><span>findByIds</span><span>(</span><span>[</span><span>...</span><span>authorIds</span><span>]</span><span>)</span><span>;</span><span>
</span><span>      </span><span>const</span><span> byId </span><span>=</span><span> </span><span>new</span><span> </span><span>Map</span><span>(</span><span>authors</span><span>.</span><span>map</span><span>(</span><span>(</span><span>author</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>[</span><span>author</span><span>.</span><span>id</span><span>,</span><span> author</span><span>]</span><span>)</span><span>)</span><span>;</span><span>
</span>
<span>      </span><span>return</span><span> authorIds</span><span>.</span><span>map</span><span>(</span><span>(</span><span>authorId</span><span>)</span><span> </span><span>=&gt;</span><span> byId</span><span>.</span><span>get</span><span>(</span><span>authorId</span><span>)</span><span> </span><span>??</span><span> </span><span>null</span><span>)</span><span>;</span><span>
</span><span>    </span><span>}</span><span>)</span><span>;</span><span>
</span><span>  </span><span>}</span><span>
</span>
<span>  </span><span>load</span><span>(</span><span>authorId</span><span>:</span><span> </span><span>number</span><span>)</span><span>:</span><span> </span><span>Promise</span><span>&lt;</span><span>Reviewer </span><span>|</span><span> </span><span>null</span><span>&gt;</span><span> </span><span>{</span><span>
</span><span>    </span><span>return</span><span> </span><span>this</span><span>.</span><span>loader</span><span>.</span><span>load</span><span>(</span><span>authorId</span><span>)</span><span>;</span><span>
</span><span>  </span><span>}</span><span>
</span><span></span><span>}</span></code></div></pre>
<p>Les 2 loaders sont déclarés avec <code>@Injectable({ scope: Scope.REQUEST })</code>, et ce n'est pas un détail : le cache de DataLoader est ainsi limité à l'opération en cours. Il évite les appels dupliqués dans cette opération sans conserver des résultats obsolètes, ni réutiliser des données dans un autre contexte utilisateur ou d'autorisation.</p>
<h3>Activer le scénario DataLoader</h3>
<p>Dans <code>examples/api/.env</code> :</p>
<pre><div><code><span>FEATURE_DATALOADER</span><span>=</span><span>true</span></code></div></pre>
<p>Relancez ensuite l'application, envoyez exactement la même query et ouvrez le nouveau profil avec son <code>X-Debug-Token-Link</code>.</p>
<h2>Mesurer les performances GraphQL avant et après DataLoader</h2>
<p>La correction n'est terminée que lorsqu'elle est vérifiée sur la même opération.</p>
<p>Après activation de DataLoader, l'execution trace ne montre plus que :</p>
<ul>
<li>1 requête SQL.</li>
<li>1 requête MongoDB avec un filtre <code>$in</code>.</li>
<li>1 appel HTTP groupé.</li>
</ul>
<p></p>
<p>Les tags <code>N+1</code> disparaissent du profil.</p>



































<div><table><thead><tr><th></th><th>Avant DataLoader</th><th>Après DataLoader</th></tr></thead><tbody><tr><td>Requêtes SQL</td><td>1</td><td>1</td></tr><tr><td>Requêtes MongoDB</td><td>4</td><td><strong>1</strong></td></tr><tr><td>Appels HTTP sortants</td><td>5</td><td><strong>1</strong></td></tr><tr><td>Total des allers-retours</td><td>10</td><td><strong>3</strong></td></tr><tr><td>Tags de performance</td><td><code>N+1 ×4</code>, <code>N+1 ×5</code></td><td>Aucun</td></tr></tbody></table></div>
<p></p>
<p>Le gain se lit surtout à l'échelle. Avec 4 produits, la query passe de 10 allers-retours à 3. Avec 20 produits, elle passerait de 26 à 3.</p>
<p>Le résultat important est structurel : le nombre d'accès à MongoDB ne dépend plus du nombre de produits renvoyés, et les appels HTTP sont regroupés et dédupliqués.</p>
<h2>DataLoader, jointure ou cache : quelle solution au N+1 GraphQL ?</h2>
<p>DataLoader est adapté lorsqu'une opération GraphQL résout plusieurs clés individuelles et que la source de données peut répondre à ces clés en lot.</p>
<div><p>DataLoader n'est pas un cache applicatif</p>
<p>Son objectif est de limiter les lectures répétées <strong>à l'intérieur d'une même opération GraphQL</strong>. Pour partager des données entre plusieurs requêtes, il faut un cache applicatif avec sa propre stratégie d'invalidation.</p>
</div>
<p>Dans d'autres cas, une autre approche peut être plus pertinente :</p>

































<div><table><thead><tr><th>Situation</th><th>Approche à privilégier</th></tr></thead><tbody><tr><td>Relation SQL connue avant l'exécution</td><td>Jointure, <code>relations</code>, <code>populate</code> ou requête dédiée</td></tr><tr><td>Même donnée demandée entre plusieurs requêtes HTTP</td><td>Cache applicatif avec TTL</td></tr><tr><td>API externe sans endpoint de batch</td><td>Cache par requête pour dédupliquer les appels</td></tr><tr><td>Très grand nombre de résultats</td><td>Pagination, limitation de profondeur ou query complexity</td></tr><tr><td>Query lente sans requêtes répétées</td><td>Index, <code>EXPLAIN</code>, optimisation SQL ou MongoDB</td></tr><tr><td>Données dépendantes des droits de l'utilisateur</td><td>Loader scopé à la requête et clés incluant le contexte nécessaire</td></tr></tbody></table></div>
<h2>Questions fréquentes</h2>
<h3>Comment détecter un N+1 dans une API GraphQL NestJS ?</h3>
<p>Il faut compter les lectures réellement émises pendant une opération, et non seulement lire les résolveurs. NestJS Profiler regroupe les requêtes répétées par empreinte et les marque dans la trace ainsi que dans les panneaux Database ou HTTP Client.</p>
<h3>Comment implémenter un DataLoader dans NestJS avec GraphQL ?</h3>
<p>Le plus simple dans NestJS est de créer un provider <code>@Injectable({ scope: Scope.REQUEST })</code> qui instancie le <code>DataLoader</code> dans son constructeur, expose une méthode <code>load()</code> et se fait injecter dans le résolveur de champ. Le scope requête remplace l'approche consistant à attacher les loaders au contexte GraphQL : NestJS crée une instance par requête et l'injection reste standard.</p>
<h3>Un DataLoader suffit-il à corriger tous les N+1 ?</h3>
<p>Non. Il faut que la source puisse servir plusieurs clés en une fois. Sans endpoint de batch côté API externe, un DataLoader peut toujours dédupliquer une même clé, mais il ne peut pas réduire plusieurs clés distinctes à un seul appel.</p>
<h3>DataLoader ou jointure SQL : que choisir ?</h3>
<p>Quand la relation vit dans la même base et que vous savez avant l'exécution qu'elle sera demandée, une jointure, un <code>relations</code> TypeORM ou un <code>populate</code> reste plus direct. DataLoader devient intéressant lorsque le champ est optionnel dans le schéma, lorsque les données viennent de sources différentes, ou lorsque plusieurs résolveurs demandent les mêmes clés pendant la même opération.</p>
<h3>Pourquoi les loaders doivent-ils être scopés à la requête ?</h3>
<p>Le cache interne de DataLoader est conçu pour la durée d'une opération GraphQL. Un loader partagé entre requêtes risque de réutiliser des données obsolètes, de retenir inutilement de la mémoire ou de mélanger des contextes d'autorisation.</p>
<h3>NestJS Profiler peut-il être utilisé en production ?</h3>
<p>NestJS Profiler est principalement conçu pour le développement et les environnements contrôlés. Les profils peuvent inclure des URLs, paramètres, payloads, logs, erreurs ou données de sécurité. Activez-le conditionnellement et ne rendez pas son interface accessible publiquement.</p>
<h3>Quelle différence entre NestJS Profiler, OpenTelemetry et un APM ?</h3>
<p>Un APM et OpenTelemetry servent généralement à suivre l'état d'un système déployé dans la durée. NestJS Profiler est orienté développement : il permet d'examiner une exécution précise dans son contexte complet, puis de rejouer le scénario après une modification. Les approches sont complémentaires.</p>
<h2>Tester NestJS Profiler</h2>
<p>NestJS Profiler est un projet open source inspiré du Symfony Web Profiler. Il permet de visualiser une exécution NestJS dans <code>/_profiler</code> et d'y relier des collecteurs modulaires : GraphQL, TypeORM, MikroORM, Mongoose, HTTP, cache, validation, sécurité, logs, RabbitMQ, CLI et plus encore.</p>
<p>Pour aller plus loin :</p>
<ul>
<li><a href="https://nest-profiler.eleven-labs.com/">Découvrir NestJS Profiler</a></li>
<li><a href="https://nest-profiler.eleven-labs.com/docs/getting-started">Installer NestJS Profiler dans une application NestJS</a></li>
<li><a href="https://nest-profiler.eleven-labs.com/docs/packages/nest-profiler-graphql">Profiler les opérations GraphQL avec le package dédié</a></li>
<li><a href="https://nest-profiler.eleven-labs.com/docs/example-api">Explorer l'application d'exemple</a></li>
<li><a href="https://nest-profiler-example.eleven-labs.com/_profiler">Tester la démo en ligne</a></li>
<li><a href="https://github.com/eleven-labs/nest-profiler" target="_blank">Accéder au dépôt GitHub</a></li>
</ul>
<p>Un N+1 n'est pas forcément visible dans les temps de réponse locaux. En revanche, il laisse une trace : des appels répétitifs que personne n'a écrits explicitement, mais que NestJS Profiler rend visibles.</p>
<p>Avec 1 query, 2 profils et des DataLoaders, vous pouvez passer d'une intuition à une optimisation mesurée.</p></div>]]></description></item><item><title>Le mythe du side project obligatoire</title><link>https://www.jdecool.fr/blog/2026/09/10/le-mythe-du-side-project-obligatoire.html</link><author/><date>Wed, 09 Sep 2026 22:00:00 +0000</date><description><![CDATA[<p>Il y a un mythe fortement ancré dans la tête des développeurs, sur l’importance d’avoir des side projects. Cassons-le immédiatement. Non, il n’est pas obligatoire d’avoir un side project pour être un bon développeur.</p>

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

<!--more-->

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

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

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

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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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



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



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



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

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

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

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

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

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

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



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



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



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



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

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

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

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

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

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

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

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

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

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

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



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



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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



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



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

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

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

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

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



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



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



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



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



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



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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



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



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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



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



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



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

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

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

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

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

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

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

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

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

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

<h2>Quand le site dépend d'un bug du bundler</h2>
<p>Voici mon anecdote préférée de cette migration. Après le passage à Vite, un test de <a href="https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker">régression visuelle</a> refusait obstinément de passer : sur une déclinaison du site, le logo s'affichait 60 % trop grand.</p>
<p>Nous avons tout vérifié : le fichier SVG copié, identique ; les règles CSS, identiques et dans le même ordre ; le HTML, identique. En dernier recours, le manifest Webpack de production, qui racontait une drôle d'histoire :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-1">"build/images/logo-pro.svg"</span><span class="syntax-2">: </span><span class="syntax-1">"/build/images/logo-pro.e42c1969.svg"</span></span></code></pre>
<p>Un hash dans le nom de fichier, là où toutes les autres entrées copiées utilisaient une query string. Et le contenu de ce fichier hashé n'était <strong>pas</strong> le fichier demandé : c'était un autre SVG du même nom, situé dans un sous-dossier <code>icons/</code>, avec une classe CSS différente, donc une taille différente.</p>
<p>L'explication : les images émises par file-loader (dont tout le dossier <code>icons/</code>, embarqué par le <code>require()</code> dynamique vu plus haut) <strong>écrasaient les clés du manifest</strong> des fichiers copiés portant le même nom. Depuis des années, le site servait le mauvais fichier à cet endroit, et ce rendu accidentel était devenu la référence, jusque dans nos baselines de screenshots. Notre build Vite, plus propre, servait enfin le bon fichier… et cassait donc le test.</p>
<p>Nous avons audité les 31 collisions de noms du projet (une seule autre était visible) et pointé les templates vers le bon fichier, explicitement. Ce que je retiens de cette histoire, c'est qu'un bundler est avant tout un système de résolution : en changer révèle toutes les résolutions accidentelles dont votre site dépend sans que vous le sachiez.</p>
<h2>Un pixel de différence</h2>
<p>Toujours côté régressions visuelles : trois baselines ont bougé d'exactement <strong>1 pixel</strong> après la migration. Un bouton centré, dont la largeur totale (icône dimensionnée en <code>em</code> + texte) tombe à un demi-pixel près différemment. En cause, le changement de minifieur CSS : cssnano (configuré avec <code>calc: false</code> précisément pour éviter ce genre d'arrondis) a laissé la place à esbuild.</p>
<p>0,01 % des pixels, invisible à l'œil, mais parfaitement reproductible. Le rendu de Vite est déterministe au pixel près d'un run à l'autre : nous avons comparé des captures à deux jours d'écart, zéro pixel de différence.</p>
<p>Corollaire qui vaut pour tous ceux qui font des tests de screenshots : <strong>ne régénérez jamais vos baselines sur le dev server</strong>. Le CSS non minifié y produit les mêmes écarts d'arrondis face au rendu buildé que compare votre CI, et vous chercherez longtemps pourquoi « ça passe en local ». Chez nous, la task de mise à jour des screenshots rebuild d'office avant de capturer.</p>
<h2>Brancher le HMR dans la stack Docker</h2>
<p>Le HMR (Hot Module Replacement ou remplacement de module à chaud) est le gain le plus visible de la migration pour les développeurs, et il mérite qu'on s'y attarde. Petite confession d'abord : avec Encore, notre « watch » se contentait d'écrire les fichiers sur le disque, et le vrai dev-server était pensé pour tourner dans Docker pour les PC sous Linux, sur la machine hôte (donc en dehors de Docker) pour les Macs (car ceux de l'époque souffraient trop). Avec Vite, nous voulions le HMR dans la stack, comme tout le reste, et pour tout le monde.</p>
<h3>Les problèmes rencontrés</h3>
<p>Nous avons eu trois problèmes à résoudre.</p>
<p><strong>Atteindre le serveur depuis le navigateur.</strong> Nous avons d'abord sur-conçu la chose : route Traefik dédiée, TLS, service discovery. Puis nous avons retenu une solution bien plus simple, déjà adoptée sur un autre de nos projets : publier le port du conteneur sur l'hôte et servir en <code>http://localhost:5173</code>. Les navigateurs traitent <code>localhost</code> comme une origine sûre, donc pas de mixed content depuis une page HTTPS et zéro certificat à gérer.</p>
<p><strong>Le cycle de vie.</strong> Notre premier montage lançait Vite dans un conteneur <code>compose run</code> éphémère. Mauvaise idée : un watch tué laisse un zombie qui squatte le port 5173 et continue d'écraser les fichiers en douce. Nous avons donc plutôt défini un service Compose dédié : <code>up</code> réutilise ou recrée toujours le même conteneur, ce qui rend le zombie impossible.</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># docker-compose.dev.yml (extrait)</span></span>
<span class="line"><span class="syntax-4">vite</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">    image</span><span class="syntax-2">: </span><span class="syntax-1">"${PROJECT_NAME}-builder"</span></span>
<span class="line"><span class="syntax-4">    command</span><span class="syntax-2">: </span><span class="syntax-1">bash -c "until [ -d node_modules/.bin ]; do sleep 2; done; yarn run ${VITE_SCRIPT:-dev}"</span></span>
<span class="line"><span class="syntax-4">    ports</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-2">        - </span><span class="syntax-1">"127.0.0.1:${PROJECT_VITE_PORT:-5173}:5173"</span></span></code></pre>
<p>Le <code>until node_modules</code> n'est pas décoratif : au premier démarrage de la stack, Docker démarre le service avant que <code>yarn install</code> ne soit passé, et on ne veut pas d'un service en crash-loop.</p>
<p><strong>Les pièges du conteneur.</strong> Deux classiques à connaître. Le watcher de Vite crawle par défaut tout le projet : avec un <code>vendor/</code> de 100 000 fichiers, la limite inotify explose. Il faut donc l'exclure explicitement. Et méfiez-vous de vos patterns d'exclusion : notre <code>**/var/**</code>, pensé pour le cache Symfony, matchait <code>/var/www</code>, le dossier de travail du conteneur. Tout le projet était ignoré par le watcher, donc le HMR était inopérant : le serveur tourne, la page se charge, et rien ne se met à jour. Ancrez vos patterns au dossier du projet :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-2">watch: {</span></span>
<span class="line"><span class="syntax-2">    ignored: [</span><span class="syntax-1">'vendor'</span><span class="syntax-2">, </span><span class="syntax-1">'var'</span><span class="syntax-2">, </span><span class="syntax-1">'web'</span><span class="syntax-2">].</span><span class="syntax-8">map</span><span class="syntax-2">((</span><span class="syntax-12">dir</span><span class="syntax-2">) </span><span class="syntax-5">=></span><span class="syntax-2"> path.</span><span class="syntax-8">resolve</span><span class="syntax-2">(__dirname, dir, </span><span class="syntax-1">'**'</span><span class="syntax-2">)),</span></span>
<span class="line"><span class="syntax-2">},</span></span></code></pre>
<p>Dernier raffinement, la cohérence entre les modes : nos tasks de build stoppent le serveur de dev s'il tourne (sinon les pages repassent sur les assets buildés pendant qu'un serveur orphelin continue de tourner pour rien), et la task de watch le démarre. On ne peut ainsi pas se retrouver dans un état intermédiaire sans le savoir.</p>
<h3>Et les worktrees ?</h3>
<p>Avec l'IA devenant progressivement incontournable pour gagner en efficacité, nous travaillons de plus en plus avec des worktrees git, chacun avec sa stack Docker complète et isolée. C'est une feature native de notre template <a rel="nofollow noopener noreferrer" href="https://github.com/jolicode/docker-starter">docker-starter</a> : le nom de projet Compose est suffixé par le worktree, et tous les ports hôtes sont décalés automatiquement, ce qui permet d'avoir plusieurs stacks qui tournent en parallèle.</p>
<p>Le port du serveur Vite rejoint simplement ce mécanisme : chaque worktree a le sien, et deux développements en parallèle ont chacun leur HMR.</p>
<p>Il restait un détail à régler : Symfony génère des URLs d'assets absolues à partir des <code>base_urls</code> configurées, qui ne connaissent pas le port décalé. Dans un worktree, les pages allaient donc chercher leurs assets sur la stack du checkout principal, et on a mis un moment à comprendre pourquoi. Notre solution : un suffixe de port paramétrable dans les <code>base_urls</code>, vide par défaut (et en production) :</p>
<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10"># config/packages/framework.yaml</span></span>
<span class="line"><span class="syntax-4">framework</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">    assets</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-4">        base_urls</span><span class="syntax-2">:</span></span>
<span class="line"><span class="syntax-2">            - </span><span class="syntax-1">'https://%http.domain.front%%http.public_port_suffix%'</span></span></code></pre>
<p>Et plutôt que de demander à chaque développeur de le renseigner à la main, la task Castor 🦫 qui démarre la stack détecte le worktree et synchronise la valeur dans un fichier <code>parameters_override.yaml</code> local (gitignoré, prévu pour la config personnelle) :</p>

<div class="c-alert c-alert--note">
    <p class="c-alert__title">
                    <span class="c-icon c-icon--monospace">
                <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="c-icon__svg" focusable="false" viewBox="0 0 70 71"><path fill-rule="nonzero" d="M35 .9c19.3 0 35 15.7 35 35s-15.7 35-35 35-35-15.7-35-35S15.7.9 35 .9m0 5c-16.552 0-30 13.449-30 30s13.448 30 30 30c16.552.103 30-13.448 30-30 0-16.551-13.448-30-30-30m0 24.9c1.7 0 3 1.3 3 3v15.3c0 1.7-1.3 3-3 3s-3-1.3-3-3V33.8c0-1.7 1.3-3 3-3m0-11c.8 0 1.6.3 2.3.9.6.5.9 1.3.9 2.1 0 .2-.1.4-.1.6-.1.2-.1.4-.2.6s-.2.3-.3.5-.3.4-.4.5c-1.1 1.1-3.1 1.1-4.2 0-.2-.2-.3-.3-.4-.5s-.2-.3-.3-.5-.2-.4-.2-.6c-.1-.2-.1-.4-.1-.6 0-.8.3-1.6.9-2.1.5-.6 1.3-.9 2.1-.9"/></svg>
            </span>
                        <strong>Info</strong>
    </p>
    <div class="c-alert__content">
                <p>
Notre projet utilise encore des fichiers parameters.yaml, nous n'avons pas migré vers des variables d'environnement et les .env associés.</p>
        </div>
</div>

<pre class="syntax-0" tabindex="0"><code><span class="line"><span class="syntax-10">// Extrait de la task, appelée par `castor up`</span></span>
<span class="line"><span class="syntax-2">$line </span><span class="syntax-4">=</span><span class="syntax-2"> \</span><span class="syntax-9">sprintf</span><span class="syntax-2">(</span><span class="syntax-1">"http.public_port_suffix: ':%d'"</span><span class="syntax-2">, </span><span class="syntax-8">get_worktree_ports</span><span class="syntax-2">(</span><span class="syntax-8">get_worktree_name</span><span class="syntax-2">())[</span><span class="syntax-1">'https'</span><span class="syntax-2">]);</span></span>
<span class="line"><span class="syntax-10">// … créé ou mis à jour dans parameters_override.yaml, sans toucher aux autres clés</span></span></code></pre>
<p>Un nouveau worktree est ainsi utilisable en une seule commande, HMR compris.</p>
<h2>Ce que nous avons perdu au passage</h2>
<p>Pour être complet, voici ce que la migration nous a coûté :</p>
<ul>
<li>la minification svgo des images copiées a été abandonnée, à refaire à la source si le poids devient un sujet ;</li>
<li>ts-loader faisait une analyse statique de code à chaque build, mais Vite ne le fait pas. Nous avons donc ajouté une nouvelle step dans la CI qui lance un <code>yarn tsc --noEmit</code> pour combler le trou (ne l'oubliez pas, c'est un vrai filet qui disparaît sinon) ;</li>
<li>Reprise était expérimental au moment de la migration, avec une API susceptible de bouger d'une version à l'autre. Ce point s'est réglé tout seul depuis : la 1.0 est sortie et adopte la promesse de rétrocompatibilité de Symfony. Notre montée depuis la 0.8 s'est résumée à changer la contrainte de version, sans une ligne de code à toucher.</li>
</ul>
<h2>Une migration largement déléguée à l'IA</h2>
<p>Un dernier point qui vous intéressera peut-être : nous avons laissé une IA faire le gros de cette migration. Pas la décision de migrer ni les choix structurants (le contrat d'assets, le mode de câblage du HMR), mais l'essentiel du travail mécanique et surtout l'itération sur les problèmes rencontrés.</p>
<p>Ce qui a rendu cela possible, ce n'est pas l'IA elle-même, c'est le filet de sécurité qui existait déjà autour du projet : une CI complète avec Behat, PHPUnit et nos <a href="https://jolicode.com/blog/detecter-les-regressions-visuelles-dans-la-ci-avec-playwright-et-docker">tests e2e Playwright avec screenshots</a>. Chaque webpack-isme du chapitre précédent a été détecté par un test, pas par un humain : les 19 scénarios Behat rouges pour le <code>global</code> fantôme, les screenshots pour le logo trop grand ou le pixel de différence, les 404 des fonts dans les captures. À chaque fois, l'IA a pu lire le rapport, reproduire le problème en local, corriger, et relancer la CI, sans que nous ayons à intervenir autrement que pour valider les choix.</p>
<p>Sans cette couverture de tests, la même migration aurait demandé une relecture visuelle de dizaines de pages à chaque itération, et nous n'aurions probablement pas osé déléguer autant. C'est un bon argument, si vous en manquiez, pour investir dans des tests de régression visuelle avant d'entreprendre ce genre de chantier.</p>
<h2>Conclusion</h2>
<p>Nous avons vu dans cet article que la migration mécanique d'Encore vers Reprise tient en quelques heures, et que le vrai travail se joue ailleurs : dans le contrat implicite que votre base de code entretient avec son bundler, et dans les quelques webpack-ismes qui ne se révèlent qu'au runtime. C'est là qu'il faut chercher si vous devez estimer une telle migration.</p>
<p>Le résultat en vaut la peine : notre build est cinq à six fois plus rapide, nous repassons sur la config par défaut de node avec 1 Go de heap (au lieu des 4 Go nécessaires auparavant), nous avons supprimé 583 packages npm, la config a été divisée par trois, et les développeurs ont enfin un hot reload avec fast-refresh React. En bonus, cela signifie également que le déploiement est également plus rapide de plus d'une minute, c'est appréciable !</p>
<p>Être early adopter d'un bundle expérimental a aussi ses bons côtés : le principal point de friction rencontré a fini en <a rel="nofollow noopener noreferrer" href="https://github.com/symfony/reprise/pull/81">contribution upstream</a>, mergée et publiée en quelques jours. La prochaine équipe qui migrera un site aux chemins d'assets figés aura une option de config là où nous avions initialement écrit un plugin local.</p>]]></description></item><item><title>Penser probl&#xE8;me avant de penser solution</title><link>https://www.jdecool.fr/blog/2026/08/26/penser-probleme-avant-de-penser-solution.html</link><author/><date>Tue, 25 Aug 2026 22:00:00 +0000</date><description><![CDATA[<p>En tant que développeur, quand on démarre une tâche ou un projet, on pense facilement au framework que l’on va utiliser, à la base de données, aux patterns que l’on va pouvoir mettre en place. Et si c’était une erreur ?</p> <!--more--> <p>Car en réfléchissant de la sorte, on prend déjà des décisions avant même que le problème ne soit posé. On choisit une solution, une architecture, un outil, et ensuite on cherche la justification. N’est-ce pas penser à l’envers ?</p> <p>Penser problème avant de penser solution, c’est d’abord clarifier le besoin auquel on doit répondre. Quelle est la douleur, pour qui, et dans quel but ? Ce n’est pas en pensant technique, à la manière dont on va l’implémenter dans le code et dans le produit, que l’on trouvera ces réponses.</p> <p>L’IA amplifie aujourd’hui ce phénomène. Elle produit une implémentation pour n’importe quelle demande. Le coût de mise en place d’une solution a drastiquement diminué, mais celui de se tromper de problème n’a pas bougé.</p> <p>Poser le problème, c’est déjà la moitié du chemin de fait. C’est du problème que découle la solution, et de la solution que découlent les outils à mettre en place. Pas l’inverse.</p> ]]></description></item></channel></rss>
