<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
  <title>Two Rings · Blog</title>
  <link>https://www.two-rings.fr/blog/</link>
  <atom:link href="https://www.two-rings.fr/blog/feed.xml" rel="self" type="application/rss+xml"/>
  <description>Veille technique de l’atelier Two Rings : PHP, Symfony, sécurité web et e-commerce.</description>
  <language>fr-FR</language>
  <lastBuildDate>Tue, 29 Sep 2026 13:40:00 GMT</lastBuildDate>
  <item>
    <title>Cache CI : vos secrets sont-ils lisibles par une PR ?</title>
    <link>https://www.two-rings.fr/blog/miri-cache-ci-secrets/</link>
    <guid isPermaLink="true">https://www.two-rings.fr/blog/miri-cache-ci-secrets/</guid>
    <pubDate>Tue, 29 Sep 2026 13:40:00 GMT</pubDate>
    <description>Une fuite de secrets via le cache de Miri sur GitHub Actions rappelle une règle simple de pipeline. Voici comment auditer vos jobs PHP ou Rust en 30 minutes.</description>
    <category>Sécurité</category>
    <category>DevOps</category>
    <category>PHP</category>
    <category>Rust</category>
    <content:encoded><![CDATA[<p>Le 21 septembre, l’équipe de sécurité de Rust a publié un avis : la commande <code>cargo miri</code> enregistre toutes les variables d’environnement dans le répertoire <code>target/</code>. Si ce répertoire est mis en cache dans GitHub Actions et que le cache est lisible depuis les pull requests, les secrets passés au job peuvent ressortir dans n’importe quelle PR. Le correctif est annoncé pour la nightly du 22 septembre, l’avis précisant qu’il peut ne pas y être encore disponible. Mais je pense que le vrai sujet n’est pas Miri : c’est une règle de conception de pipeline que la plupart des petites équipes ne s’appliquent pas, et qui vaut autant pour un projet Symfony avec un cache Composer que pour un build Rust.</p>
<h2 id="ce-que-dit-l-avis-et-ce-qu-il-ne-dit-pas">Ce que dit l’avis, et ce qu’il ne dit pas</h2>
<p>Selon l’avis signé Manish Goregaokar pour le Rust Security Response Team, Miri doit conserver certaines variables d’environnement d’un appel à l’autre pour rejouer correctement les builds. Il les écrivait donc toutes dans <code>target/</code>. Le correctif réduit la liste aux variables <code>CARGO_*</code> (hors <code>CARGO_*_TOKEN</code>) et à <code>OUT_DIR</code>.</p>
<p>Vous êtes concerné si vous cumulez trois choses : <code>cargo miri</code> en CI, des secrets exposés en variables d’environnement aux étapes qui le lancent, et un cache de <code>target/</code> accessible aux pull requests. L’avis ne mentionne pas de CVE, et précise qu’un dépôt a été confirmé vulnérable, sept autres appelant à la prudence. Ce n’est donc pas une catastrophe à grande échelle. Les mesures immédiates recommandées sont de désactiver le cache sur les jobs concernés, de restreindre les secrets aux seules étapes qui en ont besoin, de vider les caches existants et de faire tourner les secrets qui ont pu fuiter.</p>
<p>Je retiens la nature du défaut : aucun exploit sophistiqué. Un outil écrit trop de choses sur disque, et un cache est relu par un contexte moins fiable que celui qui l’a écrit.</p>
<h2 id="pourquoi-le-cache-est-un-canal-de-fuite">Pourquoi le cache est un canal de fuite</h2>
<p>La documentation GitHub est explicite. Un run de workflow peut restaurer un cache créé sur sa branche ou sur la branche par défaut, et, pour une pull request, sur la branche de base. La documentation ajoute : « Don’t store sensitive information in a cache. Anyone who can open a pull request against your repository can read the contents of caches in the base branch. » Elle précise aussi que le contenu d’un cache n’est ni signé ni vérifié.</p>
<p>Le raisonnement à garder est donc le suivant : tout ce qui est écrit dans un chemin mis en cache doit être considéré comme lisible par quiconque peut ouvrir une PR. Le problème n’est pas de stocker un secret volontairement (personne ne le fait), mais qu’un outil l’écrive à votre place. Un projet public a d’ailleurs réagi à l’avis en coupant l’enregistrement du cache Rust sur ses builds de release, qui portent des clés de signature, comme le décrit <a href="https://github.com/unslothai/unsloth/pull/11517" rel="external noopener">cette pull request</a> : son auteur y explique que Cargo sérialise la valeur des variables déclarées par <code>cargo:rerun-if-env-changed</code> dans <code>target/</code>. Je n’ai pas vérifié ce point dans la documentation de Cargo, prenez-le comme un témoignage de mainteneur.</p>
<h2 id="le-meme-schema-dans-un-projet-php">Le même schéma dans un projet PHP</h2>
<p>Sur les pipelines PHP que j’ai rencontrés, la configuration typique met en cache le dossier de cache de Composer (<code>~/.cache/composer</code> sur les runners Ubuntu) ou le dossier <code>vendor/</code>, quelquefois le dossier du projet entier pour gagner du temps. Le cache Composer contient des archives de paquets, ce qui est sans risque. Le reste dépend de ce que le job a écrit avant.</p>
<p>Un exemple concret côté Symfony : la commande <code>composer dump-env prod</code>, recommandée pour la production, compile les fichiers <code>.env*</code> en un <code>.env.local.php</code>. Les variables réellement présentes dans l’environnement n’y sont pas copiées, elles restent lues à l’exécution. En revanche, si la CI écrit des secrets dans un <code>.env.prod.local</code> avant le dump, ils se retrouvent en clair dans le fichier généré. Et si ce fichier atterrit dans un chemin mis en cache, vous venez de recréer le défaut de Miri avec un autre outil. Le même raisonnement s’applique à tout fichier de configuration généré, à un <code>docker buildx</code> qui met en cache des couches, ou à un dossier <code>var/cache</code> réchauffé avec des paramètres sensibles.</p>
<h2 id="la-regle-separer-les-jobs-qui-ecrivent-des-caches-de-ceux-qui-portent-des-secrets">La règle : séparer les jobs qui écrivent des caches de ceux qui portent des secrets</h2>
<p>GitHub a d’ailleurs commencé à durcir la plateforme de son côté. Depuis le 26 juin 2026, les événements qui peuvent être déclenchés sans droit d’écriture sur le dépôt (<code>pull_request_target</code>, <code>issue_comment</code>, les cascades <code>workflow_run</code> issues d’une PR de fork) reçoivent un jeton de cache en lecture seule quand ils s’exécutent sur la branche par défaut. Les événements <code>push</code>, <code>schedule</code> ou <code>workflow_dispatch</code> gardent la lecture et l’écriture, et <code>pull_request</code> aussi. Le 10 septembre, GitHub a ajouté un réglage <code>cache-mode</code> qui fixe l’accès au cache au niveau du workflow ou du job : <code>read</code>, <code>write</code>, <code>write-only</code> ou <code>none</code>, le réglage du job l’emportant sur celui du workflow. Pour un job de déploiement, <code>none</code> est la valeur naturelle ; la syntaxe exacte est dans la documentation des workflows.</p>
<p>Cela protège contre l’empoisonnement de cache par un tiers, pas contre l’écriture involontaire d’un secret par un outil de confiance. La discipline reste à vous. Voici la structure que je recommande : un job de construction qui écrit le cache sans aucun secret, un job de déploiement qui n’écrit aucun cache.</p>
<pre><code class="language-yaml">name: ci
on: [push, pull_request]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    # aucun secret ici : ce job lit et écrit le cache
    steps:
      - uses: actions/checkout@v6
      - id: composer-cache
        run: echo &quot;dir=$(composer config cache-files-dir)&quot; &gt;&gt; &quot;$GITHUB_OUTPUT&quot;
      - uses: actions/cache@v5
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: composer-${{ hashFiles(&#39;composer.lock&#39;) }}
      - run: composer install --no-interaction
      - run: vendor/bin/phpunit

  deploy:
    needs: test
    if: github.ref == &#39;refs/heads/main&#39;
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v6
      - run: ./bin/deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}</code></pre>
<p>Les numéros de version des actions ci-dessus sont là pour la lisibilité : épinglez celles que vous utilisez réellement, idéalement par empreinte de commit. Ce qui compte est la séparation : le secret n’existe que dans une étape du job <code>deploy</code>, qui ne restaure ni n’enregistre aucun cache.</p>
<h2 id="audit-d-un-pipeline-en-30-minutes">Audit d’un pipeline en 30 minutes</h2>
<p>La liste qui suit tient en une demi-heure sur un dépôt de taille moyenne. C’est celle que je déroule en premier sur une base que je découvre.</p>
<ol>
<li>Listez tous les usages de cache : <code>grep -rn &quot;actions/cache\|cache:&quot; .github/workflows/</code>, sans oublier les options de cache des actions <code>setup-*</code>.</li>
<li>Pour chaque cache, notez le chemin et demandez-vous quels outils ont écrit dedans avant la sauvegarde.</li>
<li>Listez tous les secrets : <code>grep -rn &quot;secrets\.&quot; .github/workflows/</code>, et dites pour chacun quel job et quelle étape le voit.</li>
<li>Repérez les secrets déclarés au niveau du workflow ou du job (<code>env:</code>) plutôt que de l’étape. Descendez-les à l’étape.</li>
<li>Repérez les jobs qui cumulent cache en écriture et secret. C’est le cas à corriger en priorité.</li>
<li>Pour les projets Rust, vérifiez si <code>cargo miri</code> tourne quelque part, et si <code>target/</code> est mis en cache.</li>
<li>Si un doute existe sur un secret passé dans un job qui écrivait un cache : videz le cache, puis faites tourner le secret. Le vider ne suffit pas si quelqu’un a pu le lire entre-temps.</li>
</ol>
<h2 id="les-limites">Les limites</h2>
<p>Cette règle a un coût. Séparer les jobs peut allonger un pipeline, et un job de déploiement sans cache repart de zéro sur ses dépendances. Sur un projet où le déploiement est court, c’est négligeable. Sur un build qui a besoin d’un secret pour récupérer des dépendances privées (un jeton de registre Composer privé, par exemple), la séparation demande un peu d’ingénierie : le jeton est nécessaire pendant l’installation, donc pendant l’écriture du cache. Dans ce cas, vérifiez que le jeton n’est pas écrit dans un chemin mis en cache (un fichier <code>auth.json</code> dans le dossier de Composer, par exemple) et préférez un jeton à portée minimale, en lecture seule.</p>
<p>Ensuite, l’avis Miri concerne un cas précis, et il est corrigé. Si vous ne lancez pas Miri, l’article ne prétend pas que votre pipeline fuit. Il dit qu’un pipeline dont vous n’avez jamais listé les secrets et les caches est un pipeline dont vous ne savez pas s’il fuit. Enfin, je n’ai pas reproduit la fuite pour cet article : je m’appuie sur l’avis de l’équipe Rust et sur la documentation de GitHub.</p>
<h2 id="concretement-par-ou-commencer">Concrètement, par où commencer</h2>
<ol>
<li>Dès cette semaine, exécutez les deux <code>grep</code> de la section précédente et dressez un tableau job, cache, secrets.</li>
<li>Corrigez d’abord le cas le plus grave : un job qui écrit un cache et voit un secret de déploiement ou de signature.</li>
<li>Si vous utilisez Miri, mettez à jour la nightly et vérifiez la date de vos caches existants ; en cas de doute, videz-les et faites tourner les secrets concernés.</li>
<li>Ajoutez au dépôt une note de trois lignes qui énonce la règle, pour qu’elle survive au prochain changement de mainteneur.</li>
<li>Relisez la question de fond avec votre équipe : qui, aujourd’hui, peut ouvrir une PR sur vos dépôts, et qu’est-ce que votre CI lui permet de lire ?</li>
</ol>
<p>Sur la plupart des audits de CI, ce qui m’a surpris n’est pas la sophistication d’une faille, c’est la quantité de secrets visibles par des jobs qui n’en avaient pas besoin.</p>
<h2 id="sources">Sources</h2>
<ul>
<li><a href="https://blog.rust-lang.org/2026/09/21/github-actions-leaking-secrets-when-miri-output-is-cached/" rel="external noopener">GitHub Actions leaking secrets when Miri output is cached</a>, Rust Blog, 21 septembre 2026</li>
<li><a href="https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching" rel="external noopener">Dependency caching reference</a>, GitHub Docs, consulté le 29 septembre 2026</li>
<li><a href="https://github.blog/changelog/2026-06-26-read-only-actions-cache-for-untrusted-triggers/" rel="external noopener">Read-only Actions cache for untrusted triggers</a>, GitHub Changelog, 26 juin 2026</li>
<li><a href="https://github.blog/changelog/2026-09-10-control-github-actions-cache-access-with-cache-mode/" rel="external noopener">Control GitHub Actions cache access with cache-mode</a>, GitHub Changelog, 10 septembre 2026</li>
<li><a href="https://github.com/unslothai/unsloth/pull/11517" rel="external noopener">Keep credentials out of cached directories, and audit the org against the miri cache disclosure</a>, pull request 11517, unslothai/unsloth, consultée le 29 septembre 2026</li>
</ul>]]></content:encoded>
  </item>
  <item>
    <title>Symfony 8.2 : l’outbox de Messenger, un filet pour votre legacy</title>
    <link>https://www.two-rings.fr/blog/symfony-messenger-outbox-legacy/</link>
    <guid isPermaLink="true">https://www.two-rings.fr/blog/symfony-messenger-outbox-legacy/</guid>
    <pubDate>Mon, 28 Sep 2026 07:58:00 GMT</pubDate>
    <description>Messenger 8.2 intègre l’outbox transactionnel et le claim check : de quoi fiabiliser la couture entre un monolithe historique et ses nouveaux services.</description>
    <category>Symfony</category>
    <category>Architecture</category>
    <category>Montée de version</category>
    <content:encoded><![CDATA[<p>Le 21 septembre 2026, le blog Symfony a présenté deux nouveautés de Messenger dans Symfony 8.2 : le transactional outbox et le claim check. La première règle un problème que presque toutes les applications à message ont un jour : enregistrer en base puis publier sur un broker, sans atomicité entre les deux. Ce problème est surtout aigu dans un legacy en cours de migration, parce que le découpage progressif crée précisément une frontière où des messages doivent traverser.</p>
<p>Ma thèse est simple : l’outbox natif transforme un pattern qu’on écrivait à la main, souvent de travers, en quelques lignes de configuration. C’est l’un des chantiers à meilleur rendement d’une montée vers 8.2, à condition de comprendre ce qu’il garantit et ce qu’il ne garantit pas.</p>
<h2 id="le-bug-qui-n-apparait-qu-en-production">Le bug qui n’apparaît qu’en production</h2>
<p>Le scénario est banal. Un contrôleur ou un handler enregistre une commande en base, puis dispatche un message vers RabbitMQ ou SQS pour prévenir le reste du système. Deux écritures, deux systèmes, aucune transaction commune.</p>
<p>Deux échecs sont possibles. Si la transaction est annulée après l’envoi du message, le broker transporte l’annonce d’un fait qui n’a pas eu lieu. Si le broker est indisponible au moment de l’envoi, la donnée est enregistrée mais personne n’en est informé. Dans les deux cas, rien ne casse bruyamment : les systèmes divergent en silence et quelqu’un finit par réconcilier à la main.</p>
<p>Sur les migrations que j’ai menées, cet endroit existe presque toujours quelque part dans le monolithe : un « on enregistre, puis on envoie » sans filet. Ça marche la plupart du temps, ce qui est exactement ce qui le rend dangereux.</p>
<h2 id="ce-que-l-outbox-de-messenger-8-2-fait-concretement">Ce que l’outbox de Messenger 8.2 fait concrètement</h2>
<p>Le principe est celui du transactional outbox. Le message n’est pas envoyé au broker : il est écrit en base, dans la même transaction que vos données métier. Un worker dédié, le relais, lit ensuite ces messages stockés et les transmet au vrai broker. Les consommateurs, eux, lisent le broker comme avant.</p>
<p>Selon l’article du blog Symfony, la configuration se réduit à une option sur le transport :</p>
<pre><code class="language-yaml"># config/packages/messenger.yaml
framework:
    messenger:
        transports:
            orders:
                dsn: &#39;%env(MESSENGER_TRANSPORT_DSN)%&#39;
                # les messages envoyés vers &quot;orders&quot; passent d&#39;abord par &quot;db_outbox&quot;
                outbox: db_outbox

            db_outbox: &#39;doctrine://default?queue_name=outbox&#39;</code></pre>
<p>Et deux workers à faire tourner :</p>
<pre><code class="language-bash"># le relais : transmet les messages stockés vers &quot;orders&quot; (il n&#39;exécute aucun handler)
php bin/console messenger:consume db_outbox

# traite les messages de &quot;orders&quot; comme d&#39;habitude
php bin/console messenger:consume orders</code></pre>
<p>Le point important est que votre code applicatif ne change pas. Vous dispatchez toujours le même message sur le même bus, dans votre transaction Doctrine ; c’est le transport qui fait la différence. Le gain de migration est là : le pattern se déploie sans réécrire les handlers, message par message si vous le souhaitez.</p>
<h2 id="les-contraintes-a-lire-avant-d-activer">Les contraintes à lire avant d’activer</h2>
<p>L’article est net sur les limites, et elles comptent plus que la configuration.</p>
<ul>
<li>L’outbox doit être un transport Doctrine utilisant la même connexion que votre application. C’est ce qui rend l’écriture atomique ; si vos données métier vivent dans une autre base ou une autre connexion, la garantie disparaît.</li>
<li>L’ordre des messages n’est pas garanti pendant la transmission. Pour un flux où « commande créée » doit précéder « commande payée », ce n’est pas un détail.</li>
<li>Le délai d’un message s’applique pendant son attente dans l’outbox ; une fois transmis, il n’a plus de délai.</li>
<li>Si la transmission échoue, c’est la stratégie de retry et le failure transport de l’outbox qui s’appliquent. Si le traitement échoue, les nouvelles tentatives repartent directement vers le transport cible, car le message est déjà passé par l’outbox.</li>
</ul>
<p>Ce dernier point mérite une vérification en préproduction : vous aurez désormais deux endroits où un message peut échouer, avec deux stratégies de reprise. Il faut savoir, avant l’incident, lequel des deux failure transports regarder.</p>
<p>Un corollaire que l’article ne développe pas et qui relève du pattern lui-même : une livraison fiable de ce type est de l’ordre de « au moins une fois ». Vos consommateurs doivent donc tolérer de recevoir deux fois le même message. Si ce n’est pas le cas aujourd’hui, l’outbox rend le sujet visible, ce qui n’est pas plus mal, mais il faut le traiter.</p>
<h2 id="le-claim-check-pour-les-messages-trop-gros">Le claim check, pour les messages trop gros</h2>
<p>La seconde nouveauté répond à un autre problème : les brokers limitent la taille des messages. Avec le claim check, un message dont la taille encodée (corps et en-têtes) dépasse un seuil est stocké dans un pool de cache, et seule une référence (un identifiant aléatoire et une somme de contrôle) est envoyée.</p>
<pre><code class="language-yaml">framework:
    cache:
        pools:
            # un pool dédié, accessible aux producteurs et aux consommateurs
            messenger.claim_check.cache:
                adapter: cache.adapter.redis
                # les claims ne sont supprimés qu&#39;à l&#39;expiration
                default_lifetime: 604_800 # 7 jours

    messenger:
        transports:
            async:
                dsn: &#39;%env(MESSENGER_TRANSPORT_DSN)%&#39;
                claim_check:
                    cache_pool: messenger.claim_check.cache
                    # gardez une marge sous la limite de votre broker
                    max_size: 200_000</code></pre>
<p>Le piège tient en une phrase de la documentation : le pool de cache fait désormais partie de la livraison des messages. Ne le partagez pas avec le cache applicatif, car un <code>cache:clear</code> ferait disparaître des messages en attente. Réglez <code>default_lifetime</code> au-delà de la durée de vie réelle d’un message, retries et attente dans le failure transport compris. Un claim expiré produit une <code>ClaimCheckNotFoundException</code>, encapsulée dans une <code>MessageDecodingFailedException</code>, qui suit le chemin habituel de retry et de failure transport.</p>
<p>Sur un legacy, je vois deux cas d’usage : les exports ou synthèses volumineux qu’on poussait dans un message faute de mieux, et les payloads qui ont grossi au fil des ans jusqu’à frôler la limite du broker.</p>
<h2 id="pourquoi-c-est-un-chantier-de-migration-pas-seulement-une-amelioration">Pourquoi c’est un chantier de migration, pas seulement une amélioration</h2>
<p>Une migration progressive, de type strangler, crée mécaniquement une couture : l’ancien monolithe d’un côté, les nouveaux services de l’autre, et des messages entre les deux. C’est là que la double écriture fait le plus de dégâts, parce que chaque incohérence est difficile à attribuer : l’ancien code, le nouveau, ou le transport ?</p>
<p>Mon avis est qu’il faut sécuriser cette couture avant de déplacer de la logique métier au-delà. Un outbox en place fait de la couture un point fixe : ce qui est en base est ce qui sera publié, et l’on peut, en cas de doute, compter les lignes de la table d’outbox plutôt que fouiller des logs de broker.</p>
<p>Je commencerais par les messages critiques (paiement, facturation, changement de statut de commande) et non par tout le bus d’un coup. Chaque transport a sa propre option <code>outbox</code>, ce qui permet cette progression.</p>
<h2 id="les-limites">Les limites</h2>
<p>L’outbox n’est pas gratuit. Vous ajoutez une table qui grossit, un worker de plus à superviser, et de la latence : un message passe par la base avant d’atteindre le broker. Pour un flux où la milliseconde compte, ce n’est peut-être pas le bon compromis.</p>
<p>L’absence de garantie d’ordre est la limite la plus sérieuse. Si votre métier dépend d’une séquence stricte pour un même agrégat, il faudra soit rendre les consommateurs tolérants au désordre, soit trouver une autre approche. Je n’ai pas vu, dans l’article, de mécanisme de partitionnement ; je ne prétends donc pas que ce cas soit couvert.</p>
<p>Il existe aussi des alternatives plus lourdes, comme la capture des changements directement dans le journal de la base, qui évitent d’écrire dans une table d’outbox. Elles demandent une infrastructure que la plupart des équipes avec un monolithe historique n’ont pas, et je les réserverais aux cas où le volume l’impose.</p>
<p>Enfin, tout cela suppose Symfony 8.2. Sur une application encore en 6.4 ou 7.4, le pattern reste faisable à la main, mais l’argument « une ligne de configuration » ne tient plus. C’est une raison de plus de planifier la montée de version.</p>
<h2 id="concretement-par-ou-commencer">Concrètement, par où commencer</h2>
<ol>
<li>Repérez dans votre code les endroits où un <code>flush()</code> ou un commit est suivi d’un dispatch vers un broker. Une recherche sur le bus de messages suffit pour un premier inventaire.</li>
<li>Classez ces messages par criticité. Les paiements et la facturation d’abord.</li>
<li>Vérifiez que vos consommateurs supportent un message reçu deux fois, et un message reçu dans le désordre. C’est le vrai pré-requis, plus que la version de Symfony.</li>
<li>Sur un environnement de préproduction, activez l’outbox sur un seul transport, faites tourner le relais et provoquez des pannes du broker. Observez où atterrissent les échecs.</li>
<li>Décidez qui surveille la table d’outbox et le relais : une alerte sur l’ancienneté du plus vieux message non transmis est un bon indicateur.</li>
</ol>
<p>Si vous préparez une montée vers 8.2 sur une application ancienne, cet inventaire des écritures doubles est un bon premier jalon : il livre de la fiabilité avant même que la migration ne commence, et cette valeur-là se voit tout de suite.</p>
<h2 id="sources">Sources</h2>
<ul>
<li><a href="https://symfony.com/blog/new-in-symfony-8-2-messenger-outbox-and-claim-check" rel="external noopener">New in Symfony 8.2: Messenger Outbox and Claim Check</a> : Symfony Blog, 21 septembre 2026</li>
<li><a href="https://symfony.com/blog/a-week-of-symfony-1030-september-21-27-2026" rel="external noopener">A Week of Symfony #1030 (21–27 septembre 2026)</a> : Symfony Blog, 27 septembre 2026</li>
</ul>]]></content:encoded>
  </item>
</channel>
</rss>
