<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Development on Guillaume Delré</title><link>https://guillaumedelre.github.io/fr/categories/development/</link><description>Recent content in Development on Guillaume Delré</description><generator>Hugo</generator><language>fr-FR</language><lastBuildDate>Sat, 06 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/fr/categories/development/index.xml" rel="self" type="application/rss+xml"/><item><title>J'ai arrêté d'attendre TVTime</title><link>https://guillaumedelre.github.io/fr/2026/06/06/jai-arr%C3%AAt%C3%A9-dattendre-tvtime/</link><pubDate>Sat, 06 Jun 2026 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/06/06/jai-arr%C3%AAt%C3%A9-dattendre-tvtime/</guid><description>Des années de spinners et une API fermée plus tard, j&amp;#39;ai reconstruit mon tracker depuis zéro avec React, Firebase et trois API publiques.</description><content:encoded><![CDATA[<p>Pendant des années, mon rituel pour cocher un épisode regardé ressemblait à : ouvrir TVTime, attendre, erreur, appuyer sur Réessayer, attendre encore, cocher enfin. Deux Réessayer et quarante secondes pour une case à cocher. Je continuais à l&rsquo;utiliser parce que le concept tenait la route — un journal de ce que je regarde, où j&rsquo;en suis, quelques stats vaguement déprimantes sur le temps que j&rsquo;ai confié aux plateformes de streaming. Mais ma patience avait atteint sa limite.</p>
<h2 id="lapi-qui-continuait-de-séloigner">L&rsquo;API qui continuait de s&rsquo;éloigner</h2>
<p>Il y a quelques années, j&rsquo;avais obtenu une clé d&rsquo;API développeur. L&rsquo;idée était d&rsquo;écrire un client minimal pour cocher des épisodes sans jamais toucher l&rsquo;interface de l&rsquo;app. J&rsquo;ai fait quelques requêtes, ça marchait, mais la lenteur venait de l&rsquo;API et non de l&rsquo;app — un client maison ne réglerait rien. J&rsquo;ai laissé tomber. Quelques mois plus tard, les requêtes étaient de toute façon bloquées — je n&rsquo;ai jamais su si ma clé avait été révoquée en silence ou si l&rsquo;API avait changé sans prévenir.</p>
<p>TVTime est une application gratuite sans modèle de monétisation évident — que l&rsquo;API soit la première victime d&rsquo;une réduction de coûts d&rsquo;infrastructure, c&rsquo;était compréhensible. J&rsquo;ai continué à utiliser l&rsquo;app, à attendre.</p>
<p>Quelques années plus tard, je commençais à jouer avec Home Assistant et l&rsquo;idée d&rsquo;un dashboard de mes épisodes en cours me paraissait évidente. J&rsquo;ai renvoyé une demande d&rsquo;accès API en mentionnant ce cas d&rsquo;usage. Cette fois la réponse était claire : plus de clé d&rsquo;API pour le public.</p>
<p>Second échec. Et en y repensant, TVTime n&rsquo;avait pas vraiment évolué depuis des années — si tant est qu&rsquo;il n&rsquo;avait pas régressé.</p>
<h2 id="le-dimanche-qui-a-rompu-lhabitude">Le dimanche qui a rompu l&rsquo;habitude</h2>
<p>Le troisième signal est arrivé un dimanche après-midi. Spinner. Erreur. Spinner. J&rsquo;ai fermé l&rsquo;app et j&rsquo;ai pensé qu&rsquo;il était temps de la remplacer.</p>
<p>Je suivais trois choses : des animés, des séries télévisées et des films. Les animés étaient la priorité — j&rsquo;en regarde davantage, le calendrier de diffusion est fragmenté sur plusieurs plateformes, et savoir ce qui sort dans la semaine est vraiment utile. Deux exigences rendraient un remplacement réellement utilisable : un calendrier hebdomadaire des sorties et une synchronisation multi-appareils (téléphone et ordinateur, au minimum).</p>
<p>Je me suis donné un week-end pour faire un POC. La stack sur laquelle je me suis appuyé : React 19, TypeScript, Vite, Firebase Auth et Firestore. Pas parce que c&rsquo;est une combinaison à la mode, mais parce qu&rsquo;elle résout le problème de synchronisation sans faire tourner un backend. Firestore fournit un document store en temps réel ; Firebase Auth gère la connexion entre les appareils ; le navigateur fait le reste. Je préférais payer la latence des lectures Firestore plutôt que de construire et maintenir un serveur API pour un projet personnel avec exactement un utilisateur.</p>
<p>Le projet s&rsquo;appelle Miru. En japonais, ça signifie « regarder ».</p>
<h2 id="trois-formats-trois-api">Trois formats, trois API</h2>
<p>La première vraie décision portait sur les API à utiliser. Les animés, les séries et les films vivent dans des écosystèmes de données séparés, et faire semblant du contraire m&rsquo;aurait coûté des semaines.</p>
<p>Pour les animés, <a href="https://anilist.gitbook.io/anilist-apiv2-docs/" target="_blank" rel="noopener noreferrer">AniList</a>
 s&rsquo;impose. Il expose une API GraphQL, couvre l&rsquo;essentiel du medium et inclut directement les calendriers de diffusion. Une seule requête avec une plage de dates retourne les prochains épisodes pour une liste d&rsquo;identifiants. Un seul appel réseau pour toute la semaine. Je ne m&rsquo;attendais pas à ce que ce soit aussi propre.</p>
<p>Pour les séries et les films, <a href="https://developer.themoviedb.org/docs/getting-started" target="_blank" rel="noopener noreferrer">TMDB</a>
 est la référence. Son API REST v3 est bien documentée, stable, et couvre le catalogue international que d&rsquo;autres bases de données ratent. La contrepartie : pas d&rsquo;endpoint de planning par lot. J&rsquo;appelle l&rsquo;endpoint de saison par série, je filtre sur la semaine en cours, et je collecte les résultats.</p>
<p>Les titres d&rsquo;épisodes pour les animés viennent d&rsquo;une troisième source : <a href="https://jikan.moe/" target="_blank" rel="noopener noreferrer">Jikan</a>
, qui agrège MyAnimeList. AniList n&rsquo;a pas toujours les titres d&rsquo;épisodes, surtout pour les séries plus anciennes. Quand un item suivi dispose d&rsquo;un MAL ID (qu&rsquo;AniList expose), je récupère la liste des épisodes depuis Jikan et j&rsquo;injecte les titres. C&rsquo;est un enrichissement optionnel — le calendrier fonctionne sans, mais la différence entre « Épisode 7 » et « La Troisième Trahison » vaut bien quelques requêtes supplémentaires.</p>
<h2 id="une-requête-pour-toute-la-semaine">Une requête pour toute la semaine</h2>
<p>Le calendrier hebdomadaire est la fonctionnalité qui justifie le projet. Chaque dimanche, je veux voir ce qui sort dans les sept prochains jours dans tout ce que je regarde, sans ouvrir quatre applications différentes.</p>
<p>Côté AniList, c&rsquo;est efficace. Une seule requête GraphQL prend la liste des identifiants AniList de mes animés en cours et une plage de dates, et retourne tous les épisodes prévus dans cette fenêtre. Une requête, toutes les données.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-graphql" data-lang="graphql"><span style="display:flex;"><span><span style="color:#66d9ef">query</span> <span style="color:#a6e22e">AiringSchedule</span>($ids: [Int], $start: <span style="color:#a6e22e">Int</span>, $end: <span style="color:#a6e22e">Int</span>) {
</span></span><span style="display:flex;"><span>  Page {
</span></span><span style="display:flex;"><span>    airingSchedules(
</span></span><span style="display:flex;"><span>      mediaId_in: $ids
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">airingAt_greater</span>: $start
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">airingAt_lesser</span>: $end
</span></span><span style="display:flex;"><span>    ) {
</span></span><span style="display:flex;"><span>      mediaId
</span></span><span style="display:flex;"><span>      episode
</span></span><span style="display:flex;"><span>      airingAt
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Côté TMDB, c&rsquo;est moins élégant. Pas d&rsquo;endpoint de planning par lot, donc j&rsquo;appelle l&rsquo;endpoint de saison par série et je filtre localement. Pour dix séries en cours, ça fait dix requêtes en parallèle — tolérable, et les limites de débit de TMDB sont suffisamment généreuses pour ne pas s&rsquo;en préoccuper.</p>
<p>L&rsquo;enrichissement Jikan tourne en dernier, après l&rsquo;assemblage des données AniList. Pour chaque épisode planifié où j&rsquo;ai un MAL ID, je récupère la liste depuis Jikan et j&rsquo;injecte le titre. Je mets ces données en cache par session pour éviter de récupérer la même liste deux fois.</p>
<h2 id="pas-de-serveur-pas-dops">Pas de serveur, pas d&rsquo;ops</h2>
<p>Firestore est l&rsquo;élément qui rend ce projet viable. Ma bibliothèque — quelles séries je suis, où j&rsquo;en suis, quels épisodes j&rsquo;ai cochés — tient dans un seul document Firestore par utilisateur. Il se charge à l&rsquo;authentification, se sauvegarde à chaque modification avec un debounce d'1,5 seconde pour ne pas saturer le quota gratuit, et se synchronise automatiquement entre les appareils.</p>
<p>Les règles de sécurité tiennent en deux lignes : lecture et écriture autorisées uniquement si <code>request.auth.uid == userId</code>. Pas de backend, pas de middleware d&rsquo;autorisation, pas de rotation de token à gérer.</p>
<p>L&rsquo;hébergement suit la même logique : l&rsquo;app est déployée sur <a href="https://vercel.com" target="_blank" rel="noopener noreferrer">Vercel</a>
, connectée au dépôt GitHub. Chaque push sur <code>main</code> déclenche un build et une mise en production automatique. Zéro configuration serveur, zéro pipeline à maintenir.</p>
<p>La seule chose que Firestore ne résout pas, c&rsquo;est le cold start. Le premier chargement sur un nouvel appareil attend l&rsquo;initialisation du SDK Firebase et l&rsquo;arrivée du document. Sur une bonne connexion, moins d&rsquo;une seconde. Sur un réseau mobile, ça se sent. J&rsquo;accepte ce compromis — un tracker personnel n&rsquo;a pas besoin de l&rsquo;architecture d&rsquo;une plateforme de streaming. Le jour où il y aurait davantage d&rsquo;utilisateurs, les choix faits ici mériteraient d&rsquo;être ré-étudiés : backend dédié, cache, séparation des responsabilités. Mais ce jour-là n&rsquo;est pas aujourd&rsquo;hui.</p>
<h2 id="lextension-qui-a-rendu-la-migration-possible">L&rsquo;extension qui a rendu la migration possible</h2>
<p>Ce que je redoutais le plus en remplaçant TVTime, c&rsquo;était l&rsquo;import initial. J&rsquo;avais quelques centaines d&rsquo;entrées suivies sur plusieurs années. Migrer ça manuellement, non merci.</p>
<p>La solution : une extension de navigateur — pas pour un usage courant, mais pour la migration unique. Des scripts de contenu injectés sur les pages <a href="https://anilist.gitbook.io/anilist-apiv2-docs/" target="_blank" rel="noopener noreferrer">AniList</a>
, MyAnimeList et Crunchyroll détectent le titre du media depuis le slug d&rsquo;URL, ajoutent un petit bouton « Ajouter à Miru » dans l&rsquo;interface existante, et transmettent le titre à l&rsquo;app au clic. L&rsquo;extension pré-remplit le dialogue de recherche. On clique, on vérifie, on confirme.</p>
<p>J&rsquo;ai migré ma liste AniList en vingt minutes environ. Mieux que prévu. Les séries et les films, j&rsquo;ai dû les faire à la main, mais ces listes étaient plus courtes et la recherche est assez rapide pour que ce ne soit pas pénible.</p>
<p>L&rsquo;extension est un build IIFE Manifest V3 — trois scripts de contenu séparés, un par plateforme, packagés pour Chrome et Firefox. Rien de complexe, mais ça a résolu le vrai point de friction qui sépare une migration qui se fait de celle qui ne se fait pas.</p>
<h2 id="ce-que-cest-devenu">Ce que c&rsquo;est devenu</h2>
<p>Miru tourne en bêta depuis environ une semaine. Le calendrier hebdomadaire fonctionne. La page de stats est brute mais fonctionnelle. La recherche dans la bibliothèque est rapide. J&rsquo;ai ouvert TVTime deux fois depuis lors, par réflexe, et refermé immédiatement les deux fois.</p>
<p>Ce que j&rsquo;avais sous-estimé, c&rsquo;est la valeur de posséder les données. Le document Firestore est le mien. Je peux l&rsquo;exporter, le migrer, l&rsquo;interroger, le brancher sur Home Assistant. C&rsquo;était l&rsquo;objectif d&rsquo;il y a deux ans — une API fermée plus tard, je l&rsquo;ai enfin.</p>
<p>Ce que j&rsquo;avais surestimé, c&rsquo;est la complexité des intégrations. AniList et TMDB sont de vraiment bonnes API. Leur documentation correspond au comportement réel, les réponses d&rsquo;erreur disent quelque chose d&rsquo;utile, et les limites de débit sont difficiles à atteindre en usage personnel. Trois heures d&rsquo;expérimentation ont suffi pour construire des clients fonctionnels pour les deux.</p>
<p>L&rsquo;extension m&rsquo;a surpris. Je m&rsquo;attendais à ce que ce soit la partie pénible. C&rsquo;est finalement la plus satisfaisante : injecter un bouton, extraire un titre, fermer un onglet. Périmètre réduit, résultat immédiat. Le genre d&rsquo;outil qui n&rsquo;a besoin de fonctionner qu&rsquo;une seule fois mais qui fait la différence entre une migration qui se fait et une qui ne se fait pas.</p>
<style>
.gh-card {
  display: block;
  border: 1px solid #d0d7de;
  padding: 16px;
  margin-top: 2em;
  border-radius: 6px;
  text-decoration: none !important;
}
.gh-card:hover { border-color: #8c959f; }
.gh-card,
.gh-card *,
.md-content .gh-card,
.md-content .gh-card * { text-decoration: none !important; }
.gh-card__head {
  display: flex;
  align-items: center;
  gap: 8px;
}
.gh-card__head svg {
  flex-shrink: 0;
  fill: #1f2328 !important;
}
.gh-card__repo {
  font-weight: 600;
  color: #1f2328 !important;
}
.gh-card__desc {
  margin: 8px 0 0;
  color: #59636e !important;
  font-size: 14px;
}
[data-theme=dark] .gh-card { border-color: #30363d; }
[data-theme=dark] .gh-card:hover { border-color: #6e7681; }
[data-theme=dark] .gh-card__head svg { fill: #e6edf3 !important; }
[data-theme=dark] .gh-card__repo { color: #e6edf3 !important; }
[data-theme=dark] .gh-card__desc { color: #8b949e !important; }
</style>
<a class="gh-card" href="https://github.com/guillaumedelre/miru" target="_blank" rel="noopener noreferrer">
  <span class="gh-card__head">
    <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
    <span class="gh-card__repo">guillaumedelre/miru</span>
  </span>
  <span class="gh-card__desc">Media tracking app (anime, series, movies) with a weekly airing calendar.</span>
</a>
]]></content:encoded></item><item><title>Symfony 8.1 : kernel sans HTTP, rate limiting déclaratif et batch fetching Messenger</title><link>https://guillaumedelre.github.io/fr/2026/06/04/symfony-8.1-kernel-sans-http-rate-limiting-d%C3%A9claratif-et-batch-fetching-messenger/</link><pubDate>Thu, 04 Jun 2026 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/06/04/symfony-8.1-kernel-sans-http-rate-limiting-d%C3%A9claratif-et-batch-fetching-messenger/</guid><description>Part 12 of 12 in &amp;quot;Sorties Symfony&amp;quot;: Symfony 8.1 découple le kernel d&amp;#39;HttpKernel, ajoute un attribut #[RateLimit] et livre des améliorations concrètes dans Messenger, Console et HttpClient.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 8.1 est sorti le 29 mai 2026. PHP 8.4 reste le minimum requis et aucun changement cassant n&rsquo;est introduit. L&rsquo;ajout principal est architectural : le kernel n&rsquo;est plus couplé à <code>HttpKernel</code>. Le reste est incrémental mais genuinement utile.</p>
<h2 id="une-application-sans-http">Une application sans HTTP</h2>
<p>Depuis les débuts de Symfony, chaque application embarque un kernel basé sur <code>HttpKernel</code>, même quand elle ne sert aucun trafic HTTP. Un worker Messenger qui consomme depuis SQS traînait malgré lui toute la machinerie HTTP. 8.1 règle ça à la racine.</p>
<p><code>AbstractKernel</code> et <code>KernelTrait</code> vivent maintenant dans le composant <code>DependencyInjection</code>. <code>HttpKernel\Kernel</code> étend <code>AbstractKernel</code>, donc les applications existantes sont entièrement compatibles. Ce qui est nouveau, c&rsquo;est la possibilité d&rsquo;écrire un kernel qui étend <code>AbstractKernel</code> directement, sans la couche HTTP.</p>
<p>Deux nouveaux bundles rendent ça utile immédiatement :</p>
<ul>
<li><code>ServicesBundle</code> câble le DI, l&rsquo;event dispatcher et le clock, sans dépendance HTTP.</li>
<li><code>ConsoleBundle</code> s&rsquo;appuie dessus et ajoute le résolveur de commandes.</li>
</ul>
<p>Pour une application CLI uniquement :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// config/bundles.php
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Symfony\Component\Console\ConsoleBundle</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;all&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">true</span>],
</span></span><span style="display:flex;"><span>];
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">namespace</span> <span style="color:#a6e22e">App</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\DependencyInjection\Kernel\AbstractKernel</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\DependencyInjection\Kernel\KernelTrait</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Kernel</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">AbstractKernel</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">use</span> <span style="color:#a6e22e">KernelTrait</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Les auteurs de bundles héritent d&rsquo;un attribut <code>#[RequiredBundle]</code> pour déclarer explicitement les dépendances entre bundles, avec un flag <code>ignoreOnInvalid</code> pour les dépendances optionnelles :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>#[<span style="color:#a6e22e">RequiredBundle</span>(<span style="color:#a6e22e">AcmeCoreBundle</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>)]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">RequiredBundle</span>(<span style="color:#a6e22e">AcmeUtilBundle</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>, <span style="color:#a6e22e">ignoreOnInvalid</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">AcmeBlogBundle</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">AbstractBundle</span> {}
</span></span></code></pre></div><h2 id="rate-limiting-déclaratif">Rate limiting déclaratif</h2>
<p>8.1 ajoute un attribut <code>#[RateLimit]</code> pour les controllers. Posé sur une action, il applique la limite configurée dans <code>framework.rate_limiter</code> et renvoie automatiquement un 429 avec <code>Retry-After</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\HttpKernel\Attribute\RateLimit</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ApiController</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">AbstractController</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    #[<span style="color:#a6e22e">RateLimit</span>(<span style="color:#e6db74">&#39;api&#39;</span>)]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">index</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">JsonResponse</span> { <span style="color:#75715e">/* ... */</span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    #[<span style="color:#a6e22e">RateLimit</span>(<span style="color:#e6db74">&#39;api&#39;</span>, <span style="color:#a6e22e">methods</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;POST&#39;</span>, <span style="color:#e6db74">&#39;PUT&#39;</span>])]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">edit</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">JsonResponse</span> { <span style="color:#75715e">/* ... */</span> }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    #[<span style="color:#a6e22e">RateLimit</span>(<span style="color:#e6db74">&#39;api&#39;</span>, <span style="color:#a6e22e">tokens</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">5</span>)]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">export</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">JsonResponse</span> { <span style="color:#75715e">/* ... */</span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>L&rsquo;attribut est répétable, il est donc possible d&rsquo;empiler deux politiques sur la même action. Par défaut, la clé de bucket combine l&rsquo;IP client, la méthode HTTP et le path. Elle peut être remplacée par une expression pour des buckets par utilisateur :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\ExpressionLanguage\Expression</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">RateLimit</span>(<span style="color:#e6db74">&#39;per_account&#39;</span>, <span style="color:#a6e22e">key</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Expression</span>(<span style="color:#e6db74">&#39;request.request.get(&#34;email&#34;)&#39;</span>))]
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">public</span> <span style="color:#a6e22e">function</span> <span style="color:#a6e22e">resetPassword</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span> { <span style="color:#75715e">/* ... */</span> }
</span></span></code></pre></div><p>La politique <code>fixed_window</code> gagne <code>anchor_at</code>, qui aligne les resets sur un moment calendaire plutôt que sur la première requête. Pratique pour les quotas de facturation mensuelle :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">rate_limiter</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">api_quota</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">policy</span>: <span style="color:#e6db74">&#39;fixed_window&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">limit</span>: <span style="color:#ae81ff">10000</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">interval</span>: <span style="color:#e6db74">&#39;1 month&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">anchor_at</span>: <span style="color:#e6db74">&#39;2026-01-05 00:00:00 UTC&#39;</span>
</span></span></code></pre></div><h2 id="injection-de-dépendances">Injection de dépendances</h2>
<p>Plusieurs améliorations DI arrivent dans 8.1.</p>
<p><strong>Env vars en <code>Closure</code> ou <code>Stringable</code>.</strong> Les workers longue durée ont parfois besoin de rafraîchir des variables d&rsquo;environnement entre les itérations. Autowirer une env var en <code>Closure</code> fournit une factory plutôt qu&rsquo;une valeur figée :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Worker</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>        #[<span style="color:#a6e22e">Autowire</span>(<span style="color:#a6e22e">env</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;DB_URL&#39;</span>)]
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">\Closure</span> $dbUrl,
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        #[<span style="color:#a6e22e">Autowire</span>(<span style="color:#a6e22e">env</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;APP_NAME&#39;</span>)]
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">string</span><span style="color:#f92672">|</span><span style="color:#a6e22e">\Stringable</span> $appName <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;default&#39;</span>,
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong><code>#[AsTagDecorator]</code>.</strong> Décorer tous les services portant un tag donné en posant un seul attribut sur la classe décorateur :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>#[<span style="color:#a6e22e">AsTagDecorator</span>(<span style="color:#e6db74">&#39;app.handler&#39;</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">LoggingHandler</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(<span style="color:#66d9ef">private</span> <span style="color:#a6e22e">object</span> $inner) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Env vars avec des points dans le nom.</strong> Les noms comme <code>DATABASE.PRIMARY.URL</code> sont désormais valides, ce qui compte quand on consomme des variables structurées par des plateformes cloud.</p>
<h2 id="messenger">Messenger</h2>
<p>Messenger reçoit plusieurs ajouts concrets dans 8.1.</p>
<p><strong>Batch fetching.</strong> La nouvelle option <code>--fetch-size</code> de <code>messenger:consume</code> réduit le nombre d&rsquo;aller-retours vers le transport. Avec SQS (qui autorise jusqu&rsquo;à 10 messages par appel), ça coupe une bonne partie de l&rsquo;overhead à fort débit :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>php bin/console messenger:consume async --fetch-size<span style="color:#f92672">=</span><span style="color:#ae81ff">8</span>
</span></span></code></pre></div><p><strong>Nom de type sérialisé personnalisé.</strong> Quand un consommateur non-Symfony identifie un message par une chaîne stable plutôt que par un nom de classe PHP, <code>#[AsMessage]</code> couvre ce cas :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>#[<span style="color:#a6e22e">AsMessage</span>(<span style="color:#a6e22e">serializedTypeName</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;crawler.vectorization_finished&#39;</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#a6e22e">readonly</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">VectorizationFinished</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(<span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $crawlId) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Priorité AMQP par message.</strong> <code>AmqpPriorityStamp</code> définit la priorité sur un dispatch individuel plutôt qu&rsquo;au niveau de la queue :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$bus<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($message, [<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">AmqpPriorityStamp</span>(<span style="color:#ae81ff">5</span>)]);
</span></span></code></pre></div><p><strong>Contrôle du reset.</strong> <code>--no-reset=100</code> exécute le reset des services tous les 100 messages plutôt qu&rsquo;après chacun, ce qui réduit l&rsquo;overhead des workers longue durée à grande échelle.</p>
<p><strong>Idle timeout pour <code>BatchHandler</code>.</strong> Les batches partiels sont maintenant flushés après une période d&rsquo;inactivité configurable, pas seulement quand le batch est plein.</p>
<p><strong>Redis receiver listable.</strong> Le receiver Redis expose maintenant <code>all()</code> et <code>find()</code>, ce qui permet d&rsquo;inspecter les messages en attente par programmation.</p>
<h2 id="console">Console</h2>
<p><strong>Commandes méthodes.</strong> Plusieurs commandes peuvent cohabiter dans une même classe comme méthodes distinctes. Moins de boilerplate quand une fonctionnalité génère un groupe de commandes liées.</p>
<p><strong><code>#[AskChoice]</code>.</strong> Déclarer un prompt de choix interactif directement dans la signature de l&rsquo;argument, avec support des enums :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>#[<span style="color:#a6e22e">AsCommand</span>(<span style="color:#e6db74">&#39;app:create-user&#39;</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">CreateUserCommand</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__invoke</span>(
</span></span><span style="display:flex;"><span>        #[<span style="color:#a6e22e">Argument</span>, <span style="color:#a6e22e">AskChoice</span>(<span style="color:#e6db74">&#39;Select a role&#39;</span>, [<span style="color:#e6db74">&#39;admin&#39;</span>, <span style="color:#e6db74">&#39;editor&#39;</span>, <span style="color:#e6db74">&#39;viewer&#39;</span>])]
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">string</span> $role,
</span></span><span style="display:flex;"><span>    )<span style="color:#f92672">:</span> <span style="color:#a6e22e">int</span> { <span style="color:#75715e">/* ... */</span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Validation sur <code>#[Ask]</code>.</strong> Les prompts interactifs acceptent maintenant des contraintes de validation :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__invoke</span>(
</span></span><span style="display:flex;"><span>    #[<span style="color:#a6e22e">Argument</span>, <span style="color:#a6e22e">Ask</span>(<span style="color:#e6db74">&#39;Enter your email:&#39;</span>, <span style="color:#a6e22e">constraints</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Assert\NotBlank</span>(),
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Assert\Email</span>(),
</span></span><span style="display:flex;"><span>    ])]
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">string</span> $email,
</span></span><span style="display:flex;"><span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">int</span> { <span style="color:#75715e">/* ... */</span> }
</span></span></code></pre></div><p><strong><code>#[MapInput]</code>.</strong> Mappe automatiquement les arguments et options d&rsquo;une commande dans un DTO validé.</p>
<p><strong><code>RawInputInterface</code>.</strong> Donne accès aux tokens bruts de l&rsquo;input, utile pour forwarder des arguments à un sous-processus sans les re-parser.</p>
<h2 id="httpclient">HttpClient</h2>
<p><strong>Connexions cURL persistantes</strong> (PHP 8.5+) : réutilisation du cache DNS et des sessions SSL entre les requêtes, ce qui réduit la latence pour les clients à haute fréquence.</p>
<p><strong><code>GuzzleHttpHandler</code>.</strong> Symfony HttpClient peut maintenant servir de transport Guzzle, ce qui permet au code existant utilisant Guzzle de bénéficier du retry, du mock et du scopage Symfony sans migration complète :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\HttpClient\GuzzleHttpHandler</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$guzzle <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\GuzzleHttp\Client</span>([<span style="color:#e6db74">&#39;handler&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">GuzzleHttpHandler</span>()]);
</span></span></code></pre></div><p><strong>Allowlist SSRF pour <code>NoPrivateNetworkHttpClient</code>.</strong> Passer une IP ou un range spécifique à autoriser à travers le blocage réseau privé :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$client <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">NoPrivateNetworkHttpClient</span>(<span style="color:#a6e22e">HttpClient</span><span style="color:#f92672">::</span><span style="color:#a6e22e">create</span>(), <span style="color:#66d9ef">null</span>, <span style="color:#e6db74">&#39;10.0.0.42&#39;</span>);
</span></span></code></pre></div><p><strong><code>max_connect_duration</code>.</strong> Un timeout limité à la phase de connexion uniquement, pour un contrôle plus fin sur les DNS lents et les handshakes TLS.</p>
<h2 id="mapping-du-payload-de-requête">Mapping du payload de requête</h2>
<p><code>#[MapRequestPayload]</code> gère maintenant le <code>multipart/form-data</code>, ce qui permet d&rsquo;avoir des propriétés <code>UploadedFile</code> dans les DTOs mappés :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ProductDto</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $name <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">UploadedFile</span> $image <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">upload</span>(#[<span style="color:#a6e22e">MapRequestPayload</span>] <span style="color:#a6e22e">ProductDto</span> $data)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span> { <span style="color:#75715e">/* ... */</span> }
</span></span></code></pre></div><p>Les arguments variadiques permettent de mapper un tableau JSON directement en une série d&rsquo;objets typés :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">createPrices</span>(#[<span style="color:#a6e22e">MapRequestPayload</span>] <span style="color:#a6e22e">Price</span> <span style="color:#f92672">...</span>$prices)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span> { <span style="color:#75715e">/* ... */</span> }
</span></span></code></pre></div><p><code>validationGroups</code> accepte maintenant une <code>Expression</code> ou une <code>Closure</code> pour une sélection de groupes dynamique. <code>mapWhenEmpty: true</code> déclenche la dénormalisation même sur un payload vide.</p>
<h2 id="formulaires">Formulaires</h2>
<p>Le thème daisyUI 5 est maintenant inclus, ce qui couvre les frontends Tailwind sans thème personnalisé :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">twig</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">form_themes</span>: [<span style="color:#e6db74">&#39;daisyui_5_layout.html.twig&#39;</span>]
</span></span></code></pre></div><p><code>DateType</code> en mode <code>choice</code> accepte une option <code>labels</code> pour renommer les selects année, mois et jour sans template personnalisé. Les checkboxes non cochées sont maintenant soumises automatiquement comme <code>false</code> plutôt qu&rsquo;être absentes du payload, ce qui corrige une incohérence de longue date.</p>
<h2 id="sérialisation-automatique-des-réponses">Sérialisation automatique des réponses</h2>
<p>L&rsquo;attribut <code>#[Serialize]</code> posé sur une méthode de controller sérialise automatiquement la valeur de retour dans une <code>Response</code>, en choisissant le format (JSON, XML) depuis le format de la requête. Fini les <code>$this-&gt;json()</code> manuels pour les endpoints API simples :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\HttpKernel\Attribute\Serialize</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\Serializer\Normalizer\DateTimeNormalizer</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#a6e22e">readonly</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">CreateProductController</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    #[<span style="color:#a6e22e">Serialize</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">code</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">201</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">headers</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;X-Custom-Header&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;abc&#39;</span>],
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">context</span><span style="color:#f92672">:</span> [<span style="color:#a6e22e">DateTimeNormalizer</span><span style="color:#f92672">::</span><span style="color:#a6e22e">FORMAT_KEY</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;d.m.Y H:i:s&#39;</span>],
</span></span><span style="display:flex;"><span>    )]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__invoke</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">ProductCreated</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">ProductCreated</span>(<span style="color:#ae81ff">101</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Une route avec <code>{_format}</code> retourne du JSON pour <code>/products/42.json</code> et du XML pour <code>/products/42.xml</code>. Les formats non supportés donnent un 415.</p>
<h2 id="deepcloner">DeepCloner</h2>
<p><code>DeepCloner</code> arrive dans le composant <code>VarExporter</code> comme remplacement de <code>Instantiator</code> et <code>Hydrator</code> (tous deux dépréciés en 8.1). Il clone des graphes d&rsquo;objets PHP complexes directement, sans le round-trip <code>unserialize(serialize())</code>, en préservant la sémantique copy-on-write pour les strings et les tableaux.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\VarExporter\DeepCloner</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// clone one-shot
</span></span></span><span style="display:flex;"><span>$clone <span style="color:#f92672">=</span> <span style="color:#a6e22e">DeepCloner</span><span style="color:#f92672">::</span><span style="color:#a6e22e">deepClone</span>($originalObject);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// cloner réutilisable sur le même prototype
</span></span></span><span style="display:flex;"><span>$cloner <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">DeepCloner</span>($prototype);
</span></span><span style="display:flex;"><span>$clone1 <span style="color:#f92672">=</span> $cloner<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">clone</span>();
</span></span><span style="display:flex;"><span>$clone2 <span style="color:#f92672">=</span> $cloner<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">clone</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// clone vers une sous-classe compatible
</span></span></span><span style="display:flex;"><span>$childDefinition <span style="color:#f92672">=</span> (<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">DeepCloner</span>($definition))
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">cloneAs</span>(<span style="color:#a6e22e">ChildDefinition</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>);
</span></span></code></pre></div><p>Une fonction <code>deepclone_hydrate()</code> remplace le <code>Hydrator</code> déprécié :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$user <span style="color:#f92672">=</span> <span style="color:#a6e22e">deepclone_hydrate</span>(<span style="color:#a6e22e">User</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>, [<span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;Alice&#39;</span>]);
</span></span></code></pre></div><p>DI, FrameworkBundle, Form et Cache (ArrayAdapter) l&rsquo;utilisent tous en interne dans 8.1. Une extension C optionnelle (<code>symfony/php-ext-deepclone</code>) est disponible pour des performances natives.</p>
<h2 id="attribut-cache-amélioré">Attribut #[Cache] amélioré</h2>
<p>L&rsquo;attribut <code>#[Cache]</code> sur les méthodes de controller gagne trois choses en 8.1.</p>
<p><strong>Variables d&rsquo;expression nommées.</strong> <code>lastModified</code> et <code>etag</code> exposent maintenant <code>request</code> (l&rsquo;objet <code>Request</code>) et <code>args</code> (les arguments du controller sous forme de tableau), en remplacement de la fusion plate précédente qui causait des collisions de noms :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>#[<span style="color:#a6e22e">Cache</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">etag</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;args[&#39;article&#39;].computeETag()&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">lastModified</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;args[&#39;article&#39;].getUpdatedAt()&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">show</span>(<span style="color:#a6e22e">Article</span> $article)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span> { <span style="color:#f92672">...</span> }
</span></span></code></pre></div><p><strong>Support des closures</strong> pour <code>lastModified</code> et <code>etag</code> :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>#[<span style="color:#a6e22e">Cache</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">lastModified</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">function</span> (<span style="color:#66d9ef">array</span> $args, <span style="color:#a6e22e">Request</span> $request)<span style="color:#f92672">:</span> <span style="color:#a6e22e">\DateTimeInterface</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">return</span> $args[<span style="color:#e6db74">&#39;post&#39;</span>]<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getUpdatedAt</span>();
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">etag</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">function</span> (<span style="color:#66d9ef">array</span> $args, <span style="color:#a6e22e">Request</span> $request)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> (<span style="color:#a6e22e">string</span>) $args[<span style="color:#e6db74">&#39;post&#39;</span>]<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getId</span>();
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">show</span>(<span style="color:#a6e22e">Post</span> $post)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span> { <span style="color:#f92672">...</span> }
</span></span></code></pre></div><p><strong>Application conditionnelle</strong> via <code>if</code> (expression string ou closure retournant un bool). Mettre en cache une réponse uniquement quand la requête ne porte pas de paramètre <code>preview</code> :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>#[<span style="color:#a6e22e">Cache</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">maxage</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">3600</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">static</span> <span style="color:#a6e22e">fn</span> (<span style="color:#66d9ef">array</span> $args, <span style="color:#a6e22e">Request</span> $request)<span style="color:#f92672">:</span> <span style="color:#a6e22e">bool</span> <span style="color:#f92672">=&gt;</span> <span style="color:#f92672">!</span>$request<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">query</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">has</span>(<span style="color:#e6db74">&#39;preview&#39;</span>),
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">public</span> <span style="color:#a6e22e">function</span> <span style="color:#a6e22e">show</span>(<span style="color:#a6e22e">Request</span> $request)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span> { <span style="color:#f92672">...</span> }
</span></span></code></pre></div><p>L&rsquo;attribut est aussi répétable, ce qui permet d&rsquo;empiler plusieurs politiques avec des conditions différentes sur la même méthode.</p>
<h2 id="json-streaming-et-jsonpath">JSON streaming et JsonPath</h2>
<p><strong>Value objects dans JsonStreamer.</strong> Une interface <code>ValueObjectTransformerInterface</code> couvre les types qui se sérialisent en scalaire et vice-versa. Il suffit de l&rsquo;implémenter et de taguer le service :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#e6db74">/** @implements ValueObjectTransformerInterface&lt;Money, string&gt; */</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">MoneyTransformer</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">ValueObjectTransformerInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">transform</span>(<span style="color:#a6e22e">object</span> $object, <span style="color:#66d9ef">array</span> $options <span style="color:#f92672">=</span> [])<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $object<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">amount</span><span style="color:#f92672">.</span><span style="color:#e6db74">&#39; &#39;</span><span style="color:#f92672">.</span>$object<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">currency</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">reverseTransform</span>(<span style="color:#a6e22e">string</span> $scalar, <span style="color:#66d9ef">array</span> $options <span style="color:#f92672">=</span> [])<span style="color:#f92672">:</span> <span style="color:#a6e22e">Money</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        [$amount, $currency] <span style="color:#f92672">=</span> <span style="color:#a6e22e">explode</span>(<span style="color:#e6db74">&#39; &#39;</span>, $scalar);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Money</span>((<span style="color:#a6e22e">int</span>) $amount, $currency);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getStreamValueType</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">BuiltinType</span> { <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Type</span><span style="color:#f92672">::</span><span style="color:#a6e22e">string</span>(); }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getValueObjectClassName</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span> { <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Money</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>; }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Types date.</strong> <code>DateInterval</code> se sérialise en ISO 8601 (<code>P2Y6M1DT12H30M5S</code>), <code>DateTimeZone</code> en nom de timezone. L&rsquo;option <code>date_time_timezone</code> gère la conversion à l&rsquo;encode/decode.</p>
<p><strong>Options par défaut via config :</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">json_streamer</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">default_options</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">include_null_properties</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p><strong>Fonctions JsonPath personnalisées</strong> via <code>#[AsJsonPathFunction]</code> :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\JsonPath\Attribute\AsJsonPathFunction</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">AsJsonPathFunction</span>(<span style="color:#e6db74">&#39;upper&#39;</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">UppercaseFunction</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__invoke</span>(<span style="color:#a6e22e">mixed</span> $value)<span style="color:#f92672">:</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">\is_string</span>($value) <span style="color:#f92672">?</span> <span style="color:#a6e22e">strtoupper</span>($value) <span style="color:#f92672">:</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$result <span style="color:#f92672">=</span> $crawler<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">find</span>(<span style="color:#e6db74">&#39;$.items[?upper(@.title) == &#34;HELLO&#34;]&#39;</span>);
</span></span></code></pre></div><h2 id="validator">Validator</h2>
<p><strong>Support Clock dans les contraintes de comparaison.</strong> <code>GreaterThan</code>, <code>GreaterThanOrEqual</code>, <code>LessThan</code>, <code>LessThanOrEqual</code> et <code>Range</code> acceptent une <code>ClockInterface</code>. Avec <code>MockClock</code>, les expressions de date relatives comme <code>-10 days</code> se résolvent par rapport à un point fixe dans le temps, rendant la validation temporelle déterministe dans les tests.</p>
<p><strong>Validators réentrants.</strong> Le pattern stateful avec <code>initialize($context)</code> posait des problèmes lors d&rsquo;appels récursifs. Une nouvelle méthode <code>validateInContext()</code> remplace <code>validate()</code> + <code>initialize()</code> sur <code>ConstraintValidatorInterface</code>. Les validators qui étendent la classe abstraite <code>ConstraintValidator</code> n&rsquo;ont rien à changer.</p>
<p><strong>Contrainte <code>Xml</code>.</strong> Valide qu&rsquo;une chaîne est du XML bien formé, avec validation optionnelle via schéma XSD qui reporte les erreurs individuelles avec numéros de ligne.</p>
<p><strong>Vérification d&rsquo;existence de propriété.</strong> <code>ValidatorBuilder::enablePropertyMetadataExistenceCheck()</code> lève une <code>ValidatorException</code> quand une contrainte cible une propriété inexistante, ce qui attrape les typos au warmup.</p>
<h2 id="en-résumé">En résumé</h2>
<p>8.1 est une version dense en fonctionnalités. Le kernel sans HTTP est la pièce architecturalement significative : elle fait de Symfony un choix crédible pour les applications CLI et worker purs, sans le poids de la couche HTTP. Tout le reste gravite autour de l&rsquo;ergonomie : moins de boilerplate sur les controllers (<code>#[Serialize]</code>, <code>#[RateLimit]</code>, <code>#[Cache]</code> amélioré), moins de boilerplate sur les commandes (attributs ask, commandes méthodes), moins de friction dans Messenger (taille de batch, noms de types, priorité AMQP), un clonage profond plus rapide dans VarExporter, et un Validator qui comprend enfin le temps. Beaucoup d&rsquo;irritants de longue date traités en une seule version.</p>
]]></content:encoded></item><item><title>API Platform 4.3 : serveur MCP, Scalar UI, et sécurité avant le provider</title><link>https://guillaumedelre.github.io/fr/2026/03/23/api-platform-4.3-serveur-mcp-scalar-ui-et-s%C3%A9curit%C3%A9-avant-le-provider/</link><pubDate>Mon, 23 Mar 2026 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/fr/2026/03/23/api-platform-4.3-serveur-mcp-scalar-ui-et-s%C3%A9curit%C3%A9-avant-le-provider/</guid><description>Part 9 of 9 in &amp;quot;Sorties API Platform&amp;quot;: API Platform 4.3 expose vos ressources à des agents IA via MCP, propose Scalar comme interface de documentation alternative, et évalue les expressions de sécurité avant la base de données.</description><category>api-platform-releases</category><content:encoded><![CDATA[<p>API Platform 4.3 est sorti le 13 mars 2026. L&rsquo;ajout principal est le support MCP : vos ressources API peuvent maintenant être exposées comme outils et ressources pour des agents LLM sans infrastructure supplémentaire. À côté de ça, deux améliorations pratiques sortent du lot : Scalar comme UI de documentation alternative, et les vérifications de sécurité qui s&rsquo;exécutent avant que le state provider soit appelé.</p>
<h2 id="support-du-serveur-mcp">Support du serveur MCP</h2>
<p>Le Model Context Protocol est le standard émergent pour connecter des LLMs à des outils et sources de données externes. 4.3 embarque une intégration MCP expérimentale qui se mappe directement sur le modèle de ressources d&rsquo;API Platform.</p>
<p>Deux nouveaux attributs dans <code>ApiPlatform\Metadata</code> gèrent l&rsquo;exposition :</p>
<p><strong><code>McpTool</code></strong> marque une opération comme outil MCP qu&rsquo;un agent peut appeler :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\McpTool</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">ApiResource</span>]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">McpTool</span>(<span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;list_books&#39;</span>, <span style="color:#a6e22e">description</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;Returns a list of available books&#39;</span>)]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">GetCollection</span>]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><p><strong><code>McpResource</code></strong> expose une ressource comme contenu MCP adressable, identifié par une URI :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\McpResource</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">ApiResource</span>]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">McpResource</span>(<span style="color:#a6e22e">uri</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;books://{id}&#39;</span>, <span style="color:#a6e22e">mimeType</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;application/ld+json&#39;</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><p>Les deux attributs sont répétables et se composent avec les opérations API Platform existantes. Le format du serveur MCP est configurable :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">api_platform</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">mcp</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">format</span>: <span style="color:#e6db74">&#39;jsonld&#39;</span>
</span></span></code></pre></div><p>L&rsquo;intégration vit dans un package séparé <code>api-platform/mcp</code>. Les collections sont supportées. Symfony et Laravel sont couverts.</p>
<h2 id="scalar-comme-ui-de-documentation">Scalar comme UI de documentation</h2>
<p>Swagger UI est l&rsquo;interface de documentation par défaut depuis API Platform 2.x. 4.3 ajoute Scalar comme alternative opt-in. Scalar affiche les specs OpenAPI avec une interface plus moderne et une meilleure lisibilité pour les grandes APIs.</p>
<p>À activer dans la config (nécessite TwigBundle) :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">api_platform</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">enable_scalar</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">openapi</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">scalar_extra_configuration</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">theme</span>: <span style="color:#e6db74">&#39;purple&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">darkMode</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><h2 id="sécurité-avant-le-provider">Sécurité avant le provider</h2>
<p>Avant 4.3, l&rsquo;expression <code>security:</code> d&rsquo;une opération était évaluée après que le state provider avait déjà chargé l&rsquo;objet depuis la base. Une requête non autorisée déclenchait quand même une requête Doctrine avant d&rsquo;être rejetée. À grande échelle, ça comptait.</p>
<p>4.3 enregistre un nouveau décorateur <code>AccessCheckerProvider</code> avec une priorité 10, avant l&rsquo;étape de lecture :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>#[<span style="color:#a6e22e">ApiResource</span>]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">Get</span>(<span style="color:#a6e22e">security</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;is_granted(&#39;ROLE_ADMIN&#39;)&#34;</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><p>Le contrôle <code>is_granted</code> s&rsquo;exécute maintenant avant toute requête base de données. La contrepartie : <code>$object</code> est <code>null</code> au stade <code>pre_read</code>. Les expressions qui dépendent de l&rsquo;objet chargé — <code>security: &quot;object.getOwner() == user&quot;</code> — doivent passer à <code>securityPostDenormalize</code>, qui s&rsquo;exécute toujours après le provider.</p>
<p>C&rsquo;est un changement comportemental. À vérifier sur vos expressions de sécurité si elles référencent <code>object</code>.</p>
<h2 id="comparisonfilter">ComparisonFilter</h2>
<p>Un nouveau décorateur <code>ComparisonFilter</code> dans <code>ApiPlatform\Doctrine\Orm\Filter</code> enveloppe n&rsquo;importe quel filtre d&rsquo;égalité et ajoute les opérateurs de comparaison (<code>gt</code>, <code>gte</code>, <code>lt</code>, <code>lte</code>, <code>ne</code>) :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Doctrine\Orm\Filter\RangeFilter</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\QueryParameter</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">ApiResource</span>]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">QueryParameter</span>(<span style="color:#a6e22e">filter</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">ComparisonFilter</span>(<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">RangeFilter</span>()), <span style="color:#a6e22e">property</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;price&#39;</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Product</span> {}
</span></span></code></pre></div><p>Les paramètres OpenAPI pour chaque variante d&rsquo;opérateur sont générés automatiquement. Le filtre est marqué expérimental.</p>
<h2 id="uuidfilter-et-support-des-relations-imbriquées">UuidFilter et support des relations imbriquées</h2>
<p>Un nouveau <code>UuidFilter</code> dans <code>ApiPlatform\Doctrine\Orm\Filter</code> filtre les collections par propriétés UUID, y compris les relations imbriquées. L&rsquo;<code>IriFilter</code> reçoit le même support dans cette version.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Doctrine\Orm\Filter\UuidFilter</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiFilter</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">ApiResource</span>]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">ApiFilter</span>(<span style="color:#a6e22e">UuidFilter</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>, <span style="color:#a6e22e">properties</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;author.uuid&#39;</span>])]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><h2 id="partialsearchfilter-avec-contrôle-de-la-casse">PartialSearchFilter avec contrôle de la casse</h2>
<p>Le nouveau <code>PartialSearchFilter</code> dans <code>ApiPlatform\Doctrine\Orm\Filter</code> effectue une recherche partielle sur les chaînes. Par défaut, il encapsule la comparaison dans <code>LOWER()</code> pour des résultats insensibles à la casse. Passer <code>caseSensitive: true</code> désactive ce comportement :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\QueryParameter</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">ApiResource</span>]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">QueryParameter</span>(<span style="color:#a6e22e">filter</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">PartialSearchFilter</span>(<span style="color:#a6e22e">caseSensitive</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>), <span style="color:#a6e22e">property</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;title&#39;</span>)]
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><h2 id="sortfilter-odm-avec-support-des-propriétés-imbriquées">SortFilter ODM avec support des propriétés imbriquées</h2>
<p>Un <code>SortFilter</code> arrive pour MongoDB (ODM) dans <code>ApiPlatform\Doctrine\Odm\Filter</code>, conçu exclusivement pour être utilisé avec le système <code>Parameters</code>. Il supporte les propriétés imbriquées nativement :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Doctrine\Odm\Filter\SortFilter</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\QueryParameter</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">ApiResource</span>]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">QueryParameter</span>(<span style="color:#a6e22e">filter</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">SortFilter</span>(), <span style="color:#a6e22e">property</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;department.name&#39;</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Employee</span> {}
</span></span></code></pre></div><h2 id="les-entités-doctrine-readonly-perdent-put-et-patch-automatiquement">Les entités Doctrine readonly perdent PUT et PATCH automatiquement</h2>
<p>Si une entité Doctrine est déclarée en lecture seule avec <code>#[ORM\Entity(readOnly: true)]</code>, API Platform supprime maintenant automatiquement les opérations <code>PUT</code> et <code>PATCH</code> de sa définition de ressource. Pas besoin de liste d&rsquo;opérations manuelle :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Doctrine\ORM\Mapping</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">ORM</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">ApiResource</span>]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">ORM\Entity</span>(<span style="color:#a6e22e">readOnly</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">AuditLog</span> {}
</span></span></code></pre></div><p><code>GET</code>, <code>GetCollection</code> et <code>DELETE</code> restent disponibles. Seules les opérations d&rsquo;écriture sont retirées.</p>
<h2 id="préfixe-de-namespace-pour-les-makers">Préfixe de namespace pour les makers</h2>
<p>Le bundle Symfony accepte une nouvelle clé de configuration qui préfixe toutes les classes générées par les makers d&rsquo;API Platform :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">api_platform</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">maker</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">namespace_prefix</span>: <span style="color:#e6db74">&#39;Api&#39;</span>
</span></span></code></pre></div><p>Avec ce réglage, <code>make:api-resource</code> génère les classes sous <code>App\Api\</code> au lieu de <code>App\</code>.</p>
<h2 id="valeurs-par-défaut-sur-queryparameter">Valeurs par défaut sur QueryParameter</h2>
<p><code>QueryParameter</code> accepte maintenant une option <code>default</code> qui fournit une valeur de repli quand le paramètre est absent de la requête :</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\QueryParameter</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">ApiResource</span>]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">GetCollection</span>]
</span></span><span style="display:flex;"><span>#[<span style="color:#a6e22e">QueryParameter</span>(<span style="color:#a6e22e">key</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;order&#39;</span>, <span style="color:#66d9ef">default</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;asc&#39;</span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><h2 id="validation-uuid-et-ulid-sur-les-paramètres">Validation UUID et ULID sur les paramètres</h2>
<p>Les paramètres de requête et headers déclarés avec un format <code>uuid</code> ou <code>ulid</code> sont maintenant validés automatiquement. Les identifiants mal formés sont rejetés avant d&rsquo;atteindre le state provider, sans contrainte supplémentaire à ajouter.</p>
<h2 id="problemexceptioninterface">ProblemExceptionInterface</h2>
<p>Les exceptions personnalisées peuvent maintenant implémenter <code>ProblemExceptionInterface</code> pour mapper directement vers les champs <a href="https://www.rfc-editor.org/rfc/rfc7807" target="_blank" rel="noopener noreferrer">RFC 7807</a>
 Problem Details (<code>type</code>, <code>title</code>, <code>detail</code> et membres d&rsquo;extension). Auparavant, cela nécessitait un normalizer d&rsquo;exception personnalisé.</p>
<h2 id="headers-ldp">Headers LDP</h2>
<p>API Platform ajoute maintenant automatiquement les headers de réponse <code>Accept-Post</code> et <code>Allow</code> sur les endpoints de collection et d&rsquo;item, conformément à la <a href="https://www.w3.org/TR/ldp/" target="_blank" rel="noopener noreferrer">spécification Linked Data Platform</a>
. Cela améliore la découvrabilité pour les clients hypermedia qui s&rsquo;appuient sur ces headers pour déterminer quelles opérations sont disponibles sur une ressource donnée.</p>
<h2 id="breaking-changes">Breaking changes</h2>
<p>Quelques changements comportementaux à connaître avant de migrer :</p>
<ul>
<li><strong>La sécurité s&rsquo;exécute avant le provider.</strong> Les expressions qui référencent <code>object</code> recevront <code>null</code>. À déplacer vers <code>securityPostDenormalize</code>.</li>
<li><strong>Les filtres Doctrine requièrent un <code>property</code> explicite.</strong> Un attribut <code>property</code> manquant lève maintenant une exception au lieu d&rsquo;échouer silencieusement.</li>
<li><strong>Le <code>@type</code> JSON-LD avec <code>output</code> et <code>itemUriTemplate</code></strong> utilise un nom de classe différent pour être sémantiquement cohérent.</li>
</ul>
]]></content:encoded></item></channel></rss>