[{"content":"Le composer.json de chaque service avait ça dans sa section post-install-cmd :\n\u0026#34;post-install-cmd\u0026#34;: [ \u0026#34;bin/console cache:clear --env=prod\u0026#34;, \u0026#34;bin/console doctrine:migrations:migrate --no-interaction\u0026#34; ] post-install-cmd s\u0026rsquo;exécute pendant composer install, qui dans le Dockerfile de production tourne au moment du build de l\u0026rsquo;image. Il n\u0026rsquo;y a pas de base de données disponible pendant un build Docker. La commande de migration échouait silencieusement, se connectait à rien, ou était ignorée par Doctrine faute de schéma à comparer. Dans tous les cas, elle ne migrait rien.\nC\u0026rsquo;est une violation nette du Facteur XII : les processus d\u0026rsquo;administration — migrations, scripts ponctuels, commandes console — doivent s\u0026rsquo;exécuter dans le même environnement que l\u0026rsquo;application, contre les vraies données de production. Les faire tourner au build inverse la relation. L\u0026rsquo;image ne devrait pas savoir qu\u0026rsquo;il y a une base de données. La base devrait être là quand l\u0026rsquo;image en a besoin.\nLe déplacement vers l\u0026rsquo;entrypoint La commande de migration a quitté composer.json pour docker-entrypoint.sh. Le changement semble petit dans un diff. Les implications ne le sont pas.\nL\u0026rsquo;entrypoint s\u0026rsquo;exécute quand le container démarre, pas quand l\u0026rsquo;image est construite. La base de données est accessible. L\u0026rsquo;entrypoint l\u0026rsquo;attend — jusqu\u0026rsquo;à 60 secondes, une tentative par seconde — avant de faire quoi que ce soit :\nATTEMPTS_LEFT_TO_REACH_DATABASE=60 until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || \\ DATABASE_ERROR=$(php bin/console dbal:run-sql -q \u0026#34;SELECT 1\u0026#34; 2\u0026gt;\u0026amp;1); do sleep 1 ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1)) done if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then echo \u0026#34;$DATABASE_ERROR\u0026#34; exit 1 fi Si la base ne répond pas dans les 60 secondes, le container sort en erreur et Kubernetes le redémarre. Une fois la base prête, la migration tourne :\nif [ \u0026#34;$( find ./migrations -iname \u0026#39;*.php\u0026#39; -print -quit )\u0026#34; ]; then php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing fi Deux changements par rapport à la commande d\u0026rsquo;origine : --all-or-nothing garantit que si une migration dans un batch échoue, le batch entier est annulé. Et le guard find passe la commande si aucun fichier de migration n\u0026rsquo;existe — utile pour les services qui n\u0026rsquo;utilisent pas les migrations Doctrine.\nC\u0026rsquo;est franchement mieux. La base est là. La migration tourne dans le vrai environnement. Le flag --all-or-nothing apporte une atomicité que la version au build n\u0026rsquo;avait jamais eue.\nCe que ça ne résout pas Deux pods qui redéploient simultanément exécutent tous les deux l\u0026rsquo;entrypoint. Tous les deux atteignent la base. Tous les deux trouvent des migrations en attente. Tous les deux appellent doctrine:migrations:migrate.\nDoctrine a un mécanisme de verrouillage : une table doctrine_migration_versions qui enregistre quelles migrations ont tourné, et la commande la consulte avant d\u0026rsquo;appliquer quoi que ce soit. Dans les conditions normales c\u0026rsquo;est suffisant : le deuxième pod trouve la table à jour et sort proprement. Les cas de défaillance réels sont plus précis : une migration assez longue pour dépasser le timeout du verrou de base de données, ce qui laisse un deuxième runner démarrer la même migration avant que le premier ait terminé ; ou un pod qui se vautre à mi-migration avant d\u0026rsquo;avoir enregistré la version dans la table, laissant le schéma dans un état appliqué-mais-non-enregistré que le pod suivant va tenter d\u0026rsquo;appliquer à nouveau.\nLa position de l\u0026rsquo;équipe est explicite : un downtime léger au déploiement est acceptable. Les versions d\u0026rsquo;application ne sont pas nécessairement compatibles avec des versions de schéma plus anciennes, donc faire tourner N et N+1 simultanément contre la même base ne serait de toute façon pas sûr. La stratégie de déploiement est Recreate : tous les anciens pods sont terminés avant que les nouveaux ne démarrent. La migration tourne au premier démarrage, sans chevauchement entre les versions. Ça fonctionne.\nMais \u0026ldquo;ça fonctionne\u0026rdquo; et \u0026ldquo;c\u0026rsquo;est la bonne architecture\u0026rdquo; sont deux réponses différentes.\nCe que feraient les alternatives Le Facteur XII dit que les processus d\u0026rsquo;administration doivent tourner dans des \u0026ldquo;processus ponctuels\u0026rdquo;. Un processus qui tourne une fois, dans un but précis, contre l\u0026rsquo;environnement de production. L\u0026rsquo;entrypoint n\u0026rsquo;est pas ponctuel — il tourne à chaque démarrage de container, y compris les redémarrages, les événements de scaling, et les déplacements de pods par Kubernetes.\nTrois alternatives existent, chacune avec une réponse différente à la question de la propriété :\nUn init container Kubernetes tourne avant le container principal, dans le même pod. Il peut exécuter la migration, sortir, et laisser le container principal démarrer seulement après son succès. La migration est isolée du runtime applicatif. Le problème : l\u0026rsquo;init container est une image supplémentaire à construire et maintenir, et il tourne à chaque démarrage de pod — une plateforme de 14 services démarrant simultanément a toujours une race potentielle.\nUn Kubernetes Job tourne une fois, à la demande ou déclenché par le pipeline de déploiement. Il peut être configuré pour s\u0026rsquo;exécuter avant la mise à jour des pods — séquentiel, isolé, avec un signal clair de succès ou d\u0026rsquo;échec. La race condition disparaît. La complexité se déplace vers le processus de déploiement : le Job doit se terminer avant que le rollout du Deployment commence, et le pipeline CI doit coordonner les deux.\nUn hook Helm est le même concept exprimé de façon déclarative dans le chart Helm. Un hook pre-upgrade exécute la migration avant la mise à jour des pods applicatifs. C\u0026rsquo;est la réponse la plus idiomatique pour Kubernetes. Ça signifie aussi que le chart Helm est désormais responsable de l\u0026rsquo;exécution des migrations — une décision qui appartient à qui possède le chart.\nCette dernière phrase explique pourquoi l\u0026rsquo;entrypoint n\u0026rsquo;a pas changé. Déplacer les migrations hors de l\u0026rsquo;application signifie décider que l\u0026rsquo;infrastructure de déploiement — pas l\u0026rsquo;application elle-même — est responsable du schéma. C\u0026rsquo;est une question de gouvernance autant que de technique, et les questions de gouvernance prennent plus de temps à résoudre que les changements de code.\nLa fin honnête Le bloc de migration dans l\u0026rsquo;entrypoint, c\u0026rsquo;est deux lignes. Littéralement : le guard if [ \u0026quot;$( find ./migrations... )\u0026quot; ], et le php bin/console doctrine:migrations:migrate qui suit. Onze autres facteurs ont des résolutions nettes. Le cache est passé sur Redis. Les logs vont vers stdout. Le système de fichiers est un bucket S3. Le CI assemble les images de production depuis le même commit qu\u0026rsquo;il teste. Les secrets ne voyagent plus dans les layers d\u0026rsquo;image.\nLe Facteur XII a une réponse. Ce n\u0026rsquo;est juste pas la réponse finale.\nLes migrations tournent au démarrage, avec une vraie base de données, avec atomicité, avec une fenêtre de retry bornée. C\u0026rsquo;est mieux que de tourner au build contre rien. La question de savoir si elles finiront dans un Job ou un hook Helm est une conversation sur qui possède le schéma — une question à laquelle un kubectl apply ne peut pas répondre.\n","permalink":"https://guillaumedelre.github.io/fr/2026/05/17/onze-sur-douze/","summary":"\u003cp\u003eLe \u003ccode\u003ecomposer.json\u003c/code\u003e de chaque service avait ça dans sa section \u003ccode\u003epost-install-cmd\u003c/code\u003e :\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-json\" data-lang=\"json\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;post-install-cmd\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#960050;background-color:#1e0010\"\u003e:\u003c/span\u003e [\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;bin/console cache:clear --env=prod\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;bin/console doctrine:migrations:migrate --no-interaction\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003epost-install-cmd\u003c/code\u003e s\u0026rsquo;exécute pendant \u003ccode\u003ecomposer install\u003c/code\u003e, qui dans le Dockerfile de production tourne au moment du build de l\u0026rsquo;image. Il n\u0026rsquo;y a pas de base de données disponible pendant un build Docker. La commande de migration échouait silencieusement, se connectait à rien, ou était ignorée par Doctrine faute de schéma à comparer. Dans tous les cas, elle ne migrait rien.\u003c/p\u003e","title":"Onze sur douze"},{"content":"Le rolling deploy avait l\u0026rsquo;air propre. Un nouveau pod démarrait. Kubernetes voyait le healthcheck passer — php -v renvoyait zéro — et commençait à router du trafic vers le nouveau container.\nPendant les quarante secondes suivantes — sur les soixante possibles — ce container était en train de poller la base de données.\nLes requêtes qui atterrissaient dessus pendant cette fenêtre récoltaient des erreurs. Pas beaucoup — la fenêtre était courte — mais assez pour apparaître comme du bruit dans le monitoring. Le genre de bruit qu\u0026rsquo;on classe comme « problème réseau transitoire » et qu\u0026rsquo;on ne signale nulle part. Le déploiement a réussi. Le pod a fini par devenir prêt. Le mécanisme qui en était la cause était toujours là, attendant le prochain déploiement.\nLe script d\u0026rsquo;entrypoint fait cinq choses avant que FrankenPHP démarre : copier un fichier de version, vérifier le répertoire vendor, attendre jusqu\u0026rsquo;à soixante secondes la base de données, jouer les migrations en attente, installer les assets et configurer les permissions filesystem. Sous Docker Compose, c\u0026rsquo;est invisible. Sur Kubernetes, l\u0026rsquo;écart devient du trafic en erreur.\nL\u0026rsquo;écart entre démarré et prêt Kubernetes décide d\u0026rsquo;envoyer du trafic à un pod en surveillant sa readiness probe. Un pod dont la readiness probe passe reçoit des requêtes. Un pod dont la readiness probe échoue est retiré de la rotation du load balancer jusqu\u0026rsquo;à ce qu\u0026rsquo;il récupère. C\u0026rsquo;est le mécanisme qui rend les rolling deploys sûrs : Kubernetes ne bascule pas vers un nouveau pod tant que ce pod n\u0026rsquo;indique pas qu\u0026rsquo;il est prêt.\nLe compose.yaml définit un healthcheck sur chaque service :\nhealthcheck: test: [ \u0026#34;CMD\u0026#34;, \u0026#34;php\u0026#34;, \u0026#34;-v\u0026#34; ] interval: 30s timeout: 10s retries: 3 start_period: 10s php -v réussit dès que le binaire PHP est présent — ce qui est vrai depuis la première milliseconde de vie du container. Le start_period: 10s donne dix secondes avant que les vérifications commencent. Mais la boucle de polling de l\u0026rsquo;entrypoint tourne jusqu\u0026rsquo;à soixante secondes avant que FrankenPHP démarre. À la dixième seconde, le healthcheck passe. L\u0026rsquo;application attend toujours la base de données.\nLe Dockerfile a un meilleur signal :\nHEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1 Le port 2019 est le serveur de métriques intégré à Caddy, embarqué directement dans FrankenPHP. L\u0026rsquo;endpoint est compatible Prometheus et ne répond qu\u0026rsquo;une fois que la stack HTTP de Caddy est pleinement initialisée et que les workers PHP acceptent des connexions. php -v se termine en cinquante millisecondes quel que soit l\u0026rsquo;état de l\u0026rsquo;application — il vérifie le binaire, pas le serveur. :2019/metrics ne répond que quand le serveur sert vraiment. Ce n\u0026rsquo;est pas non plus un endpoint ajouté exprès pour la probe : chaque service de la plateforme l\u0026rsquo;a déjà scraped par Prometheus, donc le signal est actif indépendamment de toute configuration de healthcheck.\nC\u0026rsquo;est plus proche. Mais sur Kubernetes, l\u0026rsquo;instruction HEALTHCHECK du Dockerfile est totalement ignorée. Kubernetes utilise sa propre configuration de probes. Sans définitions de probes explicites dans les manifests Kubernetes, il n\u0026rsquo;y a aucune vérification de readiness — et un pod est considéré prêt dès que son container démarre.\nCe qui signifie : le pod démarre, l\u0026rsquo;entrypoint commence à poller, Kubernetes route du trafic, l\u0026rsquo;application n\u0026rsquo;est pas encore en état de le traiter. Les requêtes arrivent sur un container qui n\u0026rsquo;est pas prêt à les gérer.\nTrois signaux, trois questions Kubernetes sépare le cycle de vie d\u0026rsquo;un container en trois questions distinctes, chacune avec son propre type de probe :\nstartupProbe — « L\u0026rsquo;application a-t-elle fini de démarrer ? » Se déclenche à répétition jusqu\u0026rsquo;à ce qu\u0026rsquo;elle passe, puis passe la main à la liveness. Empêche la liveness probe de tuer un container qui est légitimement long à initialiser. Pour un container dont l\u0026rsquo;entrypoint peut prendre soixante secondes, c\u0026rsquo;est l\u0026rsquo;outil adapté.\nreadinessProbe — « L\u0026rsquo;application est-elle prête à traiter des requêtes ? » Échoue et passe tout au long de la vie du container. Quand elle échoue, le pod est retiré du load balancer. C\u0026rsquo;est ce qui rend un rolling deploy sûr.\nlivenessProbe — « L\u0026rsquo;application est-elle toujours vivante ? » Si elle échoue, Kubernetes redémarre le container. Conçue pour détecter les processus bloqués, pas les démarrages lents.\nLa boucle de polling de soixante secondes appartient à la patience de la startupProbe, pas au code applicatif :\nstartupProbe: httpGet: path: /metrics port: 2019 failureThreshold: 12 # 12 tentatives × 5s = 60s max periodSeconds: 5 Une fois la startupProbe passée, une readinessProbe sur le même endpoint prend le relais — indiquant à Kubernetes quand le pod peut recevoir du trafic — et une livenessProbe surveille les processus bloqués. Mais c\u0026rsquo;est la startupProbe qui absorbe le démarrage lent. La boucle de polling de l\u0026rsquo;entrypoint devient redondante : son rôle était de maintenir le container en vie pendant que la base de données devenait disponible. Sans elle, l\u0026rsquo;application tente de se connecter, échoue, et le container quitte — Kubernetes redémarre alors le pod, et la startupProbe maintient son cycle de tentatives jusqu\u0026rsquo;à ce que la base réponde et que l\u0026rsquo;application démarre proprement. La responsabilité du retry passe de l\u0026rsquo;intérieur de l\u0026rsquo;entrypoint à l\u0026rsquo;orchestrateur, ce qui est exactement là où elle devrait être.\nLe problème des migrations La boucle de polling est le problème le plus visible, mais les migrations en créent un plus subtil.\nAvec un rolling deploy et deux replicas, Kubernetes démarre un nouveau pod pendant que l\u0026rsquo;ancien sert encore du trafic. Les deux pods jouent le même entrypoint. Les deux atteignent doctrine:migrations:migrate.\nLa table de migrations de Doctrine trace quelles migrations ont déjà été exécutées, donc une migration complétée ne se jouera pas deux fois. Mais si deux pods démarrent simultanément et voient tous les deux une migration en attente, les deux tentent de la jouer en même temps. Si c\u0026rsquo;est sûr ou non dépend de la migration : les changements de schéma additifs passent en général bien ; les destructifs moins. Et on ne choisit pas lesquels s\u0026rsquo;exécutent lors d\u0026rsquo;un déploiement qui n\u0026rsquo;a pas prévu de se coordonner. --all-or-nothing enveloppe les migrations dans une transaction et fait un rollback si l\u0026rsquo;une échoue — c\u0026rsquo;est une question d\u0026rsquo;atomicité au sein d\u0026rsquo;une seule exécution, pas de coordination entre processus.\nL\u0026rsquo;approche plus propre sépare ces deux préoccupations en deux init containers : l\u0026rsquo;un qui attend la base de données, l\u0026rsquo;autre qui joue les migrations. Le container principal ne démarre qu\u0026rsquo;une fois les deux terminés :\ninitContainers: - name: wait-for-db image: authentication:latest command: [\u0026#34;php\u0026#34;, \u0026#34;bin/console\u0026#34;, \u0026#34;dbal:run-sql\u0026#34;, \u0026#34;-q\u0026#34;, \u0026#34;SELECT 1\u0026#34;] - name: migrate image: authentication:latest command: [\u0026#34;php\u0026#34;, \u0026#34;bin/console\u0026#34;, \u0026#34;doctrine:migrations:migrate\u0026#34;, \u0026#34;--no-interaction\u0026#34;, \u0026#34;--all-or-nothing\u0026#34;] Les deux init containers réutilisent la même image que l\u0026rsquo;application. Ce n\u0026rsquo;est pas du gaspillage : ils ont besoin du même binaire PHP et du même câblage d\u0026rsquo;environnement pour atteindre la base de données et trouver les classes de migration. Une image dédiée plus légère réduirait le temps de démarrage, mais nécessiterait de maintenir une installation PHP séparée en synchronisation avec l\u0026rsquo;image principale.\nMême avec des init containers, plusieurs pods démarrant simultanément — déploiement initial, après une défaillance de nœud, ou sous pression d\u0026rsquo;autoscaling — tenteront chacun de jouer les migrations. Le résoudre proprement — via un hook pre-upgrade Helm, une stratégie maxSurge: 0, ou un Job de migration séparé — est un sujet en soi. Ce qui compte ici, c\u0026rsquo;est que l\u0026rsquo;entrypoint est le mauvais endroit pour prendre cette décision : il ne peut pas se coordonner entre pods, et il lie l\u0026rsquo;exécution des migrations au démarrage de l\u0026rsquo;application d\u0026rsquo;une façon difficile à démêler plus tard. La question de quelle alternative convient à cette codebase — et pourquoi l\u0026rsquo;entrypoint n\u0026rsquo;a pas encore été remplacé — fait l\u0026rsquo;objet de l\u0026rsquo;article suivant dans cette série .\nLe Facteur XII de la méthodologie twelve-factor — les processus d\u0026rsquo;administration tournent dans le même environnement que l\u0026rsquo;application — est respecté dans les deux cas. La question est de savoir si « même environnement » signifie « même script d\u0026rsquo;entrypoint » ou « même image, processus séparé ». Sur Kubernetes, le second est plus sûr.\nLa vraie responsabilité de l\u0026rsquo;entrypoint Enlever l\u0026rsquo;attente de la base de données (maintenant une startupProbe ou un init container), les migrations (maintenant un init container ou un Job), et l\u0026rsquo;installation des assets (une opération de build-time qui appartient au Dockerfile), et l\u0026rsquo;entrypoint n\u0026rsquo;a plus qu\u0026rsquo;une seule responsabilité : démarrer l\u0026rsquo;application.\nexec docker-php-entrypoint \u0026#34;$@\u0026#34; Le Facteur IX de la twelve-factor app demande un démarrage rapide et un arrêt propre. Un container dont le démarrage prend soixante secondes parce qu\u0026rsquo;il attend des dépendances externes n\u0026rsquo;est pas rapide. Ça signifie des rolling deploys lents, une reprise après crash lente, et un scale-out horizontal qui crée une fenêtre de soixante secondes avant que chaque nouveau pod contribue.\nLe démarrage rapide n\u0026rsquo;est pas juste un confort. C\u0026rsquo;est ce qui fait fonctionner le reste du modèle cloud. Quand un pod peut démarrer en secondes, l\u0026rsquo;orchestrateur peut scaler agressivement et récupérer vite. Quand ça prend une minute, on ajoute des marges partout — timeouts de probes plus longs, fenêtres de déploiement plus larges, politiques de scaling plus conservatrices — et le système devient rigide.\nLa taxe Docker Compose L\u0026rsquo;entrypoint accumule ces responsabilités pour une raison. Sous Docker Compose, il n\u0026rsquo;y a pas de concept d\u0026rsquo;init container. Pas de startupProbe. Les services déclarent depends_on, mais sans conditions de santé, c\u0026rsquo;est juste de l\u0026rsquo;ordre de démarrage — pas de la readiness. L\u0026rsquo;entrypoint comble le vide.\nCe n\u0026rsquo;est pas un défaut de conception. C\u0026rsquo;est une adaptation raisonnable aux contraintes de Docker Compose. Le script fonctionne. Il gère les cas limites (le timeout de la base, les erreurs irrécupérables, le répertoire de migrations absent). Quelqu\u0026rsquo;un l\u0026rsquo;a testé.\nLe problème, c\u0026rsquo;est l\u0026rsquo;hypothèse que le même script fonctionne aussi bien sur Kubernetes. Il tourne. L\u0026rsquo;application finit par démarrer. Mais il contourne le système de probes qui rend les déploiements Kubernetes fiables, et il place la responsabilité des migrations à un endroit où la coordination entre pods est difficile à raisonner.\nPlusieurs des changements de cette série — stockage des médias , secrets dans les images , handlers de logs , dépendances de services , parité d\u0026rsquo;environnement CI , adaptateurs de cache — étaient des changements au code applicatif ou à la configuration. Celui-ci est différent. Il demande à l\u0026rsquo;infrastructure de comprendre ce que « prêt » signifie pour cette application, et il demande à l\u0026rsquo;entrypoint de céder des responsabilités qu\u0026rsquo;il détient actuellement.\nC\u0026rsquo;est une conversation plus difficile. Mais la startupProbe attend.\n","permalink":"https://guillaumedelre.github.io/fr/2026/05/17/d%C3%A9marr%C3%A9-ne-veut-pas-dire-pr%C3%AAt/","summary":"\u003cp\u003eLe rolling deploy avait l\u0026rsquo;air propre. Un nouveau pod démarrait. Kubernetes voyait le healthcheck passer — \u003ccode\u003ephp -v\u003c/code\u003e renvoyait zéro — et commençait à router du trafic vers le nouveau container.\u003c/p\u003e\n\u003cp\u003ePendant les quarante secondes suivantes — sur les soixante possibles — ce container était en train de poller la base de données.\u003c/p\u003e\n\u003cp\u003eLes requêtes qui atterrissaient dessus pendant cette fenêtre récoltaient des erreurs. Pas beaucoup — la fenêtre était courte — mais assez pour apparaître comme du bruit dans le monitoring. Le genre de bruit qu\u0026rsquo;on classe comme « problème réseau transitoire » et qu\u0026rsquo;on ne signale nulle part. Le déploiement a réussi. Le pod a fini par devenir prêt. Le mécanisme qui en était la cause était toujours là, attendant le prochain déploiement.\u003c/p\u003e","title":"Démarré ne veut pas dire prêt"},{"content":"La première fois qu\u0026rsquo;on a lancé deux replicas du même service Symfony derrière un load balancer, tout avait l\u0026rsquo;air d\u0026rsquo;aller. Les health checks passaient. Le trafic se répartissait proprement. Les temps de réponse étaient bons.\nPuis quelqu\u0026rsquo;un a remarqué que le rate limiter se comportait bizarrement. Cinq appels à l\u0026rsquo;API, accès bloqué. Cinq appels supplémentaires à la requête suivante, accès accordé. Selon quel pod répondait, on était une personne différente.\nC\u0026rsquo;était le cache qui parlait. Une ligne de config, répliquée sur treize services, bloquait le scaling horizontal dans sa totalité.\nUn fichier de config, treize fois On préparait une plateforme de treize microservices Symfony pour passer sur Kubernetes. La stack était déjà en bon état : FrankenPHP pour le serveur HTTP, des Dockerfiles multi-étapes, un GitLab CI qui poussait des images taguées vers un registre cloud. Les pièces étaient là. Il fallait juste vérifier que rien ne casserait quand on commencerait à scaler les pods horizontalement.\nUne bonne checklist pour ce type d\u0026rsquo;audit, c\u0026rsquo;est la méthodologie twelve-factor app — douze principes pour construire des logiciels qui tournent proprement dans des environnements cloud. La plupart des facteurs étaient déjà couverts sans qu\u0026rsquo;on y ait pensé délibérément.\nLe Facteur VII (port binding) était gratuit. FrankenPHP embarque Caddy directement dans le processus PHP. Le container expose son propre endpoint HTTP, sans Apache ni Nginx à ajouter. L\u0026rsquo;image est autonome, ce que le facteur demande exactement :\nHEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1 Le Facteur II (dépendances) était géré par composer.json et les extensions du Dockerfile. Le Facteur X (parité dev/prod) était suffisamment couvert pour notre périmètre : même image, mêmes backing services en local et en CI, ce qui est la partie qui compte vraiment pour l\u0026rsquo;audit.\nPuis j\u0026rsquo;en suis arrivé au Facteur VI.\nLe problème avec « ça marche sur un seul serveur » Le Facteur VI dit que les processus ne doivent rien partager. Rien d\u0026rsquo;écrit sur disque entre les requêtes, rien en mémoire locale qu\u0026rsquo;une autre instance ne puisse pas voir. Si on a besoin de persister de l\u0026rsquo;état, on le met dans un backing service — une base de données, un cluster de cache, une queue. Le processus lui-même reste jetable.\nJ\u0026rsquo;ai ouvert authentication/config/packages/cache.yaml. Puis content/config/packages/cache.yaml. Puis media/config/packages/cache.yaml.\nframework: cache: app: cache.adapter.filesystem Treize services. Treize fois, mot pour mot.\nChaque instance de chaque service écrivait son cache sur le filesystem local. Ce qui signifiait que chaque pod avait son propre cache privé, invisible pour tous les autres pods. Quand le load balancer envoyait une requête au pod A, il obtenait la version mise en cache par le pod A. Le pod B avait construit la sienne. Elles pouvaient avoir été générées à des moments différents, depuis des données sources différentes, ou l\u0026rsquo;une d\u0026rsquo;elles pouvait ne pas encore avoir été construite du tout.\nLe rate limiter était le symptôme le plus visible parce qu\u0026rsquo;il avait un compteur. Mais la même divergence affectait chaque donnée qu\u0026rsquo;on mettait en cache : métadonnées du sérialiseur, collections de routes, caches de résultats Doctrine. Deux utilisateurs envoyant des requêtes identiques pouvaient obtenir des réponses différentes selon quel nœud avait récupéré la connexion.\nRedis était déjà là C\u0026rsquo;est la partie qui pique un peu. Redis était déjà dans la stack. Chaque service l\u0026rsquo;avait configuré via SncRedisBundle :\n# config/packages/snc_redis.yaml — présent sur les 13 services snc_redis: clients: default: type: \u0026#39;phpredis\u0026#39; alias: \u0026#39;default\u0026#39; dsn: \u0026#39;%env(IN_MEM_STORE__URI)%\u0026#39; Le Facteur IV de la twelve-factor app dit que les backing services doivent être des ressources attachées, interchangeables via la configuration. Redis était exactement ça : joignable via une variable d\u0026rsquo;environnement, prêt à être remplacé par une instance managée dans le cloud. La plomberie était faite. On ne s\u0026rsquo;en servait juste pas pour le cache applicatif.\nCertains services l\u0026rsquo;avaient même juste pour des pools spécifiques. Le rate limiter dans le service d\u0026rsquo;authentification :\npools: rate_limiter.cache: adapter: cache.adapter.redis Ce qui explique l\u0026rsquo;incohérence qu\u0026rsquo;on a vue en premier. Le compteur du rate limit allait vers Redis (partagé entre les pods). Le cache qui alimentait la vérification du rate limit allait vers le filesystem (local au pod). Deux sources de vérité, l\u0026rsquo;une invisible à l\u0026rsquo;autre.\nLa correction tenait en une ligne par service :\nframework: cache: app: cache.adapter.redis default_redis_provider: snc_redis.default Treize fichiers. Treize changements identiques. Le genre de correction qui donne l\u0026rsquo;impression qu\u0026rsquo;on aurait dû la repérer avant, sauf qu\u0026rsquo;elle est parfaitement invisible quand on tourne sur une seule instance.\nCe qui doit migrer vers Redis Le cache filesystem violait le Facteur VI (les processus portent de l\u0026rsquo;état local qu\u0026rsquo;ils ne devraient pas) et le Facteur VIII (on ne peut pas scaler sans partager cet état). C\u0026rsquo;est le même problème vu sous deux angles : VI décrit ce qui ne va pas, VIII décrit ce qu\u0026rsquo;on ne peut pas faire à cause de ça.\nAvec un backend de cache partagé, un deuxième pod est sûr. Les deux pods construisent le même cache, voient les mêmes invalidations, s\u0026rsquo;accordent sur les mêmes limites de rate. On peut ajouter un troisième pod sous charge et le retirer quand le trafic baisse. L\u0026rsquo;orchestrateur s\u0026rsquo;en occupe ; l\u0026rsquo;application n\u0026rsquo;a pas besoin de le savoir.\nSans ça, le scaling horizontal est un risque. Plus de pods, c\u0026rsquo;est plus de divergence, plus de bugs « ça marche chez moi » qu\u0026rsquo;il est impossible de reproduire en local parce qu\u0026rsquo;en local on tourne avec un seul container.\nLes sessions avaient le même problème — et potentiellement pire. Douze des treize services utilisaient session.storage.factory.native — qui écrit les sessions sur le filesystem par défaut. Un utilisateur dont la requête atterrit sur le pod A obtient une session liée au pod A. Sa requête suivante va sur le pod B. Session perdue, il est déconnecté. Un seul service avait RedisSessionHandler configuré.\nLa mitigation partielle : la plupart de la plateforme tourne sur des APIs stateless avec des JWT, donc l\u0026rsquo;usage des sessions est limité. Mais « limité » n\u0026rsquo;est pas « zéro ». Les services qui créent des sessions — flows d\u0026rsquo;authentification, état temporaire pendant les handshakes OAuth — ont un mode de défaillance visible par l\u0026rsquo;utilisateur qui attend le deuxième pod. Soit ces sessions migrent vers Redis, soit le code qui les crée est supprimé. Les laisser en l\u0026rsquo;état est une décision qui attend le premier utilisateur dont la session disparaît sans explication.\nL\u0026rsquo;autre genre d\u0026rsquo;état Redis résout le problème cross-pod. FrankenPHP introduit un autre problème qu\u0026rsquo;il vaut la peine de connaître.\nDans le modèle PHP-FPM standard, chaque requête forke un processus frais. Tout objet en mémoire — toute valeur mise en cache, tout résultat calculé — meurt avec la réponse. Le processus est stateless par construction.\nFrankenPHP a un mode worker qui ne suit pas ce modèle. En mode worker, un seul processus PHP démarre une fois, charge le kernel, câble le container, et gère plusieurs requêtes successives sans redémarrer. Le débit de requêtes s\u0026rsquo;améliore : pas de cold start de l\u0026rsquo;autoloader, pas de rebuild du container par requête, moins d\u0026rsquo;allocations. La contrepartie : le processus PHP a maintenant un cycle de vie qui enjambe les requêtes.\nPour le cache, ça ajoute une complexité. Un adaptateur array ou un pool APCu accumule des entrées à travers les requêtes sur le même worker. Une invalidation de cache poussée vers Redis atteint immédiatement les autres pods — mais ne vide pas ce qui est assis dans la mémoire du worker. Deux requêtes sur le même pod peuvent voir des choses différentes : l\u0026rsquo;une touche une entrée en mémoire chaude, la suivante déclenche un fetch Redis après expiration de l\u0026rsquo;entrée in-process.\nLa plateforme garde le mode worker désactivé (APP__WORKER_MODE__ENABLED=false). Il est disponible — l\u0026rsquo;infrastructure est là, le flag est câblé — mais pas actif. Le gain de performance ne justifiait pas l\u0026rsquo;audit. Chaque pool de cache aurait besoin d\u0026rsquo;être vérifié contre la sémantique du mode worker ; chaque endroit où de l\u0026rsquo;état fuit entre les requêtes deviendrait un bug potentiel.\nLa position conservatrice : garder PHP stateless au niveau du processus même quand le runtime ne l\u0026rsquo;exige pas. Le principe shared-nothing du Facteur VI s\u0026rsquo;applique non seulement au filesystem — il s\u0026rsquo;applique au processus lui-même.\nCe qui fonctionnait déjà Pour être juste envers la codebase : le Scheduler Symfony utilisait déjà Redis pour les locks distribués :\n$schedule-\u0026gt;lock($this-\u0026gt;lockFactory-\u0026gt;createLock(\u0026#39;schedule_purge\u0026#39;)); Dans un environnement multi-pod, on ne veut pas cinq instances lancer le même job de purge simultanément. Le lock l\u0026rsquo;empêche. Redis rend le lock visible entre les pods. Celui qui a écrit le scheduler savait exactement ce qu\u0026rsquo;il faisait.\nLe même raisonnement ne s\u0026rsquo;était juste pas propagé à la configuration du cache — probablement parce qu\u0026rsquo;en tournant sur une seule instance, cache.adapter.filesystem est invisible. Ça fonctionne, c\u0026rsquo;est rapide, ça ne demande aucune configuration. Le problème n\u0026rsquo;apparaît qu\u0026rsquo;à deux.\nLes quatre questions Le Facteur VI prend la plupart des applications par surprise lors d\u0026rsquo;une migration cloud. Pas parce que les développeurs ne connaissent pas les processus stateless — ils le savent généralement — mais parce que le filesystem est toujours là, et le problème reste caché jusqu\u0026rsquo;à ce qu\u0026rsquo;on essaie de lancer une deuxième instance.\nAvant de scaler un service Symfony horizontalement, quatre questions méritent une réponse :\nOù va le cache applicatif ? (cache.adapter.filesystem doit devenir cache.adapter.redis) Où vont les sessions ? (session.storage.factory.native a besoin de Redis — ou supprimer les sessions entièrement si on est full JWT) Est-ce que quelque chose écrit dans var/ à l\u0026rsquo;exécution qu\u0026rsquo;un autre pod aurait besoin de lire ? Est-ce qu\u0026rsquo;il y a quelque chose dans le chemin de code qui doit être mutuellement exclusif entre pods ? (si oui, c\u0026rsquo;est un job pour le composant Lock de Symfony adossé à Redis, pas un mutex local) Si toutes les réponses pointent vers des backing services partagés, on est prêt. Si l\u0026rsquo;une d\u0026rsquo;elles pointe vers le filesystem local, la production finira par trouver le pod qui a construit son cache il y a trois heures et le servira à l\u0026rsquo;utilisateur qui s\u0026rsquo;y attend le moins.\n","permalink":"https://guillaumedelre.github.io/fr/2026/05/16/le-cache-qui-nous-mentait/","summary":"\u003cp\u003eLa première fois qu\u0026rsquo;on a lancé deux replicas du même service Symfony derrière un load balancer, tout avait l\u0026rsquo;air d\u0026rsquo;aller. Les health checks passaient. Le trafic se répartissait proprement. Les temps de réponse étaient bons.\u003c/p\u003e\n\u003cp\u003ePuis quelqu\u0026rsquo;un a remarqué que le rate limiter se comportait bizarrement. Cinq appels à l\u0026rsquo;API, accès bloqué. Cinq appels supplémentaires à la requête suivante, accès accordé. Selon quel pod répondait, on était une personne différente.\u003c/p\u003e","title":"Le cache qui nous mentait"},{"content":"Le pipeline avait deux stages qui n\u0026rsquo;avaient rien à voir avec le code : provision et deprovision. Entre eux, dans l\u0026rsquo;ordre : phpunit, phpmetrics, behat.\nstages: - build - provision - phpunit - phpmetrics - behat - deprovision - deploy Avant que la première assertion s\u0026rsquo;exécute, quinze minutes s\u0026rsquo;étaient écoulées. Terraform avait cloné un dépôt d\u0026rsquo;infrastructure, s\u0026rsquo;était authentifié sur Azure, avait appliqué une configuration de VM. Ansible s\u0026rsquo;était connecté à la nouvelle VM, avait installé PHP, configuré l\u0026rsquo;application, câblé une base de données et une instance Redis. Ensuite les tests tournaient. Ensuite Terraform détruisait ce qu\u0026rsquo;Ansible avait construit.\nPour chaque pipeline. Depuis chaque branche. Pour chaque pull request, de l\u0026rsquo;ouverture au merge.\nCe que ces quinze minutes ne contenaient pas Le stage provision mettait en place deux services : PostgreSQL et Redis. Trois services dont l\u0026rsquo;application dépendait en production étaient absents : RabbitMQ, MinIO et Varnish.\nRabbitMQ traitait tout le travail asynchrone — 56 consumers sur 14 microservices. MinIO gérait le stockage de médias. Varnish était devant le cache HTTP. En CI, aucun d\u0026rsquo;eux n\u0026rsquo;existait. Les tests qui couvraient les files de messages ou le stockage de fichiers avaient deux options : ignorer ces chemins, ou les laisser non testés jusqu\u0026rsquo;au staging. Varnish est un cas à part : les tests tapent directement dans l\u0026rsquo;application et contournent intentionnellement la couche de cache, son absence en CI est donc un choix délibéré plutôt qu\u0026rsquo;un manque.\nC\u0026rsquo;est le problème que le Facteur X décrit comme l\u0026rsquo;écart d\u0026rsquo;environnement. L\u0026rsquo;écart ici n\u0026rsquo;était pas une question de configuration — il était structurel. La VM était construite par Ansible depuis un script dans un dépôt séparé. Ce n\u0026rsquo;était pas une image de container. Elle n\u0026rsquo;était pas versionnée aux côtés de l\u0026rsquo;application. Si une branche modifiait la topologie de messages RabbitMQ, il n\u0026rsquo;y avait aucun moyen de tester cette modification en CI. Le changement de topologie et le code qui en dépendait ne se rencontreraient qu\u0026rsquo;en staging.\nLe script de provisioning Ansible lui-même fait partie du problème :\nlaunch_vm: stage: provision script: - git clone git@gitlab.internal/infra/ci-vm.git - cd ci-vm - az login --service-principal -u $ARM_CLIENT_ID ... - terraform apply -var \u0026#34;prefix=${CI_PIPELINE_ID}-vm\u0026#34; ... - sleep 45 - ansible-playbook behat/test-env.yml ... Le sleep 45 est là parce qu\u0026rsquo;Ansible a besoin que la VM finisse de booter avant de pouvoir s\u0026rsquo;y connecter. Ce n\u0026rsquo;est pas un oubli — c\u0026rsquo;est le délai minimum qu\u0026rsquo;une VM fraîchement provisionnée nécessite avant que SSH fonctionne. C\u0026rsquo;est inscrit dans le processus.\nCe qui l\u0026rsquo;a remplacé Le nouveau pipeline n\u0026rsquo;a pas de stage provision. Il n\u0026rsquo;a pas de stage deprovision. L\u0026rsquo;environnement, ce sont les images, et les images existent avant que les tests commencent.\nChaque job de test déclare ses dépendances comme des services Docker :\nservices: - name: $REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG alias: rabbitmq - name: $REGISTRY_URL/platform/minio:$CI_COMMIT_REF_SLUG alias: minio - name: redis:7.4.1 alias: redis - name: $ARTIFACTORY_URL/postgresql:13 alias: postgresql Les services démarrent en parallèle quand le job commence. Avant que le script de test tourne, un before_script attend qu\u0026rsquo;ils soient tous prêts :\nbefore_script: - $CI_PROJECT_DIR/dockerize -wait tcp://postgresql:5432 -wait tcp://rabbitmq:5672 -wait tcp://minio:9000 -wait tcp://redis:6379 -timeout 120s Du démarrage du pipeline à la première assertion : quatre-vingt-dix secondes — en supposant que les images sont déjà dans le cache du runner ; un cold pull rallonge les choses, mais devient négligeable une fois que le pipeline a tourné une fois sur une branche donnée.\nCe que signifie $CI_COMMIT_REF_SLUG Le timing est le résultat visible. Ce qui le produit est plus intéressant encore : les noms des images.\n$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG n\u0026rsquo;est pas l\u0026rsquo;image officielle RabbitMQ de Docker Hub. C\u0026rsquo;est une image construite par le même pipeline, depuis la même branche, au même commit que le code testé. L\u0026rsquo;image RabbitMQ embarque la topologie : un definitions.json avec chaque exchange, chaque queue, chaque binding, chaque configuration de dead-letter — versionné dans git aux côtés de l\u0026rsquo;application qui en dépend.\nSi une branche modifie la topologie de messages, le pipeline CI construit une nouvelle image RabbitMQ qui inclut ces modifications, puis exécute les tests contre elle. Le changement de topologie et le code qui en dépend sont testés ensemble, au même commit, avant que quoi que ce soit n\u0026rsquo;atteigne le staging.\nLa même logique s\u0026rsquo;applique à MinIO, décrite dans le premier article de cette série : l\u0026rsquo;image MinIO embarque des fixtures de test préchargées. L\u0026rsquo;environnement CI n\u0026rsquo;a pas besoin d\u0026rsquo;une étape de setup pour peupler le stockage. L\u0026rsquo;état est intégré à l\u0026rsquo;artefact.\nLe runner de tests lui-même suit le même pattern. Chaque job utilise une variante debug de l\u0026rsquo;image applicative — construite depuis la même branche, au même commit — avec les dépendances de test incluses :\nimage: $REGISTRY_URL/platform/$service:$CI_COMMIT_REF_SLUG-debug Tout l\u0026rsquo;environnement s\u0026rsquo;assemble depuis des artefacts construits au même point de l\u0026rsquo;historique git.\nCe que ça a demandé d\u0026rsquo;abandonner Behat et la VM provisionnée étaient couplés. La suite de tests Behat tournait contre un serveur HTTP sur la VM ; supprimer la VM signifiait supprimer Behat.\nÇa s\u0026rsquo;est révélé moins bloquant que ça n\u0026rsquo;en avait l\u0026rsquo;air. La suite Behat vivait dans un dépôt séparé, nécessitait la VM pour tourner, et avait accumulé une charge de maintenance significative. PHPUnit, tournant dans le container applicatif avec les services Docker, couvrait les mêmes scénarios par un chemin plus direct : tests fonctionnels qui exercent la couche HTTP, tests unitaires pour les composants individuels, suites organisées par domaine fonctionnel et générées dynamiquement en jobs CI parallèles.\nLa couche BDD a disparu. La couverture de tests est restée — et pouvait désormais tourner contre les vrais services.\nLe Facteur X, appliqué Le Facteur X se lit souvent comme \u0026ldquo;utilise la même base de données en local qu\u0026rsquo;en production.\u0026rdquo; C\u0026rsquo;est la version la plus simple. La version plus profonde concerne l\u0026rsquo;écart entre ce qu\u0026rsquo;on teste et ce qu\u0026rsquo;on livre.\nL\u0026rsquo;écart dans l\u0026rsquo;ancien pipeline était large : une VM configurée manuellement, privée de services clés, reconstruite de zéro à chaque run. L\u0026rsquo;écart dans le nouveau pipeline est étroit : le CI assemble l\u0026rsquo;environnement depuis les mêmes images que la production, construites au même commit que le code sous test.\nLes quinze minutes de Terraform et Ansible n\u0026rsquo;étaient pas seulement lentes. Elles construisaient quelque chose qui n\u0026rsquo;était pas ce que la production faisait tourner, à chaque fois, avant que le moindre test puisse commencer. Les quatre-vingt-dix secondes de docker pull construisent exactement ce que la production fait tourner — et les tests qui suivent testent ça, pas une approximation.\n","permalink":"https://guillaumedelre.github.io/fr/2026/05/16/quinze-minutes-avant-le-premier-test/","summary":"\u003cp\u003eLe pipeline avait deux stages qui n\u0026rsquo;avaient rien à voir avec le code : \u003ccode\u003eprovision\u003c/code\u003e et \u003ccode\u003edeprovision\u003c/code\u003e. Entre eux, dans l\u0026rsquo;ordre : \u003ccode\u003ephpunit\u003c/code\u003e, \u003ccode\u003ephpmetrics\u003c/code\u003e, \u003ccode\u003ebehat\u003c/code\u003e.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003estages\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ebuild\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003eprovision\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ephpunit\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ephpmetrics\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ebehat\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003edeprovision\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003edeploy\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAvant que la première assertion s\u0026rsquo;exécute, quinze minutes s\u0026rsquo;étaient écoulées. Terraform avait cloné un dépôt d\u0026rsquo;infrastructure, s\u0026rsquo;était authentifié sur Azure, avait appliqué une configuration de VM. Ansible s\u0026rsquo;était connecté à la nouvelle VM, avait installé PHP, configuré l\u0026rsquo;application, câblé une base de données et une instance Redis. Ensuite les tests tournaient. Ensuite Terraform détruisait ce qu\u0026rsquo;Ansible avait construit.\u003c/p\u003e","title":"Quinze minutes avant le premier test"},{"content":"Chaque service de la plateforme avait ces six variables :\nAPP__GATEWAY__PRIVATE__HOST=\u0026#34;platform.internal\u0026#34; APP__GATEWAY__PRIVATE__PORT=80 APP__GATEWAY__PRIVATE__SCHEME=\u0026#34;http\u0026#34; APP__GATEWAY__PUBLIC__HOST=\u0026#34;platform.internal\u0026#34; APP__GATEWAY__PUBLIC__PORT=80 APP__GATEWAY__PUBLIC__SCHEME=\u0026#34;http\u0026#34; Treize services, six variables chacun, une seule valeur. En lisant la config d\u0026rsquo;un service quelconque, l\u0026rsquo;architecture semblait plate. Tout parlait au même hôte. C\u0026rsquo;était tout le tableau.\nCe ne l\u0026rsquo;était pas.\nComment fonctionnait la gateway La gateway se trouvait devant chaque service et gérait tout le trafic inter-services. Un service appelant l\u0026rsquo;API content construisait une requête vers http://platform.internal/content/api/ — la gateway la recevait, identifiait la cible depuis le chemin de l\u0026rsquo;URL, et la transmettait au bon backend. Chaque client HTTP inter-service dans framework.yaml suivait le même schéma :\ncontent.client: base_uri: \u0026#34;%http_client.gateway.base_uri%/content/api/\u0026#34; headers: Host: \u0026#34;%env(APP__GATEWAY__PRIVATE__HOST)%\u0026#34; Le paramètre http_client.gateway.base_uri était assemblé depuis les variables GATEWAY. La gateway savait où tournait chaque service. Les services n\u0026rsquo;avaient pas besoin de le savoir. De leur point de vue, tout était platform.internal.\nÇa fonctionnait. Pendant des années, ça fonctionnait bien. Ajouter un service signifiait ajouter un alias DNS dans la config de la gateway, pas toucher treize fichiers .env. La gateway abstraisait la topologie. Les services restaient découplés du détail d\u0026rsquo;infrastructure de qui tournait où.\nCe que la gateway absorbait L\u0026rsquo;abstraction avait un coût qui n\u0026rsquo;apparaissait pas tant qu\u0026rsquo;on n\u0026rsquo;essayait pas de lire le système.\nEn regardant le fichier env de content, on voyait six variables de gateway et rien d\u0026rsquo;autre sur la communication inter-services. Pour découvrir que content appelait conversion, shorty et media, il fallait lire framework.yaml. Pour découvrir que pilot appelait dix services externes, il fallait tracer les clients HTTP un par un et compter.\nLe chiffre était dix. Authentication, bam, config, content, conversion, media, product, shorty, sitemap, social. Dix des treize services de la plateforme dont pilot dépendait à l\u0026rsquo;exécution, aucun d\u0026rsquo;eux visible depuis sa configuration. Six variables disaient : parle à la gateway. Elles ne disaient rien de la forme de ce qui se trouvait derrière.\nCette information existait — dans le code, dans la config framework, dans les têtes des gens qui avaient construit ces intégrations. Elle ne vivait juste nulle part où on pouvait la lire d\u0026rsquo;un coup d\u0026rsquo;œil.\nCe que Kubernetes a rendu explicite On-premise, la gateway était un seul hostname résolvable. Un enregistrement DNS, un jeu de variables, un seul endroit à mettre à jour. Kubernetes ne fonctionne pas comme ça. Chaque service obtient son propre nom DNS à l\u0026rsquo;intérieur du cluster — content.namespace.svc.cluster.local, conversion.namespace.svc.cluster.local. Le trafic inter-services passe directement, service à service, sans gateway partagée.\nPasser à Kubernetes signifiait que l\u0026rsquo;abstraction de la gateway devait céder la place. Chaque service devait savoir, concrètement, où vivait chacune de ses dépendances. Les six variables génériques ne pouvaient pas exprimer ça.\nLe refacto les a remplacées par des variables HOST par cible — une par dépendance de service, nommée d\u0026rsquo;après la cible :\n# content/.env — content appelle ces quatre services APP__CONFIG__HOST=\u0026#34;platform.internal\u0026#34; APP__CONVERSION__HOST=\u0026#34;platform.internal\u0026#34; APP__MEDIA__HOST=\u0026#34;platform.internal\u0026#34; APP__SHORTY__HOST=\u0026#34;platform.internal\u0026#34; # pilot/.env — dix dépendances de service APP__AUTHENTICATION__HOST=\u0026#34;platform.internal\u0026#34; APP__BAM__HOST=\u0026#34;platform.internal\u0026#34; APP__CONFIG__HOST=\u0026#34;platform.internal\u0026#34; APP__CONTENT__HOST=\u0026#34;platform.internal\u0026#34; APP__CONVERSION__HOST=\u0026#34;platform.internal\u0026#34; APP__MEDIA__HOST=\u0026#34;platform.internal\u0026#34; APP__PRODUCT__HOST=\u0026#34;platform.internal\u0026#34; APP__SHORTY__HOST=\u0026#34;platform.internal\u0026#34; APP__SITEMAP__HOST=\u0026#34;platform.internal\u0026#34; APP__SOCIAL__HOST=\u0026#34;platform.internal\u0026#34; Chaque client HTTP dans framework.yaml a reçu sa propre base_uri construite depuis la variable HOST de sa cible, et le header Host a cédé la place à un User-Agent qui identifie l\u0026rsquo;appelant :\ncontent.client: base_uri: \u0026#34;%env(APP__HTTP__SCHEME)%://%env(APP__CONTENT__HOST)%:%env(APP__HTTP__PORT)%/content/api/\u0026#34; headers: User-Agent: \u0026#34;Platform Content - %semver%\u0026#34; Le changement n\u0026rsquo;est pas cosmétique. Dans l\u0026rsquo;ancienne configuration, le header Host explicite garantissait que les requêtes atteignaient le bon virtual host de la gateway quelle que soit la résolution DNS. Dans la nouvelle, chaque client pointe directement vers le nom DNS de sa cible — le Host correct est dérivé automatiquement de la base_uri. L\u0026rsquo;emplacement du header ne reste pas vide : le User-Agent identifie désormais le service appelant, ce qui remonte dans les logs et le traçage distribué sans instrumentation supplémentaire.\nL\u0026rsquo;inconfort de la lisibilité Le fichier env de pilot est passé de neuf variables de gateway à dix variables HOST spécifiques par service. Le fichier est devenu plus long. L\u0026rsquo;architecture n\u0026rsquo;est pas devenue plus simple — les dix dépendances étaient là avant et elles sont toujours là. Ce qui a changé, c\u0026rsquo;est qu\u0026rsquo;elles sont lisibles.\nLe Facteur III dit de stocker la config dans l\u0026rsquo;environnement. L\u0026rsquo;ancienne approche satisfaisait ça à la lettre : six variables, toutes dans des fichiers env, aucune en dur dans le code. Mais des variables qui effondrent le graphe de dépendances entier dans un seul hostname opaque ne sont pas vraiment de la configuration — elles sont un raccourci qui échange la lisibilité contre la commodité. Le Facteur III ne demande pas seulement que la config soit externalisée — il suppose implicitement qu\u0026rsquo;elle reste informative une fois externalisée.\nLe refacto n\u0026rsquo;a rien simplifié. Il a rendu la complexité visible. Les dix variables HOST de pilot documentent, dans le fichier .env lui-même, les dix services dont il dépend. Un nouveau membre d\u0026rsquo;équipe qui lit ce fichier apprend quelque chose de réel sur l\u0026rsquo;architecture. L\u0026rsquo;ancien fichier lui apprenait qu\u0026rsquo;il y avait une gateway.\nIl y a une version de cette histoire où on lit l\u0026rsquo;état final et on conclut que l\u0026rsquo;équipe a fait un travail inutile — elle a remplacé six variables par dix, toutes pointant vers le même hôte de toute façon. En développement local, platform.internal résout toujours au même endroit. Le comportement fonctionnel n\u0026rsquo;a pas changé.\nLe changement est dans ce que la config communique. Dans Kubernetes, les valeurs HOST divergent : chaque cible obtient son propre nom DNS interne au cluster, différent par environnement. Les variables portent maintenant une vraie information. Le refacto a préparé la config à être honnête sur une topologie qu\u0026rsquo;elle simplifiait silencieusement depuis des années.\n","permalink":"https://guillaumedelre.github.io/fr/2026/05/15/lh%C3%B4te-qui-cachait-le-graphe/","summary":"\u003cp\u003eChaque service de la plateforme avait ces six variables :\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PRIVATE__HOST\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;platform.internal\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PRIVATE__PORT\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e80\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PRIVATE__SCHEME\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;http\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PUBLIC__HOST\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;platform.internal\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PUBLIC__PORT\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e80\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PUBLIC__SCHEME\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;http\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eTreize services, six variables chacun, une seule valeur. En lisant la config d\u0026rsquo;un service quelconque, l\u0026rsquo;architecture semblait plate. Tout parlait au même hôte. C\u0026rsquo;était tout le tableau.\u003c/p\u003e\n\u003cp\u003eCe ne l\u0026rsquo;était pas.\u003c/p\u003e\n\u003ch2 id=\"comment-fonctionnait-la-gateway\"\u003eComment fonctionnait la gateway\u003c/h2\u003e\n\u003cp\u003eLa gateway se trouvait devant chaque service et gérait tout le trafic inter-services. Un service appelant l\u0026rsquo;API content construisait une requête vers \u003ccode\u003ehttp://platform.internal/content/api/\u003c/code\u003e — la gateway la recevait, identifiait la cible depuis le chemin de l\u0026rsquo;URL, et la transmettait au bon backend. Chaque client HTTP inter-service dans \u003ccode\u003eframework.yaml\u003c/code\u003e suivait le même schéma :\u003c/p\u003e","title":"L'hôte qui cachait le graphe"},{"content":"Le service s\u0026rsquo;était crashé. On avait l\u0026rsquo;alerte. On avait le timestamp à la seconde. On avait Loki ouvert avec une requête prête.\nCe qu\u0026rsquo;on n\u0026rsquo;avait pas, c\u0026rsquo;était les logs des cinq minutes précédant le crash.\nPromtail tournait. Il était healthy. Il collectait les logs de tous les autres services sans problème. Mais pour celui-ci, dans la fenêtre qui comptait, il n\u0026rsquo;y avait rien. Le service s\u0026rsquo;était crashé sans laisser de trace.\nLe setup qui semblait correct La stack de logging était raisonnable. Chaque service écrivait du JSON structuré vers stdout avec le formatter logstash de Monolog :\nstdout: type: stream path: \u0026#34;php://stdout\u0026#34; level: \u0026#34;%env(MONOLOG_LEVEL__DEFAULT)%\u0026#34; formatter: \u0026#39;monolog.formatter.logstash\u0026#39; Promtail collectait la sortie des containers via la socket Docker, parsait le JSON, extrayait des labels, poussait vers Loki :\nscrape_configs: - job_name: docker docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s pipeline_stages: - drop: older_than: 168h - json: expressions: level: level msg: message service: service - labels: level: service: relabel_configs: - source_labels: [ \u0026#39;__meta_docker_container_log_stream\u0026#39; ] target_label: stream Deux stages font plus de travail que les autres. Le stage json extrait level et service de chaque ligne de log ; le stage labels qui suit immédiatement les promeut en labels d\u0026rsquo;index Loki, ce qui fait de {service=\u0026quot;content\u0026quot;, level=\u0026quot;error\u0026quot;} une lookup directe plutôt qu\u0026rsquo;un scan plein texte sur les lignes stockées. Le relabeling stream conserve si une ligne venait de stdout ou stderr — une distinction requêtable dès que Monolog envoie les erreurs vers stderr et le reste vers stdout. Le stage drop older_than: 168h est une soupape de sécurité : si Promtail redémarre après une longue interruption et rejoue des lignes bufferisées, tout ce qui est plus vieux de sept jours est écarté avant d\u0026rsquo;atteindre Loki.\nEn théorie : les logs vont vers stdout, Promtail lit stdout, les logs apparaissent dans Loki. La méthodologie twelve-factor décrit exactement ce modèle pour le Facteur XI — traiter les logs comme des flux d\u0026rsquo;événements, écrire vers stdout, laisser l\u0026rsquo;environnement gérer la collecte et le routage.\nL\u0026rsquo;application avait stdout. Promtail lisait stdout. Qu\u0026rsquo;est-ce qui pouvait mal tourner.\nCe que fingers_crossed emporte avec lui En production, le bloc when@prod remplaçait le simple handler stream par quelque chose de plus sophistiqué :\nwhen@prod: monolog: handlers: main: type: fingers_crossed action_level: error handler: main_group excluded_http_codes: [404] La ligne excluded_http_codes: [404] est elle-même révélatrice : sans elle, chaque 404 d\u0026rsquo;un scanner ou d\u0026rsquo;un crawler déclenche un flush complet du buffer, déversant des mégaoctets de logs debug pour des URLs malformées. Quelqu\u0026rsquo;un avait déjà appris ça à ses dépens.\nfingers_crossed est un pattern Monolog bien connu. L\u0026rsquo;idée est élégante : ne pas noyer les logs de production dans le bruit debug, mais si quelque chose tourne mal, retrouver rétrospectivement ce qui s\u0026rsquo;est passé avant l\u0026rsquo;erreur. Le handler bufferise chaque entrée de log en mémoire. Au moment où il voit une error, il flush le buffer entier vers le handler imbriqué — en donnant le contexte complet qui a précédé la défaillance.\nLe problème, c\u0026rsquo;est ce qui se passe quand la défaillance n\u0026rsquo;est pas une erreur loguée. C\u0026rsquo;est un OOM kill. Un SIGKILL de l\u0026rsquo;orchestrateur. Un segfault. Un process qui arrête de répondre et est tué de force.\nDans ces cas, fingers_crossed n\u0026rsquo;atteint jamais son action_level. Le buffer existe, plein des cinq dernières minutes d\u0026rsquo;activité, et il disparaît avec le process. Les logs étaient là. Ils étaient en mémoire. Ils sont morts avant d\u0026rsquo;atteindre stdout.\nLe Facteur IX du twelve-factor parle de disposabilité : les processus doivent démarrer vite et s\u0026rsquo;arrêter proprement. Sur un arrêt normal (SIGTERM), un processus bien élevé finit son travail en cours et quitte. Mais les crashes ne sont pas des arrêts propres, et les buffers mémoire ne sont pas résistants aux crashes. Le service était disposable au sens où on pouvait le redémarrer ; il ne l\u0026rsquo;était pas au sens où sa sortie était transparente.\nLes fichiers que personne ne lisait Il y avait un deuxième problème, plus silencieux mais tout aussi persistant.\nChaque service avait un handler main_group qui routait les logs vers deux destinations en parallèle :\nmain_group: type: group members: [main_file, stdout] main_file: type: stream path: \u0026#34;%kernel.logs_dir%/%kernel.environment%.log\u0026#34; formatter: \u0026#34;monolog.formatter.logstash\u0026#34; var/log/prod.log était écrit sur chaque service, dans chaque environnement, y compris en production. Le même contenu qui allait vers stdout allait aussi vers un fichier à l\u0026rsquo;intérieur du container. Le fichier grossissait sans rotation. Le fichier n\u0026rsquo;était pas accessible à Promtail (qui lisait depuis la socket Docker, pas depuis le filesystem du container). Le fichier consommait de l\u0026rsquo;espace disque. Personne ne le lisait.\nLe channel audit était pire :\naudit_file: type: stream path: \u0026#34;%kernel.logs_dir%/audit.log\u0026#34; formatter: \u0026#39;monolog.formatter.line\u0026#39; audit: type: group members: [audit_file, stderr] channels: [\u0026#39;audit\u0026#39;] Les logs d\u0026rsquo;audit allaient vers stderr (visible par Promtail) et vers audit.log (invisible à Promtail). Le format dans le fichier était une ligne brute, pas le JSON structuré qu\u0026rsquo;attendait Promtail. En pratique, la piste d\u0026rsquo;audit existait à deux endroits : l\u0026rsquo;une requêtable, l\u0026rsquo;autre enfouie dans un répertoire de container qui ne survivait que le temps du container.\nCe que le Facteur XI demande vraiment Le onzième facteur est direct là-dessus : une application ne doit pas se soucier du routage ou du stockage de son flux de sortie. Elle écrit vers stdout. Tout le reste est le job de l\u0026rsquo;environnement.\nÇa veut dire pas de handlers de fichiers en production. Pas en backup. Pas pour les pistes d\u0026rsquo;audit. Pas \u0026ldquo;au cas où\u0026rdquo;. Du moment qu\u0026rsquo;une application se met à gérer des fichiers, elle prend en charge la rotation, la rétention, l\u0026rsquo;espace disque, et l\u0026rsquo;accessibilité — rien de tout ça n\u0026rsquo;appartient à l\u0026rsquo;intérieur d\u0026rsquo;un container.\nLa correction pour les handlers de fichiers est directe. Dans when@prod, supprimer chaque handler *_file et chaque group qui en inclut un. Le channel audit reçoit le même traitement : stderr uniquement, JSON structuré, pas de fichier :\nwhen@prod: monolog: handlers: stdout: type: stream path: \u0026#34;php://stdout\u0026#34; # défaut \u0026#34;warning\u0026#34; — configurable par déploiement via variable d\u0026#39;env pour du debug ciblé level: \u0026#34;%env(default:default_log_level:MONOLOG_LEVEL__DEFAULT)%\u0026#34; formatter: \u0026#39;monolog.formatter.logstash\u0026#39; stderr: type: stream path: \u0026#34;php://stderr\u0026#34; level: error formatter: \u0026#39;monolog.formatter.logstash\u0026#39; main: type: group members: [stdout] channels: [\u0026#39;!event\u0026#39;, \u0026#39;!http_client\u0026#39;, \u0026#39;!doctrine\u0026#39;, \u0026#39;!deprecation\u0026#39;, \u0026#39;!audit\u0026#39;] audit: type: stream path: \u0026#34;php://stderr\u0026#34; level: debug formatter: \u0026#39;monolog.formatter.logstash\u0026#39; channels: [\u0026#39;audit\u0026#39;] stdout pour le channel principal. stderr pour les erreurs et l\u0026rsquo;audit. Rien d\u0026rsquo;autre. Promtail récupère les deux via la socket Docker. Le container n\u0026rsquo;écrit rien sur disque. Et les logs d\u0026rsquo;audit sont maintenant du JSON structuré, requêtable dans Loki avec tout le reste.\nLa question plus dure sur fingers_crossed Les handlers de fichiers, c\u0026rsquo;était simple. fingers_crossed est plus nuancé.\nLe pattern résout un vrai problème : dans un service de production actif, tout logger en debug crée du bruit et des coûts. fingers_crossed permet de capturer le contexte sans le payer sauf si quelque chose tourne vraiment mal. C\u0026rsquo;est un compromis raisonnable quand le mode de défaillance contre lequel on protège est une erreur applicative (une exception, une 500, une requête lente).\nCe n\u0026rsquo;est pas un compromis raisonnable quand le mode de défaillance est un crash de process. Et dans un environnement Kubernetes, les crashes de process arrivent : évictions OOM, échecs de liveness probe, pression sur les nodes. Exactement les cas où on a le plus besoin des logs.\nUne approche : garder fingers_crossed mais réduire la taille du buffer. Par défaut il garde tout depuis le dernier reset. Mettre buffer_size: 50 plafonne l\u0026rsquo;usage mémoire, ce qui limite aussi ce qui se perd lors d\u0026rsquo;un crash. On n\u0026rsquo;aura pas le contexte complet, mais on aura les cinquante dernières entrées. Cette voie réduit le périmètre de perte sans supprimer la cause : l\u0026rsquo;opacité dépend toujours d\u0026rsquo;un seuil d\u0026rsquo;erreur qui peut ne jamais se déclencher.\nUne autre approche : accepter que les logs debug soient coûteux et monter le niveau par défaut en production. Alors on n\u0026rsquo;a plus besoin de fingers_crossed du tout — si info et au-dessus vont directement vers stdout, rien n\u0026rsquo;est jamais bufferisé.\nL\u0026rsquo;approche retenue : supprimer fingers_crossed, monter le niveau par défaut à warning, garder un override debug disponible via variable d\u0026rsquo;env pour les investigations ciblées. Les logs qui comptent apparaissent immédiatement. Ceux qui ne comptent pas ne sont jamais écrits. Rien n\u0026rsquo;est bufferisé.\nLes crashes ne flushent pas Le Facteur XI et le Facteur IX se rejoignent au même point : un process qui meurt en plein milieu d\u0026rsquo;une requête. un autre article de cette série décrivait l\u0026rsquo;illusion d\u0026rsquo;un service qui fonctionnait parfaitement sur un pod mais se comportait silencieusement mal sur deux. C\u0026rsquo;est la même illusion, un niveau au-dessus : un service qui semblait logger correctement, jusqu\u0026rsquo;au moment où il en avait le plus besoin.\nLa règle pour Monolog en production est sans appel : si ça n\u0026rsquo;atteint pas stdout ou stderr avant que le process quitte, ça n\u0026rsquo;existe pas. Un handler de fichier à l\u0026rsquo;intérieur d\u0026rsquo;un container est invisible pour le collecteur de logs et meurt avec le pod. Un buffer fingers_crossed est invisible pour le collecteur de logs et meurt avec le process.\nLa production tend à créer les conditions où on a le plus besoin des logs — pression OOM, défaillances en cascade, mauvais déploiements — et c\u0026rsquo;est exactement les conditions où ces deux patterns échouent simultanément. Écrire vers stdout, adopter un niveau par défaut qui ne nécessite pas de bufferisation, et rendre l\u0026rsquo;override disponible pour quand on en a vraiment besoin. Les logs seront là. Ils n\u0026rsquo;attendront pas un seuil d\u0026rsquo;erreur qui ne se déclenche jamais.\n","permalink":"https://guillaumedelre.github.io/fr/2026/05/15/aucun-t%C3%A9moin/","summary":"\u003cp\u003eLe service s\u0026rsquo;était crashé. On avait l\u0026rsquo;alerte. On avait le timestamp à la seconde. On avait \u003ca href=\"https://grafana.com/oss/loki/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eLoki\u003c/a\u003e ouvert avec une requête prête.\u003c/p\u003e\n\u003cp\u003eCe qu\u0026rsquo;on n\u0026rsquo;avait pas, c\u0026rsquo;était les logs des cinq minutes précédant le crash.\u003c/p\u003e\n\u003cp\u003ePromtail tournait. Il était healthy. Il collectait les logs de tous les autres services sans problème. Mais pour celui-ci, dans la fenêtre qui comptait, il n\u0026rsquo;y avait rien. Le service s\u0026rsquo;était crashé sans laisser de trace.\u003c/p\u003e","title":"Aucun témoin"},{"content":"À un moment de l\u0026rsquo;audit de migration cloud, quelqu\u0026rsquo;un a lancé ça :\ndocker run --rm \u0026lt;image\u0026gt; php -r \u0026#34;var_dump(require \u0026#39;.env.local.php\u0026#39;);\u0026#34; La sortie montrait tout ce que composer dump-env prod avait compilé dans l\u0026rsquo;image au moment du build. Ce qui voulait dire tout ce qui se trouvait dans le fichier .env quand l\u0026rsquo;image avait été construite. Ce qui voulait dire, entre autres, ça :\nINFLUXDB_INIT_ADMIN_TOKEN=\u0026lt;influxdb-admin-token\u0026gt; GF_SECURITY_ADMIN_USER=admin GF_SECURITY_ADMIN_PASSWORD=admin123 BLACKFIRE_CLIENT_ID=\u0026lt;blackfire-client-id\u0026gt; BLACKFIRE_CLIENT_TOKEN=\u0026lt;blackfire-client-token\u0026gt; BLACKFIRE_SERVER_ID=\u0026lt;blackfire-server-id\u0026gt; BLACKFIRE_SERVER_TOKEN=\u0026lt;blackfire-server-token\u0026gt; NGROK_AUTHTOKEN=replace-me-optionnal Vingt-cinq variables au total. Chaque credential accumulé dans le .env racine sur trois ans, désormais permanent dans un layer d\u0026rsquo;image.\nComment dump-env fonctionne composer dump-env prod est une optimisation Symfony légitime. Au lieu de parser les fichiers .env à chaque requête, le runtime charge un tableau PHP pré-compilé depuis .env.local.php. Plus rapide et plus simple.\nLe problème, c\u0026rsquo;est ce qu\u0026rsquo;il lit. Le Dockerfile copie le dépôt dans l\u0026rsquo;image avec COPY . ./, .env inclus. Ensuite dump-env prod lit ce fichier et compile chaque variable dans .env.local.php. L\u0026rsquo;image est livrée avec une capture figée des credentials qui se trouvaient dans .env au moment du build.\nLes layers Docker sont des archives immuables. Même si une étape ultérieure supprimait .env du système de fichiers du container, le layer qui le contient existerait toujours dans l\u0026rsquo;image. docker save \u0026lt;image\u0026gt; produit une archive tar de chaque layer ; extraire un fichier spécifique de n\u0026rsquo;importe quel point de l\u0026rsquo;historique de build est une opération simple. Les credentials sont invisibles à l\u0026rsquo;exécution. Ils ne sont pas partis.\nLe Facteur V est explicite là-dessus : un artefact de build doit être agnostique à l\u0026rsquo;environnement, la config arrivant à l\u0026rsquo;étape de release depuis l\u0026rsquo;extérieur. Dès que des credentials sont compilés dedans, l\u0026rsquo;image n\u0026rsquo;est plus portable. On ne peut plus la promouvoir entre environnements. On builde deux fois en espérant que le deuxième se comporte comme le premier.\nComment vingt-cinq variables s\u0026rsquo;accumulent Avant de voir comment on a réparé ça, il vaut la peine de comprendre comment on en est arrivé là.\nLes tokens BLACKFIRE_* sont le cas facile à comprendre. Un membre de l\u0026rsquo;équipe configure le profiling, a besoin de partager la configuration, et le dépôt est déjà ouvert à tout le monde. Une ligne dans .env est la voie de moindre résistance. Les credentials InfluxDB et Grafana suivent la même logique — outillage partagé, dépôt partagé, un commit.\nPuis il y a les variables qui révèlent une autre dérive. Dans certains .env de services :\nAPP__RATINGS__SERIALS=\u0026#39;{\u0026#34;marque1\u0026#34;:{\u0026#34;fr\u0026#34;:\u0026#34;12345\u0026#34;},...}\u0026#39; # ~40 lignes de JSON APP__YOUTUBE__CREDENTIALS=\u0026#39;{\u0026#34;marque1\u0026#34;:{\u0026#34;client_id\u0026#34;:\u0026#34;xxx\u0026#34;,\u0026#34;refresh_token\u0026#34;:\u0026#34;yyy\u0026#34;},...}\u0026#39; Des numéros de série pour la mesure d\u0026rsquo;audience. Des refresh tokens YouTube par marque. Ce ne sont pas des secrets au sens des tokens Blackfire. Ce sont des données métier — le genre de valeurs qui varient entre marques et environnements, que quelqu\u0026rsquo;un a décidé de versionner dans .env parce qu\u0026rsquo;elles se comportaient comme de la configuration et que .env était l\u0026rsquo;endroit où vivait la configuration.\nVingt-cinq variables, c\u0026rsquo;est la somme de décisions incrémentales, dont aucune ne semblait fausse isolément. Le problème est structurel : quand .env est la seule réponse disponible, tout finit par y ressembler.\nOù les choses appartiennent vraiment Vider le fichier exigeait de répondre à une question pour chaque variable : où est-ce que ça appartient vraiment ?\nLes réponses ont révélé trois catégories que l\u0026rsquo;équipe n\u0026rsquo;avait jamais explicitement nommées :\nLa config statique vit dans le code. Règles métier, logique de routing, fichiers de paramètres Symfony — tout ce qui ne varie pas entre les déploiements. Un changement exige un rebuild. Les blocs JSON de numéros de série se sont révélés ne pas être de la config statique du tout : ils étaient interrogés depuis un service Config dédié à l\u0026rsquo;exécution. Ils n\u0026rsquo;avaient rien à faire dans un fichier.\nLa config environnementale varie entre les déploiements : hostnames, chaînes de connexion, credentials de services tiers. C\u0026rsquo;est ce que le Facteur III désigne par \u0026ldquo;config dans les variables d\u0026rsquo;environnement\u0026rdquo; — de vraies variables au niveau OS, injectées à l\u0026rsquo;exécution, jamais des fichiers qui voyagent avec le code. Dans Kubernetes, c\u0026rsquo;est un ConfigMap pour les valeurs non sensibles et un Kubernetes Secret pour les credentials. Le choix retenu pour les secrets a été SOPS — les credentials sont chiffrés et committés dans git, plutôt que stockés dans un coffre-fort externe comme Azure Key Vault ou HashiCorp Vault. Un coffre-fort échange la simplicité contre l\u0026rsquo;auditabilité : rotation automatique, logs d\u0026rsquo;audit centralisés, accès via workload identity sans clé à protéger. SOPS échange ces capacités contre un modèle opérationnel plus simple — pas de service externe à interroger au déploiement, les secrets transitent par le processus de review normal du code, l\u0026rsquo;historique git fait office de piste d\u0026rsquo;audit. Les contreparties acceptées sont la rotation manuelle et la responsabilité de protéger la clé de déchiffrement elle-même. Pour la taille de l\u0026rsquo;équipe, le compromis était délibéré.\nLa config dynamique change sans déploiement : paramètres éditoriaux, seuils par marque, configuration de modération de contenu. Elle appartient à une base de données, gérée via le service Config de l\u0026rsquo;application. Une partie de ce qui s\u0026rsquo;était accumulé dans les .env de services était cette catégorie depuis le début, passant pour des valeurs par défaut statiques parce qu\u0026rsquo;elle changeait assez rarement pour que personne ne le remarque.\nUne fois les catégories nommées, les variables se sont triées. Le .env racine est arrivé à quatre lignes :\nDOMAIN=platform.127.0.0.1.sslip.io XDEBUG_MODE=off SERVER_NAME=:80 APP_ENV=dev Des valeurs par défaut sûres. Rien de sensible. dump-env prod compile maintenant des chaînes vides ; les vraies valeurs arrivent à l\u0026rsquo;exécution depuis Kubernetes.\nL\u0026rsquo;image PostgreSQL L\u0026rsquo;image PostgreSQL utilisée en CI a un mot de passe codé en dur :\nFROM postgres:15 ENV POSTGRES_PASSWORD=admin123 Ça ressemble au même problème. Ce n\u0026rsquo;en est pas un, parce que le modèle de menace est différent. La base CI est éphémère — elle existe le temps d\u0026rsquo;un run de pipeline, ne contient pas de vraies données, tourne dans un réseau isolé. Un mot de passe codé en dur sur une base de test jetable est un risque acceptable, pas une entorse à la règle.\nEn production, la question ne se pose pas : la plateforme utilise Azure Flexible Server, un service PostgreSQL managé. Il n\u0026rsquo;y a pas d\u0026rsquo;image Docker. Les credentials arrivent via injection dans les charts Helm, sans jamais toucher un layer.\nCe qui survit au build maintenant L\u0026rsquo;image qui part en production contient maintenant une garantie : var_dump(require '.env.local.php') ne retourne que des chaînes vides et des valeurs par défaut sûres. Les credentials ne sont pas là parce qu\u0026rsquo;ils n\u0026rsquo;y ont jamais été mis — ils arrivent à l\u0026rsquo;exécution, depuis l\u0026rsquo;extérieur.\nC\u0026rsquo;est la frontière de responsabilité que dump-env avait silencieusement effacée : l\u0026rsquo;image est l\u0026rsquo;application, le runtime est l\u0026rsquo;environnement. Ils ne devraient pas connaître les secrets de l\u0026rsquo;autre.\n","permalink":"https://guillaumedelre.github.io/fr/2026/05/14/ce-qui-survit-au-build/","summary":"\u003cp\u003eÀ un moment de l\u0026rsquo;audit de migration cloud, quelqu\u0026rsquo;un a lancé ça :\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker run --rm \u0026lt;image\u0026gt; php -r \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;var_dump(require \u0026#39;.env.local.php\u0026#39;);\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eLa sortie montrait tout ce que \u003ccode\u003ecomposer dump-env prod\u003c/code\u003e avait compilé dans l\u0026rsquo;image au moment du build. Ce qui voulait dire tout ce qui se trouvait dans le fichier \u003ccode\u003e.env\u003c/code\u003e quand l\u0026rsquo;image avait été construite. Ce qui voulait dire, entre autres, ça :\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-dotenv\" data-lang=\"dotenv\"\u003eINFLUXDB_INIT_ADMIN_TOKEN=\u0026lt;influxdb-admin-token\u0026gt;\nGF_SECURITY_ADMIN_USER=admin\nGF_SECURITY_ADMIN_PASSWORD=admin123\nBLACKFIRE_CLIENT_ID=\u0026lt;blackfire-client-id\u0026gt;\nBLACKFIRE_CLIENT_TOKEN=\u0026lt;blackfire-client-token\u0026gt;\nBLACKFIRE_SERVER_ID=\u0026lt;blackfire-server-id\u0026gt;\nBLACKFIRE_SERVER_TOKEN=\u0026lt;blackfire-server-token\u0026gt;\nNGROK_AUTHTOKEN=replace-me-optionnal\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eVingt-cinq variables au total. Chaque credential accumulé dans le \u003ccode\u003e.env\u003c/code\u003e racine sur trois ans, désormais permanent dans un layer d\u0026rsquo;image.\u003c/p\u003e","title":"Ce qui survit au build"},{"content":"APP__COLD_STORAGE__FILESYSTEM_PATH=\u0026#34;/home/jenkins-slave/share_media/media\u0026#34; APP__COLD_STORAGE__FILESYSTEM_PATH_CACHE=\u0026#34;/home/jenkins-slave/share_media/media/cache\u0026#34; APP__COLD_STORAGE__RAW_IMAGE_PATH=\u0026#34;/home/jenkins-slave/share_media/media_raw\u0026#34; APP__SHARE_STORAGE__FILESYSTEM_PATH=\u0026#34;/home/jenkins-slave/share_storage\u0026#34; Ces lignes se trouvaient dans le .env de production du service media. Pas le staging. Pas un override local. La production, committée dans le dépôt, lue à chaque démarrage.\nLes chemins se terminent là où on s\u0026rsquo;y attendrait : /media, /share_storage. Ils commencent ailleurs : /home/jenkins-slave, le répertoire home d\u0026rsquo;un runner CI issu d\u0026rsquo;une ancienne installation Jenkins.\nComment le home d\u0026rsquo;un runner atterrit dans la config de production La plateforme avait grandi depuis une seule machine. Un serveur faisait tout tourner — l\u0026rsquo;application, le runner CI, la base de données, le stockage de fichiers. Les fichiers transitaient entre l\u0026rsquo;app et le système CI via NFS : un répertoire monté sur le même hôte, accessible aux containers comme au runner.\nLe chemin /home/jenkins-slave/share_media était là où le partage NFS atterrissait sur cette machine. Quand l\u0026rsquo;équipe a migré vers Docker Compose, les containers ont hérité du montage NFS. Le chemin est entré dans le .env parce que l\u0026rsquo;application devait savoir où trouver les fichiers. Personne ne l\u0026rsquo;a changé parce que ça marchait. Le montage était toujours là. Le chemin était valide. L\u0026rsquo;application démarrait. Les fichiers apparaissaient où ils devaient.\nTrois ans plus tard, personne n\u0026rsquo;y pensait plus du tout. C\u0026rsquo;était juste comme ça que le chemin media était configuré.\nCe que kubectl apply a trouvé Le premier kubectl apply du service media s\u0026rsquo;est terminé avec un pod bloqué en CrashLoopBackOff. Le container démarrait. L\u0026rsquo;entrypoint tournait. L\u0026rsquo;application essayait d\u0026rsquo;accéder à /home/jenkins-slave/share_media/media. Fichier ou répertoire inexistant. Pas de montage NFS. Pas de runner.\nLe chemin ne documentait pas une décision de design. Il documentait la machine qui tournait par hasard au moment où le .env avait été écrit.\nC\u0026rsquo;est exactement le problème que le Facteur IV de l\u0026rsquo;application twelve-factor décrit. Les backing services — stockage, files, bases de données — doivent être des ressources attachées, configurées via URL ou chaîne de connexion, interchangeables entre environnements sans toucher au code. Un chemin de fichier sur un hôte partagé n\u0026rsquo;est pas un backing service. C\u0026rsquo;est une hypothèse physique sur la machine. Quand la machine change, l\u0026rsquo;hypothèse lâche.\nLe chemin était le symptôme La première étape évidente était de supprimer la référence au runner :\nAPP__COLD_STORAGE__FILESYSTEM_PATH=\u0026#34;/share_media/media\u0026#34; APP__SHARE_STORAGE__FILESYSTEM_PATH=\u0026#34;/share_storage\u0026#34; Plus propre. Plus de références CI dans une config de production. Toujours incorrect. L\u0026rsquo;application supposait encore un système de fichiers POSIX — soit un volume monté, soit un répertoire sur le nœud. Dans Kubernetes, un volume partagé entre plusieurs pods nécessite un PersistentVolumeClaim en mode ReadWriteMany. La plupart des fournisseurs de stockage ne le supportent pas. Ceux qui le font ont tendance à être lents et coûteux. Et même là où ça fonctionne, on a juste remplacé une hypothèse sur le système de fichiers par une autre.\nRenommer le chemin gagnait du temps. Ça ne réglait pas le problème.\nLe problème, c\u0026rsquo;est qu\u0026rsquo;environ douze téraoctets d\u0026rsquo;images — originaux et déclinaisons pré-générées dans différents formats — pour plusieurs marques éditoriales — étaient traités comme un répertoire. Un répertoire ne se monte pas proprement sur plusieurs pods. Un backing service, si.\nFlysystem comme forme de la solution Le service media avait déjà Flysystem de configuré. Trois adaptateurs concrets — système de fichiers local, AWS S3, Azure Blob — et un adaptateur lazy par-dessus :\n# config/packages/flysystem.yaml flysystem: storages: media.storage.local: adapter: \u0026#39;local\u0026#39; options: directory: \u0026#34;/\u0026#34; media.storage.aws: adapter: \u0026#39;aws\u0026#39; options: client: \u0026#39;aws_client_service\u0026#39; bucket: \u0026#39;media\u0026#39; streamReads: true media.storage: adapter: \u0026#39;lazy\u0026#39; options: source: \u0026#39;%env(APP__FLYSYSTEM_MEDIA_STORAGE)%\u0026#39; Tout le code de l\u0026rsquo;application dépend de media.storage. Il ne sait pas si les fichiers vivent sur le système de fichiers ou dans un bucket cloud. Une variable d\u0026rsquo;environnement détermine quel backend est actif :\nAPP__FLYSYSTEM_MEDIA_STORAGE=media.storage.aws # production APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.local # fallback local toujours disponible Le chemin est parti. L\u0026rsquo;hypothèse sur le système de fichiers est partie. Ce qui reste, c\u0026rsquo;est un nom de service — une ressource attachée au sens twelve-factor, configurable sans rebuilder l\u0026rsquo;image.\nLe même pattern s\u0026rsquo;étend au cache de vignettes. LiipImagine génère des images redimensionnées à la demande ; les originaux et le cache généré passent par des adaptateurs Flysystem séparés :\nliip_imagine: loaders: default: flysystem: filesystem_service: \u0026#39;media.storage\u0026#39; default_cache: flysystem: filesystem_service: \u0026#39;media.cache.storage\u0026#39; Deux variables d\u0026rsquo;environnement, deux buckets. Toute la chaîne — recevoir l\u0026rsquo;upload, stocker l\u0026rsquo;original, générer la vignette, la mettre en cache — est portable vers le cloud sans toucher une ligne de PHP.\nCe que l\u0026rsquo;article ne couvre pas, c\u0026rsquo;est le déplacement des données. Le lazy adapter change une variable d\u0026rsquo;environnement. Faire passer douze téraoctets d\u0026rsquo;un montage NFS vers un bucket S3, c\u0026rsquo;est un autre projet — une fenêtre de migration, une double-écriture pendant le cutover, une vérification qu\u0026rsquo;il ne manque rien.\nCe que Minio rend possible en CI La production utilise S3. Le développement local utilise Minio , un stockage objet compatible S3 qui tourne dans un container Docker. L\u0026rsquo;adaptateur AWS parle à Minio en local et à S3 en production. L\u0026rsquo;application ne voit pas la différence :\n# local/CI APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.aws APP__MINIO_ENDPOINT=http://minio:9000 APP__MINIO_ACCESS_KEY=minioadmin APP__MINIO_SECRET_KEY=minioadmin Le même code, le même adaptateur, un endpoint différent. Pas de mock, pas de chemins de test spéciaux, pas de branches conditionnelles par environnement.\nMais la configuration CI va un cran plus loin. L\u0026rsquo;image Minio utilisée dans le pipeline n\u0026rsquo;est pas l\u0026rsquo;image officielle upstream — c\u0026rsquo;est une image custom buildée avec des fixtures de test préchargées :\nFROM minio/minio:latest COPY tests/fixtures/ /fixtures_media/ Chaque run CI démarre avec une instance Minio qui contient déjà les données attendues par la suite de tests. Pas de script de setup, pas de commande de seed, pas d\u0026rsquo;étape \u0026ldquo;attendre le chargement des fixtures\u0026rdquo; avant que les tests commencent. L\u0026rsquo;état initial de l\u0026rsquo;environnement de test fait partie de l\u0026rsquo;artefact de build.\nLe Facteur V appliqué à l\u0026rsquo;infrastructure de test : l\u0026rsquo;état de l\u0026rsquo;environnement est buildé, versionné, immuable. Le pipeline CI construit l\u0026rsquo;image Minio depuis la même source et au même commit que l\u0026rsquo;image applicative. Les fixtures de test et le code qui les exploite sont toujours synchronisés.\nLe compromis S3, honnêtement S3 introduit un coût de latence que le stockage local n\u0026rsquo;a pas. Les premières données d\u0026rsquo;un fichier prennent 10 à 30 millisecondes à arriver depuis S3 — c\u0026rsquo;est la latence first-byte documentée du service, pas une mesure sur ce trafic spécifique.\nÀ 300 requêtes par seconde, le raisonnement pour accepter ce compromis était le suivant : la majorité des lectures touche des vignettes déjà générées dans le cache S3, pas les fichiers originaux. Une image fraîchement uploadée paie la pénalité du cold miss une fois, à la première demande de vignette. Tout ce qui suit est un cache hit. Savoir si la latence de queue sous charge réelle confirmait ce raisonnement nécessitait des tests de charge suivis séparément — la décision d\u0026rsquo;architecture et la validation étaient découplées.\nLe compromis a été accepté : comportement prévisible sur plusieurs pods, pas de problèmes d\u0026rsquo;état partagé, une couche de stockage qui scale sans coordination. L\u0026rsquo;histoire complète des mesures appartient au rapport de tests de performance, pas ici.\nLe fantôme s\u0026rsquo;en va Le chemin /home/jenkins-slave n\u0026rsquo;apparaît plus dans la configuration. Mais ce à quoi il pointait était un couplage qui précédait Docker, précédait les microservices, précédait n\u0026rsquo;importe quelle conversation sur la migration cloud. Le runner CI et l\u0026rsquo;application de production partageaient un système de fichiers parce qu\u0026rsquo;ils vivaient sur la même machine. Personne ne l\u0026rsquo;avait conçu comme ça. Ça s\u0026rsquo;était accumulé.\nUne erreur kubectl apply sur un chemin qui n\u0026rsquo;aurait pas dû exister a forcé la question : pourquoi cette application suppose-t-elle qu\u0026rsquo;un runner CI spécifique est présent sur l\u0026rsquo;hôte ? La réponse était \u0026ldquo;parce que ça a toujours été comme ça.\u0026rdquo; Ce n\u0026rsquo;est pas une raison. C\u0026rsquo;est une histoire.\nRenommer le chemin était un correctif en carton. L\u0026rsquo;adaptateur lazy de Flysystem était la vraie réponse — pas parce qu\u0026rsquo;il est plus élégant, mais parce qu\u0026rsquo;il fait du backend de stockage une décision qui appartient à l\u0026rsquo;environnement, pas à l\u0026rsquo;application. Le container démarre, lit une variable, se connecte à ce qui est à l\u0026rsquo;autre bout. Il ne sait pas si c\u0026rsquo;est un bucket dans un datacenter ou un container sur un laptop.\nLe répertoire home du runner a disparu de la config. Ce qui l\u0026rsquo;a remplacé, c\u0026rsquo;est un nom de service. C\u0026rsquo;est la différence.\n","permalink":"https://guillaumedelre.github.io/fr/2026/05/14/le-fant%C3%B4me-du-runner-ci/","summary":"\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-dotenv\" data-lang=\"dotenv\"\u003eAPP__COLD_STORAGE__FILESYSTEM_PATH=\u0026#34;/home/jenkins-slave/share_media/media\u0026#34;\nAPP__COLD_STORAGE__FILESYSTEM_PATH_CACHE=\u0026#34;/home/jenkins-slave/share_media/media/cache\u0026#34;\nAPP__COLD_STORAGE__RAW_IMAGE_PATH=\u0026#34;/home/jenkins-slave/share_media/media_raw\u0026#34;\nAPP__SHARE_STORAGE__FILESYSTEM_PATH=\u0026#34;/home/jenkins-slave/share_storage\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eCes lignes se trouvaient dans le \u003ccode\u003e.env\u003c/code\u003e de production du service media. Pas le staging. Pas un override local. La production, committée dans le dépôt, lue à chaque démarrage.\u003c/p\u003e\n\u003cp\u003eLes chemins se terminent là où on s\u0026rsquo;y attendrait : \u003ccode\u003e/media\u003c/code\u003e, \u003ccode\u003e/share_storage\u003c/code\u003e. Ils commencent ailleurs : \u003ccode\u003e/home/jenkins-slave\u003c/code\u003e, le répertoire home d\u0026rsquo;un runner CI issu d\u0026rsquo;une ancienne installation Jenkins.\u003c/p\u003e\n\u003ch2 id=\"comment-le-home-dun-runner-atterrit-dans-la-config-de-production\"\u003eComment le home d\u0026rsquo;un runner atterrit dans la config de production\u003c/h2\u003e\n\u003cp\u003eLa plateforme avait grandi depuis une seule machine. Un serveur faisait tout tourner — l\u0026rsquo;application, le runner CI, la base de données, le stockage de fichiers. Les fichiers transitaient entre l\u0026rsquo;app et le système CI via NFS : un répertoire monté sur le même hôte, accessible aux containers comme au runner.\u003c/p\u003e","title":"Le fantôme du runner CI"},{"content":"Ça fait des années que j\u0026rsquo;avais envie d\u0026rsquo;un homelab à la maison. Un endroit à moi pour héberger mes outils de développement, surveiller mes machines, faire tourner de la domotique, tester des trucs sans risquer de casser quoi que ce soit d\u0026rsquo;important. L\u0026rsquo;idée est simple. La mise en place un peu moins.\nÀ l\u0026rsquo;époque, Kubernetes n\u0026rsquo;existait pas encore. Les options pour faire tourner plusieurs services sur une machine se résumaient à du scripting bash, des configurations Nginx écrites à la main, et beaucoup de café. Les tutoriels \u0026ldquo;homelab pour les humains\u0026rdquo; brillaient par leur absence.\nCe tuto, c\u0026rsquo;est ce que j\u0026rsquo;aurais voulu trouver à l\u0026rsquo;époque. Ça tourne depuis plusieurs années maintenant. Pas sans évoluer : des services ajoutés, d\u0026rsquo;autres abandonnés, des choix revisités. Mais la base est là, stable, et c\u0026rsquo;est bien ça le succès en self-hosting.\nLe setup : dix services web auto-hébergés sur une machine locale, accessibles depuis un navigateur via des URLs lisibles, sans toucher à la configuration DNS, sans louer un VPS, sans certificat TLS à gérer. L\u0026rsquo;ingrédient qui rend ça possible : sslip.io , un service DNS public qui encode l\u0026rsquo;IP directement dans le nom de domaine. service.192.168.1.10.sslip.io résout vers 192.168.1.10, sans rien configurer, depuis n\u0026rsquo;importe quelle machine du réseau local.\nCe tutoriel s\u0026rsquo;adresse à quelqu\u0026rsquo;un qui connaît Docker mais qui part de zéro sur l\u0026rsquo;orchestration de services self-hosted.\nTable des matières Philosophie et choix d\u0026rsquo;architecture Les briques fondamentales Mise en place pas à pas Ajouter un nouveau service Patterns et conventions Pièges courants Conclusion Références 1. Philosophie et choix d\u0026rsquo;architecture Objectif Faire tourner plusieurs services web sur une machine locale, accessibles depuis un navigateur via des URLs lisibles, sans toucher à la configuration DNS, sans louer un VPS, sans certificat TLS à gérer.\nPourquoi Docker Compose et pas autre chose ? Docker Compose est le bon niveau de complexité pour un homelab personnel. Kubernetes est trop lourd pour une seule machine. Docker Swarm est en déclin. Compose est simple, lisible, versionnable, et suffisant pour des dizaines de services.\nPourquoi Traefik et pas Nginx Proxy Manager ? Nginx Proxy Manager (NPM) est une interface graphique pour configurer Nginx comme reverse proxy. Les routes sont stockées dans une base de données et configurées via une UI.\nTraefik lit automatiquement les labels Docker des containers et génère sa configuration à la volée. Quand on démarre un container avec les bons labels, Traefik le découvre et crée la route immédiatement, sans redémarrage, sans UI à ouvrir.\nCe comportement \u0026ldquo;configuration as code\u0026rdquo; a deux avantages majeurs :\nLa configuration d\u0026rsquo;un service est dans son compose.yaml, au même endroit que tout le reste. Ajouter un service ne nécessite pas de toucher à Traefik. Pourquoi Dockge et pas Portainer ? Portainer est un outil de gestion Docker complet : images, volumes, réseaux, containers individuels\u0026hellip; puissant mais complexe.\nDockge est focalisé sur une seule chose : gérer des stacks Docker Compose. Son UI est minimaliste et intuitive. Pour un homelab où tout est géré en Compose, c\u0026rsquo;est suffisant et bien plus agréable à utiliser.\nPourquoi sslip.io ? Les services web ont besoin d\u0026rsquo;un nom d\u0026rsquo;hôte (ex: dozzle.monserveur.local) pour que Traefik puisse les router correctement. Les options habituelles :\nModifier /etc/hosts sur chaque machine : fastidieux, non partageable. Configurer un vrai DNS local (Pi-hole, AdGuard) : nécessite une infrastructure supplémentaire. Acheter un domaine et configurer les DNS : coûte de l\u0026rsquo;argent et du temps. sslip.io est un service DNS public qui résout automatiquement \u0026lt;anything\u0026gt;.\u0026lt;IP\u0026gt;.sslip.io vers \u0026lt;IP\u0026gt;. Exemple : dozzle.192.168.1.10.sslip.io résout vers 192.168.1.10. Il n\u0026rsquo;y a rien à configurer, le DNS fonctionne partout sans toucher à quoi que ce soit.\n2. Les briques fondamentales Le réseau Docker partagé Tous les services et Traefik doivent partager le même réseau Docker pour que Traefik puisse communiquer avec eux. Ce réseau s\u0026rsquo;appelle traefik et est créé une seule fois :\ndocker network create traefik C\u0026rsquo;est un réseau externe (créé hors de tout Compose). Chaque compose.yaml le déclare comme externe :\nnetworks: traefik: external: true Pourquoi externe plutôt qu\u0026rsquo;interne à un Compose ? Parce que plusieurs stacks indépendants doivent tous y être connectés. Un réseau interne à un Compose n\u0026rsquo;est accessible qu\u0026rsquo;aux services de ce Compose.\nTraefik : le reverse proxy Traefik écoute sur le port 80 et route les requêtes HTTP vers le bon container selon le Host header.\nSa configuration principale est dans stacks/traefik/docker/traefik/traefik.yaml :\napi: dashboard: true insecure: true entryPoints: web: address: :80 ping: address: :8082 providers: docker: endpoint: unix:///var/run/docker.sock exposedByDefault: false log: level: INFO global: sendAnonymousUsage: false exposedByDefault: false est important : Traefik ignore tous les containers par défaut. Un container doit explicitement s\u0026rsquo;exposer avec le label traefik.enable: true. Cela évite d\u0026rsquo;exposer accidentellement des services.\nL\u0026rsquo;entrypoint ping sur le port 8082 est dédié aux health checks. Le séparer de l\u0026rsquo;entrypoint web évite que les checks apparaissent dans les logs d\u0026rsquo;accès.\nPour accéder au daemon Docker, Traefik monte le socket :\nvolumes: - /var/run/docker.sock:/var/run/docker.sock Dockge : le gestionnaire de stacks Dockge tourne lui-même dans un container (le compose.yaml à la racine du repo). Il a besoin de deux choses :\nAccès au socket Docker pour piloter les autres containers. Accès aux dossiers des stacks pour lire et modifier les compose.yaml. Le point critique est le montage des stacks. Dockge lance les stacks en passant des chemins absolus au daemon Docker. Ces chemins doivent être identiques dans le container Dockge et sur le host. La solution :\nvolumes: - ${PWD}/stacks:${PWD}/stacks environment: DOCKGE_STACKS_DIR: ${PWD}/stacks ${PWD} est une variable shell résolue au moment du docker compose up. Elle vaut le répertoire courant. Si on lance Dockge depuis /home/user/homelab, le dossier stacks sera monté à /home/user/homelab/stacks des deux côtés. C\u0026rsquo;est la seule façon d\u0026rsquo;éviter que Docker crée des répertoires fantômes au mauvais endroit.\nConséquence pratique : toujours lancer docker compose up -d depuis la racine du repo.\nLa donnée persistante de Dockge (configuration, historique) est dans un volume nommé créé à l\u0026rsquo;avance :\ndocker volume create homelab_dockge_data Un volume nommé survit à un docker compose down -v. Un volume anonyme serait détruit avec la stack.\n3. Mise en place pas à pas Étape 1 : cloner et configurer git clone \u0026lt;repo\u0026gt; homelab cd homelab Trouver l\u0026rsquo;IP locale de la machine :\nhostname -I | awk \u0026#39;{print $1}\u0026#39; # ex: 192.168.1.10 Créer et éditer le .env racine :\ncp .env.example .env # Éditer .env : # IP=192.168.1.10 # DOMAIN=sslip.io # COMPOSE_PROJECT_NAME=dockge ← important, voir section conventions Étape 2 : prérequis Docker docker network create traefik docker volume create homelab_dockge_data Étape 3 : démarrer Dockge echo \u0026#34;STACKS_DIR=$(pwd)/stacks\u0026#34; \u0026gt;\u0026gt; .env docker compose up -d Dockge est accessible sur http://\u0026lt;IP\u0026gt;:5001. Il est exposé directement sur le port 5001, pas via Traefik (Traefik n\u0026rsquo;est pas encore démarré à ce stade). Créer un compte admin à la première ouverture.\nÉtape 4 : configurer les stacks Pour chaque dossier dans stacks/, copier le .env.example :\nfor stack in stacks/*/; do cp \u0026#34;${stack}.env.example\u0026#34; \u0026#34;${stack}.env\u0026#34; done Puis éditer chaque .env pour renseigner IP et DOMAIN avec les mêmes valeurs qu\u0026rsquo;à l\u0026rsquo;étape 1. La valeur COMPOSE_PROJECT_NAME est pré-remplie avec le nom du dossier, ne pas la changer (voir section conventions).\nPour filebrowser, renseigner aussi FILEBROWSER_ROOT avec le chemin local à exposer.\nÉtape 5 : lancer les stacks depuis Dockge Depuis l\u0026rsquo;interface Dockge (http://\u0026lt;IP\u0026gt;:5001), dans cet ordre :\n1. Traefik en premier\nTraefik doit être actif avant les autres services. Sans Traefik, les routes n\u0026rsquo;existent pas et les services sont inaccessibles via leur URL.\nAprès démarrage, vérifier que Traefik est healthy :\ndocker ps --filter name=traefik 2. Les autres stacks dans n\u0026rsquo;importe quel ordre\nChaque stack se déclare automatiquement auprès de Traefik via ses labels Docker. Traefik découvre les nouveaux containers en temps réel.\n3. Homepage en dernier\nHomepage lit les labels Docker de tous les containers au démarrage pour construire le dashboard. Le démarrer en dernier garantit qu\u0026rsquo;il découvre tous les services actifs dès le premier lancement.\n4. Ajouter un nouveau service Voici le template de compose.yaml pour tout nouveau service :\nservices: monservice: image: editeur/monservice:latest restart: unless-stopped healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;wget -qO- http://127.0.0.1:\u0026lt;PORT\u0026gt;/ || exit 1\u0026#34;] interval: 30s timeout: 10s retries: 3 start_period: 10s labels: # Homepage - apparition automatique dans le dashboard homepage.group: outils homepage.name: Mon Service homepage.icon: https://cdn.jsdelivr.net/gh/selfhst/icons/webp/monservice.webp homepage.href: http://${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN} # Traefik - routage HTTP traefik.enable: true traefik.http.routers.monservice.entrypoints: web traefik.http.routers.monservice.rule: Host(`${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}`) traefik.http.services.monservice.loadbalancer.server.port: \u0026lt;PORT\u0026gt; networks: - traefik networks: traefik: external: true Et le .env.example associé :\nCOMPOSE_PROJECT_NAME=monservice IP=127.0.0.1 DOMAIN=sslip.io Le nom du dossier détermine le sous-domaine. Si le dossier s\u0026rsquo;appelle monservice, le service sera accessible sur monservice.\u0026lt;IP\u0026gt;.\u0026lt;DOMAIN\u0026gt;. C\u0026rsquo;est tout.\nPour trouver des services à ajouter, selfh.st est une excellente ressource : c\u0026rsquo;est un catalogue de logiciels self-hosted organisé par catégorie (media, sécurité, productivité, monitoring\u0026hellip;), avec pour chacun une description, une capture d\u0026rsquo;écran et le lien GitHub. Le site publie aussi une newsletter hebdomadaire sur les nouvelles releases.\nChecklist pour un nouveau service Créer stacks/\u0026lt;nom-du-sous-domaine\u0026gt;/compose.yaml Créer stacks/\u0026lt;nom-du-sous-domaine\u0026gt;/.env.example avec COMPOSE_PROJECT_NAME=\u0026lt;nom\u0026gt; Copier .env.example en .env et renseigner IP/DOMAIN Vérifier le port dans les labels Traefik Choisir le groupe Homepage : infra, observabilité, ou outils Trouver l\u0026rsquo;icône sur selfhst/icons Ajouter les données persistantes dans un volume si nécessaire Lancer depuis Dockge et vérifier que le container est healthy 5. Patterns et conventions La variable ${COMPOSE_PROJECT_NAME} Docker Compose valorise automatiquement COMPOSE_PROJECT_NAME avec le nom du dossier du stack. On l\u0026rsquo;utilise pour construire dynamiquement les URLs :\ntraefik.http.routers.dozzle.rule: Host(`${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}`) homepage.href: http://${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN} Avantage : pas de variable *_HOST à maintenir dans chaque .env. Renommer le dossier change automatiquement le sous-domaine.\nAttention : dans le .env, il faut définir COMPOSE_PROJECT_NAME explicitement avec le nom du dossier du stack. Si on ne le définit pas, Docker Compose utilise le nom du répertoire courant au moment du lancement, ce qui peut donner des valeurs inattendues selon d\u0026rsquo;où on lance la commande.\nLes groupes Homepage Les services sont organisés en trois groupes dans le dashboard :\nGroupe Services infra Traefik , Dockge , Watchtower , Homepage observabilité Dozzle , Glances , Uptime Kuma outils FileBrowser , IT-Tools , Stirling PDF Ce découpage est celui de ce homelab, pas une convention imposée. Homepage accepte n\u0026rsquo;importe quelle valeur dans homepage.group : on peut créer autant de groupes que nécessaire et les nommer comme on veut (media, domotique, dev\u0026hellip;). Le dashboard se réorganise automatiquement.\nHealth checks Tous les services ont un health check. C\u0026rsquo;est crucial car Traefik ignore silencieusement les containers unhealthy : un service avec un health check défaillant n\u0026rsquo;apparaît pas dans le routage, même avec traefik.enable: true.\nTrois cas particuliers rencontrés en pratique :\n1. localhost ne résout pas toujours en 127.0.0.1\nDans certaines images minimalistes, localhost n\u0026rsquo;est pas résolu. Utiliser 127.0.0.1 explicitement :\ntest: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;wget -qO- http://127.0.0.1:8080/ || exit 1\u0026#34;] 2. Images sans shell (scratch-based)\nLes images basées sur scratch (ex: Dozzle) ne contiennent pas /bin/sh. CMD-SHELL échoue. Utiliser le binaire embarqué :\ntest: [\u0026#34;CMD\u0026#34;, \u0026#34;/dozzle\u0026#34;, \u0026#34;healthcheck\u0026#34;] 3. Images sans wget ni curl\nCertaines images Node.js ou JVM n\u0026rsquo;ont ni wget ni curl. Solutions possibles :\nSi Node.js est disponible : node -e \u0026quot;require('http').get('http://localhost:PORT', r =\u0026gt; process.exit(r.statusCode \u0026lt; 400 ? 0 : 1)).on('error', () =\u0026gt; process.exit(1))\u0026quot; Si curl est disponible : curl -fs http://127.0.0.1:PORT/ Si le binaire de l\u0026rsquo;app expose une sous-commande healthcheck : l\u0026rsquo;utiliser directement. Persistance des données Pour les services qui ont des données (configuration, base utilisateurs, base de données) :\nvolumes: - ./docker/data:/chemin/dans/container Le dossier ./docker/ est dans le dossier du stack et peut être versionné, à l\u0026rsquo;exception des données runtime qui vont dans .gitignore.\nRègle : ajouter stacks/\u0026lt;service\u0026gt;/docker/ dans .gitignore si le dossier contient des données qui ne doivent pas être committées (base SQLite, uploads\u0026hellip;).\nOrganisation des labels Traefik Par convention, le nom utilisé dans les labels Traefik (traefik.http.routers.\u0026lt;nom\u0026gt;) correspond au nom du service Docker dans le compose.yaml. En pratique on les aligne avec le nom du dossier :\nstacks/it-tools/ → service: ittools → traefik.http.routers.ittools.* Ce n\u0026rsquo;est pas une contrainte technique de Traefik, juste une convention de lisibilité.\n6. Pièges courants Dockge : Stop puis Start, pas Restart Quand on modifie un compose.yaml depuis l\u0026rsquo;IDE et qu\u0026rsquo;on veut appliquer les changements, il faut faire Stop + Start depuis Dockge, pas \u0026ldquo;Restart\u0026rdquo;. Le Restart redémarre le container existant sans relire le compose.yaml. Le Stop + Start recrée le container avec la nouvelle configuration.\nLabels modifiés : redémarrer Homepage Homepage lit les labels Docker au démarrage. Si on change le homepage.group ou homepage.name d\u0026rsquo;un service, Homepage ne le voit pas tant qu\u0026rsquo;il n\u0026rsquo;est pas redémarré.\nLe container démarre mais n\u0026rsquo;est pas routable Vérifier dans l\u0026rsquo;ordre :\ndocker ps : le container est-il healthy ? Traefik ignore les containers unhealthy. Le container est-il sur le réseau traefik ? docker inspect \u0026lt;container\u0026gt; --format \u0026#39;{{json .NetworkSettings.Networks}}\u0026#39; Le label traefik.enable: true est-il présent ? La règle Host(...) correspond-elle à l\u0026rsquo;URL testée ? Montage de fichiers inexistants sous Docker Desktop / WSL Quand Docker Desktop (WSL) monte un fichier qui n\u0026rsquo;existe pas encore sur le host, il crée un répertoire à la place. Ce répertoire fantôme bloque ensuite le montage du vrai fichier. Symptôme : le container refuse de démarrer avec une erreur de montage.\nSolution : s\u0026rsquo;assurer que le fichier existe sur le host avant de démarrer le container, ou utiliser un montage de répertoire plutôt que de fichier.\nWatchtower : API Docker trop ancienne Sur certaines configurations, Watchtower tente de communiquer avec le daemon en commençant la négociation à l\u0026rsquo;API v1.25 (son minimum historique). Les versions récentes de Docker refusent cette version. Symptôme : le container redémarre en boucle avec client version 1.25 is too old. Minimum supported API version is 1.40.\nFix dans le compose.yaml de Watchtower :\nenvironment: DOCKER_API_VERSION: \u0026#34;1.40\u0026#34; 1.40 est la valeur à mettre, quelle que soit ta version de Docker. Ce n\u0026rsquo;est pas ta version exacte, c\u0026rsquo;est le minimum que le daemon accepte, indiqué dans le message d\u0026rsquo;erreur. Pour vérifier la version d\u0026rsquo;API réelle de ton daemon :\ndocker version --format \u0026#39;{{.Server.APIVersion}}\u0026#39; ${PWD} dans le compose de Dockge ${PWD} n\u0026rsquo;est pas une variable .env, c\u0026rsquo;est une variable shell résolue au moment du docker compose up. Elle vaut le répertoire courant du terminal. Lancer docker compose up -d depuis n\u0026rsquo;importe quel autre répertoire donnera une mauvaise valeur et cassera les montages de volumes des stacks.\nCe homelab est conçu pour tourner sur une machine Linux ou WSL. Toutes les commandes sont testées sur Ubuntu/WSL2 avec Docker Desktop.\nConclusion J\u0026rsquo;ai bien conscience que ce tuto ne couvre pas tout. On aurait pu ajouter de l\u0026rsquo;authentification devant chaque service, faire tourner l\u0026rsquo;ensemble en HTTPS, mettre en place un socket proxy pour limiter l\u0026rsquo;exposition du daemon Docker, ou épingler précisément chaque version d\u0026rsquo;image. Mais chacun de ces points aurait considérablement allongé l\u0026rsquo;article et la complexité de mise en place. L\u0026rsquo;objectif était de démarrer avec quelque chose de fonctionnel et maintenable, pas de construire une forteresse dès le premier jour.\nLe homelab parfait n\u0026rsquo;existe pas. Celui qui tourne, si.\nguillaumedelre/homelab Homelab Docker Compose avec Traefik — stacks indépendants, dashboard auto-configuré, et zéro configuration DNS grâce à sslip.io.\nRéférences Projet GitHub sslip.io sslip.io selfh.st selfh.st Traefik github.com/traefik/traefik Dockge github.com/louislam/dockge Homepage github.com/gethomepage/homepage Dozzle github.com/amir20/dozzle Glances github.com/nicolargo/glances FileBrowser github.com/gtsteffaniak/filebrowser IT-Tools github.com/CorentinTh/it-tools Stirling PDF github.com/Stirling-Tools/Stirling-PDF Uptime Kuma github.com/louislam/uptime-kuma Watchtower github.com/containrrr/watchtower selfhst/icons github.com/selfhst/icons ","permalink":"https://guillaumedelre.github.io/fr/2026/02/17/construire-un-homelab-self-hosted-avec-docker-compose-et-traefik/","summary":"\u003cp\u003eÇa fait des années que j\u0026rsquo;avais envie d\u0026rsquo;un homelab à la maison. Un endroit à moi pour héberger mes outils de développement, surveiller mes machines, faire tourner de la domotique, tester des trucs sans risquer de casser quoi que ce soit d\u0026rsquo;important. L\u0026rsquo;idée est simple. La mise en place un peu moins.\u003c/p\u003e\n\u003cp\u003eÀ l\u0026rsquo;époque, Kubernetes n\u0026rsquo;existait pas encore. Les options pour faire tourner plusieurs services sur une machine se résumaient à du scripting bash, des configurations Nginx écrites à la main, et beaucoup de café. Les tutoriels \u0026ldquo;homelab pour les humains\u0026rdquo; brillaient par leur absence.\u003c/p\u003e","title":"Construire un homelab self-hosted avec Docker Compose et Traefik"},{"content":"Symfony 8.0 est sorti le 27 novembre 2025, le même jour que 7.4. Il exige PHP 8.4 et abandonne tout ce qui était déprécié dans 7.4. Les deux changements les plus intéressants sont ce qu\u0026rsquo;il arrête de faire et ce qu\u0026rsquo;il commence à faire avec PHP 8.4.\nLes objets paresseux natifs Le système de proxy de Symfony, utilisé pour l\u0026rsquo;initialisation paresseuse des services et les proxies d\u0026rsquo;entités de Doctrine, a historiquement reposé sur la génération de code. Les classes proxy étaient générées au cache warmup, stockées sous forme de fichiers, et chargées à la demande. Ça fonctionnait, mais ça ajoutait une vraie complexité : des fichiers générés à gérer, un cache à invalider, du code qui ne ressemblait en rien à la classe qu\u0026rsquo;il proxyifiait.\nPHP 8.4 a ajouté des objets paresseux natifs. Symfony 8.0 les utilise. Le LazyGhostTrait et le LazyProxyTrait qui alimentaient l\u0026rsquo;ancien système sont supprimés. La création de proxy est maintenant une opération à l\u0026rsquo;exécution soutenue par le moteur lui-même, pas une étape de génération de code.\nPour les développeurs d\u0026rsquo;applications, le changement est essentiellement invisible : les services paresseux fonctionnent toujours. Pour les auteurs de frameworks et bibliothèques, une surface significative de complexité vient de disparaître.\nFormFlow Les formulaires multi-étapes ont toujours été un exercice DIY dans Symfony. Gestion de session, suivi des étapes, validation partielle, navigation entre les étapes : chaque projet roulait sa propre solution ou importait un bundle tiers.\n8.0 introduit FormFlow : un mécanisme intégré pour les wizards de formulaires multi-étapes. Les étapes sont définies comme une séquence de types de formulaires, la validation partielle est scopée à l\u0026rsquo;étape courante, et la gestion de session est gérée automatiquement.\n#[AsFormFlow] class CheckoutFlow extends AbstractFormFlow { protected function defineSteps(): Steps { return Steps::create() -\u0026gt;add(\u0026#39;shipping\u0026#39;, ShippingType::class) -\u0026gt;add(\u0026#39;payment\u0026#39;, PaymentType::class) -\u0026gt;add(\u0026#39;review\u0026#39;, ReviewType::class); } } La config XML et PHP fluent supprimées La dépréciation de 7.4 du format de configuration PHP fluent devient une suppression définitive dans 8.0. La configuration XML sort aussi comme format de première classe. Les formats supportés pour la configuration applicative sont maintenant YAML et tableaux PHP. L\u0026rsquo;empreinte rétrécit, mais ce qui reste est genuinement meilleur.\nCe qui est supprimé d\u0026rsquo;autre Le support PHP 8.2 et 8.3 (8.4 minimum) ContainerAwareInterface et ContainerAwareTrait L\u0026rsquo;usage interne de LazyGhostTrait et LazyProxyTrait par Symfony La surcharge de méthode HTTP pour GET et HEAD (seul POST a du sens sémantiquement) Symfony 8.0 est une rupture propre, et ce genre de rupture ne devient possible que quand le plancher PHP s\u0026rsquo;élève. Les objets paresseux de PHP 8.4 sont l\u0026rsquo;exemple le plus clair : la fonctionnalité existe maintenant dans le langage, donc le framework peut simplement arrêter de l\u0026rsquo;implémenter.\nConsole devient plus ergonomique pour les commandes invocables Les commandes invocables reçoivent une mise à niveau significative. L\u0026rsquo;attribut #[Input] transforme un DTO en bag d\u0026rsquo;arguments/options de la commande. Fini d\u0026rsquo;appeler $input-\u0026gt;getArgument() dans le handler :\n#[AsCommand(name: \u0026#39;app:send-report\u0026#39;)] class SendReportCommand { public function __invoke( #[Input] SendReportInput $input, ): int { // $input-\u0026gt;email, $input-\u0026gt;dryRun, etc. return Command::SUCCESS; } } BackedEnum est supporté dans les commandes invocables, donc une option déclarée comme enum Status est validée et castée automatiquement. Les commandes interactives reçoivent les attributs #[Interact] et #[Ask] pour déclarer les prompts de questions en ligne. CommandTester fonctionne avec les commandes invocables sans câblage supplémentaire.\nLe Routing trouve ses propres contrôleurs Les routes définies via #[Route] sur les classes de contrôleurs sont auto-enregistrées sans avoir besoin d\u0026rsquo;une entrée resource: explicite dans config/routes.yaml. Le tag routing.controller est appliqué automatiquement. On contrôle toujours quels répertoires sont scannés, mais la config YAML rétrécit jusqu\u0026rsquo;à un pointeur vers un répertoire plutôt qu\u0026rsquo;une liste de fichiers manuelle.\n#[Route] reçoit aussi un paramètre _query pour définir des paramètres de query à la génération, et plusieurs environnements dans l\u0026rsquo;option env.\nSécurité : CSRF et OIDC reçoivent de meilleurs outils #[IsCsrfTokenValid] reçoit un argument $tokenSource pour spécifier d\u0026rsquo;où vient le token (header, cookie, champ de formulaire) plutôt que de s\u0026rsquo;appuyer sur une convention fixe. SameOriginCsrfTokenManager ajoute la validation du header Sec-Fetch-Site, un mécanisme de protection CSRF natif au navigateur qui n\u0026rsquo;a pas besoin d\u0026rsquo;injection de token du tout.\nLa commande security:oidc-token:generate crée des tokens pour tester les endpoints protégés OIDC en local. Plusieurs endpoints de découverte OIDC sont maintenant supportés, utile dans les setups multi-tenant où chaque tenant a son propre identity provider.\nDeux nouvelles fonctions Twig : access_decision() et access_decision_for_user() exposent le résultat du voter d\u0026rsquo;autorisation dans les templates sans passer par la façade de sécurité. #[IsGranted] peut être sous-classé pour les patterns d\u0026rsquo;autorisation répétés qui méritent leur propre attribut nommé.\nObjectMapper et JsonStreamer sortent d\u0026rsquo;expérimental Les deux composants introduits dans 7.x sont stables dans 8.0. ObjectMapper mappe entre objets sans transformateurs écrits à la main, via une configuration basée sur des attributs. JsonStreamer lit et écrit de grands JSON sans charger le document entier en mémoire, et il supporte maintenant les propriétés synthétiques : des champs virtuels calculés à la sérialisation.\nJsonStreamer abandonne aussi sa dépendance sur nikic/php-parser. La génération de code pour le reader/writer utilise maintenant un mécanisme interne plus simple, supprimant une lourde dépendance de dev.\nUid par défaut vers UUIDv7 UuidFactory génère maintenant UUIDv7 par défaut au lieu d\u0026rsquo;UUIDv4. La différence : v7 est ordonné dans le temps, donc les UUIDs générés se trient chronologiquement. Ça compte beaucoup pour la performance des index de base de données. MockUuidFactory fournit une génération déterministe d\u0026rsquo;UUID dans les tests.\nYaml lève une erreur sur les clés dupliquées Auparavant, un fichier YAML avec deux clés identiques gardait silencieusement la dernière. 8.0 lève une erreur de parsing. Ça attrape de vrais bugs : les clés dupliquées dans services.yaml ou config/packages/*.yaml sont presque toujours des erreurs de copier-coller et on veut définitivement être informé.\nValidator : contrainte Video et protocoles wildcard Une contrainte Video rejoint la contrainte Image pour valider les fichiers vidéo uploadés (type MIME, durée, codec). La contrainte Url accepte protocols: ['*'] pour autoriser n\u0026rsquo;importe quel schéma conforme à la RFC 3986, utile pour stocker des URLs arbitraires qui incluent git+ssh://, file://, ou des schémas d\u0026rsquo;application personnalisés.\nMessenger : retry natif SQS et nouveaux événements Le transport SQS peut maintenant utiliser sa propre configuration native de retry et de dead-letter queue au lieu du middleware de retry de Symfony. Pour les queues à haut volume sur AWS, ça supprime un aller-retour par PHP pour les échecs transitoires. Un MessageSentToTransportsEvent se déclenche après qu\u0026rsquo;un message est dispatché, portant des informations sur quels transports l\u0026rsquo;ont réellement reçu.\nmessenger:consume reçoit --exclude-receivers pour se combiner avec --all.\nMailer : transport Microsoft Graph Un nouveau transport envoie des emails via l\u0026rsquo;API Microsoft Graph, ce que Microsoft recommande pour les applications sur Azure Active Directory ces jours-ci. Les autres options (relai SMTP, Exchange EWS) fonctionnent encore, mais Graph est le bon choix pour les nouveaux déploiements Azure.\nWorkflow : transitions pondérées Les transitions peuvent maintenant déclarer des poids. Quand plusieurs transitions sont activées depuis la même place, celle avec le poids le plus élevé gagne. Ça permet d\u0026rsquo;exprimer la priorité directement dans la définition du workflow sans ajouter un guard qui lit un compteur externe.\nreturn (new Definition(states: [\u0026#39;draft\u0026#39;, \u0026#39;review\u0026#39;, \u0026#39;published\u0026#39;])) -\u0026gt;addTransition(new Transition(\u0026#39;publish\u0026#39;, \u0026#39;review\u0026#39;, \u0026#39;published\u0026#39;, weight: 10)) -\u0026gt;addTransition(new Transition(\u0026#39;reject\u0026#39;, \u0026#39;review\u0026#39;, \u0026#39;draft\u0026#39;, weight: 1)); Lock : LockKeyNormalizer LockKeyNormalizer normalise une clé de lock vers une string cohérente avant le hachage. Utile quand la clé est dérivée d\u0026rsquo;entrées utilisateur ou de données externes qui peuvent varier en espaces blancs ou casse : le normalizer s\u0026rsquo;assure que la même clé logique correspond toujours au même lock.\nHttpFoundation : méthode QUERY et parsing de corps plus propre La méthode IETF QUERY (une méthode sûre et idempotente avec un corps, contrairement à GET) est maintenant supportée partout dans la stack : Request, cache HTTP, WebProfiler et HttpClient. Si on construit des APIs de recherche qui nécessitent un corps de requête structuré et veulent aussi du cache, QUERY est le bon choix sémantique.\nRequest::createFromGlobals() parse maintenant automatiquement le corps des requêtes PUT, DELETE, PATCH et QUERY.\nConfig : schéma JSON pour la validation YAML Symfony 8.0 auto-génère un fichier JSON Schema pour chaque section de configuration. Les IDEs qui supportent JSON Schema pour les fichiers YAML (VS Code, PhpStorm) peuvent maintenant valider config/packages/*.yaml contre ces schémas et fournir de l\u0026rsquo;autocomplétion sans plugin. Le schéma est généré pendant le cache warmup et placé dans config/reference.php.\nRuntime : auto-détection FrankenPHP Le composant Runtime détecte FrankenPHP automatiquement et active le mode worker sans package supplémentaire ni variable d\u0026rsquo;environnement. Si $_SERVER['APP_RUNTIME'] est défini, cette classe de runtime a la priorité. On peut aussi choisir le renderer d\u0026rsquo;erreurs basé sur APP_RUNTIME_MODE, ce qui est utile quand on fait tourner la même codebase dans des contextes HTTP et CLI avec des besoins de présentation d\u0026rsquo;erreurs différents.\n","permalink":"https://guillaumedelre.github.io/fr/2026/01/12/symfony-8.0-php-8.4-minimum-objets-paresseux-natifs-et-formflow/","summary":"\u003cp\u003eSymfony 8.0 est sorti le 27 novembre 2025, le même jour que 7.4. Il exige PHP 8.4 et abandonne tout ce qui était déprécié dans 7.4. Les deux changements les plus intéressants sont ce qu\u0026rsquo;il arrête de faire et ce qu\u0026rsquo;il commence à faire avec PHP 8.4.\u003c/p\u003e\n\u003ch2 id=\"les-objets-paresseux-natifs\"\u003eLes objets paresseux natifs\u003c/h2\u003e\n\u003cp\u003eLe système de proxy de Symfony, utilisé pour l\u0026rsquo;initialisation paresseuse des services et les proxies d\u0026rsquo;entités de Doctrine, a historiquement reposé sur la génération de code. Les classes proxy étaient générées au cache warmup, stockées sous forme de fichiers, et chargées à la demande. Ça fonctionnait, mais ça ajoutait une vraie complexité : des fichiers générés à gérer, un cache à invalider, du code qui ne ressemblait en rien à la classe qu\u0026rsquo;il proxyifiait.\u003c/p\u003e","title":"Symfony 8.0 : PHP 8.4 minimum, objets paresseux natifs et FormFlow"},{"content":"Symfony 7.4 est sorti en novembre 2025, aux côtés de 8.0. C\u0026rsquo;est la dernière LTS de la ligne 7.x : PHP 8.2 minimum, trois ans de corrections de bugs, quatre de sécurité. Pour les équipes qui ne peuvent pas ou ne veulent pas suivre l\u0026rsquo;exigence PHP 8.4 de 8.0, 7.4 est l\u0026rsquo;endroit où atterrir.\nLa signature de messages dans Messenger La sécurité des transports dans Messenger a toujours été le problème de l\u0026rsquo;application à résoudre. 7.4 ajoute la signature de messages : un mécanisme basé sur des stamps qui signe les messages dispatchés et valide les signatures à la réception.\nLe cas d\u0026rsquo;usage cible est les scénarios multi-tenant ou de transport externe où on a besoin d\u0026rsquo;une preuve cryptographique qu\u0026rsquo;un message n\u0026rsquo;a pas été altéré ni injecté depuis l\u0026rsquo;extérieur. La configuration vit au niveau du transport :\nframework: messenger: transports: async: dsn: \u0026#39;%env(MESSENGER_TRANSPORT_DSN)%\u0026#39; options: signing_key: \u0026#39;%env(MESSENGER_SIGNING_KEY)%\u0026#39; Configuration en tableaux PHP Les formats de configuration de Symfony ont toujours été YAML (défaut), XML et PHP. Le format PHP existait mais était maladroit : un DSL builder fluent qui nécessitait du chaînage de méthodes et ne donnait rien d\u0026rsquo;utile à l\u0026rsquo;IDE.\n7.4 remplace le format fluent par des tableaux PHP standard. Les IDEs peuvent maintenant vraiment l\u0026rsquo;analyser, config/reference.php est auto-généré comme référence avec annotations de types, et le résultat ressemble à des données plutôt qu\u0026rsquo;à du code :\nreturn static function (FrameworkConfig $framework): void { $framework-\u0026gt;router()-\u0026gt;strictRequirements(null); $framework-\u0026gt;session()-\u0026gt;enabled(true); }; Le format fluent est déprécié. Les tableaux sont l\u0026rsquo;avenir, et honnêtement c\u0026rsquo;est un meilleur format.\nAméliorations OIDC #[IsSignatureValid] valide les URLs signées directement dans les contrôleurs, supprimant le boilerplate de la validation manuelle. OpenID Connect supporte maintenant plusieurs endpoints de découverte, et une nouvelle commande security:oidc-token:generate rend le dev et les tests beaucoup moins pénibles.\nLa fenêtre de support 7.4 LTS : bugs jusqu\u0026rsquo;en novembre 2028, correctifs de sécurité jusqu\u0026rsquo;en novembre 2029. Le chemin vers 8.4 LTS (la prochaine cible long terme) passe par les notices de dépréciation de 7.4 et la mise à jour PHP 8.4. Corriger les dépréciations maintenant et le saut vers 8.x sera beaucoup moins douloureux.\nLes attributs deviennent plus précis #[CurrentUser] accepte maintenant les types union, ce qui compte en pratique quand une route est accessible par plus d\u0026rsquo;une classe utilisateur :\npublic function index(#[CurrentUser] AdminUser|Customer $user): Response #[Route] accepte un tableau pour l\u0026rsquo;option env, donc une route de debug active seulement en dev et test n\u0026rsquo;a plus besoin de deux définitions séparées. #[AsDecorator] est maintenant répétable, ce qui signifie qu\u0026rsquo;une classe peut décorer plusieurs services à la fois. Les signatures de méthode #[AsEventListener] acceptent les types d\u0026rsquo;événements union. #[IsGranted] reçoit une option methods pour limiter une vérification d\u0026rsquo;autorisation à des verbes HTTP spécifiques sans dupliquer la route.\nLa classe Request arrête d\u0026rsquo;en faire trop Request::get() est dépréciée, et franchement bonne débarrassance. La méthode cherchait dans les attributs de route, puis les paramètres de query, puis le corps de la requête, dans cet ordre, retournant silencieusement ce qu\u0026rsquo;elle trouvait en premier. Cette ambiguïté causait de vrais bugs. Elle est supprimée dans 8.0 ; dans 7.4 elle fonctionne encore mais déclenche une dépréciation. Les remplacements sont explicites : $request-\u0026gt;attributes-\u0026gt;get(), $request-\u0026gt;query-\u0026gt;get(), $request-\u0026gt;request-\u0026gt;get().\nLe parsing du corps pour les requêtes PUT, PATCH, DELETE et QUERY arrive en même temps. Auparavant Symfony ne parsait application/x-www-form-urlencoded et multipart/form-data que pour POST. Ces mêmes types de contenu sont maintenant parsés pour les autres méthodes accessibles en écriture aussi, ce qui tue un contournement REST API courant.\nLa surcharge de méthode HTTP pour GET, HEAD, CONNECT et TRACE est dépréciée. Surcharger une méthode sûre avec un header était de toute façon toujours sémantiquement cassé. On peut maintenant autoriser explicitement seulement les méthodes qui ont du sens pour son application :\nRequest::setAllowedHttpMethodOverride([\u0026#39;PUT\u0026#39;, \u0026#39;PATCH\u0026#39;, \u0026#39;DELETE\u0026#39;]); Les Workflows acceptent les BackedEnums Les places et transitions de Workflow peuvent maintenant être définies avec des backed enums PHP, à la fois en YAML (via le tag !php/enum) et en config PHP. Le marking store fonctionne avec les valeurs d\u0026rsquo;enum directement, donc le modèle de domaine et la définition du workflow utilisent enfin les mêmes types :\nframework: workflows: blog_publishing: initial_marking: !php/enum App\\Status\\PostStatus::Draft places: !php/enum App\\Status\\PostStatus transitions: publish: from: !php/enum App\\Status\\PostStatus::Review to: !php/enum App\\Status\\PostStatus::Published Étendre la validation et la sérialisation pour les classes tierces Besoin d\u0026rsquo;ajouter des métadonnées de validation ou de sérialisation à une classe d\u0026rsquo;un bundle qu\u0026rsquo;on ne possède pas ? 7.4 a #[ExtendsValidationFor] et #[ExtendsSerializationFor] pour ça. On écrit une classe compagnon avec ses annotations supplémentaires, on pointe l\u0026rsquo;attribut vers la classe cible, et Symfony fusionne les métadonnées à la compilation du conteneur :\n#[ExtendsValidationFor(UserRegistration::class)] abstract class UserRegistrationValidation { #[Assert\\NotBlank(groups: [\u0026#39;my_app\u0026#39;])] #[Assert\\Length(min: 3, groups: [\u0026#39;my_app\u0026#39;])] public string $name = \u0026#39;\u0026#39;; #[Assert\\Email(groups: [\u0026#39;my_app\u0026#39;])] public string $email = \u0026#39;\u0026#39;; } Symfony vérifie à la compilation que les propriétés déclarées existent réellement sur la classe cible. Un renommage ne cassera pas silencieusement la validation.\nDX : ce qui ne fait pas la une mais compte Le helper Question dans Console accepte un timeout. Demander à l\u0026rsquo;utilisateur de confirmer quelque chose, et s\u0026rsquo;il ne répond pas en N secondes, la réponse par défaut s\u0026rsquo;applique. Très pratique dans les scripts de déploiement qui ne peuvent pas se permettre d\u0026rsquo;attendre éternellement un humain.\nmessenger:consume reçoit --exclude-receivers. Combiné avec --all, il permet de consommer depuis tous les transports sauf des spécifiques :\nbin/console messenger:consume --all --exclude-receivers=low_priority Le mode worker FrankenPHP est maintenant auto-détecté. Si le processus tourne dans FrankenPHP, Symfony bascule en mode worker automatiquement. Pas de package supplémentaire nécessaire.\nLa commande debug:router cache les colonnes Scheme et Host quand toutes les routes utilisent ANY, ce qui supprime beaucoup de bruit de la sortie par défaut. Les méthodes HTTP sont maintenant aussi colorées.\nLes tests fonctionnels reçoivent $client-\u0026gt;getSession() avant la première requête. Auparavant il fallait faire au moins une requête pour accéder à la session, ce qui était agaçant. Maintenant on peut pré-remplir les tokens CSRF ou les flags A/B en amont :\n$session = $client-\u0026gt;getSession(); $session-\u0026gt;set(\u0026#39;_csrf/checkout\u0026#39;, \u0026#39;test-token\u0026#39;); $session-\u0026gt;save(); Lock : store DynamoDB DynamoDbStore arrive comme nouveau backend de Lock. Utile dans les déploiements AWS-natifs où Redis n\u0026rsquo;est pas dans la stack, et ça fonctionne exactement comme n\u0026rsquo;importe quel autre store :\n$store = new DynamoDbStore(\u0026#39;dynamodb://default/locks\u0026#39;); $factory = new LockFactory($store); Bridge Doctrine : types day point et time point Deux nouveaux types de colonnes Doctrine : day_point stocke une valeur date uniquement (sans composant heure) et time_point stocke une valeur heure uniquement, tous deux mappant vers DatePoint. Bien quand le domaine sépare genuinement la date de l\u0026rsquo;heure :\n#[ORM\\Column(type: \u0026#39;day_point\u0026#39;)] public DatePoint $birthDate; #[ORM\\Column(type: \u0026#39;time_point\u0026#39;)] public DatePoint $openingTime; Routing : paramètres de query explicites La clé _query dans la génération d\u0026rsquo;URL permet de définir les paramètres de query explicitement, séparément des paramètres de route. Ça compte quand un paramètre de route et un paramètre de query partagent le même nom :\n$url = $urlGenerator-\u0026gt;generate(\u0026#39;report\u0026#39;, [ \u0026#39;site\u0026#39; =\u0026gt; \u0026#39;fr\u0026#39;, \u0026#39;_query\u0026#39; =\u0026gt; [\u0026#39;site\u0026#39; =\u0026gt; \u0026#39;us\u0026#39;], ]); // /report/fr?site=us WebLink : parsing des en-têtes Link entrants HttpHeaderParser parse les en-têtes de réponse Link en objets structurés. Avant ça, parser des en-têtes Link depuis des réponses d\u0026rsquo;API nécessitait soit d\u0026rsquo;importer une bibliothèque tierce, soit d\u0026rsquo;écrire des regex. Le cas d\u0026rsquo;usage : les APIs HTTP qui annoncent des ressources liées ou la pagination via des en-têtes Link, comme le fait l\u0026rsquo;API GitHub.\nLe parsing HTML5 est plus rapide sur PHP 8.4 DomCrawler et HtmlSanitizer basculent vers le parser HTML5 natif de PHP 8.4 quand il est disponible. Pas de changements de code nécessaires de votre côté. Le parser natif est plus rapide et plus conforme à la spec que le fallback précédent. Sur PHP 8.2 ou 8.3, rien ne change.\nTranslation : StaticMessage StaticMessage implémente TranslatableInterface mais ne traduit intentionnellement pas. Elle passe la string inchangée quelle que soit la locale. Le cas d\u0026rsquo;usage : les réponses d\u0026rsquo;API qui doivent rester dans une langue fixe quelle que soit la locale de l\u0026rsquo;utilisateur, ou les entrées de log d\u0026rsquo;audit où on doit préserver le texte original tel quel.\n","permalink":"https://guillaumedelre.github.io/fr/2026/01/10/symfony-7.4-lts-signature-de-messages-tableaux-php-en-config-et-le-dernier-7.x/","summary":"\u003cp\u003eSymfony 7.4 est sorti en novembre 2025, aux côtés de 8.0. C\u0026rsquo;est la dernière LTS de la ligne 7.x : PHP 8.2 minimum, trois ans de corrections de bugs, quatre de sécurité. Pour les équipes qui ne peuvent pas ou ne veulent pas suivre l\u0026rsquo;exigence PHP 8.4 de 8.0, 7.4 est l\u0026rsquo;endroit où atterrir.\u003c/p\u003e\n\u003ch2 id=\"la-signature-de-messages-dans-messenger\"\u003eLa signature de messages dans Messenger\u003c/h2\u003e\n\u003cp\u003eLa sécurité des transports dans Messenger a toujours été le problème de l\u0026rsquo;application à résoudre. 7.4 ajoute la signature de messages : un mécanisme basé sur des stamps qui signe les messages dispatchés et valide les signatures à la réception.\u003c/p\u003e","title":"Symfony 7.4 LTS : signature de messages, tableaux PHP en config et le dernier 7.x"},{"content":"PHP 8.5 est sorti le 20 novembre. Deux fonctionnalités définissent cette version : l\u0026rsquo;opérateur pipe et l\u0026rsquo;extension URI. Elles résolvent des problèmes différents, mais partagent la même motivation : rendre les opérations courantes moins maladroites à exprimer.\nL\u0026rsquo;opérateur pipe Les pipelines fonctionnels en PHP ont toujours été un bazar. Enchaîner des transformations nécessitait soit d\u0026rsquo;imbriquer les appels de fonctions à l\u0026rsquo;envers, soit de les découper en variables intermédiaires :\n// avant — se lit de droite à gauche $result = array_sum(array_map(\u0026#39;strlen\u0026#39;, array_filter($strings, \u0026#39;strlen\u0026#39;))); // ou verbeux mais lisible $filtered = array_filter($strings, \u0026#39;strlen\u0026#39;); $lengths = array_map(\u0026#39;strlen\u0026#39;, $filtered); $result = array_sum($lengths); // après — se lit de gauche à droite $result = $strings |\u0026gt; array_filter(?, \u0026#39;strlen\u0026#39;) |\u0026gt; array_map(\u0026#39;strlen\u0026#39;, ?) |\u0026gt; array_sum(?); L\u0026rsquo;opérateur |\u0026gt; passe la valeur de gauche dans l\u0026rsquo;expression de droite. Le placeholder ? marque où elle va. Les pipelines se lisent maintenant dans l\u0026rsquo;ordre où les opérations se produisent : gauche à droite, haut en bas.\nÇa se marie bien avec les callables de première classe de PHP 8.1. Les deux fonctionnalités se composent bien :\n$result = $input |\u0026gt; trim(...) |\u0026gt; strtolower(...) |\u0026gt; $this-\u0026gt;normalize(...); L\u0026rsquo;extension URI Gérer les URIs en PHP a toujours signifié soit se tourner vers une bibliothèque tierce, soit assembler parse_url() (retourne un tableau, pas un objet), http_build_query(), et de la concaténation manuelle de strings.\nLa nouvelle extension Uri donne une vraie API orientée objet :\n$uri = Uri\\Uri::parse(\u0026#39;https://example.com/path?query=value#fragment\u0026#39;); $modified = $uri-\u0026gt;withPath(\u0026#39;/new-path\u0026#39;)-\u0026gt;withQuery(\u0026#39;key=val\u0026#39;); echo $modified; // https://example.com/new-path?key=val#fragment Des objets-valeur immutables, un parsing conforme aux RFC, modifier des composants individuels sans parser et reconstruire la string entière. Attendu depuis longtemps.\n#[\\NoDiscard] Un nouvel attribut qui génère un avertissement quand la valeur de retour est ignorée :\n#[\\NoDiscard(\u0026#34;Use the returned collection, the original is unchanged\u0026#34;)] public function filter(callable $fn): static { ... } Utile pour les méthodes immutables où ignorer la valeur de retour est presque certainement un bug. Courant dans d\u0026rsquo;autres langages depuis des années, maintenant en PHP où ça appartient.\nclone with Cloner un objet avec des propriétés modifiées sans utiliser de property hooks ni une méthode with() personnalisée :\n$updated = clone($point) with { x: 10, y: 20 }; Syntaxe propre pour un pattern que les objets readonly nécessitaient : on clone pour \u0026ldquo;modifier\u0026rdquo; puisque la mutation directe n\u0026rsquo;est pas permise.\nPHP 8.5 a une tendance fonctionnelle. L\u0026rsquo;opérateur pipe et l\u0026rsquo;extension URI ensemble rendent le code de transformation de données significativement plus lisible. Le langage continue dans une direction cohérente.\nLes closures dans les expressions constantes Une contrainte qui existait depuis PHP 5 : les expressions constantes (arguments d\u0026rsquo;attributs, valeurs par défaut de propriétés, valeurs par défaut de paramètres, déclarations const) ne pouvaient pas contenir de closures ni de callables de première classe. 8.5 supprime ça.\n#[Validate(fn($v) =\u0026gt; $v \u0026gt; 0)] public int $count = 0; const NORMALIZER = strtolower(...); class Config { public function __construct( public readonly Closure $transform = trim(...), ) {} } C\u0026rsquo;est la pièce manquante qui rend les attributs vraiment expressifs pour les règles de validation et de transformation. Avant 8.5, il fallait passer des noms de classes ou des références string aux attributs et laisser le framework les retrouver. Maintenant le callable vit directement dans l\u0026rsquo;attribut.\nLes attributs sur les constantes L\u0026rsquo;attribut #[\\Deprecated] de 8.4 ne pouvait pas être appliqué aux déclarations const. 8.5 ajoute le support des attributs pour les constantes en général :\nconst OLD_LIMIT = 100; #[\\Deprecated(\u0026#39;Use RATE_LIMIT instead\u0026#39;, since: \u0026#39;3.0\u0026#39;)] const API_TIMEOUT = 30; const RATE_LIMIT = 60; ReflectionConstant, une nouvelle classe de réflexion dans 8.5, expose getAttributes() pour que les outils puissent les lire. Combiné avec les closures dans les expressions constantes, les attributs sur les constantes deviennent une vraie couche de métadonnées pour les valeurs à la compilation.\n#[\\Override] s\u0026rsquo;étend aux propriétés PHP 8.3 a apporté #[\\Override] pour les méthodes. 8.5 l\u0026rsquo;étend aux propriétés :\nclass Base { public string $name = \u0026#39;default\u0026#39;; } class Derived extends Base { #[\\Override] public string $name = \u0026#39;derived\u0026#39;; } Si la propriété n\u0026rsquo;existe pas dans le parent, PHP lève une erreur. Particulièrement utile avec les property hooks de 8.4 : on peut maintenant signaler qu\u0026rsquo;une propriété hookée surcharge intentionnellement celle d\u0026rsquo;un parent.\nLa visibilité asymétrique statique 8.4 a introduit la visibilité asymétrique (public private(set)) pour les propriétés d\u0026rsquo;instance. 8.5 l\u0026rsquo;apporte aussi aux propriétés static :\nclass Registry { public static private(set) array $items = []; public static function register(string $key, mixed $value): void { self::$items[$key] = $value; } } echo Registry::$items[\u0026#39;foo\u0026#39;]; // lisible Registry::$items[\u0026#39;bar\u0026#39;] = 1; // Error: cannot write outside class Pattern direct : exposer une collection statique en lecture, bloquer la mutation externe.\nLa promotion de constructeur pour les propriétés final La promotion de propriétés dans les constructeurs existe depuis PHP 8.0. Le modificateur final sur les propriétés promuées était la pièce manquante, 8.5 l\u0026rsquo;ajoute :\nclass ValueObject { public function __construct( public final readonly string $id, public final readonly string $name, ) {} } Une sous-classe ne peut pas surcharger $id ni $name avec une propriété du même nom. La combinaison final readonly sur les propriétés promuées rend les objets-valeur aussi verrouillés que possible sans sceller la classe entière.\nLes casts dans les expressions constantes Autre lacune dans les expressions constantes : pas de casts de type. 8.5 les permet :\nconst PRECISION = (int) 3.7; // 3 const THRESHOLD = (float) \u0026#39;1.5\u0026#39;; // 1.5 const FLAG = (bool) 1; // true Ça semble mineur jusqu\u0026rsquo;à ce qu\u0026rsquo;on ait des constantes de configuration dérivées de variables d\u0026rsquo;environnement qui nécessitent une coercition de type directement à la déclaration.\nLes erreurs fatales incluent les backtraces Avant 8.5, une erreur fatale (mémoire insuffisante, dépassement de pile, erreur de type dans certains contextes) produisait un message sans contexte sur où dans le code ça s\u0026rsquo;est passé. Trouver la cause signifiait insérer des logs de debug et reproduire.\n8.5 ajoute des backtraces à la stack aux messages d\u0026rsquo;erreur fatale, dans le même format que les backtraces d\u0026rsquo;exceptions. Une nouvelle directive INI, fatal_error_backtraces, contrôle le comportement. Elle est activée par défaut.\narray_first() et array_last() PHP avait reset() et end() pour accéder au premier et dernier élément d\u0026rsquo;un tableau depuis PHP 3. Les deux mutent le pointeur interne du tableau (pas sûr à appeler sur une référence), et ils retournent false pour les tableaux vides d\u0026rsquo;une façon indiscernable d\u0026rsquo;une valeur false stockée.\n$values = [10, 20, 30]; $first = array_first($values); // 10 $last = array_last($values); // 30 $first = array_first([]); // null Les nouvelles fonctions retournent null pour les tableaux vides, ne touchent pas le pointeur interne, et fonctionnent sur n\u0026rsquo;importe quelle expression de tableau sans avoir besoin d\u0026rsquo;une variable. reset($this-\u0026gt;getItems()) était un avertissement de dépréciation en attente de se produire.\nget_error_handler() et get_exception_handler() PHP a set_error_handler() et set_exception_handler(). Obtenir le handler courant nécessitait soit de le stocker soi-même avant de le définir, soit d\u0026rsquo;appeler set_error_handler(null) et de capturer ce qui revenait, ce qui effaçait aussi le handler au passage.\n8.5 ajoute :\n$current = get_error_handler(); $current = get_exception_handler(); Pratique dans les chaînes de middleware où on veut envelopper le handler existant sans le perdre, ou dans les tests où on veut vérifier qu\u0026rsquo;un handler a bien été installé.\nIntlListFormatter Formater une liste avec les conjonctions appropriées à la locale nécessitait toujours un assemblage manuel de strings. 8.5 ajoute IntlListFormatter :\n$formatter = new IntlListFormatter(\u0026#39;en_US\u0026#39;, IntlListFormatter::TYPE_AND); echo $formatter-\u0026gt;format([\u0026#39;apples\u0026#39;, \u0026#39;oranges\u0026#39;, \u0026#39;pears\u0026#39;]); // \u0026#34;apples, oranges, and pears\u0026#34; $formatter = new IntlListFormatter(\u0026#39;fr_FR\u0026#39;, IntlListFormatter::TYPE_OR); echo $formatter-\u0026gt;format([\u0026#39;rouge\u0026#39;, \u0026#39;bleu\u0026#39;, \u0026#39;vert\u0026#39;]); // \u0026#34;rouge, bleu ou vert\u0026#34; La classe enveloppe le ListFormatter d\u0026rsquo;ICU. Trois types : TYPE_AND, TYPE_OR, TYPE_UNITS. Les constantes de largeur contrôlent si on obtient \u0026ldquo;et\u0026rdquo; ou \u0026ldquo;\u0026amp;\u0026rdquo;. Gestion de la virgule d\u0026rsquo;Oxford, placement de conjonction spécifique à la locale, tout géré par ICU.\nFILTER_THROW_ON_FAILURE pour filter_var() filter_var() retourne false en cas d\u0026rsquo;échec de validation, ce qui produit l\u0026rsquo;ambiguïté classique false vs null vs 0 quand on filtre des entrées non fiables. Un nouveau flag change ça :\ntry { $email = filter_var($input, FILTER_VALIDATE_EMAIL, FILTER_THROW_ON_FAILURE); } catch (Filter\\FilterFailedException $e) { // explicitement invalide, pas faussement false } Les classes Filter\\FilterFailedException et Filter\\FilterException sont nouvelles dans 8.5. Le flag ne peut pas être combiné avec FILTER_NULL_ON_FAILURE : les comportements sont mutuellement exclusifs.\nLes dépréciations qui nettoient des années de dette technique L\u0026rsquo;opérateur backtick (`commande` comme alias de shell_exec()) est déprécié. C\u0026rsquo;est une syntaxe obscure qui surprend quiconque lit le code et est incohérente avec tous les autres appels de fonctions PHP.\nLes noms de cast non canoniques ((boolean), (integer), (double), (binary)) sont dépréciés au profit de leurs formes courtes : (bool), (int), (float), (string). Les formes longues sont non documentées depuis des années ; 8.5 commence la suppression formelle.\nLes instructions case terminées par un point-virgule sont dépréciées :\n// déprécié switch ($x) { case 1; break; } // correct switch ($x) { case 1: break; } La forme avec point-virgule est syntaxiquement valide depuis PHP 4 mais personne ne l\u0026rsquo;utilise intentionnellement. C\u0026rsquo;est une faute de frappe que PHP acceptait.\n__sleep() et __wakeup() sont dépréciés au profit de __serialize() et __unserialize(), qui retournent et reçoivent des tableaux et se composent correctement avec l\u0026rsquo;héritage. Les anciennes méthodes avaient une sémantique confuse autour de la visibilité des propriétés.\nmax_memory_limit plafonne les allocations incontrôlées Une nouvelle directive INI accessible seulement au démarrage : max_memory_limit. Elle définit un plafond que memory_limit ne peut pas dépasser à l\u0026rsquo;exécution. Si un script appelle ini_set('memory_limit', '10G') et que max_memory_limit est à 512M, PHP avertit et plafonne la valeur.\nUtile dans les environnements d\u0026rsquo;hébergement partagé, ou partout où on veut s\u0026rsquo;assurer qu\u0026rsquo;un bug ou un payload malveillant ne peut pas convaincre PHP d\u0026rsquo;élever sa propre limite et de dévorer toute la RAM de la machine.\nOpcache est toujours présent Dans 8.5, Opcache est toujours compilé dans le binaire PHP et toujours chargé. L\u0026rsquo;ancienne situation (Opcache comme extension chargeable qui pouvait ou non être présente selon la configuration de build) est révolue.\nOn peut toujours le désactiver : opcache.enable=0 fonctionne bien. Ce qui change, c\u0026rsquo;est la garantie que l\u0026rsquo;API Opcache (opcache_get_status(), opcache_invalidate(), etc.) est toujours disponible, quelle que soit la façon dont PHP a été compilé. Tout code qui vérifie extension_loaded('opcache') avant d\u0026rsquo;appeler les fonctions Opcache peut supprimer la vérification.\n","permalink":"https://guillaumedelre.github.io/fr/2026/01/04/php-8.5-lop%C3%A9rateur-pipe-une-biblioth%C3%A8que-uri-et-beaucoup-de-nettoyage/","summary":"\u003cp\u003ePHP 8.5 est sorti le 20 novembre. Deux fonctionnalités définissent cette version : l\u0026rsquo;opérateur pipe et l\u0026rsquo;extension URI. Elles résolvent des problèmes différents, mais partagent la même motivation : rendre les opérations courantes moins maladroites à exprimer.\u003c/p\u003e\n\u003ch2 id=\"lopérateur-pipe\"\u003eL\u0026rsquo;opérateur pipe\u003c/h2\u003e\n\u003cp\u003eLes pipelines fonctionnels en PHP ont toujours été un bazar. Enchaîner des transformations nécessitait soit d\u0026rsquo;imbriquer les appels de fonctions à l\u0026rsquo;envers, soit de les découper en variables intermédiaires :\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// avant — se lit de droite à gauche\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$result \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_sum\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003earray_map\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003earray_filter\u003c/span\u003e($strings, \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e)));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// ou verbeux mais lisible\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$filtered   \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_filter\u003c/span\u003e($strings, \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$lengths    \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_map\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e, $filtered);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$result     \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_sum\u003c/span\u003e($lengths);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// après — se lit de gauche à droite\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$result \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e $strings\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_filter\u003c/span\u003e(\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_map\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e, \u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_sum\u003c/span\u003e(\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eL\u0026rsquo;opérateur \u003ccode\u003e|\u0026gt;\u003c/code\u003e passe la valeur de gauche dans l\u0026rsquo;expression de droite. Le placeholder \u003ccode\u003e?\u003c/code\u003e marque où elle va. Les pipelines se lisent maintenant dans l\u0026rsquo;ordre où les opérations se produisent : gauche à droite, haut en bas.\u003c/p\u003e","title":"PHP 8.5 : l'opérateur pipe, une bibliothèque URI et beaucoup de nettoyage"},{"content":"API Platform 4.2 est arrivé en septembre 2025. Trois changements se distinguent : un JSON streamer pour les grandes collections qui évite de bufferiser toute la réponse en mémoire, un ObjectMapper qui remplace le câblage manuel dans les flux DTO basés sur stateOptions, et l\u0026rsquo;autoconfiguration de #[ApiResource] sans enregistrement de service explicite.\nJSON streamer pour les grandes collections Le serializer Symfony par défaut construit la réponse complète en mémoire avant de l\u0026rsquo;écrire dans la sortie. Pour une collection de 10 000 éléments, cela signifie allouer un tableau PHP, le sérialiser en string, et garder les deux en mémoire jusqu\u0026rsquo;au flush de la réponse. À grande échelle, c\u0026rsquo;est la source des erreurs OOM qui forcent à ajouter de la pagination partout.\nLa 4.2 ajoute un encodeur JSON en streaming qui écrit la réponse de façon incrémentale :\napi_platform: serializer: enable_json_streamer: true Avec le streaming activé, la réponse est écrite directement dans le buffer de sortie au fur et à mesure que chaque élément est sérialisé. L\u0026rsquo;utilisation mémoire reste approximativement constante quelle que soit la taille de la collection. La contrepartie : on ne peut pas définir de headers de réponse après le début du streaming, et le code de statut HTTP doit être validé avant que le premier octet soit écrit.\nObjectMapper remplace le câblage DTO manuel La 3.1 avait introduit stateOptions avec DoctrineOrmOptions pour séparer la ressource API de l\u0026rsquo;entité Doctrine. Le provider recevait des objets entité et le serializer les mappait sur le DTO. Ça fonctionnait, mais le mapping était implicite — le serializer utilisait les noms de propriétés pour faire correspondre les champs, et tout ce qui ne correspondait pas était soit ignoré soit causait une erreur de normalisation.\nLa 4.2 introduit ObjectMapper, une couche de mapping déclarative entre entités et DTOs :\nuse Symfony\\Component\\ObjectMapper\\Attribute\\Map; #[Map(source: BookEntity::class)] class BookDto { public string $title; public string $authorName; } L\u0026rsquo;attribut #[Map] indique à ObjectMapper que BookDto peut être peuplé depuis BookEntity. Les noms de champs sont mis en correspondance par convention ; les inadéquations sont déclarées explicitement au niveau de chaque propriété :\nuse Symfony\\Component\\ObjectMapper\\Attribute\\Map; #[Map(source: BookEntity::class)] class BookDto { #[Map(source: \u0026#39;author.fullName\u0026#39;)] public string $authorName; } La notation pointée traverse les objets imbriqués. Le mapping s\u0026rsquo;exécute avant la sérialisation et remplace le comportement implicite de correspondance de propriétés du serializer. Les champs non mappés lèvent une erreur au moment de la configuration, pas à l\u0026rsquo;exécution.\nObjectMapper fonctionne avec stateOptions :\nuse ApiPlatform\\Doctrine\\Orm\\State\\Options; use ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\GetCollection; use Symfony\\Component\\ObjectMapper\\Attribute\\Map; #[ApiResource( operations: [ new GetCollection( stateOptions: new Options(entityClass: BookEntity::class), ), ] )] #[Map(source: BookEntity::class)] class BookDto {} Le provider récupère les objets BookEntity depuis Doctrine. ObjectMapper les convertit en instances BookDto. Le serializer écrit le DTO. Trois étapes distinctes, chacune avec un contrat clair.\nIntégration TypeInfo dans toute la pile Symfony 7.1 a introduit le composant TypeInfo , une couche unifiée d\u0026rsquo;introspection des types qui comprend les types union, intersection, les collections génériques et les types nullable à travers la reflection, PHPDoc et la syntaxe PHP 8.x.\nLa 4.2 remplace la résolution de types interne d\u0026rsquo;API Platform par TypeInfo. Cela affecte la génération de schémas de filtres, l\u0026rsquo;inférence de schéma OpenAPI, et la coercition de types du serializer. Le bénéfice visible est que les types qui généraient auparavant des schémas OpenAPI incorrects ou manquants — Collection\u0026lt;int, Book\u0026gt;, list\u0026lt;string\u0026gt;, types intersection — produisent maintenant des schémas précis sans annotations @ApiProperty manuelles.\nAutoconfigure #[ApiResource] Avant la 4.2, ajouter #[ApiResource] à une classe suffisait pour qu\u0026rsquo;Hugo la découvre uniquement si la classe était dans un chemin scanné par le chargeur de ressources d\u0026rsquo;API Platform. En dehors de ce chemin, une configuration de service explicite était nécessaire.\nLa 4.2 se branche sur le système d\u0026rsquo;autoconfigure de Symfony. Toute classe taguée avec #[ApiResource] est automatiquement enregistrée comme ressource indépendamment de son emplacement, tant qu\u0026rsquo;elle est dans un répertoire couvert par le scan de composants de Symfony. Aucune entrée dans config/services.yaml nécessaire.\nPour Laravel, l\u0026rsquo;équivalent utilise l\u0026rsquo;autoloading du service provider de Laravel — les modèles Eloquent avec #[ApiResource] sont récupérés automatiquement sans enregistrement manuel.\nDoctrine ExistsFilter L\u0026rsquo;ExistsFilter contraint une collection selon qu\u0026rsquo;une relation ou un champ nullable est défini :\n#[ApiFilter(ExistsFilter::class, properties: [\u0026#39;publishedAt\u0026#39;])] class Book {} GET /books?exists[publishedAt]=true retourne les livres où publishedAt n\u0026rsquo;est pas null. exists[publishedAt]=false retourne les livres où il l\u0026rsquo;est.\n","permalink":"https://guillaumedelre.github.io/fr/2025/09/18/api-platform-4.2-json-streamer-objectmapper-et-autoconfigure/","summary":"\u003cp\u003eAPI Platform 4.2 est arrivé en septembre 2025. Trois changements se distinguent : un JSON streamer pour les grandes collections qui évite de bufferiser toute la réponse en mémoire, un ObjectMapper qui remplace le câblage manuel dans les flux DTO basés sur \u003ccode\u003estateOptions\u003c/code\u003e, et l\u0026rsquo;autoconfiguration de \u003ccode\u003e#[ApiResource]\u003c/code\u003e sans enregistrement de service explicite.\u003c/p\u003e\n\u003ch2 id=\"json-streamer-pour-les-grandes-collections\"\u003eJSON streamer pour les grandes collections\u003c/h2\u003e\n\u003cp\u003eLe serializer Symfony par défaut construit la réponse complète en mémoire avant de l\u0026rsquo;écrire dans la sortie. Pour une collection de 10 000 éléments, cela signifie allouer un tableau PHP, le sérialiser en string, et garder les deux en mémoire jusqu\u0026rsquo;au flush de la réponse. À grande échelle, c\u0026rsquo;est la source des erreurs OOM qui forcent à ajouter de la pagination partout.\u003c/p\u003e","title":"API Platform 4.2 : JSON streamer, ObjectMapper, et autoconfigure"},{"content":"Quand on fait tourner des workloads on-premise, on peut s\u0026rsquo;en sortir avec presque aucune observabilité. On a SSH. On a top. On a quelqu\u0026rsquo;un qui sait que le service d\u0026rsquo;authentification monte toujours le lundi matin. La connaissance institutionnelle se substitue à l\u0026rsquo;instrumentation, et personne ne budgète le temps pour la remplacer.\nPuis on migre vers le cloud. La connaissance institutionnelle ne suit pas. L\u0026rsquo;accès SSH est parti ou peu pratique. Et pour la première fois, on se retrouve à fixer quatorze conteneurs FrankenPHP sans la moindre idée de ce qu\u0026rsquo;ils font réellement.\nC\u0026rsquo;est le moment où on a besoin de métriques. Pas éventuellement. Avant que la migration soit terminée.\nLe problème à le faire correctement La bonne façon d\u0026rsquo;instrumenter un service PHP pour Prometheus : ajouter une bibliothèque client, écrire des compteurs et histogrammes autour de ce qui importe, exposer une route /metrics, mettre à jour la config de scrape. Pour un seul service, c\u0026rsquo;est un après-midi raisonnable. Pour quatorze services en pleine migration, c\u0026rsquo;est un projet de plusieurs sprints qui entre en compétition avec tout le reste qui doit bouger.\nLe calcul est gênant. On a besoin de métriques pour avoir confiance que la migration se passe bien. Mais ajouter des métriques à tout avant la migration signifie que la migration prend plus longtemps. Et plus elle prend longtemps, plus on a besoin de métriques pour savoir où on en est.\nIl fallait bien que quelque chose cède.\nCe que FrankenPHP embarque sans l\u0026rsquo;annoncer FrankenPHP n\u0026rsquo;est pas un runtime PHP qui utilise Caddy comme serveur web. La relation est inversée : Caddy est le serveur, et PHP est un module Caddy. Chaque requête HTTP passe par Caddy avant d\u0026rsquo;atteindre le code applicatif.\nCaddy embarque un endpoint compatible Prometheus intégré. Pas de plugin, pas de binaire supplémentaire. Activer l\u0026rsquo;admin API et il est là.\nCADDY_GLOBAL_OPTIONS est une variable d\u0026rsquo;environnement FrankenPHP qui injecte des directives directement dans le bloc de configuration global de Caddy. Deux lignes suffisent :\nenvironment: CADDY_GLOBAL_OPTIONS: | admin 0.0.0.0:2019 metrics admin 0.0.0.0:2019 lie l\u0026rsquo;admin API à toutes les interfaces réseau — le défaut est localhost uniquement, inaccessible depuis un conteneur Prometheus sur le même réseau. metrics active l\u0026rsquo;endpoint.\nAprès ça, chaque conteneur répond à GET :2019/metrics avec un payload Prometheus complet. Comptes de requêtes labellisés par code de statut, histogrammes de latence, connexions actives. Aucune route ajoutée à l\u0026rsquo;application. Aucun composer require. Aucun changement au Dockerfile.\nUne variable d\u0026rsquo;environnement, ajoutée à chaque définition de service dans un seul commit. Quatorze cibles de scrape, toutes produisant des données.\nUne image utilisable dans Grafana La config de scrape Prometheus liste chaque service par son nom de conteneur :\nscrape_configs: - job_name: caddy metrics_path: /metrics static_configs: - targets: - authentication:2019 - content:2019 - media:2019 # les 14 services Grafana se pose au-dessus de Prometheus. Le dashboard communautaire Caddy donne les taux de requêtes, les taux d\u0026rsquo;erreur et les percentiles de latence par service, par endpoint, par code de statut. En moins d\u0026rsquo;une journée après que la migration a atterri dans le nouvel environnement, il y avait quelque chose de significatif à regarder.\nLa couche données suit la même logique : des exporters pour PostgreSQL, Redis et RabbitMQ scrappent au niveau infrastructure sans toucher le code applicatif. Des dashboards communautaires existent pour tous.\nCe que cette baseline couvre réellement Les métriques HTTP de Caddy sont des métriques de serveur web, pas des métriques applicatives. Elles répondent à : est-ce que ce service reçoit du trafic, est-ce qu\u0026rsquo;il retourne des erreurs, à quelle vitesse répond-il. Le genre de questions qu\u0026rsquo;on pose quand quelque chose est cassé et qu\u0026rsquo;on doit trier dans le noir.\nElles ne répondent pas à : combien d\u0026rsquo;éléments ont été traités aujourd\u0026rsquo;hui, quel job en arrière-plan est bloqué, quel est l\u0026rsquo;impact business de ce pic de latence. Pour ça, il faut de l\u0026rsquo;instrumentation applicative, et ce travail existe encore quand on a des choses spécifiques à mesurer.\nMais dans un contexte de migration, cette distinction compte moins qu\u0026rsquo;elle n\u0026rsquo;en a l\u0026rsquo;air. Les choses qui cassent pendant une migration cloud sont principalement des problèmes d\u0026rsquo;infrastructure : un service qui ne peut pas atteindre sa base de données, une limite mémoire définie trop bas, un consumer de queue qui a arrêté de traiter les messages. Ce sont exactement les choses que la baseline couvre.\nAvoir l\u0026rsquo;instrumentation parfaite pour les événements au niveau business peut attendre que la plateforme soit stable. Avoir assez de visibilité pour savoir si la migration a réussi ne peut pas.\n","permalink":"https://guillaumedelre.github.io/fr/2025/06/07/observabilit%C3%A9-sur-des-conteneurs-frankenphp-avant-que-la-migration-cloud-soit-finie/","summary":"\u003cp\u003eQuand on fait tourner des workloads on-premise, on peut s\u0026rsquo;en sortir avec presque aucune observabilité. On a SSH. On a \u003ccode\u003etop\u003c/code\u003e. On a quelqu\u0026rsquo;un qui sait que le service d\u0026rsquo;authentification monte toujours le lundi matin. La connaissance institutionnelle se substitue à l\u0026rsquo;instrumentation, et personne ne budgète le temps pour la remplacer.\u003c/p\u003e\n\u003cp\u003ePuis on migre vers le cloud. La connaissance institutionnelle ne suit pas. L\u0026rsquo;accès SSH est parti ou peu pratique. Et pour la première fois, on se retrouve à fixer quatorze conteneurs FrankenPHP sans la moindre idée de ce qu\u0026rsquo;ils font réellement.\u003c/p\u003e","title":"Observabilité sur des conteneurs FrankenPHP avant que la migration cloud soit finie"},{"content":"La configuration semblait parfaite. Pointer *.traefik.me sur 127.0.0.1, télécharger un certificat wildcard depuis le même domaine, le déposer dans Traefik, et chaque service local obtient une URL HTTPS propre sans IP dans la barre d\u0026rsquo;adresse. Pas de limites de débit Let\u0026rsquo;s Encrypt, pas de mkcert à expliquer aux collègues, pas d\u0026rsquo;avertissements de certificat auto-signé à contourner. Juste https://myapp.traefik.me et un cadenas vert.\nPuis en mars 2025, Let\u0026rsquo;s Encrypt a révoqué le certificat. Le wildcard cert pour traefik.me est parti et il ne reviendra pas.\nCe que traefik.me vendait vraiment traefik.me est un résolveur DNS wildcard. Tapez anything.traefik.me et ça résout vers 127.0.0.1. Tapez anything.10.0.0.1.traefik.me et ça résout vers 10.0.0.1. Aucun compte, aucune configuration, aucune infrastructure à maintenir. La partie DNS fonctionne toujours bien, soit dit en passant.\nLe certificat était le bonus: un wildcard cert pour *.traefik.me que pyrou, le mainteneur, avait généré avec Let\u0026rsquo;s Encrypt et distribué sur https://traefik.me/cert.pem et https://traefik.me/privkey.pem. C\u0026rsquo;était pratique précisément parce que c\u0026rsquo;était partagé: télécharger, déposer dans Traefik, terminé.\nPartager une clé privée, c\u0026rsquo;est ce qui l\u0026rsquo;a tué.\nLes Baseline Requirements du CA/Browser Forum, section 9.6.3, exigent que les souscripteurs \u0026ldquo;maintiennent le contrôle exclusif\u0026rdquo; de leur clé privée. La distribuer à quiconque visite une URL, c\u0026rsquo;est exactement le contraire du contrôle exclusif. Let\u0026rsquo;s Encrypt a envoyé une notification, bloqué toute future émission pour le domaine, et révoqué le certificat existant. Pyrou a confirmé la situation et recommandé mkcert comme alternative. Le projet survivra uniquement en tant que résolveur DNS.\nLe cert avait déjà été révoqué deux fois avant 2025. La troisième était la dernière.\nsslip.io fait la même chose, différemment sslip.io est aussi un résolveur DNS wildcard, avec une différence: l\u0026rsquo;IP est encodée dans le hostname plutôt que résolue depuis un fallback. 10-0-0-1.sslip.io résout vers 10.0.0.1. myapp.192-168-1-10.sslip.io résout vers 192.168.1.10. IPv6 fonctionne aussi.\nL\u0026rsquo;infrastructure derrière sslip.io est aussi plus visible: trois serveurs de noms à Singapour, aux États-Unis et en Pologne, traitant plus de 10 000 requêtes par seconde, avec un monitoring public. Environ 1 000 étoiles GitHub et une maintenance active sous licence Apache 2.0.\nEn mettant de côté l\u0026rsquo;histoire des certificats, la comparaison est assez directe:\ntraefik.me sslip.io DNS wildcard oui oui Fallback vers 127.0.0.1 oui non IPv6 non oui Certificat wildcard oui révoqué non Infrastructure opaque documentée Activité du projet au point mort active Le seul avantage restant de traefik.me est le fallback vers 127.0.0.1: des URLs sans segment IP. Ça compte si on tient vraiment à myapp.traefik.me plutôt que myapp.127-0-0-1.sslip.io. La question est de savoir si cette différence vaut l\u0026rsquo;incertitude sur l\u0026rsquo;infrastructure.\nmkcert comble le vide mkcert crée une autorité de certification locale, l\u0026rsquo;installe dans le trust store système et dans les navigateurs qu\u0026rsquo;il trouve, puis émet des certificats signés par cette CA. Les navigateurs voient une chaîne de confiance valide. Aucun avertissement, aucun clic, aucun \u0026ldquo;continuer quand même\u0026rdquo;.\nmkcert -install C\u0026rsquo;est la configuration unique. Ensuite, générer un certificat se fait en une commande:\nmkcert \u0026#34;*.127-0-0-1.sslip.io\u0026#34; # produit _wildcard.127-0-0-1.sslip.io.pem # _wildcard.127-0-0-1.sslip.io-key.pem La limitation: la CA de mkcert est locale. Les autres machines du réseau ne lui feront pas confiance par défaut. Pour un setup solo, c\u0026rsquo;est très bien. Pour un environnement d\u0026rsquo;équipe partagé, il faudrait distribuer la CA root, ce qui est essentiellement le même problème opérationnel que traefik.me tentait d\u0026rsquo;éviter, juste à plus petite échelle.\nLa configuration Traefik Le setup est le même quelle que soit la solution DNS choisie. Traefik a besoin du certificat monté en volume et d\u0026rsquo;un file provider statique pointant vers un fichier de configuration TLS.\n# traefik/config/tls.yml tls: certificates: - certFile: /certs/cert.pem keyFile: /certs/key.pem stores: default: defaultCertificate: certFile: /certs/cert.pem keyFile: /certs/key.pem La bonne pratique: faire tourner Traefik dans son propre projet Compose, séparé des services qu\u0026rsquo;il route. Chaque projet de service se connecte à Traefik via un réseau externe partagé. On démarre et arrête les services indépendamment sans toucher au reverse proxy.\nOn commence par créer le réseau externe une seule fois:\ndocker network create traefik-public traefik/compose.yml - Traefik seul, propriétaire du réseau:\nservices: traefik: image: traefik:v3 ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; volumes: - /var/run/docker.sock:/var/run/docker.sock - ./config:/etc/traefik/config - ./certs:/certs command: - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --providers.docker=true - --providers.docker.network=traefik-public - --providers.file.directory=/etc/traefik/config networks: - traefik-public networks: traefik-public: external: true On copie la sortie de mkcert dans ./certs/, on renomme en cert.pem et key.pem, puis:\ndocker compose -f traefik/compose.yml up -d Traefik est lancé, il écoute sur le port 80 et 443, et surveille Docker pour les nouveaux containers. Aucune route n\u0026rsquo;est encore configurée.\nwhoami/compose.yml - un service qui rejoint le même réseau:\nservices: whoami: image: traefik/whoami labels: - \u0026#34;traefik.enable=true\u0026#34; - \u0026#34;traefik.http.routers.whoami.rule=Host(`whoami.127-0-0-1.sslip.io`)\u0026#34; - \u0026#34;traefik.http.routers.whoami.tls=true\u0026#34; - \u0026#34;traefik.http.routers.whoami.entrypoints=websecure\u0026#34; networks: - traefik-public networks: traefik-public: external: true docker compose -f whoami/compose.yml up -d Traefik détecte le nouveau container via le Docker provider, lit ses labels, et ajoute la route. https://whoami.127-0-0-1.sslip.io répond immédiatement. Arrêter whoami et la route disparaît. Traefik continue de tourner sans s\u0026rsquo;en apercevoir.\nLa déclaration external: true est la ligne qui porte tout le poids. Sans elle, Compose crée un réseau limité au périmètre du projet: Traefik et whoami se retrouvent sur des réseaux différents et ne peuvent pas communiquer, même s\u0026rsquo;ils tournent tous les deux. Le réseau externe est le bus partagé auquel chaque projet de service doit explicitement adhérer.\nSi on préfère les URLs traefik.me, il suffit de remplacer la commande mkcert et le label de host:\nmkcert \u0026#34;*.traefik.me\u0026#34; - \u0026#34;traefik.http.routers.whoami.rule=Host(`whoami.traefik.me`)\u0026#34; Le fallback DNS vers 127.0.0.1 gère le reste.\nCe que l\u0026rsquo;histoire traefik.me enseigne vraiment Le modèle de distribution de certificats a toujours été fragile. Une \u0026ldquo;paire clé publique-clé privée\u0026rdquo; est une contradiction dans les termes. Chaque révocation était un avertissement que la suivante pourrait être définitive. Finalement, ça l\u0026rsquo;a été.\nLa leçon ne se limite pas à traefik.me. Tout service qui apporte de la commodité en supprimant discrètement une frontière de sécurité finira par se heurter à cette frontière. mkcert est le bon outil pour ce problème parce qu\u0026rsquo;il opère entièrement dans votre propre domaine de confiance: on génère la CA, on l\u0026rsquo;installe, on émet les certificats. Rien ne dépend de la volonté continue d\u0026rsquo;un tiers de contourner les règles d\u0026rsquo;émission de certificats.\nsslip.io résout proprement la partie DNS. mkcert résout proprement la partie TLS. Ils se combinent bien. Le setup traefik.me était plus simple, pendant un temps. Jusqu\u0026rsquo;à ce que ce ne soit plus le cas.\n","permalink":"https://guillaumedelre.github.io/fr/2025/04/17/https-local-avec-traefik-traefik.me-est-mort-vive-sslip.io/","summary":"\u003cp\u003eLa configuration semblait parfaite. Pointer \u003ccode\u003e*.traefik.me\u003c/code\u003e sur 127.0.0.1, télécharger un certificat wildcard depuis le même domaine, le déposer dans Traefik, et chaque service local obtient une URL HTTPS propre sans IP dans la barre d\u0026rsquo;adresse. Pas de limites de débit Let\u0026rsquo;s Encrypt, pas de \u003ccode\u003emkcert\u003c/code\u003e à expliquer aux collègues, pas d\u0026rsquo;avertissements de certificat auto-signé à contourner. Juste \u003ccode\u003ehttps://myapp.traefik.me\u003c/code\u003e et un cadenas vert.\u003c/p\u003e\n\u003cp\u003ePuis en mars 2025, Let\u0026rsquo;s Encrypt a révoqué le certificat. Le wildcard cert pour traefik.me est parti et il ne reviendra pas.\u003c/p\u003e","title":"HTTPS local avec Traefik: traefik.me est mort, vive sslip.io"},{"content":"API Platform 4.1 est arrivé en février 2025 avec un lot de fonctionnalités moins axées sur de nouvelles capacités que sur la mise en production des existantes. La validation stricte des paramètres de requête gagne une propriété de première classe. OpenAPI gagne un mécanisme pour découper les grandes APIs en specs séparées. GraphQL obtient les contrôles de prévention des abus qui lui manquaient.\nValidation stricte des paramètres de requête La 3.3 avait introduit la validation des paramètres de requête en opt-in. La 3.4 avait déprécié le comportement permissif. La 4.1 la formalise avec une propriété native strictQueryParameterValidation sur les ressources et opérations : quand elle est à true, les paramètres de requête inconnus renvoient 400.\nuse ApiPlatform\\Metadata\\GetCollection; use ApiPlatform\\Metadata\\QueryParameter; #[GetCollection( strictQueryParameterValidation: true, parameters: [ new QueryParameter(key: \u0026#39;utm_source\u0026#39;, required: false, schema: [\u0026#39;type\u0026#39; =\u0026gt; \u0026#39;string\u0026#39;]), new QueryParameter(key: \u0026#39;feature_flag\u0026#39;, required: false, schema: [\u0026#39;type\u0026#39; =\u0026gt; \u0026#39;string\u0026#39;]), ] )] class Book {} Les paramètres déclarés passent ; les paramètres non déclarés sont rejetés. Pour désactiver la validation stricte sur une opération spécifique quand elle est activée au niveau de la ressource, passer strictQueryParameterValidation: false sur cette opération.\nx-apiplatform-tag pour OpenAPI multi-spec Les grandes APIs ont souvent besoin de plusieurs specs OpenAPI : une par équipe, une par version d\u0026rsquo;API, une interne et une publique. Avant la 4.1, la spec générée était un seul document, et la diviser nécessitait un post-traitement ou des instances API Platform séparées.\nLa 4.1 ajoute une extension vendor x-apiplatform-tag (sans s final). On tague les opérations avec des noms de groupes logiques via les extensionProperties d\u0026rsquo;un objet Operation OpenAPI, puis on demande la spec filtrée sur un ou plusieurs groupes :\nuse ApiPlatform\\Metadata\\GetCollection; use ApiPlatform\\OpenApi\\Factory\\OpenApiFactory; use ApiPlatform\\OpenApi\\Model\\Operation; #[GetCollection( openapi: new Operation( extensionProperties: [OpenApiFactory::API_PLATFORM_TAG =\u0026gt; [\u0026#39;public\u0026#39;, \u0026#39;v2\u0026#39;]] ) )] class Book {} Demander /api/docs.json?filter_tags[]=public retourne uniquement les opérations taguées public. La spec complète reste disponible sans filtre. Les groupes n\u0026rsquo;affectent pas le comportement réel de l\u0026rsquo;API — c\u0026rsquo;est uniquement une préoccupation de couche documentation.\nCela rend faisable de maintenir une configuration API Platform unique tout en servant différentes vues de spec à différents consommateurs : un Swagger UI public, un portail partenaire, et un outil interne qui expose les endpoints d\u0026rsquo;administration.\nAuthentification HTTP dans Swagger UI Avant la 4.1, le Swagger UI fourni avec API Platform supportait l\u0026rsquo;authentification par token Bearer via son dialogue \u0026ldquo;Authorize\u0026rdquo;. L\u0026rsquo;authentification par clé API et HTTP Basic n\u0026rsquo;étaient pas câblées.\nLa 4.1 ajoute le support de plusieurs schémas de sécurité dans le document OpenAPI généré. Les schémas de sécurité sont ajoutés en décorant l\u0026rsquo;OpenApiFactory et en modifiant l\u0026rsquo;objet components.securitySchemes de la spec. Chaque schéma déclaré apparaît ensuite dans le dialogue \u0026ldquo;Authorize\u0026rdquo; de Swagger UI et est appliqué aux requêtes faites depuis l\u0026rsquo;UI. C\u0026rsquo;est une amélioration de la documentation et de l\u0026rsquo;expérience développeur — la logique d\u0026rsquo;authentification réelle dans votre application n\u0026rsquo;est pas affectée.\nLimites de profondeur et complexité des requêtes GraphQL La structure de requête récursive de GraphQL rend triviale la construction d\u0026rsquo;une requête petite en octets mais énorme en coût d\u0026rsquo;exécution. Sans limites, une requête imbriquée sur quatre niveaux à travers une relation many-to-many peut frapper la base de données des centaines de fois.\nLa 4.1 ajoute des limites configurables de profondeur et complexité :\napi_platform: graphql: max_query_depth: 10 max_query_complexity: 100 max_query_depth est le niveau d\u0026rsquo;imbrication maximum. max_query_complexity assigne un coût à chaque champ et rejette les requêtes dont le coût total dépasse le seuil. Les requêtes qui dépassent l\u0026rsquo;une ou l\u0026rsquo;autre limite sont rejetées avant exécution avec une réponse 400.\nIl n\u0026rsquo;y a pas de valeur universellement correcte pour ces limites — elles dépendent de la forme de votre schéma et des patterns de requête attendus. Les valeurs par défaut sont intentionnellement permissives pour éviter de casser des APIs existantes lors de la mise à jour. Les resserrer est un choix de configuration délibéré.\nFormats de sortie au niveau opération La 4.0 et les versions antérieures configuraient les types de contenu acceptés et retournés au niveau de l\u0026rsquo;API. La 4.1 permet de les restreindre par opération :\nuse ApiPlatform\\Metadata\\GetCollection; #[GetCollection( outputFormats: [\u0026#39;jsonld\u0026#39; =\u0026gt; [\u0026#39;application/ld+json\u0026#39;]], inputFormats: [\u0026#39;json\u0026#39; =\u0026gt; [\u0026#39;application/json\u0026#39;]], )] class Book {} Les opérations qui ne spécifient pas de formats héritent de la configuration au niveau de l\u0026rsquo;API. C\u0026rsquo;est utile pour les endpoints qui doivent retourner un format spécifique (une export CSV, un flux binaire) sans changer les défauts pour le reste de l\u0026rsquo;API.\n","permalink":"https://guillaumedelre.github.io/fr/2025/02/28/api-platform-4.1-param%C3%A8tres-de-requ%C3%AAte-stricts-openapi-multi-spec-et-limites-graphql/","summary":"\u003cp\u003eAPI Platform 4.1 est arrivé en février 2025 avec un lot de fonctionnalités moins axées sur de nouvelles capacités que sur la mise en production des existantes. La validation stricte des paramètres de requête gagne une propriété de première classe. OpenAPI gagne un mécanisme pour découper les grandes APIs en specs séparées. GraphQL obtient les contrôles de prévention des abus qui lui manquaient.\u003c/p\u003e\n\u003ch2 id=\"validation-stricte-des-paramètres-de-requête\"\u003eValidation stricte des paramètres de requête\u003c/h2\u003e\n\u003cp\u003eLa 3.3 avait introduit la validation des paramètres de requête en opt-in. La 3.4 avait déprécié le comportement permissif. La 4.1 la formalise avec une propriété native \u003ccode\u003estrictQueryParameterValidation\u003c/code\u003e sur les ressources et opérations : quand elle est à \u003ccode\u003etrue\u003c/code\u003e, les paramètres de requête inconnus renvoient 400.\u003c/p\u003e","title":"API Platform 4.1 : paramètres de requête stricts, OpenAPI multi-spec, et limites GraphQL"},{"content":"Le champ de recherche de la médiathèque renvoyait des résultats en 800 millisecondes en staging. En production, il y avait quarante fois plus de lignes. Le plan d\u0026rsquo;exécution révélait un sequential scan: aucun index sollicité, aucune façon d\u0026rsquo;y remédier avec un B-tree classique. L\u0026rsquo;équipe produit voulait aussi une recherche multi-mots: taper \u0026ldquo;interview président\u0026rdquo;, obtenir des résultats contenant les deux termes. Une requête LIKE avec des wildcards n\u0026rsquo;a pas de manière propre d\u0026rsquo;exprimer ça sans conditions indépendantes multiples, chacune nécessitant son propre scan.\nPostgreSQL embarque une recherche full-text depuis plus de quinze ans. La plateforme tournait déjà sous PostgreSQL. Le hic: le projet utilise Doctrine ORM, et Doctrine ne sait pas nativement ce qu\u0026rsquo;est un tsvector.\nUne bibliothèque communautaire, postgresql-for-doctrine, couvre une partie de cette lacune. Elle enregistre des fonctions DQL basiques comme TO_TSQUERY, TO_TSVECTOR, et l\u0026rsquo;opérateur de correspondance @@ en tant que pièces atomiques séparées. La fondation était là. Trois choses restaient à construire par-dessus.\nLe type que Doctrine n\u0026rsquo;a jamais vu La recherche full-text de PostgreSQL repose sur deux types: tsvector (une liste pré-traitée de tokens normalisés) et tsquery (une expression de recherche). On maintient une colonne tsvector, on l\u0026rsquo;indexe avec GIN, et on interroge avec l\u0026rsquo;opérateur @@.\nLe DBAL de Doctrine ne livre aucun type tsvector. Déclarer #[ORM\\Column(type: 'tsvector')] sans l\u0026rsquo;enregistrer au préalable lève une UnknownColumnTypeException. La solution: un type DBAL personnalisé:\nclass TsVector extends Type { final public const string DBAL_TYPE = \u0026#39;tsvector\u0026#39;; public function getSQLDeclaration(array $column, AbstractPlatform $platform): string { return self::DBAL_TYPE; } public function getName(): string { return self::DBAL_TYPE; } public function convertToDatabaseValueSQL(string $sqlExpr, AbstractPlatform $platform): string { return sprintf(\u0026#34;to_tsvector(\u0026#39;simple\u0026#39;, %s)\u0026#34;, $sqlExpr); } public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): mixed { if (is_array($value) \u0026amp;\u0026amp; isset($value[\u0026#39;data\u0026#39;])) { return $value[\u0026#39;data\u0026#39;]; } return is_string($value) ? $value : null; } public function getMappedDatabaseTypes(AbstractPlatform $platform): array { return [self::DBAL_TYPE]; } } La méthode intéressante est convertToDatabaseValueSQL(). Doctrine l\u0026rsquo;appelle pour envelopper le placeholder SQL avant que la valeur n\u0026rsquo;atteigne la base de données. La valeur écrite devient automatiquement to_tsvector('simple', ?) à la frontière DBAL, sans étape supplémentaire côté appelant.\nOn enregistre le type dans doctrine.yaml, puis on mappe la colonne sur l\u0026rsquo;entité:\ndoctrine: dbal: types: tsvector: App\\Doctrine\\DBAL\\Types\\TsVector #[ORM\\Column(type: \u0026#39;tsvector\u0026#39;, nullable: true)] protected ?string $textSearch = null; Côté PHP, la valeur est une simple chaîne. La conversion en vrai tsvector se fait invisiblement au niveau DBAL.\nNous avons utilisé le dictionnaire 'simple', qui tokenise sur les espaces et la ponctuation sans stemming spécifique à une langue. La plateforme gère plusieurs langues, et les règles de stemming français auraient cassé l\u0026rsquo;espagnol. Simple suffit largement pour la phonétique.\nGarder la colonne à jour Une colonne tsvector est une donnée dérivée: elle doit rester synchronisée avec les champs source chaque fois que l\u0026rsquo;entité change. Un event listener Doctrine s\u0026rsquo;en charge:\n#[AsDoctrineListener(event: Events::prePersist)] #[AsDoctrineListener(event: Events::preUpdate)] class MediaTsVectorSubscriber { public function prePersist(PrePersistEventArgs $event): void { if (!$event-\u0026gt;getObject() instanceof Media) { return; } $this-\u0026gt;updateTextSearch($event-\u0026gt;getObject()); } public function preUpdate(PreUpdateEventArgs $event): void { if (!$event-\u0026gt;getObject() instanceof Media) { return; } $this-\u0026gt;updateTextSearch($event-\u0026gt;getObject()); } private function updateTextSearch(Media $entity): void { $entity-\u0026gt;setTextSearch( sprintf(\u0026#39;%s %s\u0026#39;, $entity-\u0026gt;getTitle(), $entity-\u0026gt;getCaption()) ); } } Avant chaque persist et update, le subscriber concatène les champs qui doivent être recherchables dans textSearch. Doctrine flush la chaîne combinée, le type DBAL l\u0026rsquo;enveloppe dans to_tsvector('simple', ...), et PostgreSQL stocke la forme tokenisée.\nUne subtilité: la valeur côté PHP est \u0026quot;title caption\u0026quot;, pas la sortie tsvector réelle. La base affiche 'caption' 'title' (tokens triés), mais l\u0026rsquo;entité contient une chaîne brute. C\u0026rsquo;est attendu: la conversion est une responsabilité DBAL, pas PHP. Ça peut dérouter le débogage jusqu\u0026rsquo;à ce qu\u0026rsquo;on se souvienne où se situe la frontière.\nÉtendre DQL avec les opérateurs FTS Le DQL de Doctrine couvre les opérations SQL courantes, mais tout ce qui est spécifique à PostgreSQL est hors périmètre. C\u0026rsquo;est là que postgresql-for-doctrine entre en jeu: il enregistre TO_TSQUERY, TO_TSVECTOR, et TSMATCH comme fonctions DQL individuelles. Écrire une requête full-text en DQL sans lui signifierait basculer en SQL natif.\nLes fonctions de la bibliothèque sont atomiques, cependant. Chacune correspond à un appel SQL. Exprimer une vérification de correspondance complète en DQL ressemble à TSMATCH(o.textSearch, TO_TSQUERY(:term)). Assez lisible, mais l\u0026rsquo;équipe voulait quelque chose de plus compact: une seule fonction DQL encodant à la fois l\u0026rsquo;opérateur de correspondance et le type de requête, y compris websearch_to_tsquery que postgresql-for-doctrine ne fournissait pas.\nLa solution: des fonctions DQL personnalisées via FunctionNode. On parse la syntaxe DQL, puis on émet du SQL. Toutes les fonctions FTS partagent la même signature à deux arguments, donc une classe abstraite de base gère le parsing:\nabstract class TsFunction extends FunctionNode { public PathExpression|Node|null $ftsField = null; public PathExpression|Node|null $queryString = null; public function parse(Parser $parser): void { $parser-\u0026gt;match(TokenType::T_IDENTIFIER); $parser-\u0026gt;match(TokenType::T_OPEN_PARENTHESIS); $this-\u0026gt;ftsField = $parser-\u0026gt;StringPrimary(); $parser-\u0026gt;match(TokenType::T_COMMA); $this-\u0026gt;queryString = $parser-\u0026gt;StringPrimary(); $parser-\u0026gt;match(TokenType::T_CLOSE_PARENTHESIS); } } Chaque classe concrète implémente getSql() pour émettre son expression PostgreSQL:\n// e.textSearch @@ websearch_to_tsquery(\u0026#39;simple\u0026#39;, :term) class TsWebsearchQueryFunction extends TsFunction { public function getSql(SqlWalker $sqlWalker): string { return $this-\u0026gt;ftsField-\u0026gt;dispatch($sqlWalker) .\u0026#34; @@ websearch_to_tsquery(\u0026#39;simple\u0026#39;, \u0026#34; .$this-\u0026gt;queryString-\u0026gt;dispatch($sqlWalker).\u0026#39;)\u0026#39;; } } // ts_rank(e.textSearch, to_tsquery(:term)) pour le tri par pertinence class TsRankFunction extends TsFunction { public function getSql(SqlWalker $sqlWalker): string { return \u0026#39;ts_rank(\u0026#39; .$this-\u0026gt;ftsField-\u0026gt;dispatch($sqlWalker) .\u0026#39;, to_tsquery(\u0026#39;.$this-\u0026gt;queryString-\u0026gt;dispatch($sqlWalker).\u0026#39;))\u0026#39;; } } doctrine: orm: entity_managers: default: dql: string_functions: tswebsearchquery: App\\Doctrine\\ORM\\Query\\AST\\Functions\\TsWebsearchQueryFunction tsrank: App\\Doctrine\\ORM\\Query\\AST\\Functions\\TsRankFunction tsquery: App\\Doctrine\\ORM\\Query\\AST\\Functions\\TsQueryFunction tsplainquery: App\\Doctrine\\ORM\\Query\\AST\\Functions\\TsPlainQueryFunction websearch_to_tsquery est le bon choix pour la recherche côté utilisateur: les espaces deviennent des AND, les chaînes entre guillemets deviennent des phrases, -mot exclut un terme. Inutile d\u0026rsquo;apprendre aux utilisateurs à taper interview \u0026amp; président. C\u0026rsquo;est disponible depuis PostgreSQL 11. Sur les versions antérieures, plainto_tsquery est l\u0026rsquo;équivalent le plus proche.\nLe filtre API Platform et l\u0026rsquo;index GIN Avec les fonctions DQL enregistrées, le filtre API Platform est simple. Un AbstractFilter personnalisé appelle directement la fonction DQL dans le QueryBuilder:\nclass TextSearchFilter extends AbstractFilter { protected function filterProperty( string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = [] ): void { if (\u0026#39;textSearch\u0026#39; !== $property || empty($value)) { return; } $queryBuilder -\u0026gt;andWhere(\u0026#39;tswebsearchquery(o.textSearch, :value) = true\u0026#39;) -\u0026gt;setParameter(\u0026#39;:value\u0026#39;, $value); } public function getDescription(string $resourceClass): array { return []; } } On l\u0026rsquo;applique sur l\u0026rsquo;entité avec la déclaration d\u0026rsquo;index:\n#[ORM\\Index( columns: [\u0026#39;text_search\u0026#39;], name: \u0026#39;media_text_search_idx_gin\u0026#39;, options: [\u0026#39;USING\u0026#39; =\u0026gt; \u0026#39;gin (text_search)\u0026#39;] )] #[ApiFilter(TextSearchFilter::class, properties: [\u0026#39;textSearch\u0026#39; =\u0026gt; \u0026#39;partial\u0026#39;])] class Media { // ... #[ORM\\Column(type: \u0026#39;tsvector\u0026#39;, nullable: true)] protected ?string $textSearch = null; } L\u0026rsquo;option USING gin n\u0026rsquo;est pas négociable. Un index B-tree standard sur une colonne tsvector est inutile: PostgreSQL ne peut pas l\u0026rsquo;utiliser pour les requêtes @@. GIN (Generalized Inverted Index) fonctionne différemment: il indexe chaque token individuellement, donc les recherches par n\u0026rsquo;importe quel token sont en O(log n) plutôt que O(n). Sans ça, on a construit un système qui donne l\u0026rsquo;impression d\u0026rsquo;être rapide mais qui fait quand même un full table scan.\nUn GET /media?textSearch=interview+president touche maintenant l\u0026rsquo;index GIN et répond en quelques millisecondes quel que soit la taille de la table.\nCe que la répartition ressemblait vraiment La bibliothèque couvrait les fonctions atomiques bas niveau. Le code personnalisé couvrait les lacunes: un type DBAL tsvector que la bibliothèque ne fournissait pas, des wrappers DQL pratiques combinant @@ et websearch_to_tsquery en un seul appel, et la colle applicative reliant tout ça au système d\u0026rsquo;événements de Doctrine et à API Platform. Aucune requête native n\u0026rsquo;a été nécessaire.\nLa répartition vaut d\u0026rsquo;être notée en général: postgresql-for-doctrine donne les briques atomiques PostgreSQL, mais il faut quand même les composer en quelque chose que le reste du code peut utiliser sans y penser. Le pattern FunctionNode et le hook convertToDatabaseValueSQL() sont les deux points d\u0026rsquo;extension qui rendent cette composition propre. Les deux valent d\u0026rsquo;être connus, quelle que soit la bibliothèque de départ.\n","permalink":"https://guillaumedelre.github.io/fr/2025/02/10/la-recherche-full-text-postgresql-avec-doctrine-sans-une-ligne-de-sql-brut/","summary":"\u003cp\u003eLe champ de recherche de la médiathèque renvoyait des résultats en 800 millisecondes en staging. En production, il y avait quarante fois plus de lignes. Le plan d\u0026rsquo;exécution révélait un sequential scan: aucun index sollicité, aucune façon d\u0026rsquo;y remédier avec un B-tree classique. L\u0026rsquo;équipe produit voulait aussi une recherche multi-mots: taper \u0026ldquo;interview président\u0026rdquo;, obtenir des résultats contenant les deux termes. Une requête \u003ccode\u003eLIKE\u003c/code\u003e avec des wildcards n\u0026rsquo;a pas de manière propre d\u0026rsquo;exprimer ça sans conditions indépendantes multiples, chacune nécessitant son propre scan.\u003c/p\u003e","title":"La recherche full-text PostgreSQL avec Doctrine, sans une ligne de SQL brut"},{"content":"PHP 8.4 est sorti le 21 novembre. Les property hooks sont la fonctionnalité. Tout le reste, et il y en a beaucoup, est secondaire.\nLes property hooks Pendant vingt ans, si on voulait du comportement à l\u0026rsquo;accès d\u0026rsquo;une propriété en PHP, il fallait écrire des getters et setters :\nclass User { private string $_name; public function getName(): string { return $this-\u0026gt;_name; } public function setName(string $name): void { $this-\u0026gt;_name = strtoupper($name); } } PHP 8.4 ajoute des hooks directement sur la propriété :\nclass User { public string $name { set(string $name) { $this-\u0026gt;name = strtoupper($name); } } } On peut définir les hooks get et set indépendamment. Une propriété avec seulement un hook get est calculée à l\u0026rsquo;accès :\nclass Circle { public float $area { get =\u0026gt; M_PI * $this-\u0026gt;radius ** 2; } public function __construct(public float $radius) {} } Pas de stockage backing, pas de méthode getter explicite, support IDE complet. Les interfaces peuvent aussi déclarer des propriétés avec des hooks, ce qui signifie que les contrats peuvent maintenant spécifier un comportement à l\u0026rsquo;accès aux propriétés, quelque chose qui était tout simplement impossible avant.\nLa visibilité asymétrique Une option plus légère pour quand on veut juste une lecture publique et une écriture privée :\nclass Version { public private(set) string $value = \u0026#39;1.0.0\u0026#39;; } $v = new Version(); echo $v-\u0026gt;value; // fonctionne $v-\u0026gt;value = \u0026#39;2.0\u0026#39;; // Error Élimine le pattern private $x + public getX() pour les propriétés publiques en lecture seule sans avoir besoin de la sémantique readonly complète.\narray_find() et amis $first = array_find($users, fn($u) =\u0026gt; $u-\u0026gt;isActive()); $any = array_any($users, fn($u) =\u0026gt; $u-\u0026gt;isPremium()); $all = array_all($users, fn($u) =\u0026gt; $u-\u0026gt;isVerified()); Ces fonctions existent dans la bibliothèque standard de chaque autre langage depuis des décennies. En PHP, il fallait utiliser array_filter() + accès par index ou écrire une boucle manuelle. Elles existent maintenant : array_find(), array_find_key(), array_any(), array_all().\nInstanciation sans parenthèses supplémentaires // avant (new MyClass())-\u0026gt;method(); // après new MyClass()-\u0026gt;method(); Une restriction syntaxique qui était toujours agaçante et jamais justifiée est supprimée.\nLes objets paresseux Des objets dont l\u0026rsquo;initialisation est différée jusqu\u0026rsquo;au premier accès à une propriété :\n$user = $reflector-\u0026gt;newLazyProxy(fn() =\u0026gt; $repository-\u0026gt;find($id)); // Pas d\u0026#39;appel en base encore $user-\u0026gt;name; // Maintenant le proxy s\u0026#39;initialise Le public direct est les auteurs de frameworks ORM et de conteneurs DI, pas les développeurs d\u0026rsquo;applications. Mais l\u0026rsquo;effet se fait sentir dans chaque application qui utilise Doctrine ou Symfony : le lazy loading implémenté au niveau du langage plutôt qu\u0026rsquo;à travers la génération de code.\nPHP 8.4 est un langage qui ressemble à peine au PHP 5 avec lequel la plupart d\u0026rsquo;entre nous avons commencé. Les property hooks en particulier : ce ne sont pas des contournements, ce sont une fonctionnalité de conception.\n#[\\Deprecated] pour son propre code PHP émet des notices de dépréciation pour les fonctions intégrées depuis des années. 8.4 permet de câbler le même mécanisme dans son propre code :\nclass ApiClient { #[\\Deprecated( message: \u0026#39;Use fetchJson() instead\u0026#39;, since: \u0026#39;2.0\u0026#39;, )] public function get(string $url): string { ... } } Appeler une méthode dépréciée émet maintenant E_USER_DEPRECATED, exactement comme appeler mysql_connect(). Les IDEs le détectent, les analyseurs statiques le signalent, le log d\u0026rsquo;erreurs le capture. Avant ça, la seule option était un commentaire PHPDoc @deprecated : bien pour les IDEs, complètement invisible pour le moteur.\nBcMath\\Number rend la précision arbitraire utilisable Les fonctions bcmath existent en PHP depuis toujours, mais leur API procédurale rend tout chaînage pénible. 8.4 ajoute BcMath\\Number, un wrapper objet avec surcharge d\u0026rsquo;opérateurs :\n$a = new BcMath\\Number(\u0026#39;10.5\u0026#39;); $b = new BcMath\\Number(\u0026#39;3.2\u0026#39;); $result = $a + $b; // BcMath\\Number(\u0026#39;13.7\u0026#39;) $result = $a * $b - new BcMath\\Number(\u0026#39;1\u0026#39;); echo $result; // 32.6 Les opérateurs +, -, *, /, **, % fonctionnent tous. L\u0026rsquo;objet est immutable. L\u0026rsquo;échelle se propage automatiquement à travers les opérations. Les calculs financiers, qui nécessitaient des chaînes de bcadd(bcmul(...), ...), se lisent maintenant comme de l\u0026rsquo;arithmétique.\nDe nouvelles fonctions procédurales complètent le tableau : bcceil(), bcfloor(), bcround(), bcdivmod().\nL\u0026rsquo;enum RoundingMode remplace les constantes PHP_ROUND_* round() a toujours pris un $mode entier depuis un ensemble de constantes PHP_ROUND_*. 8.4 les remplace par un enum RoundingMode avec des noms plus propres et quatre modes supplémentaires qui n\u0026rsquo;étaient pas disponibles avant :\nround(2.5, mode: RoundingMode::HalfAwayFromZero); // 3 round(2.5, mode: RoundingMode::HalfTowardsZero); // 2 round(2.5, mode: RoundingMode::HalfEven); // 2 (arrondi du banquier) round(2.5, mode: RoundingMode::HalfOdd); // 3 // Les quatre nouveaux modes (disponibles uniquement via l\u0026#39;enum) round(2.3, mode: RoundingMode::TowardsZero); // 2 round(2.7, mode: RoundingMode::AwayFromZero); // 3 round(2.3, mode: RoundingMode::PositiveInfinity); // 3 round(2.3, mode: RoundingMode::NegativeInfinity); // 2 Les anciennes constantes PHP_ROUND_* fonctionnent encore. L\u0026rsquo;enum est la voie à suivre.\nLes fonctions de string multibyte qui auraient dû exister mb_trim(), mb_ltrim(), mb_rtrim() : des fonctions de trim qui respectent les frontières de caractères multibyte, pas juste les espaces ASCII. Aussi nouvelles : mb_ucfirst() et mb_lcfirst() pour la mise en majuscule correcte des strings multibyte.\n$s = \u0026#34;\\u{200B}hello\\u{200B}\u0026#34;; // Espaces de largeur nulle echo mb_trim($s); // \u0026#34;hello\u0026#34; echo mb_ucfirst(\u0026#39;über\u0026#39;); // \u0026#34;Über\u0026#34; Ces fonctions comblent des lacunes présentes depuis que mbstring a été introduit.\nrequest_parse_body() pour les requêtes non-POST PHP parse automatiquement application/x-www-form-urlencoded et multipart/form-data dans $_POST et $_FILES, mais seulement pour les requêtes POST. Les requêtes PATCH et PUT avec les mêmes types de contenu nécessitaient un parsing manuel avec file_get_contents('php://input') et du code personnalisé.\n// Dans un handler PATCH [$_POST, $_FILES] = request_parse_body(); La fonction retourne un tuple. Même logique de parsing que PHP utilise pour POST, maintenant disponible pour n\u0026rsquo;importe quelle méthode HTTP.\nUne nouvelle API DOM qui suit la spec L\u0026rsquo;API DOMDocument existante était construite sur une spec DOM level 3 plus ancienne avec des spécificités PHP superposées. 8.4 ajoute un namespace Dom\\ parallèle qui implémente le WHATWG Living Standard :\n$doc = Dom\\HTMLDocument::createFromString(\u0026#39;\u0026lt;p class=\u0026#34;lead\u0026#34;\u0026gt;Hello\u0026lt;/p\u0026gt;\u0026#39;); $p = $doc-\u0026gt;querySelector(\u0026#39;p\u0026#39;); echo $p-\u0026gt;classList; // \u0026#34;lead\u0026#34; echo $p-\u0026gt;id; // \u0026#34;\u0026#34; $doc2 = Dom\\HTMLDocument::createFromFile(\u0026#39;page.html\u0026#39;); Dom\\HTMLDocument parse correctement HTML5, tag soup inclus. Dom\\XMLDocument gère le XML strict. Les nouvelles classes sont strictes sur les types, retournent les bons types de nœuds, et exposent des propriétés modernes comme classList, id, className. L\u0026rsquo;ancien DOMDocument reste, inchangé, pour la compatibilité ascendante.\nPDO reçoit des sous-classes spécifiques au driver PDO::connect() et l\u0026rsquo;instanciation directe retournent maintenant des sous-classes spécifiques au driver quand elles sont disponibles :\n$pdo = PDO::connect(\u0026#39;mysql:host=localhost;dbname=test\u0026#39;, \u0026#39;user\u0026#39;, \u0026#39;pass\u0026#39;); // $pdo est maintenant une instance Pdo\\Mysql $pdo = new Pdo\\Pgsql(\u0026#39;pgsql:host=localhost;dbname=test\u0026#39;, \u0026#39;user\u0026#39;, \u0026#39;pass\u0026#39;); Chaque sous-classe driver (Pdo\\Mysql, Pdo\\Pgsql, Pdo\\Sqlite, Pdo\\Firebird, Pdo\\Odbc, Pdo\\DbLib) peut exposer des méthodes spécifiques au driver sans polluer l\u0026rsquo;interface PDO de base. Doctrine, Laravel et autres ORMs similaires peuvent maintenant type-hinter contre la classe de driver spécifique quand ils ont besoin d\u0026rsquo;un comportement spécifique au driver.\nOpenSSL reçoit le support des clés modernes openssl_pkey_new() et les fonctions associées supportent maintenant Curve25519 et Curve448, les courbes elliptiques modernes qui ont remplacé les anciennes courbes NIST dans la plupart des recommandations de sécurité :\n$key = openssl_pkey_new([\u0026#39;curve_name\u0026#39; =\u0026gt; \u0026#39;ed25519\u0026#39;, \u0026#39;private_key_type\u0026#39; =\u0026gt; OPENSSL_KEYTYPE_EC]); $details = openssl_pkey_get_details($key); x25519 et x448 pour l\u0026rsquo;échange de clés, ed25519 et ed448 pour les signatures. Les quatre fonctionnent maintenant avec openssl_sign() et openssl_verify().\nPCRE : lookbehind de longueur variable La mise à jour de la bibliothèque PCRE2 embarquée (10.44) apporte les assertions lookbehind de longueur variable, quelque chose que les moteurs regex de Perl et Python avaient et que PHP ne pouvait pas faire :\n// Correspondre \u0026#34;bar\u0026#34; seulement quand précédé de \u0026#34;foo\u0026#34; ou \u0026#34;foooo\u0026#34; preg_match(\u0026#39;/(?\u0026lt;=foo+)bar/\u0026#39;, \u0026#39;foooobar\u0026#39;, $matches); Les assertions lookbehind nécessitaient auparavant un pattern de largeur fixe. Elles peuvent maintenant correspondre à des patterns de longueur variable. Le modificateur r (PCRE2_EXTRA_CASELESS_RESTRICT) est aussi nouveau : il empêche le mélange de caractères ASCII et non-ASCII dans les correspondances insensibles à la casse, fermant une classe d\u0026rsquo;attaques de confusion Unicode.\nDateTime reçoit les microsecondes et une factory de timestamp $dt = DateTimeImmutable::createFromTimestamp(1700000000.5); echo $dt-\u0026gt;getMicrosecond(); // 500000 $with_micros = $dt-\u0026gt;setMicrosecond(123456); createFromTimestamp() accepte un float pour une précision sous-seconde. getMicrosecond() et setMicrosecond() complètent l\u0026rsquo;API pour le composant microseconde qui était à l\u0026rsquo;intérieur de DateTime mais inaccessible directement.\nfpow() pour la conformité IEEE 754 pow(0, -2) en PHP retournait historiquement une valeur définie par l\u0026rsquo;implémentation. 8.4 déprécie pow() avec une base zéro et un exposant négatif et introduit fpow(), qui suit strictement IEEE 754 : fpow(0, -2) retourne INF, comme le standard le définit :\necho fpow(2.0, 3.0); // 8.0 echo fpow(0.0, -1.0); // INF echo fpow(-1.0, INF); // 1.0 À retenir dans tout code faisant des calculs mathématiques où la conformité IEEE compte.\nLe coût de bcrypt augmente Le coût par défaut pour password_hash() avec PASSWORD_BCRYPT est passé de 10 à 12. Ça impacte tout code appelant password_hash($pass, PASSWORD_BCRYPT) sans coût explicite. L\u0026rsquo;objectif est de maintenir le défaut grossièrement à \u0026ldquo;quelques centaines de millisecondes sur du matériel moderne\u0026rdquo; à mesure que le matériel devient plus rapide.\nSi on stocke des hash bcrypt et qu\u0026rsquo;on monte sur 8.4, les hash existants restent valides : password_verify() lit le coût depuis le hash lui-même. Les nouveaux hash utilisent le coût 12. password_needs_rehash() retourne true pour les anciens hash si on passe ['cost' =\u0026gt; 12], donc on peut les mettre à jour à la prochaine connexion.\nLes dépréciations qui comptent Les paramètres implicitement nullable sont dépréciés. Si un paramètre a un défaut de null, le type doit le dire explicitement :\n// Déprécié dans 8.4 function foo(string $s = null) {} // Correct function foo(?string $s = null) {} function foo(string|null $s = null) {} trigger_error() avec E_USER_ERROR est déprécié : le remplacer par une exception ou exit(). Le niveau E_USER_ERROR a toujours été un hybride maladroit entre une erreur récupérable et une erreur fatale, et personne n\u0026rsquo;était sûr lequel.\nlcg_value() est aussi déprécié. Utiliser Random\\Randomizer::getFloat() à la place. Le générateur LCG avait de mauvaises propriétés d\u0026rsquo;aléatoire et aucun contrôle de graine.\n","permalink":"https://guillaumedelre.github.io/fr/2025/01/05/php-8.4-les-property-hooks-et-la-fin-de-la-c%C3%A9r%C3%A9monie-getter/setter/","summary":"\u003cp\u003ePHP 8.4 est sorti le 21 novembre. Les property hooks sont la fonctionnalité. Tout le reste, et il y en a beaucoup, est secondaire.\u003c/p\u003e\n\u003ch2 id=\"les-property-hooks\"\u003eLes property hooks\u003c/h2\u003e\n\u003cp\u003ePendant vingt ans, si on voulait du comportement à l\u0026rsquo;accès d\u0026rsquo;une propriété en PHP, il fallait écrire des getters et setters :\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUser\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eprivate\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e $_name;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efunction\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003egetName\u003c/span\u003e()\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e { \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e $this\u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003e_name\u003c/span\u003e; }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efunction\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003esetName\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e $name)\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003evoid\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        $this\u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003e_name\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estrtoupper\u003c/span\u003e($name);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003ePHP 8.4 ajoute des hooks directement sur la propriété :\u003c/p\u003e","title":"PHP 8.4 : les property hooks et la fin de la cérémonie getter/setter"},{"content":"API Platform 4.0 est sorti neuf jours après la 3.4, fin septembre 2024. Le numéro de version est honnête : il n\u0026rsquo;y a pas de nouvelle architecture, et la migration depuis la 3.4 est courte si on a résolu les dépréciations. Ce qui en fait un majeur, c\u0026rsquo;est le changement de scope — API Platform n\u0026rsquo;est plus un framework uniquement Symfony — et un défaut d\u0026rsquo;opinion qui inverse six ans de comportement PUT.\nLaravel comme cible de première classe Depuis sa première release, API Platform était construit sur Symfony. La couche HTTP, les métadonnées, le serializer et le bridge Doctrine supposaient tous le container de Symfony, l\u0026rsquo;event dispatcher et le cycle de vie des requêtes. Les utilisateurs Laravel pouvaient faire tourner API Platform via un adaptateur léger, mais les filtres, la sécurité et l\u0026rsquo;intégration Doctrine ne fonctionnaient pas avec Eloquent.\nLa 4.0 livre un bridge Laravel dédié. Il mappe la couche d\u0026rsquo;état d\u0026rsquo;API Platform sur le cycle de vie des requêtes de Laravel, s\u0026rsquo;intègre directement avec les modèles Eloquent, et se branche sur le système d\u0026rsquo;autorisation de Laravel :\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\Get; use ApiPlatform\\Metadata\\GetCollection; use Illuminate\\Database\\Eloquent\\Model; #[ApiResource( operations: [new GetCollection(), new Get()] )] class Book extends Model { protected $fillable = [\u0026#39;title\u0026#39;, \u0026#39;author\u0026#39;]; } L\u0026rsquo;autorisation utilise les policies et gates de Laravel plutôt que les security voters de Symfony. Les opérations exposent un paramètre policy dédié qui correspond à un nom de méthode de policy :\n#[Get(policy: \u0026#39;view\u0026#39;)] class Book extends Model {} API Platform mappe la valeur de policy vers Gate::allows() de Laravel avec l\u0026rsquo;instance du modèle. Les policies peuvent aussi être auto-détectées : si un modèle a une classe de policy enregistrée, API Platform infère la bonne méthode (view, viewAny, create, update, delete) selon le type d\u0026rsquo;opération. Les filtres pour les collections Eloquent couvrent le même périmètre que leurs homologues Doctrine : PartialSearchFilter, EqualsFilter, RangeFilter, OrderFilter, DateFilter, et des variantes de recherche (StartSearchFilter, EndSearchFilter). La pagination, le tri et la validation fonctionnent via les mécanismes natifs de Laravel.\nCe n\u0026rsquo;est pas un shim de compatibilité. Le bridge Laravel est maintenu aux côtés du bridge Symfony et est couvert par la même suite de tests. Les projets utilisant l\u0026rsquo;un ou l\u0026rsquo;autre framework ont la même API de définition des ressources.\nPUT supprimé des opérations par défaut Depuis API Platform 1.0, #[ApiResource] sans tableau operations explicite générait des opérations CRUD incluant PUT. Le handler PUT mettait à jour les ressources existantes et, depuis la 3.1, pouvait aussi les créer via allowCreate: true.\nLa 4.0 supprime PUT de l\u0026rsquo;ensemble par défaut. #[ApiResource] génère maintenant GET, POST, PATCH et DELETE. Pour utiliser PUT, il faut le déclarer explicitement :\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\Put; #[ApiResource( operations: [ // ... autres opérations new Put(), ] )] class Book {} La motivation est la clarté sémantique. PATCH remplace PUT pour la plupart des cas d\u0026rsquo;usage de mise à jour partielle. La sémantique de PUT — remplacer la représentation entière de la ressource — est rarement ce qu\u0026rsquo;une API implémente réellement, mais le défaut le faisait apparaître dans toutes les APIs à moins de l\u0026rsquo;enlever activement. Rendre PUT opt-in aligne les défauts sur la façon dont la sémantique HTTP est réellement utilisée en pratique.\nPHP 8.2 minimum La 4.0 abandonne PHP 8.0 et 8.1. PHP 8.2 est le nouveau minimum. La syntaxe de classe readonly, AllowDynamicProperties et les types DNF1 introduits en 8.2 sont disponibles dans toute la codebase. Aucune fonctionnalité spécifique de 8.2 n\u0026rsquo;est structurante pour la 4.0 — le bump de version concerne principalement l\u0026rsquo;abandon du fardeau de maintenance plus ancien.\nSymfony 6.4+ et Doctrine ORM 2.17+ minimum Côté Symfony, la 4.0 requiert Symfony 6.4 ou 7.x et Doctrine ORM 2.17 ou 3.x. Les deux étaient déjà supportés en 3.4. La migration de la 3.4 vers la 4.0 sur la piste Symfony est : résoudre les dépréciations 3.4, vérifier qu\u0026rsquo;on est sur Symfony 6.4+ et ORM 2.17+, puis mettre à jour. Aucun travail de migration supplémentaire n\u0026rsquo;est nécessaire si c\u0026rsquo;est déjà en place.\nCe que la 4.0 n\u0026rsquo;est pas La 4.0 n\u0026rsquo;est pas une nouvelle architecture. Les state providers, processors et le modèle de métadonnées de ressources de la 3.0 sont inchangés. Le bridge Laravel ajoute un nouveau contexte d\u0026rsquo;exécution mais ne change pas la façon dont les ressources ou les opérations sont déclarées. La séparation est intentionnelle : si la 3.0 était le \u0026ldquo;quoi\u0026rdquo;, la 4.0 est le \u0026ldquo;où\u0026rdquo;.\nTypes en Forme Normale Disjonctive : types intersection combinés avec union, comme (A\u0026amp;B)|null.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","permalink":"https://guillaumedelre.github.io/fr/2024/09/27/api-platform-4.0-support-laravel-et-put-repens%C3%A9/","summary":"\u003cp\u003eAPI Platform 4.0 est sorti neuf jours après la 3.4, fin septembre 2024. Le numéro de version est honnête : il n\u0026rsquo;y a pas de nouvelle architecture, et la migration depuis la 3.4 est courte si on a résolu les dépréciations. Ce qui en fait un majeur, c\u0026rsquo;est le changement de scope — API Platform n\u0026rsquo;est plus un framework uniquement Symfony — et un défaut d\u0026rsquo;opinion qui inverse six ans de comportement PUT.\u003c/p\u003e","title":"API Platform 4.0 : support Laravel et PUT repensé"},{"content":"API Platform 3.4 est arrivé en septembre 2024 comme dernier mineur avant le saut vers la 4.0. La fonctionnalité principale est le BackedEnum comme ressource complète — pas juste un champ typé, mais un enum qui est lui-même un endpoint API.\nBackedEnum comme ressources API Depuis PHP 8.1, les classes BackedEnum ont un ensemble fixe de cas avec des valeurs de support string ou integer. API Platform 3.4 permet de mettre #[ApiResource] directement sur un BackedEnum :\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\GetCollection; #[ApiResource( operations: [new GetCollection()] )] enum BookStatus: string { case Draft = \u0026#39;draft\u0026#39;; case Published = \u0026#39;published\u0026#39;; case Archived = \u0026#39;archived\u0026#39;; } Un endpoint GET /book_statuses retourne la liste des cas. Chaque cas est sérialisé avec son nom et sa valeur. L\u0026rsquo;endpoint est en lecture seule — les enums sont immuables par nature.\nC\u0026rsquo;est surtout utile pour les consommateurs frontend qui veulent une liste lisible par machine des valeurs valides sans les coder en dur. L\u0026rsquo;alternative était un contrôleur personnalisé ou une ressource DTO dédiée listant manuellement les valeurs de l\u0026rsquo;enum.\nBackedEnumFilter Le compagnon des ressources enum est BackedEnumFilter, un nouveau filtre pour les collections Doctrine qui contraint une requête par une propriété BackedEnum :\nuse ApiPlatform\\Doctrine\\Orm\\Filter\\BackedEnumFilter; use ApiPlatform\\Metadata\\ApiFilter; use ApiPlatform\\Metadata\\ApiResource; #[ApiResource] #[ApiFilter(BackedEnumFilter::class, properties: [\u0026#39;status\u0026#39;])] class Book { public BookStatus $status; } GET /books?status=published filtre la collection aux livres où status est égal à BookStatus::Published. Les valeurs d\u0026rsquo;enum invalides retournent une réponse 400. Avant ce filtre, on devait soit écrire un filtre personnalisé, soit utiliser SearchFilter et valider la valeur manuellement.\nExpressions de sécurité sur les paramètres La 3.3 avait ajouté la sécurité aux liens et aux propriétés. La 3.4 étend cela aux paramètres de requête. Un paramètre peut déclarer une expression de sécurité qui contrôle s\u0026rsquo;il est accepté :\nuse ApiPlatform\\Metadata\\GetCollection; use ApiPlatform\\Metadata\\QueryParameter; #[GetCollection( parameters: [ new QueryParameter( key: \u0026#39;includeDeleted\u0026#39;, security: \u0026#34;is_granted(\u0026#39;ROLE_ADMIN\u0026#39;)\u0026#34; ), ] )] class Book {} Quand l\u0026rsquo;expression de sécurité est false, le paramètre est rejeté avec un 403, pas silencieusement ignoré. C\u0026rsquo;est plus explicite que vérifier le rôle de l\u0026rsquo;utilisateur dans le provider après avoir reçu le paramètre.\nSupport DBAL 4 ajouté La 3.4 ajoute le support de Doctrine DBAL 4, qui apporte des changements au système de types qui affectent la façon dont les types personnalisés et le SQL spécifique à la plateforme fonctionnent. Les filtres Doctrine Orm et les extensions de requête dans API Platform ont été mis à jour pour fonctionner avec la nouvelle API de types DBAL 4.\nDBAL 3 (^3.4.0) et DBAL 4 sont supportés simultanément en 3.4. C\u0026rsquo;est la release à adopter pour migrer vers DBAL 4 tout en restant sur une branche stable API Platform 3.x.\nValidateur de paramètres de requête déprécié La 3.3 avait ajouté le validateur strict de paramètres de requête en opt-in. La 3.4 déprécie l\u0026rsquo;ancien comportement (paramètres inconnus silencieusement ignorés) en préparation pour rendre la validation stricte la valeur par défaut en 4.0. Les projets qui s\u0026rsquo;appuient sur des paramètres de requête pass-through ont une release de plus pour les déclarer explicitement.\nDernier arrêt avant la 4.0 La 3.4 est la dernière release 3.x avec de nouvelles fonctionnalités. Tout ce qui était déprécié d\u0026rsquo;ici la 3.4 disparaît en 4.0. Le chemin de migration de la 3.4 vers la 4.0 est intentionnellement court : résoudre les dépréciations, puis mettre à jour.\n","permalink":"https://guillaumedelre.github.io/fr/2024/09/18/api-platform-3.4-backedenum-comme-ressources-et-support-dbal-4/","summary":"\u003cp\u003eAPI Platform 3.4 est arrivé en septembre 2024 comme dernier mineur avant le saut vers la 4.0. La fonctionnalité principale est le BackedEnum comme ressource complète — pas juste un champ typé, mais un enum qui est lui-même un endpoint API.\u003c/p\u003e\n\u003ch2 id=\"backedenum-comme-ressources-api\"\u003eBackedEnum comme ressources API\u003c/h2\u003e\n\u003cp\u003eDepuis PHP 8.1, les classes BackedEnum ont un ensemble fixe de cas avec des valeurs de support string ou integer. API Platform 3.4 permet de mettre \u003ccode\u003e#[ApiResource]\u003c/code\u003e directement sur un BackedEnum :\u003c/p\u003e","title":"API Platform 3.4 : BackedEnum comme ressources et support DBAL 4"},{"content":"API Platform 3.3 est sorti en avril 2024 avec un ensemble d\u0026rsquo;ajouts ciblés. Aucun d\u0026rsquo;eux ne remodèle l\u0026rsquo;architecture — la 3.2 avait déjà clos ce chapitre. Ce que la 3.3 apporte, c\u0026rsquo;est du contrôle sur des choses qui étaient soit codées en dur soit nécessitaient un contournement : les headers de réponse, la visibilité des liens sur les sous-ressources, et les webhooks dans la spec générée.\nConfiguration déclarative des headers Avant la 3.3, définir des headers de réponse personnalisés nécessitait soit un processor personnalisé qui modifiait l\u0026rsquo;objet réponse, soit un event listener Symfony sur kernel.response. Les deux approches fonctionnaient mais vivaient en dehors de la définition de la ressource.\nLa 3.3 ajoute un paramètre parameters aux métadonnées d\u0026rsquo;opération :\nuse ApiPlatform\\Metadata\\Get; use ApiPlatform\\Metadata\\HeaderParameter; #[Get( parameters: [ \u0026#39;X-Custom-Header\u0026#39; =\u0026gt; new HeaderParameter(description: \u0026#39;A custom header\u0026#39;), ] )] Pour les headers qui varient par réponse (comme Cache-Control avec un max-age calculé), le processor peut encore les définir directement sur l\u0026rsquo;objet réponse. Le paramètre parameters sert principalement à documenter les headers attendus dans la spec OpenAPI et pour les valeurs de headers statiques.\nSécurité des liens sur les sous-ressources Quand une ressource expose des liens vers des ressources liées, ces liens apparaissent dans la sortie sérialisée indépendamment du fait que l\u0026rsquo;utilisateur courant puisse accéder à la ressource liée. Cela crée un problème de divulgation : un utilisateur qui peut lire un livre mais pas le profil de son auteur voit quand même l\u0026rsquo;URI de l\u0026rsquo;auteur dans la réponse.\nLa 3.3 ajoute des expressions de sécurité au descripteur Link :\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\Get; use ApiPlatform\\Metadata\\Link; #[ApiResource] #[Get] class Book { #[Link( toClass: Author::class, security: \u0026#34;is_granted(\u0026#39;ROLE_ADMIN\u0026#39;)\u0026#34; )] public Author $author; } Le lien est omis de la réponse quand l\u0026rsquo;expression de sécurité évalue à false. La ressource liée elle-même n\u0026rsquo;est pas affectée — seulement le fait que la réponse courante inclue la référence à elle.\nApiProperty::security Le même mécanisme d\u0026rsquo;expression de sécurité est disponible au niveau propriété via ApiProperty::security. Cela permet de cacher des champs individuels selon l\u0026rsquo;utilisateur courant sans écrire un normalizer personnalisé :\nuse ApiPlatform\\Metadata\\ApiProperty; class Book { #[ApiProperty(security: \u0026#34;is_granted(\u0026#39;ROLE_ADMIN\u0026#39;)\u0026#34;)] public string $internalNote; } La propriété est exclue de la sérialisation quand l\u0026rsquo;expression est false. C\u0026rsquo;est plus propre qu\u0026rsquo;un normalizer pour le cas courant de champs conditionnels par rôle.\nWebhooks OpenAPI OpenAPI 3.1 supporte les webhooks — des appels HTTP sortants que votre API fait à des listeners enregistrés — dans le document spec lui-même. Avant la 3.3, il n\u0026rsquo;y avait pas de moyen de les documenter dans la spec générée par API Platform.\nLa 3.3 ajoute une classe Webhook à passer au paramètre openapi d\u0026rsquo;une opération. On déclare une classe PHP dédiée avec #[ApiResource] et on utilise Webhook sur chaque opération pour décrire la forme de l\u0026rsquo;appel sortant :\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\Post; use ApiPlatform\\OpenApi\\Attributes\\Webhook; use ApiPlatform\\OpenApi\\Model\\Operation; use ApiPlatform\\OpenApi\\Model\\PathItem; #[ApiResource( operations: [ new Post( openapi: new Webhook( name: \u0026#39;bookCreated\u0026#39;, pathItem: new PathItem( post: new Operation(summary: \u0026#39;Un livre a été créé\u0026#39;), ), ) ), ] )] class BookWebhook {} Les définitions de webhooks apparaissent dans la spec générée sous la clé webhooks aux côtés des paths réguliers. Swagger UI les affiche dans une section séparée.\nDeep linking dans Swagger UI Swagger UI supporte le deep linking — des URLs mémorisables qui ouvrent directement sur une opération spécifique dans l\u0026rsquo;interface. Avant la 3.3, l\u0026rsquo;intégration API Platform n\u0026rsquo;activait pas cela. La 3.3 active l\u0026rsquo;option deepLinking de Swagger UI, configurable via swagger_ui_extra_configuration :\napi_platform: openapi: swagger_ui_extra_configuration: deepLinking: true Avec cette option activée, le fragment d\u0026rsquo;URL se met à jour pendant la navigation dans l\u0026rsquo;UI, et coller ou partager l\u0026rsquo;URL ouvre la même opération. Utile quand on écrit de la doc qui pointe directement vers un endpoint spécifique.\nValidation stricte des paramètres de requête La 3.3 renforce le validateur de paramètres de requête : les paramètres non déclarés sur l\u0026rsquo;opération renvoient maintenant une réponse 400 au lieu d\u0026rsquo;être silencieusement ignorés. Ce comportement est opt-in :\napi_platform: validator: query_parameter_validation: true L\u0026rsquo;intention est de détecter les fautes de frappe et les mauvaises utilisations de l\u0026rsquo;API tôt. Si vous vous appuyez sur des paramètres de requête pass-through pour une logique personnalisée (logging, feature flags), vous devez les déclarer explicitement sur l\u0026rsquo;opération avant d\u0026rsquo;activer cela.\n","permalink":"https://guillaumedelre.github.io/fr/2024/04/29/api-platform-3.3-headers-s%C3%A9curit%C3%A9-des-liens-et-webhooks-openapi/","summary":"\u003cp\u003eAPI Platform 3.3 est sorti en avril 2024 avec un ensemble d\u0026rsquo;ajouts ciblés. Aucun d\u0026rsquo;eux ne remodèle l\u0026rsquo;architecture — la 3.2 avait déjà clos ce chapitre. Ce que la 3.3 apporte, c\u0026rsquo;est du contrôle sur des choses qui étaient soit codées en dur soit nécessitaient un contournement : les headers de réponse, la visibilité des liens sur les sous-ressources, et les webhooks dans la spec générée.\u003c/p\u003e\n\u003ch2 id=\"configuration-déclarative-des-headers\"\u003eConfiguration déclarative des headers\u003c/h2\u003e\n\u003cp\u003eAvant la 3.3, définir des headers de réponse personnalisés nécessitait soit un processor personnalisé qui modifiait l\u0026rsquo;objet réponse, soit un event listener Symfony sur \u003ccode\u003ekernel.response\u003c/code\u003e. Les deux approches fonctionnaient mais vivaient en dehors de la définition de la ressource.\u003c/p\u003e","title":"API Platform 3.3 : headers, sécurité des liens, et webhooks OpenAPI"},{"content":"Symfony 7.0 est sorti le 29 novembre 2023, le même jour que 6.4. Le pattern tient : la version X.0 coupe le code déprécié et élève le plancher PHP. 7.0 exige PHP 8.2 et supprime tout ce que 6.4 avait marqué comme déprécié.\nLa suppression la plus visible : les annotations Doctrine. @Route, @ORM\\Column, @Assert — disparus. Les attributs PHP natifs sont l\u0026rsquo;approche recommandée depuis Symfony 5.2. 7.0 rend juste ça officiel.\nLes attributs partout La migration des annotations vers les attributs est principalement mécanique : la syntaxe passe de @ à #[], et les références de classes passent des classes d\u0026rsquo;annotation Doctrine aux classes d\u0026rsquo;attribut PHP :\n// avant /** @Route(\u0026#39;/users\u0026#39;, methods={\u0026#34;GET\u0026#34;}) */ // après #[Route(\u0026#39;/users\u0026#39;, methods: [\u0026#39;GET\u0026#39;])] Le vrai gain n\u0026rsquo;est pas juste la syntaxe : les attributs sont validés par le moteur PHP, pas un parseur de docblock. Les IDEs peuvent les résoudre sans plugins personnalisés. Les outils d\u0026rsquo;analyse statique les comprennent nativement. Fini les \u0026ldquo;ça échoue silencieusement à l\u0026rsquo;exécution à cause d\u0026rsquo;une faute de frappe dans un commentaire.\u0026rdquo;\nWorkflow avec attributs PHP Les listeners et guards d\u0026rsquo;événements Workflow peuvent maintenant être enregistrés via des attributs :\n#[AsGuard(workflow: \u0026#39;order\u0026#39;, transition: \u0026#39;ship\u0026#39;)] public function canShip(Event $event): void { if (!$event-\u0026gt;getSubject()-\u0026gt;isPaymentConfirmed()) { $event-\u0026gt;setBlocked(true); } } Le profiler de workflow, un panneau dédié montrant le marquage courant et les transitions disponibles, est un outil de debug vraiment utile quand on travaille avec des machines à états complexes.\nDatePoint dans le composant Clock DatePoint, le DateTime immutable avec gestion stricte des erreurs introduit dans 6.4, est maintenant la façon recommandée de travailler avec les dates. Combiné avec les propriétés readonly de PHP 8.2, les objets-valeur de dates dans le code de domaine deviennent presque trivialement propres :\nreadonly class Order { public function __construct( public DatePoint $createdAt, public ?DatePoint $shippedAt = null, ) {} } Ce que 7.0 supprime La liste complète des suppressions : le support des annotations Doctrine, le bridge du composant Templating, le bridge ProxyManager, le bridge Monolog pour les versions inférieures à 3.0, et le transport Sendinblue (remplacé par Brevo). Le support PHP 8.0 et 8.1 se termine aussi. 8.2 est le plancher maintenant.\nMonter de 6.4 avec toutes les notices de dépréciation corrigées, et 7.0 est fluide. Sauter cette étape et on s\u0026rsquo;expose à une mauvaise surprise.\nScheduler et AssetMapper diplômés Deux composants qui sont sortis en expérimental dans 6.3 sont maintenant stables : Scheduler et AssetMapper. Stable signifie des APIs verrouillées, plus de mises en garde @experimental, et ils apparaissent correctement dans le guide de mise à jour. On peut vraiment compter sur eux maintenant.\nScheduler reçoit #[AsCronTask] et #[AsPeriodicTask] pour l\u0026rsquo;enregistrement de tâches par attribut, la modification de planning à l\u0026rsquo;exécution avec recalcul du heap, FailureEvent, et une option --date sur schedule:debug. AssetMapper ajoute le support des fichiers CSS dans l\u0026rsquo;importmap, une commande outdated, une commande audit, et le préchargement automatique via WebLink.\n#[AsCronTask(\u0026#39;0 2 * * *\u0026#39;)] class NightlyReportMessage {} #[AsPeriodicTask(frequency: \u0026#39;1 hour\u0026#39;)] class HourlyCleanupMessage {} Le câblage de services reçoit deux nouveaux attributs #[AutowireLocator] et #[AutowireIterator] ont atterri dans 6.4 et sont stables dans 7.0. Ils remplacent la configuration verbose XML/YAML des service locators taggués par quelque chose qu\u0026rsquo;on peut juste mettre directement en PHP :\nclass HandlerRegistry { public function __construct( #[AutowireLocator(\u0026#39;app.handler\u0026#39;, indexAttribute: \u0026#39;key\u0026#39;)] private ContainerInterface $handlers, ) {} } #[Target] est aussi plus intelligent : quand un service a un alias d\u0026rsquo;autowiring nommé comme invoice.lock.factory, on peut maintenant écrire #[Target('invoice')] au lieu du nom complet de l\u0026rsquo;alias. Moins de bruit quand le type dit déjà ce qu\u0026rsquo;on veut.\nMessenger reçoit une gestion plus précise des échecs RejectRedeliveredMessageException dit au worker de ne pas retenter un message, ce qui est pratique quand un message arrive deux fois à cause d\u0026rsquo;un timeout d\u0026rsquo;ack du transport et qu\u0026rsquo;on a besoin d\u0026rsquo;une sémantique exactly-once. messenger:failed:remove --all vide tout le transport d\u0026rsquo;échec en un coup, pas de boucle nécessaire. Les retries échoués peuvent aussi aller directement au transport d\u0026rsquo;échec, en contournant entièrement la queue de retry.\nPlusieurs hôtes Redis Sentinel sont maintenant supportés dans le DSN :\nredis-sentinel://host1:26379,host2:26379,host3:26379/mymaster Console reçoit les noms de signaux et le profilage de commandes SignalMap mappe les entiers de signaux à leurs noms POSIX. Quand un worker attrape SIGTERM, le log dit maintenant SIGTERM au lieu de 15. Petite chose, vraie amélioration. ConsoleTerminateEvent est dispatché même quand le processus se termine via signal, ce qui n\u0026rsquo;était pas le cas avant 7.0.\nLe profilage de commandes arrive aussi : passer --profile à bin/console et les données collectées vont directement dans le profiler Symfony, navigable depuis l\u0026rsquo;UI web.\nForm : des petites choses qui s\u0026rsquo;accumulent ChoiceType reçoit une option duplicate_preferred_choices. La définir à false et on arrête de montrer la même option deux fois quand les choix préférés chevauchent la liste complète. FormEvent::setData() est déprécié pour les événements où les données sont déjà verrouillées à ce point du cycle de vie. La barre oblique auto-fermante sur les éléments \u0026lt;input\u0026gt; est aussi supprimée : \u0026lt;input\u0026gt; est un élément void en HTML5 et la barre oblique était techniquement invalide.\nLe support des enums dans les formulaires est bien fait : ChoiceType rend les backed enums directement, et les enums translatable reçoivent leurs labels via le translator sans câblage personnalisé.\nHttpFoundation : small but useful Response::send() reçoit un paramètre $flush. Passer false pour bufferiser la sortie sans la flusher au client, utile quand on enchaîne des middlewares qui doivent inspecter la réponse avant qu\u0026rsquo;elle quitte le processus.\nUriSigner passe de HttpKernel à HttpFoundation, où il appartient sémantiquement. Même nom de classe, namespace différent.\nLes cookies reçoivent le support CHIPS (Cookies Having Independent Partitioned State), le mécanisme navigateur pour les cookies cross-site dans une partition first-party. Ça ne compte que si on construit des widgets embarquables, mais bon à savoir que c\u0026rsquo;est là.\nTranslation : provider Phrase et sortie arborescente Phrase rejoint Crowdin et Lokalise comme provider de traduction supporté. Le configurer dans config/packages/translation.yaml et les commandes translation:push / translation:pull gèrent la synchronisation.\ntranslation:pull reçoit une option --as-tree qui écrit les fichiers de traduction en YAML imbriqué plutôt qu\u0026rsquo;en clés à notation pointée plate. Si c\u0026rsquo;est vraiment mieux dépend entièrement de l\u0026rsquo;équipe.\nLocaleSwitcher::runWithLocale() passe maintenant la locale courante comme argument au callback, évitant un appel getLocale() à l\u0026rsquo;intérieur :\n$switcher-\u0026gt;runWithLocale(\u0026#39;fr\u0026#39;, function (string $locale) use ($mailer) { $mailer-\u0026gt;send($this-\u0026gt;buildEmail($locale)); }); Quelques choses dans Serializer et DomCrawler L\u0026rsquo;attribut Context du Serializer peut maintenant cibler des classes spécifiques, donc un seul DTO peut se comporter différemment pendant la (dé)sérialisation selon quelle classe détient le contexte. TranslatableNormalizer arrive pour normaliser les objets qui implémentent TranslatableInterface : le translator est appelé pendant la normalisation, pas avant.\nCrawler::attr() reçoit un paramètre $default. Au lieu de null-checker la valeur de retour, on passe une valeur de repli :\n$src = $crawler-\u0026gt;attr(\u0026#39;src\u0026#39;, \u0026#39;/placeholder.png\u0026#39;); assertAnySelectorText() et assertAnySelectorTextContains() rejoignent l\u0026rsquo;ensemble d\u0026rsquo;assertions DomCrawler. Ils passent si au moins un élément correspondant satisfait la condition, plutôt que d\u0026rsquo;en exiger que tous correspondent.\nHttpClient : réponses HAR pour les tests MockResponse accepte maintenant les fichiers HAR (HTTP Archive). Enregistrer de vraies interactions HTTP dans le navigateur ou avec un proxy, déposer le fichier .har dans les fixtures de tests, et les rejouer :\n$client = new MockHttpClient(HarFileResponseFactory::createFromFile(__DIR__.\u0026#39;/fixtures/api.har\u0026#39;)); Bien mieux qu\u0026rsquo;écrire des stubs de réponse à la main quand on traite avec une API complexe.\n","permalink":"https://guillaumedelre.github.io/fr/2024/01/12/symfony-7.0-php-8.2-minimum-et-les-annotations-enfin-disparues/","summary":"\u003cp\u003eSymfony 7.0 est sorti le 29 novembre 2023, le même jour que 6.4. Le pattern tient : la version X.0 coupe le code déprécié et élève le plancher PHP. 7.0 exige PHP 8.2 et supprime tout ce que 6.4 avait marqué comme déprécié.\u003c/p\u003e\n\u003cp\u003eLa suppression la plus visible : les annotations Doctrine. \u003ccode\u003e@Route\u003c/code\u003e, \u003ccode\u003e@ORM\\Column\u003c/code\u003e, \u003ccode\u003e@Assert\u003c/code\u003e — disparus. Les attributs PHP natifs sont l\u0026rsquo;approche recommandée depuis Symfony 5.2. 7.0 rend juste ça officiel.\u003c/p\u003e","title":"Symfony 7.0 : PHP 8.2 minimum et les annotations enfin disparues"},{"content":"Symfony 6.4 est sorti le 29 novembre 2023. C\u0026rsquo;est une LTS avec une histoire : quatre composants qui sont sortis en expérimental dans des versions précédentes sont maintenant stables. Le plus important, c\u0026rsquo;est AssetMapper.\nAssetMapper La gestion frontend moderne dans Symfony, ça voulait dire Webpack Encore. Encore fonctionne : il gère la transpilation, le bundling, le versioning, le hot reload. Il nécessite aussi Node.js, une étape de build séparée, et une quantité non négligeable de configuration pour ce qui est souvent un frontend assez modeste.\nAssetMapper prend une position différente. Les navigateurs modernes supportent les modules ES nativement. Au lieu de bundler, livrer les fichiers tels quels, laisser le navigateur résoudre les imports via une importmap, et gérer les dépendances vendor via des fichiers téléchargés plutôt que des packages npm.\ncomposer require symfony/asset-mapper php bin/console importmap:require lodash Pas de Node.js. Pas de npm. Pas d\u0026rsquo;étape de build. Les fichiers JavaScript et CSS sont versionnés et servis directement, avec un digest dans l\u0026rsquo;URL pour le cache busting. Pour les applications où le frontend n\u0026rsquo;est pas la principale préoccupation d\u0026rsquo;ingénierie, ça supprime toute une chaîne d\u0026rsquo;outils de l\u0026rsquo;équation.\n6.4 ajoute les fichiers CSS à l\u0026rsquo;importmap, le préchargement CSS automatique via WebLink, et des commandes pour auditer et mettre à jour les dépendances vendor. L\u0026rsquo;expérience package.json, sans npm.\nScheduler Le composant Scheduler (planification de tâches périodiques et de style cron sans runner externe) sort d\u0026rsquo;expérimental et devient stable. L\u0026rsquo;API utilise des attributs :\n#[AsCronTask(\u0026#39;0 * * * *\u0026#39;)] class HourlyReport implements ScheduledTaskInterface { public function run(): void { ... } } Soutenu par les transports Messenger, les tâches tournent dans tout environnement où un worker est en cours d\u0026rsquo;exécution. Pour beaucoup de cas d\u0026rsquo;usage, ça remplace le pattern classique entrée cron + commande console.\nWebhook et RemoteEvent Aussi diplômés d\u0026rsquo;expérimental : le composant Webhook gère les webhooks entrants depuis des services externes. Au lieu d\u0026rsquo;écrire des contrôleurs bruts qui parsent les payloads et dispatchent des événements à la main, on configure des parseurs pour des services connus (Stripe, GitHub, Mailgun) et on obtient des événements typés.\nDatePoint Une nouvelle classe DatePoint dans le composant Clock : un wrapper DateTime immutable qui lève des exceptions sur les modificateurs invalides au lieu de retourner silencieusement false. Petite chose, mais significative pour le code qui manipule des dates et veut réellement savoir quand quelque chose va mal.\nLa fenêtre de support 6.4 LTS reçoit des corrections de bugs jusqu\u0026rsquo;en novembre 2026 et des correctifs de sécurité jusqu\u0026rsquo;en novembre 2027. Le chemin de 6.4 vers 7.4 (la prochaine LTS) passe par les notices de dépréciation de 6.4, comme d\u0026rsquo;habitude.\nRoutes sans strings magiques Les alias de routes basés sur le FQCN sont maintenant générés automatiquement. Si une méthode de contrôleur a une seule route, Symfony crée un alias en utilisant son nom de classe complet :\n// Auparavant : seul \u0026#39;blog_index\u0026#39; fonctionnait // Maintenant : les deux fonctionnent de manière identique $this-\u0026gt;urlGenerator-\u0026gt;generate(\u0026#39;blog_index\u0026#39;); $this-\u0026gt;urlGenerator-\u0026gt;generate(BlogController::class.\u0026#39;::index\u0026#39;); Pour les contrôleurs invocables, l\u0026rsquo;alias est juste le nom de classe. L\u0026rsquo;avantage pratique : navigation IDE et sécurité au refactoring — on référence une constante de classe, pas une string qui peut silencieusement diverger.\nDeux nouveaux attributs DI #[AutowireLocator] et #[AutowireIterator] rejoignent la famille d\u0026rsquo;attributs DI. Au lieu de configurer des service locators et des itérables taggués en YAML, on les déclare juste sur les paramètres du constructeur :\npublic function __construct( #[AutowireLocator([FooHandler::class, BarHandler::class])] private ContainerInterface $handlers, ) {} Alias, services optionnels (préfixés avec ?), et injection de paramètres via SubscribedService sont tous supportés. Le locator charge paresseusement, donc seuls les handlers qu\u0026rsquo;on appelle vraiment sont instanciés.\nMessenger reçoit des handlers intégrés Trois nouvelles classes de message couvrent des tâches courantes qui nécessitaient auparavant des handlers personnalisés.\nRunProcessMessage dispatche une commande Process via le bus. RunCommandMessage fait de même pour les commandes console. Les deux retournent un objet de contexte avec le code de sortie et la sortie. PingWebhookMessage pingue une URL, ce qui est utile pour surveiller les tâches planifiées sans mettre en place un service de health-check dédié :\n$this-\u0026gt;bus-\u0026gt;dispatch(new RunCommandMessage(\u0026#39;cache:clear\u0026#39;)); $this-\u0026gt;bus-\u0026gt;dispatch(new PingWebhookMessage(\u0026#39;GET\u0026#39;, \u0026#39;https://healthchecks.io/ping/abc123\u0026#39;)); Le problème d\u0026rsquo;héritage des sous-processus a aussi été résolu avec PhpSubprocess. Quand on lance PHP avec une limite mémoire personnalisée (-d memory_limit=-1), les processus enfants lancés avec Process ne l\u0026rsquo;héritent pas. PhpSubprocess le fait :\n$sub = new PhpSubprocess([\u0026#39;bin/console\u0026#39;, \u0026#39;app:heavy-import\u0026#39;]); Sécurité : trois corrections pour des situations réelles Le profiler montre maintenant comment les badges de sécurité ont été résolus pendant l\u0026rsquo;authentification : lesquels ont passé, lesquels ont échoué, et pourquoi. Avant, il fallait ajouter de la sortie de debug manuellement quand un authentificateur personnalisé ne se comportait pas bien.\nLe throttling de login via RateLimiter hache maintenant automatiquement les PII dans les logs. Les adresses IP et les noms d\u0026rsquo;utilisateur sont hachés avec le secret du kernel avant d\u0026rsquo;être écrits. Pas de config nécessaire, pas de regex sur les lignes de log.\nLes patterns de firewall acceptent maintenant des tableaux :\nfirewalls: no_security: pattern: - \u0026#34;^/register$\u0026#34; - \u0026#34;^/api/webhooks/\u0026#34; Fini les acrobaties regex pour les exclusions multi-chemins.\nDéconnexion sans contrôleur bidon La route de déconnexion nécessitait auparavant un contrôleur qui ne faisait rien que lever une exception, avec un commentaire expliquant que oui, c\u0026rsquo;est intentionnel. 6.4 élimine ça :\n# config/routes/security.yaml _security_logout: resource: security.route_loader.logout type: service Le route loader s\u0026rsquo;en occupe. Le contrôleur bidon est parti. Flex met à jour la recette.\nLe sérialiseur en meilleure forme Trois améliorations du sérialiseur qui résolvent chacune un vrai problème.\nAttribut #[Groups] au niveau de la classe : appliquer un groupe à la classe entière, puis surcharger par propriété. Utile quand une ressource a un groupe de sérialisation par défaut et quelques champs qui nécessitent un contrôle plus fin.\nLes objets translatable ont maintenant un normaliseur dédié. Les strings translatable (enveloppant TranslatableInterface de Doctrine) sont traduites vers la locale passée via NORMALIZATION_LOCALE_KEY pendant la normalisation. Avant ça, il fallait écrire un normaliseur personnalisé.\nEn mode debug, les erreurs de décodage JSON utilisent maintenant seld/jsonlint pour de meilleurs messages. Au lieu de \u0026ldquo;Syntax error\u0026rdquo;, on obtient la ligne et ce qui s\u0026rsquo;est vraiment passé :\nParse error on line 1: {\u0026#39;foo\u0026#39;: \u0026#39;bar\u0026#39;} ^ Invalid string, used single quotes instead of double quotes Profilers pour les choses qui n\u0026rsquo;étaient pas des requêtes HTTP Le profiler de commande étend le profiler existant aux commandes console. Ajouter --profile à n\u0026rsquo;importe quelle commande et obtenir une entrée complète dans le profiler : entrée/sortie, temps d\u0026rsquo;exécution, mémoire, requêtes en base, messages de log. Les commandes qui nécessitaient --verbose plus du timing manuel ont maintenant la même expérience de debug que les requêtes HTTP.\nLe profiler de workflow fait de même pour les machines à états. Un nouveau panneau montre une représentation graphique des workflows et les transitions déclenchées pendant la requête. Zéro configuration.\nL\u0026rsquo;accumulation de DX Plusieurs additions plus petites qui se combinent.\nrenderBlock() et renderBlockView() sur AbstractController permettent de rendre un bloc Twig nommé et de le retourner comme Response ou string. Pratique pour les réponses Turbo Stream où on veut mettre à jour un fragment sans une action de contrôleur complète.\nLe processeur d\u0026rsquo;env defined retourne un booléen plutôt que la valeur : true si la variable existe et n\u0026rsquo;est pas vide, false sinon. Utile pour les feature flags pilotés par des variables d\u0026rsquo;environnement :\nparameters: is_feature_enabled: \u0026#39;%env(defined:FEATURE_FLAG_KEY)%\u0026#39; HttpClient accepte maintenant max_retries par requête, surchargeant la stratégie globale de retry. La méthode filter() du composant Finder accepte un second argument pour élaguer des répertoires entiers tôt, ce qui compte quand on cherche dans de grands arbres.\nLa méthode click() de BrowserKit accepte maintenant des paramètres serveur comme en-têtes supplémentaires, utile dans les tests fonctionnels qui doivent simuler des appels API authentifiés en suivant des liens.\nL\u0026rsquo;impersonation devient utilisable dans les templates Deux nouveaux helpers Twig : impersonation_path() et impersonation_url(). Ils génèrent les URLs correctes incluant le paramètre de query switch-user, qui est configurable et n\u0026rsquo;a aucune raison d\u0026rsquo;être codé en dur dans les templates. Les associer avec l\u0026rsquo;existant impersonation_exit_path() pour le flux complet d\u0026rsquo;impersonation admin.\nContrôle des locales, partout où ça manquait Trois lacunes comblées. TemplatedEmail a maintenant une méthode locale() pour rendre les emails dans la langue du destinataire. runWithLocale() du locale switcher passe maintenant la locale comme argument au callback, donc on n\u0026rsquo;a pas à la capturer depuis la portée extérieure. Et app.enabledLocales est disponible dans Twig, donc on peut construire des sélecteurs de langue sans coder en dur les listes de locales.\nDéployer sur des filesystems en lecture seule APP_BUILD_DIR est maintenant une variable d\u0026rsquo;environnement reconnue par le kernel. La définir pour rediriger les artefacts compilés (cache du router, proxies Doctrine, traductions préchargées) vers un répertoire qui existe, même quand le répertoire cache par défaut n\u0026rsquo;existe pas. MicroKernelTrait l\u0026rsquo;utilise automatiquement. WarmableInterface a reçu un paramètre $buildDir pour supporter cette séparation : les warmers de cache personnalisés qui écrivent des artefacts en lecture seule doivent se mettre à jour en conséquence.\n","permalink":"https://guillaumedelre.github.io/fr/2024/01/10/symfony-6.4-lts-assetmapper-scheduler-webhook-et-la-version-long-terme/","summary":"\u003cp\u003eSymfony 6.4 est sorti le 29 novembre 2023. C\u0026rsquo;est une LTS avec une histoire : quatre composants qui sont sortis en expérimental dans des versions précédentes sont maintenant stables. Le plus important, c\u0026rsquo;est AssetMapper.\u003c/p\u003e\n\u003ch2 id=\"assetmapper\"\u003eAssetMapper\u003c/h2\u003e\n\u003cp\u003eLa gestion frontend moderne dans Symfony, ça voulait dire Webpack Encore. Encore fonctionne : il gère la transpilation, le bundling, le versioning, le hot reload. Il nécessite aussi Node.js, une étape de build séparée, et une quantité non négligeable de configuration pour ce qui est souvent un frontend assez modeste.\u003c/p\u003e","title":"Symfony 6.4 LTS : AssetMapper, Scheduler, Webhook et la version long terme"},{"content":"PHP 8.3 est sorti le 23 novembre. Une version discrète par les standards PHP : pas de bouleversement à la taille des enums, pas de JIT. Ce qu\u0026rsquo;elle apporte, c\u0026rsquo;est un ensemble ciblé d\u0026rsquo;améliorations qui comblent des lacunes de longue date dans le système de types et ajoutent des fonctions qui auraient dû exister depuis des années.\nLes constantes de classe typées Les constantes de classe n\u0026rsquo;ont jamais été typées depuis leur introduction. PHP 8.3 corrige ça :\ninterface HasVersion { const string VERSION; } class App implements HasVersion { const string VERSION = \u0026#39;1.0.0\u0026#39;; } Sans constantes typées, une constante d\u0026rsquo;interface pouvait être redéfinie avec un type complètement différent dans une classe implémentante sans que rien ne se plaigne. Les constantes typées comblent ce trou, et sur les bases de code pilotées par les interfaces, l\u0026rsquo;impact est immédiat.\nL\u0026rsquo;accès dynamique aux constantes de classe Une lacune qui nécessitait un contournement depuis que les constantes existent :\n$name = \u0026#39;STATUS\u0026#39;; echo MyClass::{$name}; // ça marche maintenant Avant, accéder à une constante avec un nom dynamique signifiait appeler constant('MyClass::STATUS'). La nouvelle syntaxe est cohérente avec la façon dont PHP gère déjà les variables variables et les appels de méthodes dynamiques.\nreadonly peut maintenant être modifié dans clone Une limitation spécifique mais vraiment agaçante de 8.1 avec readonly : on ne pouvait pas cloner un objet et changer une propriété readonly. 8.3 ajoute la possibilité de réinitialiser les propriétés readonly pendant le clonage, ce qui rend les value objects immuables utilisables dans beaucoup plus de patterns.\njson_validate() if (json_validate($string)) { $data = json_decode($string); } Avant 8.3, la seule façon de valider une chaîne JSON était de la décoder et de vérifier les erreurs. json_validate() vérifie sans allouer la structure décodée, ce qui compte quand on a juste besoin de savoir si la chaîne est du JSON valide, pas ce qu\u0026rsquo;elle contient.\nAméliorations du Randomizer getBytesFromString() génère une chaîne aléatoire composée uniquement de caractères d\u0026rsquo;un ensemble donné :\n$rng = new Random\\Randomizer(); $token = $rng-\u0026gt;getBytesFromString(\u0026#39;abcdefghijklmnopqrstuvwxyz0123456789\u0026#39;, 32); L\u0026rsquo;approche précédente : str_split, array_map, sélection aléatoire, implode. Ça marchait, mais c\u0026rsquo;était plus long que ça n\u0026rsquo;avait le droit d\u0026rsquo;être.\n8.3 est pour les équipes qui adoptent les versions PHP rapidement et veulent les améliorations incrémentales. Les constantes typées seules valent le coup sur toute base de code avec des constantes d\u0026rsquo;interface.\n#[\\Override] rend l\u0026rsquo;héritage explicite Avant 8.3, rien n\u0026rsquo;empêchait d\u0026rsquo;écrire une méthode qu\u0026rsquo;on croyait surcharger celle d\u0026rsquo;un parent, alors qu\u0026rsquo;on avait un typo dans le nom ou que le parent l\u0026rsquo;avait silencieusement supprimée. Des bugs silencieux, zéro retour du moteur.\nclass Cache { #[\\Override] public function get(string $key): mixed { // Le moteur vérifie que cette méthode existe dans un parent ou une interface } } Si la méthode n\u0026rsquo;existe dans aucune classe parente ou interface implémentée, PHP lève une erreur. Même concept que @Override en Java ou override en C#, enfin en PHP.\nfinal sur les méthodes de trait Les traits ont toujours eu des aspérités dans le modèle OOP de PHP. Un problème spécifique : une classe utilisant un trait pouvait surcharger n\u0026rsquo;importe laquelle de ses méthodes, sapant les garanties que le trait essayait de fournir. 8.3 laisse le trait lui-même marquer une méthode comme final :\ntrait Singleton { final public static function getInstance(): static { // ... } } Maintenant, une classe utilisant le trait ne peut pas surcharger getInstance(). La garantie tient.\nLes classes anonymes peuvent être readonly PHP 8.1 avait apporté les classes readonly. Les classes anonymes avaient été laissées de côté pour une raison obscure. 8.3 corrige ça :\n$point = new readonly class(3, 4) { public function __construct( public float $x, public float $y, ) {} }; Pratique quand on a besoin d\u0026rsquo;un value object immuable jetable sans la cérémonie de lui donner un nom.\nLes initialiseurs de variables statiques acceptent des expressions Une restriction petite mais ancienne : les initialiseurs de variables statiques n\u0026rsquo;acceptaient que des expressions constantes, pas d\u0026rsquo;appels de fonctions. 8.3 lève cette contrainte :\nfunction connection(): PDO { static $pdo = new PDO(getenv(\u0026#39;DATABASE_URL\u0026#39;)); return $pdo; } L\u0026rsquo;initialiseur s\u0026rsquo;exécute une seule fois au premier appel, la variable statique persiste. Faisable avec un test null avant, c\u0026rsquo;est juste plus propre.\nmb_str_pad() existe enfin str_pad() a toujours été conscient des octets, pas des caractères. Pour les chaînes multibyte (arabe, japonais, caractères accentués) il produisait une sortie incorrecte. 8.3 ajoute enfin la variante multibyte :\n$padded = mb_str_pad(\u0026#39;日本\u0026#39;, 10, \u0026#39;*\u0026#39;, STR_PAD_BOTH); La fonction respecte les limites de caractères, pas les comptages d\u0026rsquo;octets.\nstr_increment() et str_decrement() L\u0026rsquo;opérateur ++ de PHP sur les chaînes a une histoire de bizarreries : il incrémente les séquences de lettres ('a' → 'b', 'z' → 'aa'), mais -- n\u0026rsquo;a jamais fonctionné symétriquement. Le comportement était suffisamment surprenant que 8.3 déprécie ++/-- sur les chaînes non alphanumériques et introduit des fonctions explicites :\necho str_increment(\u0026#39;a\u0026#39;); // b echo str_increment(\u0026#39;Az\u0026#39;); // Ba echo str_decrement(\u0026#39;b\u0026#39;); // a echo str_decrement(\u0026#39;Ba\u0026#39;); // Az Les fonctions rendent l\u0026rsquo;intention évidente et le comportement prévisible.\nRandom\\Randomizer gagne le support des flottants 8.3 comble le côté flottant de l\u0026rsquo;API Randomizer :\n$rng = new Random\\Randomizer(); // Un flottant dans [0.0, 1.0) $f = $rng-\u0026gt;nextFloat(); // Un flottant dans une plage spécifique avec contrôle de l\u0026#39;inclusion des bornes $f = $rng-\u0026gt;getFloat(1.5, 3.5, Random\\IntervalBoundary::ClosedOpen); IntervalBoundary est un nouvel enum avec quatre valeurs : ClosedOpen, ClosedClosed, OpenClosed, OpenOpen. C\u0026rsquo;est important pour la justesse statistique : l\u0026rsquo;approche naïve avec rand() / getrandmax() ne produit pas une distribution uniforme sur les flottants.\nLa hiérarchie d\u0026rsquo;exceptions de Date Les erreurs de date/heure en PHP levaient des exceptions génériques sans moyen de distinguer \u0026ldquo;chaîne malformée\u0026rdquo; de \u0026ldquo;timezone invalide\u0026rdquo; sans parser le message. 8.3 ajoute une hiérarchie propre :\ntry { new DateTimeImmutable(\u0026#39;not a date\u0026#39;); } catch (DateMalformedStringException $e) { // spécifiquement un échec de parsing } catch (DateException $e) { // autres erreurs liées aux dates } L\u0026rsquo;arbre complet : DateError (niveau moteur), DateException (base), avec des sous-classes spécifiques pour timezone invalide, chaîne d\u0026rsquo;intervalle malformée, chaîne de période malformée, et chaîne de date malformée.\ngc_status() en dit plus gc_status() retourne maintenant huit champs supplémentaires : running, protected, full, buffer_size, et des décompositions temporelles (application_time, collector_time, destructor_time, free_time). Si vous profilez la pression mémoire ou les pauses GC, ces données étaient auparavant inaccessibles sans passer par une extension.\nstrrchr() gagne un argument de direction strrchr() (trouver la dernière occurrence d\u0026rsquo;un caractère, retourner de là jusqu\u0026rsquo;à la fin) accepte maintenant un booléen $before_needle, alignant son API sur celle de strstr() :\n$path = \u0026#39;/var/www/html/index.php\u0026#39;; echo strrchr($path, \u0026#39;/\u0026#39;, before_needle: true); // /var/www/html echo strrchr($path, \u0026#39;/\u0026#39;); // /index.php Une fonction présente dans PHP depuis 1994, enfin cohérente avec sa voisine.\nDépréciations à noter get_class() et get_parent_class() sans arguments émettent maintenant des avertissements de dépréciation. Les formes sans argument reposaient sur un contexte $this implicite, facile à mal lire. Passez l\u0026rsquo;objet explicitement.\nassert_options() et les constantes ASSERT_* sont dépréciées au profit de la directive INI zend.assertions, qui est le bon outil pour contrôler le comportement des assertions selon les environnements.\nLes opérateurs ++/-- sur les chaînes vides et les chaînes non numériques non alphanumériques émettent maintenant des avertissements de dépréciation. Le comportement était un territoire indéfini. 8.3 amorce la migration vers un comportement défini en 9.0.\nProtection contre les débordements de pile Deux nouvelles directives INI : zend.max_allowed_stack_size fixe une limite dure sur la profondeur de pile de PHP, et zend.reserved_stack_size réserve un buffer pour le nettoyage après qu\u0026rsquo;une limite soit atteinte. Avant 8.3, un code récursif profond pouvait tout simplement crasher au niveau OS. Maintenant PHP l\u0026rsquo;intercepte et lève une Error avec un message utile.\n","permalink":"https://guillaumedelre.github.io/fr/2024/01/07/php-8.3-les-constantes-typ%C3%A9es-et-les-petites-victoires-qui-restent/","summary":"\u003cp\u003ePHP 8.3 est sorti le 23 novembre. Une version discrète par les standards PHP : pas de bouleversement à la taille des enums, pas de JIT. Ce qu\u0026rsquo;elle apporte, c\u0026rsquo;est un ensemble ciblé d\u0026rsquo;améliorations qui comblent des lacunes de longue date dans le système de types et ajoutent des fonctions qui auraient dû exister depuis des années.\u003c/p\u003e\n\u003ch2 id=\"les-constantes-de-classe-typées\"\u003eLes constantes de classe typées\u003c/h2\u003e\n\u003cp\u003eLes constantes de classe n\u0026rsquo;ont jamais été typées depuis leur introduction. PHP 8.3 corrige ça :\u003c/p\u003e","title":"PHP 8.3 : les constantes typées et les petites victoires qui restent"},{"content":"API Platform 3.2 est arrivé en octobre 2023 avec trois changements qui ont fait avancer le modèle d\u0026rsquo;état : les erreurs sont devenues des ressources, les sous-ressources sont revenues sous une forme qui s\u0026rsquo;intègre vraiment dans l\u0026rsquo;architecture, et le dernier point d\u0026rsquo;extension hérité — les event listeners — a été formellement remplacé.\nLes erreurs comme ressources Avant la 3.2, la gestion des erreurs était en dehors du modèle de ressources. Les exceptions étaient interceptées par un event listener Symfony et converties en réponse, avec un contrôle limité sur la forme de la sortie.\nLa 3.2 fait des erreurs des classes ApiResource de première classe conformes à la RFC 9457 (Problem Details for HTTP APIs). La classe d\u0026rsquo;erreur intégrée est ApiPlatform\\ApiResource\\Error, et on peut créer la sienne :\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\ErrorResource; use ApiPlatform\\Metadata\\Exception\\ProblemExceptionInterface; #[ApiResource] #[ErrorResource] class BookNotFoundError extends \\RuntimeException implements ProblemExceptionInterface { public function __construct(private readonly string $bookId) { parent::__construct(\u0026#34;Book $bookId not found\u0026#34;); } public function getType(): string { return \u0026#39;/errors/book-not-found\u0026#39;; } } Quand cette exception est levée n\u0026rsquo;importe où dans la couche d\u0026rsquo;état, API Platform l\u0026rsquo;intercepte, la sérialise comme une réponse Problem Detail, et génère un schéma OpenAPI approprié pour elle. Le type d\u0026rsquo;erreur, le titre, le détail et le statut font tous partie du contrat de la ressource — pas des chaînes codées en dur dans un listener.\nLes sous-ressources sans les contournements Les sous-ressources existaient en 2.x mais ont été supprimées en 3.0 parce qu\u0026rsquo;elles étaient étroitement couplées à l\u0026rsquo;ancien modèle de data provider et ne pouvaient pas être proprement mappées à la nouvelle architecture orientée opération. La 3.2 les réintroduit d\u0026rsquo;une façon qui s\u0026rsquo;intègre.\nUne sous-ressource est une ressource accessible via l\u0026rsquo;URI d\u0026rsquo;une ressource parente. En 3.2, elle est déclarée directement sur la ressource enfant en utilisant uriTemplate :\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\GetCollection; #[ApiResource( operations: [ new GetCollection( uriTemplate: \u0026#39;/books/{bookId}/reviews\u0026#39;, uriVariables: [ \u0026#39;bookId\u0026#39; =\u0026gt; new Link(fromClass: Book::class), ], ), ] )] class Review { // ... } Le descripteur Link rend la relation explicite. Le provider reçoit bookId dans $uriVariables et peut l\u0026rsquo;utiliser pour délimiter la requête. Pas d\u0026rsquo;inférence magique, pas de jointures implicites — la structure d\u0026rsquo;URI et l\u0026rsquo;accès aux données sont tous les deux déclarés.\ncanonical_uri_template pour plusieurs chemins d\u0026rsquo;accès Quand une ressource est accessible via plusieurs URI (un endpoint direct et un endpoint de sous-ressource), OpenAPI doit savoir quelle URI est canonique pour les liens $ref. La 3.2 utilise le uriTemplate de niveau supérieur sur ApiResource comme URI canonique par défaut. Pour plus de contrôle, l\u0026rsquo;option canonical_uri_template peut être passée via extraProperties sur n\u0026rsquo;importe quelle opération pour la définir explicitement.\n#[ApiResource( uriTemplate: \u0026#39;/reviews/{id}\u0026#39;, operations: [ new Get(), new GetCollection( uriTemplate: \u0026#39;/books/{bookId}/reviews\u0026#39;, uriVariables: [\u0026#39;bookId\u0026#39; =\u0026gt; new Link(fromClass: Book::class)], ), ] )] class Review {} La spec OpenAPI générée utilise l\u0026rsquo;URI canonique pour les références de schéma, gardant le document cohérent quand une ressource apparaît sous plusieurs chemins.\nTypes union et intersection La 3.2 ajoute le support des types union et intersection PHP dans la couche de métadonnées. Une propriété déclarée comme Book|Magazine génère un schéma oneOf approprié dans OpenAPI. C\u0026rsquo;était auparavant non supporté — on devait tomber sur un mixed non typé ou annoter la propriété manuellement.\nLes event listeners rendus optionnels Le dernier shim de compatibilité venu de la 2.x était la possibilité d\u0026rsquo;utiliser les event listeners Symfony sur les événements kernel.request et kernel.view pour intercepter le flux de données d\u0026rsquo;API Platform. La 3.2 ne les supprime pas, mais introduit un moyen de s\u0026rsquo;en passer : passer event_listeners_backward_compatibility_layer: false dans la configuration d\u0026rsquo;API Platform désactive entièrement les hooks basés sur les événements. Le remplacement est un provider ou processor décoré par un autre provider ou processor. Le hook basé sur les événements était avec état, dépendant de l\u0026rsquo;ordre, et court-circuitait entièrement le contexte d\u0026rsquo;opération. Les providers décorés reçoivent l\u0026rsquo;objet opération et peuvent appeler le provider interne quand ils sont prêts.\nLe modèle d\u0026rsquo;état est maintenant complet La 3.0 a introduit l\u0026rsquo;architecture. La 3.1 a ajouté la séparation ressource/entité. La 3.2 ferme les lacunes restantes : les erreurs ont un contrat de ressource, les sous-ressources ont un modèle de déclaration propre, et la couche d\u0026rsquo;état couvre désormais tous les points d\u0026rsquo;extension que les event listeners géraient autrefois. Les shims 2.x existent encore, mais s\u0026rsquo;en passer n\u0026rsquo;est plus qu\u0026rsquo;une ligne de configuration.\n","permalink":"https://guillaumedelre.github.io/fr/2023/10/12/api-platform-3.2-les-erreurs-comme-ressources-et-le-retour-des-sous-ressources/","summary":"\u003cp\u003eAPI Platform 3.2 est arrivé en octobre 2023 avec trois changements qui ont fait avancer le modèle d\u0026rsquo;état : les erreurs sont devenues des ressources, les sous-ressources sont revenues sous une forme qui s\u0026rsquo;intègre vraiment dans l\u0026rsquo;architecture, et le dernier point d\u0026rsquo;extension hérité — les event listeners — a été formellement remplacé.\u003c/p\u003e\n\u003ch2 id=\"les-erreurs-comme-ressources\"\u003eLes erreurs comme ressources\u003c/h2\u003e\n\u003cp\u003eAvant la 3.2, la gestion des erreurs était en dehors du modèle de ressources. Les exceptions étaient interceptées par un event listener Symfony et converties en réponse, avec un contrôle limité sur la forme de la sortie.\u003c/p\u003e","title":"API Platform 3.2 : les erreurs comme ressources et le retour des sous-ressources"},{"content":"Quatre mois après la 3.0, API Platform 3.1 est arrivé avec le premier lot de fonctionnalités construites sur le nouveau modèle d\u0026rsquo;état. Tous les changements ne sont pas spectaculaires, mais l\u0026rsquo;un d\u0026rsquo;eux résout un problème qui a engendré beaucoup de contournements alambiqués en 2.x : votre ressource API n\u0026rsquo;a plus besoin d\u0026rsquo;être votre entité Doctrine.\nLa séparation ressource/entité En 2.x, API Platform fonctionnait mieux quand votre ressource API et votre modèle de persistance étaient la même classe. Utiliser un DTO comme surface API était possible via le système Input/Output DTO, mais ce système a été supprimé en 3.0 — il compliquait le modèle d\u0026rsquo;état sans apporter suffisamment de bénéfices.\nLa 3.1 le remplace par quelque chose de plus propre. Le paramètre stateOptions d\u0026rsquo;une opération accepte un objet DoctrineOrmOptions qui pointe vers une entité différente :\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\GetCollection; use ApiPlatform\\Doctrine\\Orm\\State\\Options; #[ApiResource( operations: [ new GetCollection( stateOptions: new Options(entityClass: BookEntity::class), ), ] )] class BookDto { public string $title; public string $author; } Le provider reçoit les objets BookEntity de Doctrine et la couche de sérialisation les mappe sur BookDto. Les filtres Doctrine, la pagination et le tri fonctionnent tous sur BookEntity. La surface API expose BookDto. Les deux peuvent évoluer indépendamment.\nÇa compte plus qu\u0026rsquo;il n\u0026rsquo;y paraît. Votre modèle de persistance accumule des champs internes, des relations et des colonnes qui n\u0026rsquo;ont aucune raison d\u0026rsquo;apparaître dans votre API. Avant la 3.1, on les exposait quand même ou on construisait un normaliseur élaboré pour les cacher. Maintenant on déclare ce que l\u0026rsquo;API expose comme classe distincte et on laisse le framework gérer la correspondance.\nUn PUT conforme à la spec Depuis la version 1.0, le handler PUT d\u0026rsquo;API Platform mettait à jour des ressources existantes. Créer une ressource via PUT — ce que la spec HTTP autorise explicitement — n\u0026rsquo;était pas supporté. La 3.1 ajoute la création basée sur l\u0026rsquo;uriTemplate :\n#[Put( uriTemplate: \u0026#39;/books/{id}\u0026#39;, allowCreate: true, )] Avec allowCreate: true, un PUT vers une URI qui n\u0026rsquo;existe pas crée la ressource au lieu de retourner une 404. L\u0026rsquo;identifiant vient de l\u0026rsquo;URI, pas du corps de la requête. C\u0026rsquo;est ce que la RFC 9110 décrit pour PUT : \u0026ldquo;Si la ressource cible n\u0026rsquo;a pas de représentation courante et que le PUT en crée une avec succès, le serveur d\u0026rsquo;origine DOIT informer le user agent en envoyant une réponse 201 (Created).\u0026rdquo;\nC\u0026rsquo;est un petit paramètre, mais il ouvre API Platform à des cas d\u0026rsquo;usage — création idempotente, identifiants assignés par le client — qui nécessitaient auparavant un contrôleur personnalisé.\nLes erreurs de dénormalisation collectées, pas jetées Avant la 3.1, les erreurs de désérialisation s\u0026rsquo;arrêtaient au premier problème. Envoyer un corps de requête avec cinq champs invalides renvoyait une erreur sur le premier. On le corrigeait, on renvoyait, on trouvait le deuxième. À répéter cinq fois.\nLa 3.1 ajoute une option collect_denormalization_errors sur l\u0026rsquo;opération qui change ce comportement :\n#[Post(collectDenormalizationErrors: true)] Avec cette option activée, API Platform intercepte toutes les erreurs de type et les violations de contraintes pendant la désérialisation et les retourne sous forme de liste structurée dans la réponse, formatée de la même façon que les erreurs de validation. Un seul aller-retour, une vue complète.\nApiResource::openapi remplace openapiContext L\u0026rsquo;ancien paramètre openapiContext acceptait un tableau brut fusionné dans le schéma OpenAPI généré — pratique mais non typé. La 3.1 introduit un paramètre openapi de premier ordre qui accepte un objet OpenApiOperation :\nuse ApiPlatform\\OpenApi\\Model\\Operation; use ApiPlatform\\OpenApi\\Model\\RequestBody; #[Post( openapi: new Operation( requestBody: new RequestBody( description: \u0026#39;Créer un livre\u0026#39;, required: true, ), summary: \u0026#39;Créer une nouvelle entrée livre\u0026#39;, ) )] L\u0026rsquo;ancien tableau openapiContext fonctionne encore mais est déprécié. La nouvelle approche est typée, compatible avec l\u0026rsquo;autocomplétion IDE, et se valide à la construction plutôt qu\u0026rsquo;à la génération du schéma. Les backed enums PHP 8.1 obtiennent également une génération de schéma OpenAPI correcte en 3.1 — un champ typé comme backed enum produit un schéma avec des valeurs enum et le type correct, sans annotation supplémentaire.\nLe pattern est clair La 3.0 a établi l\u0026rsquo;architecture. La 3.1 montre ce que cette architecture permet : une séparation propre ressource/entité sans système DTO parallèle, une sémantique HTTP correcte selon la RFC, un meilleur rapport d\u0026rsquo;erreurs. Aucune de ces fonctionnalités n\u0026rsquo;aurait été aussi propre à implémenter sur le modèle data provider de la 2.x. Les features de la 3.1 sont la première preuve que la réécriture était la bonne décision.\n","permalink":"https://guillaumedelre.github.io/fr/2023/01/23/api-platform-3.1-votre-ressource-na-pas-%C3%A0-%C3%AAtre-votre-entit%C3%A9/","summary":"\u003cp\u003eQuatre mois après la 3.0, API Platform 3.1 est arrivé avec le premier lot de fonctionnalités construites sur le nouveau modèle d\u0026rsquo;état. Tous les changements ne sont pas spectaculaires, mais l\u0026rsquo;un d\u0026rsquo;eux résout un problème qui a engendré beaucoup de contournements alambiqués en 2.x : votre ressource API n\u0026rsquo;a plus besoin d\u0026rsquo;être votre entité Doctrine.\u003c/p\u003e\n\u003ch2 id=\"la-séparation-ressourceentité\"\u003eLa séparation ressource/entité\u003c/h2\u003e\n\u003cp\u003eEn 2.x, API Platform fonctionnait mieux quand votre ressource API et votre modèle de persistance étaient la même classe. Utiliser un DTO comme surface API était possible via le système Input/Output DTO, mais ce système a été supprimé en 3.0 — il compliquait le modèle d\u0026rsquo;état sans apporter suffisamment de bénéfices.\u003c/p\u003e","title":"API Platform 3.1 : votre ressource n'a pas à être votre entité"},{"content":"PHP 8.2 est sorti le 8 décembre. Les classes readonly font les gros titres. La dépréciation des propriétés dynamiques, elle, demande votre attention concrète.\nLes propriétés dynamiques dépréciées PHP a toujours permis d\u0026rsquo;ajouter des propriétés à des objets sans les déclarer dans la classe :\nclass User {} $user = new User(); $user-\u0026gt;name = \u0026#39;Alice\u0026#39;; // aucune déclaration, aucune erreur... jusqu\u0026#39;ici En 8.2, ça déclenche un avertissement de dépréciation. En PHP 9.0, ce sera une erreur fatale. Le délai de grâce existe, mais le compteur tourne.\nLa logique est solide : les propriétés dynamiques sont une source classique de typos qui passent silencieusement (écrivez $user-\u0026gt;nmae et PHP crée simplement une nouvelle propriété au lieu de se plaindre). Des déclarations explicites rendent le contrat de la classe lisible et donnent aux outils matière à travailler.\nLa migration est essentiellement mécanique : déclarez les propriétés, ou posez #[AllowDynamicProperties] sur les classes legacy que vous ne pouvez pas encore toucher.\nLes classes readonly 8.1 avait ajouté readonly sur les propriétés individuelles. 8.2 l\u0026rsquo;ajoute sur la déclaration de classe elle-même :\nreadonly class Point { public function __construct( public float $x, public float $y, public float $z, ) {} } Toutes les propriétés promues et déclarées explicitement deviennent readonly automatiquement. Les value objects (coordonnées, montants monétaires, identifiants) sont la cible évidente. La syntaxe est propre et l\u0026rsquo;intention se lit clairement.\nUne contrainte : les classes readonly ne peuvent pas avoir de propriétés non typées, ce qui était déjà une mauvaise idée avec readonly de toute façon.\nLes types DNF Les types en Forme Normale Disjonctive permettent de combiner types union et types intersection :\nfunction process(Countable\u0026amp;Iterator|null $collection): void { ... } (Countable\u0026amp;Iterator)|null : un objet qui implémente les deux interfaces, ou null. Cela couvre des expressions de type que les unions de 8.0 et les intersections de 8.1 approchaient chacune sans pouvoir les représenter ensemble.\nL\u0026rsquo;extension Random Une extension Random dédiée remplace les fonctions éparpillées rand(), mt_rand(), random_int() par une API orientée objet :\n$rng = new Random\\Randomizer(); $rng-\u0026gt;getInt(1, 100); $rng-\u0026gt;shuffleArray($items); Les moteurs sont interchangeables : Mersenne Twister, PCG64, Xoshiro256StarStar, ou CryptoSafeEngine pour les contextes sensibles à la sécurité. Même code, moteur déterministe avec seed dans les tests, moteur cryptographique en production.\n8.2 est une version de consolidation. La dépréciation des propriétés dynamiques est la seule décision à prendre maintenant.\nnull, false, et true comme types autonomes PHP avait les types nullable depuis 7.1 et les types union depuis 8.0, mais null comme déclaration de type autonome n\u0026rsquo;était pas valide. 8.2 corrige ça :\nfunction alwaysNull(): null { return null; } function disabled(): false { return false; } function enabled(): true { return true; } false et true comme types autonomes sont utiles quand on veut être précis sur ce qu\u0026rsquo;une fonction peut réellement retourner. C\u0026rsquo;est étroit mais juste : une fonction qui retourne false en cas d\u0026rsquo;échec et une chaîne en cas de succès devrait déclarer string|false, et maintenant les deux côtés de cette union sont de vrais types.\nLes constantes dans les traits Les traits pouvaient contenir des propriétés et des méthodes. Les constantes étaient le trou dans la raquette. 8.2 le comble :\ntrait Timestamps { public const DATE_FORMAT = \u0026#39;Y-m-d H:i:s\u0026#39;; public function formatCreatedAt(): string { return $this-\u0026gt;createdAt-\u0026gt;format(self::DATE_FORMAT); } } class Article { use Timestamps; } echo Article::DATE_FORMAT; // \u0026#39;Y-m-d H:i:s\u0026#39; La constante appartient à la classe qui utilise le trait, pas au trait lui-même, donc on ne peut pas accéder à Timestamps::DATE_FORMAT directement. Comportement de scope attendu, cohérent avec le fonctionnement des méthodes de trait.\n#[SensitiveParameter] Les stack traces ont toujours été un risque : les arguments de fonction sont loggés verbatim, ce qui veut dire que les mots de passe et les tokens se retrouvent dans les logs d\u0026rsquo;erreur et les dashboards de monitoring. 8.2 ajoute un attribut pour stopper ça :\nfunction authenticate( string $user, #[\\SensitiveParameter] string $password, ): bool { // si ça lève une exception, la stack trace affiche : // authenticate(\u0026#39;alice\u0026#39;, Object(SensitiveParameterValue)) return hash(\u0026#39;sha256\u0026#39;, $password) === getStoredHash($user); } La valeur du paramètre dans la trace est remplacée par un objet SensitiveParameterValue. Un attribut, zéro excuse pour ne pas l\u0026rsquo;ajouter sur chaque fonction qui touche à des credentials.\nLes syntaxes d\u0026rsquo;interpolation de chaînes dépréciées Deux façons d\u0026rsquo;interpoler des expressions dans des chaînes sont dépréciées en 8.2 :\n$name = \u0026#39;world\u0026#39;; // Celles-ci sont dépréciées : echo \u0026#34;Hello ${name}\u0026#34;; // utiliser \u0026#34;$name\u0026#34; ou \u0026#34;{$name}\u0026#34; echo \u0026#34;Hello ${getName()}\u0026#34;; // utiliser \u0026#34;{$this-\u0026gt;getName()}\u0026#34; Les formes ${...} créaient une ambiguïté entre les variables variables et les expressions. La syntaxe plus propre {$...} a toujours été là et fait la même chose. C\u0026rsquo;est essentiellement un travail de recherche-remplacement sur les bases de code qui ont adopté les formes dépréciées par habitude.\nutf8_encode() et utf8_decode() dépréciées Ces deux fonctions sont dépréciées en 8.2 et disparaissent en 9.0. Leur comportement a toujours été plus étroit que ce que les noms suggéraient : utf8_encode() convertit ISO-8859-1 en UTF-8, pas \u0026ldquo;n\u0026rsquo;importe quel encodage en UTF-8\u0026rdquo;.\n// Déprécié en 8.2 : $utf8 = utf8_encode($latin1String); // Utiliser à la place : $utf8 = mb_convert_encoding($latin1String, \u0026#39;UTF-8\u0026#39;, \u0026#39;ISO-8859-1\u0026#39;); mb_convert_encoding() ou iconv() gèrent le cas général. Si vous traitez vraiment des entrées en Latin-1, le remplacement est direct.\nLes fonctions de chaîne indépendantes de la locale Plusieurs fonctions de chaîne variaient silencieusement leur comportement selon la locale système, produisant des résultats différents en production par rapport à un container de dev. En 8.2, elles sont indépendantes de la locale et ne gèrent que l\u0026rsquo;ASCII :\n// strtolower, strtoupper, stristr, stripos, strripos, // lcfirst, ucfirst, ucwords, str_ireplace font maintenant une conversion // de casse ASCII uniquement. // Pour un comportement sensible à la locale, utiliser les équivalents mb_* : $lowered = mb_strtolower($text, \u0026#39;UTF-8\u0026#39;); C\u0026rsquo;est un correctif de cohérence. Si votre code reposait sur le comportement sensible à la locale de ces fonctions, il était déjà cassé sur des systèmes avec des configurations de locale différentes. 8.2 rend le comportement déterministe partout, ce qui est ce qu\u0026rsquo;on voulait vraiment.\nstr_split() sur une chaîne vide Un changement de comportement discret à noter :\n// PHP 8.1 : str_split(\u0026#39;\u0026#39;) === [\u0026#39;\u0026#39;] // PHP 8.2 : str_split(\u0026#39;\u0026#39;) === [] Le nouveau comportement a plus de sens : découper rien ne produit rien. Si vous vérifiez count(str_split($input)), une entrée vide ne produit plus un count de 1.\n","permalink":"https://guillaumedelre.github.io/fr/2023/01/22/php-8.2-les-classes-readonly-et-la-d%C3%A9pr%C3%A9ciation-qui-compte-vraiment/","summary":"\u003cp\u003ePHP 8.2 est sorti le 8 décembre. Les classes readonly font les gros titres. La dépréciation des propriétés dynamiques, elle, demande votre attention concrète.\u003c/p\u003e\n\u003ch2 id=\"les-propriétés-dynamiques-dépréciées\"\u003eLes propriétés dynamiques dépréciées\u003c/h2\u003e\n\u003cp\u003ePHP a toujours permis d\u0026rsquo;ajouter des propriétés à des objets sans les déclarer dans la classe :\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUser\u003c/span\u003e {}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$user \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUser\u003c/span\u003e();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$user\u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Alice\u0026#39;\u003c/span\u003e; \u003cspan style=\"color:#75715e\"\u003e// aucune déclaration, aucune erreur... jusqu\u0026#39;ici\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eEn 8.2, ça déclenche un avertissement de dépréciation. En PHP 9.0, ce sera une erreur fatale. Le délai de grâce existe, mais le compteur tourne.\u003c/p\u003e","title":"PHP 8.2 : les classes readonly et la dépréciation qui compte vraiment"},{"content":"API Platform 3.0 est arrivé en septembre 2022 avec Symfony 6.1 comme prérequis strict et une architecture de base qui ne ressemblait en rien à la 2.x. Le guide de migration est long. La raison pour laquelle il est long est intéressante.\nL\u0026rsquo;ancien modèle avait une fuite conceptuelle. DataProviderInterface et DataPersisterInterface étaient appelés pour chaque requête HTTP, mais le provider recevait le contexte de l\u0026rsquo;opération comme un indice — pas comme un contrat. Un provider de collection et un provider d\u0026rsquo;item étaient des interfaces distinctes, mais les deux vivaient dans le même seau mental : \u0026ldquo;choses qui retournent des données.\u0026rdquo; La couche HTTP savait ce qui était demandé ; le provider devait reconstruire cette connaissance à partir d\u0026rsquo;indices passés dans le tableau $context.\nLa 3.0 inverse le modèle. Les opérations sont déclarées en premier. L\u0026rsquo;accès aux données est câblé aux opérations.\nLes state providers remplacent les data providers L\u0026rsquo;ancien DataProviderInterface a disparu. Le remplaçant est ProviderInterface :\nuse ApiPlatform\\State\\ProviderInterface; use ApiPlatform\\Metadata\\Operation; class BookProvider implements ProviderInterface { public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { if ($operation instanceof CollectionOperationInterface) { return $this-\u0026gt;repository-\u0026gt;findAll(); } return $this-\u0026gt;repository-\u0026gt;find($uriVariables[\u0026#39;id\u0026#39;]); } } La différence n\u0026rsquo;est pas syntaxique. En 2.x, on enregistrait un provider et API Platform l\u0026rsquo;appelait pour toute ressource correspondante. En 3.0, on lie un provider à une opération spécifique. Le provider n\u0026rsquo;a plus à deviner ce qui l\u0026rsquo;a déclenché — l\u0026rsquo;objet opération qu\u0026rsquo;il reçoit est le contrat.\nLes state processors remplacent les data persisters DataPersisterInterface avait le même problème côté écriture : une seule classe gérant la création, la mise à jour et la suppression, les distinguant en inspectant la méthode HTTP ou l\u0026rsquo;état de l\u0026rsquo;objet. ProcessorInterface reçoit l\u0026rsquo;opération comme argument typé :\nuse ApiPlatform\\State\\ProcessorInterface; use ApiPlatform\\Metadata\\Operation; class BookProcessor implements ProcessorInterface { public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { $this-\u0026gt;entityManager-\u0026gt;persist($data); $this-\u0026gt;entityManager-\u0026gt;flush(); return $data; } } Plus utile encore : on peut lier un processor différent par opération. L\u0026rsquo;opération de suppression en reçoit un qui supprime. L\u0026rsquo;opération de création en reçoit un qui valide et stocke. Pas de switch, pas d\u0026rsquo;inspection de méthode, pas de classe partagée qui essaie d\u0026rsquo;être trois choses à la fois.\nLes opérations déclarées explicitement en attributs PHP 8.1 L\u0026rsquo;autre moitié de la 3.0 est la couche de métadonnées. Les annotations Doctrine sont remplacées par des attributs natifs PHP 8.1, et chaque opération est déclarée explicitement sur la classe ressource :\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\Get; use ApiPlatform\\Metadata\\GetCollection; use ApiPlatform\\Metadata\\Post; #[ApiResource( operations: [ new GetCollection(provider: BookProvider::class), new Get(provider: BookProvider::class), new Post(processor: BookProcessor::class), ] )] class Book { // ... } C\u0026rsquo;est plus verbeux que @ApiResource avec ses valeurs par défaut magiques. C\u0026rsquo;est aussi explicite. On sait exactement quelles opérations HTTP existent pour cette ressource, ce qui récupère les données, ce qui les écrit, et où vit la logique. Les valeurs par défaut de la 2.x étaient pratiques jusqu\u0026rsquo;au jour où il fallait en surcharger une sans réussir à déterminer quel service décorer sans lire le code source.\nPHP 8.1 n\u0026rsquo;était pas un hasard L\u0026rsquo;exigence stricte pour PHP 8.1 est structurante. Les callables de première classe rendent l\u0026rsquo;enregistrement des filtres plus propre. L\u0026rsquo;immuabilité des métadonnées d\u0026rsquo;opération est assurée par un pattern de clonage (méthodes withX()) qui s\u0026rsquo;appuie sur les arguments nommés et les propriétés de constructeur promues — des fondations PHP 8.0 sur lesquelles l\u0026rsquo;architecture s\u0026rsquo;appuie massivement.\nPlus concrètement : l\u0026rsquo;expression complète de l\u0026rsquo;architecture 3.0 — opérations typées, providers scopés à l\u0026rsquo;opération, métadonnées explicites — avait besoin de la 8.1 pour ne pas ressembler à des contournements. Abandonner PHP 7.x et 8.0 n\u0026rsquo;était pas une décision de nettoyage.\nLa migration est un vrai travail Le passage de 2.x à 3.0 n\u0026rsquo;est pas un simple bump de version. Chaque DataProvider devient un ProviderInterface. Chaque DataPersister devient un ProcessorInterface. Les annotations deviennent des attributs. Les normaliseurs et filtres personnalisés peuvent nécessiter une restructuration. Le guide de mise à jour documente tout cela, mais \u0026ldquo;documenté\u0026rdquo; ne veut pas dire \u0026ldquo;rapide.\u0026rdquo;\nCe qu\u0026rsquo;on obtient de l\u0026rsquo;autre côté est une architecture qui passe à l\u0026rsquo;échelle sans la complexité ambiante de la 2.x : plus besoin de deviner quelle interface implémenter, plus de chaînes $this-\u0026gt;supports(), plus de valeurs par défaut invisibles qui surchargent silencieusement la config explicite.\nLa 3.0 est l\u0026rsquo;API Platform qu\u0026rsquo;on concevrait de zéro en sachant ce qu\u0026rsquo;on sait après des années de 2.x. Le prix est la migration. Le numéro de version est honnête là-dessus.\n","permalink":"https://guillaumedelre.github.io/fr/2022/11/18/api-platform-3.0-un-nouveau-mod%C3%A8le-d%C3%A9tat-et-la-fin-des-dataproviders/","summary":"\u003cp\u003eAPI Platform 3.0 est arrivé en septembre 2022 avec Symfony 6.1 comme prérequis strict et une architecture de base qui ne ressemblait en rien à la 2.x. Le guide de migration est long. La raison pour laquelle il est long est intéressante.\u003c/p\u003e\n\u003cp\u003eL\u0026rsquo;ancien modèle avait une fuite conceptuelle. \u003ccode\u003eDataProviderInterface\u003c/code\u003e et \u003ccode\u003eDataPersisterInterface\u003c/code\u003e étaient appelés pour chaque requête HTTP, mais le provider recevait le contexte de l\u0026rsquo;opération comme un indice — pas comme un contrat. Un provider de collection et un provider d\u0026rsquo;item étaient des interfaces distinctes, mais les deux vivaient dans le même seau mental : \u0026ldquo;choses qui retournent des données.\u0026rdquo; La couche HTTP savait ce qui était demandé ; le provider devait reconstruire cette connaissance à partir d\u0026rsquo;indices passés dans le tableau \u003ccode\u003e$context\u003c/code\u003e.\u003c/p\u003e","title":"API Platform 3.0 : un nouveau modèle d'état et la fin des DataProviders"},{"content":"J\u0026rsquo;ai utilisé Vagrant pendant des années. Un Vagrantfile par projet, une box de base partagée, un script de provision qui marchait le mardi mais pas le jeudi. La promesse était simple : des environnements reproductibles pour tout le monde dans l\u0026rsquo;équipe. La réalité était plus compliquée.\nLes années Vagrant Le setup avait du sens à l\u0026rsquo;époque. Une VM par projet, provisionnée avec des scripts shell ou Ansible, partagée via un Vagrantfile versionné. L\u0026rsquo;onboarding était théoriquement vagrant up et c\u0026rsquo;est terminé.\nEn pratique, c\u0026rsquo;était vagrant up, attendre quatre minutes, regarder la provision échouer sur un package qui avait changé son URL de téléchargement, corriger, reprovisionner, attendre à nouveau. Les Vagrantfiles accumulaient de la configuration au fil du temps : des contournements pour des machines spécifiques, du pinning de version d\u0026rsquo;OS, des ajustements mémoire pour le membre de l\u0026rsquo;équipe dont le laptop n\u0026rsquo;avait que 8 Go. Les fichiers devenaient des documents historiques que personne ne voulait toucher.\nLa VM elle-même était l\u0026rsquo;autre problème. Le boot prenait du temps. Faire tourner la VM consommait de la mémoire et du CPU qui auraient pu aller à l\u0026rsquo;application. La synchronisation des fichiers entre host et guest ajoutait une latence qui faisait paraître les applications PHP plus lentes qu\u0026rsquo;elles n\u0026rsquo;avaient le droit d\u0026rsquo;être. L\u0026rsquo;overhead était significatif pour ce qui était finalement juste \u0026ldquo;faire tourner un serveur web\u0026rdquo;.\nOn vivait avec parce que tout le monde le faisait. Vagrant était le standard pour le développement PHP local, et l\u0026rsquo;alternative (chaque développeur gérant son propre stack LAMP) était clairement pire.\nLe projet qui a changé le modèle Le changement n\u0026rsquo;était pas une décision qu\u0026rsquo;on a prise. C\u0026rsquo;était un projet qui est arrivé déjà conteneurisé.\nUn nouveau projet client avait un docker-compose.yml à la racine, un Dockerfile, et un README qui disait docker compose up. On l\u0026rsquo;a lancé. Les conteneurs ont démarré en secondes. PHP-FPM, nginx, PostgreSQL, Redis : tout tournait, tout était en réseau, pas d\u0026rsquo;étape de provision. Arrêter les conteneurs, les redémarrer, même état.\nLe contraste avec notre setup Vagrant était immédiat. Pas plus rapide d\u0026rsquo;un pourcentage : plus rapide d\u0026rsquo;un ordre de grandeur différent. Et le fichier Compose était réellement lisible : chaque service, son image, ses volumes, ses variables d\u0026rsquo;environnement, ses dépendances. Comparé à un script de provision qui SSH-ait dans une VM et lançait apt-get, c\u0026rsquo;était lisible.\nOn a tout migré. Pas progressivement, tout à la fois, sur un sprint. Chaque projet a reçu un docker-compose.yml. Chaque Vagrantfile a été supprimé. La transition a été les trois semaines de travail d\u0026rsquo;infrastructure les plus douloureuses dont je me souvienne, et aussi les plus clairement valables.\nCe que docker-compose a vraiment changé Au-delà de la vitesse, Compose a changé le modèle mental. Vagrant abstrayait une machine. Compose abstrayait un ensemble de processus. La distinction compte : avec Compose, on peut arrêter la base de données sans arrêter le serveur d\u0026rsquo;application, scaler un service worker indépendamment, échanger l\u0026rsquo;image PostgreSQL contre une version plus récente sans toucher à quoi que ce soit d\u0026rsquo;autre.\nLa déclaration services a aussi entièrement remplacé le problème de provision des VMs. Si un nouveau développeur rejoint l\u0026rsquo;équipe, il ne lance pas un script de provision qui peut ou ne pas fonctionner sur sa version d\u0026rsquo;OS. Il lance docker compose up et obtient exactement les mêmes images que tout le monde.\nLe CI/CD est devenu plus simple aussi. Le même docker-compose.yml qui tournait en local pouvait tourner dans le pipeline. La parité d\u0026rsquo;environnement que Vagrant promettait mais livrait rarement était réellement réelle avec Compose.\nLa dépréciation silencieuse Pendant des années, la commande était docker-compose : un binaire séparé, installé indépendamment de Docker lui-même, écrit en Python, versionné indépendamment. On l\u0026rsquo;utilisait, ça marchait, personne n\u0026rsquo;y pensait vraiment.\nÀ un moment, un collègue a mentionné que Docker avait intégré Compose directement dans le CLI docker. La nouvelle commande était docker compose, sans tiret, réécriture en Go, intégré avec Docker Desktop. L\u0026rsquo;ancien binaire docker-compose était déprécié.\nOn avait utilisé v1 pendant deux ans après que v2 était sortie. Nos scripts CI, nos Makefiles, notre documentation disaient tous docker-compose. Rien n\u0026rsquo;avait cassé parce que Docker avait maintenu l\u0026rsquo;ancien binaire longtemps. Mais l\u0026rsquo;écosystème avait évolué silencieusement, et on l\u0026rsquo;avait raté.\nLa migration était triviale : un tiret retiré de chaque script, quelques alias mis à jour. La leçon était moins triviale. Les outils d\u0026rsquo;infrastructure évoluent sans cérémonie. L\u0026rsquo;annonce avait eu lieu, les articles de blog avaient été écrits, les notices de dépréciation étaient là. On ne faisait juste pas attention.\nLa vraie rétrospective En regardant en arrière à travers Vagrant → docker-compose → docker compose, le pattern concerne moins les outils que les defaults.\nVagrant defaultait à \u0026ldquo;ça marche sur ma VM\u0026rdquo;. L\u0026rsquo;overhead de partager cette VM était permanent.\nCompose defaultait à \u0026ldquo;ça marche dans ces conteneurs\u0026rdquo;. Les images sont les artefacts ; la machine host est hors sujet.\nLe tiret entre docker et compose a toujours été cosmétique. Ce qui comptait, c\u0026rsquo;était le passage des machines provisionnées aux services déclaratifs. Ce passage a eu lieu le jour où on a lancé un projet que quelqu\u0026rsquo;un d\u0026rsquo;autre avait conteneurisé et où on a réalisé qu\u0026rsquo;on ne voulait jamais revenir en arrière.\n","permalink":"https://guillaumedelre.github.io/fr/2022/04/18/de-vagrant-%C3%A0-docker-compose-une-r%C3%A9trospective/","summary":"\u003cp\u003eJ\u0026rsquo;ai utilisé Vagrant pendant des années. Un Vagrantfile par projet, une box de base partagée, un script de provision qui marchait le mardi mais pas le jeudi. La promesse était simple : des environnements reproductibles pour tout le monde dans l\u0026rsquo;équipe. La réalité était plus compliquée.\u003c/p\u003e\n\u003ch2 id=\"les-années-vagrant\"\u003eLes années Vagrant\u003c/h2\u003e\n\u003cp\u003eLe setup avait du sens à l\u0026rsquo;époque. Une VM par projet, provisionnée avec des scripts shell ou Ansible, partagée via un Vagrantfile versionné. L\u0026rsquo;onboarding était théoriquement \u003ccode\u003evagrant up\u003c/code\u003e et c\u0026rsquo;est terminé.\u003c/p\u003e","title":"De Vagrant à Docker Compose : une rétrospective"},{"content":"On a migré une plateforme de microservices médias vers Symfony 6 début 2022. Douze services, la plupart consommant des messages depuis RabbitMQ via Swarrot. Symfony 6 a rendu Messenger plus central que jamais, et pendant la planification de la migration un développeur a posé la question évidente : pourquoi ne pas migrer en même temps ?\nÇa vient avec le framework. Ça a de la logique de retry, du support AMQP natif, de la documentation first-party. Notre setup ressemblait à de l\u0026rsquo;artisanat par comparaison.\nQuestion légitime. On l\u0026rsquo;a prise au sérieux. Voilà ce qu\u0026rsquo;on a trouvé.\nCâbler la topologie à la main Swarrot est une bibliothèque consumer qui enveloppe l\u0026rsquo;extension PECL AMQP. Elle lit des octets depuis une queue, les fait passer à travers une chaîne de processors (leur terme pour middleware), et laisse votre code décider quoi faire avec le payload. C\u0026rsquo;est vraiment tout.\nLa chaîne de middleware est la partie intéressante. Les processors sont des décorateurs imbriqués, chacun enveloppant le suivant. Les couches extérieures gèrent les préoccupations d\u0026rsquo;infrastructure avant même que le message n\u0026rsquo;atteigne la logique métier :\nmiddleware_stack: - configurator: \u0026#39;swarrot.processor.signal_handler\u0026#39; - configurator: \u0026#39;swarrot.processor.max_execution_time\u0026#39; - configurator: \u0026#39;swarrot.processor.exception_catcher\u0026#39; - configurator: \u0026#39;swarrot.processor.doctrine_object_manager\u0026#39; - configurator: \u0026#39;swarrot.processor.ack\u0026#39; - configurator: \u0026#39;app.processor.retry\u0026#39; signal_handler est en tête parce qu\u0026rsquo;il doit intercepter SIGTERM avant que tout autre processor ne le voie. ack est près du bas parce qu\u0026rsquo;on n\u0026rsquo;acquitte le message qu\u0026rsquo;après que le traitement réussit. L\u0026rsquo;ordre n\u0026rsquo;est pas arbitraire, et il est entièrement visible dans la configuration.\nLa topologie est tout aussi explicite. On déclare tout soi-même : exchanges, routing keys, queues de retry, queues de lettres mortes :\nmessages_types: content.ingest: exchange: e.app.content routing_key: q.app.content.ingest content.ingest_retry: exchange: e.app.content routing_key: q.app.content.ingest.retry content.ingest_dead: exchange: e.app.content routing_key: q.app.content.ingest.dead Trois entrées par type de message logique : queue principale, queue de retry, queue de lettres mortes. Tout ce qui existe sur le broker est nommé ici. La config est verbeuse mais honnête : pas d\u0026rsquo;inférence, pas de convention plutôt que configuration. Si une queue existe dans RabbitMQ, on peut la tracer jusqu\u0026rsquo;à une seule ligne de YAML.\nQuand le nom de classe devient la route Symfony Messenger opère un niveau plus haut. On définit une classe de message, un handler, et un transport. La bibliothèque gère la sérialisation, le routing, le retry et les queues d\u0026rsquo;échec automatiquement.\nclass IngestContent { public function __construct( public readonly string $contentId, public readonly string $source, ) {} } framework: messenger: transports: async: dsn: \u0026#39;%env(MESSENGER_TRANSPORT_DSN)%\u0026#39; retry_strategy: max_retries: 3 delay: 1000 routing: \u0026#39;App\\Message\\IngestContent\u0026#39;: async Messenger sérialise l\u0026rsquo;objet, le met sur le transport, et le désérialise de l\u0026rsquo;autre côté dans la classe correcte. Pas de topologie manuelle, pas de noms d\u0026rsquo;exchange explicites. Le nom de classe est la primitive de routing.\nCette dernière phrase est exactement là où les choses se sont compliquées pour nous.\nQuand le typage devient du couplage Messenger suppose que le producteur et le consumer partagent une définition de classe PHP. C\u0026rsquo;est bien pour une seule application, ou pour des services qui partagent un package de contrats dédié. Dans un monorepo d\u0026rsquo;applications Symfony indépendantes, ça crée un couplage qui n\u0026rsquo;existe tout simplement pas aujourd\u0026rsquo;hui.\nPrenez un message d\u0026rsquo;ingestion de contenu que douze services consomment. Avec Swarrot, chaque service lit le payload JSON brut et prend les champs qui l\u0026rsquo;intéressent. Ajouter un nouveau champ signifie mettre à jour le producteur. Les consumers qui n\u0026rsquo;ont pas besoin du champ continuent de fonctionner sans modification.\nAvec Messenger, IngestContent doit être définie quelque part que les douze services peuvent référencer. Ça signifie soit :\nUn package PHP partagé, versionné, déployé et maintenu à travers les services. Chaque changement de schéma devient un exercice de coordination inter-services. Des classes dupliquées dans chaque service, qui divergent silencieusement sous la pression. Ni l\u0026rsquo;une ni l\u0026rsquo;autre n\u0026rsquo;est gratuite. L\u0026rsquo;approche package partagé inverse le modèle de propriété : le schéma de message devient une dépendance plutôt qu\u0026rsquo;un contrat défini à la frontière. L\u0026rsquo;approche duplication est juste le problème original différé.\nLa différence fondamentale est ce que représente un message. Messenger est conçu pour des commandes typées : un objet qui porte du sens et se distribue à un handler spécifique. Swarrot traite les messages comme des données opaques : des octets qui coulent à travers une topologie, traités par le consumer qui écoute. Si vos messages sont des données, l\u0026rsquo;abstraction supplémentaire qu\u0026rsquo;ajoute Messenger ne vous aide pas. Elle crée de la friction.\nLe bloquant Le problème de sérialisation était le décisif. Dans un monorepo où les services sont autonomes, partager des classes PHP entre eux n\u0026rsquo;est pas architecturalement neutre : c\u0026rsquo;est une décision de couplage qui rend les changements futurs plus difficiles. On aurait échangé une bibliothèque nominalement \u0026ldquo;legacy\u0026rdquo; pour une plus moderne tout en introduisant exactement le genre de couplage fort qu\u0026rsquo;on avait passé des années à éviter.\nIl y avait des préoccupations secondaires aussi. L\u0026rsquo;extension PECL AMQP donne un accès direct aux fonctionnalités du broker (priorités de messages, TTL par queue, routing par headers exchange) que Messenger abstrait. Et migrer quinze consumers sans jour J signifie faire tourner les deux bibliothèques en parallèle, ce qui est une vraie contrainte opérationnelle.\nMais le problème de sérialisation seul aurait suffi.\nDonnées ou commandes : voilà la question Le choix ne concerne pas la qualité des bibliothèques. Messenger est bien maintenu, bien documenté, et s\u0026rsquo;intègre proprement dans l\u0026rsquo;écosystème Symfony.\nLa question à se poser en premier est : que sont vos messages ?\nSi ce sont des commandes typées avec un schéma connu et un seul consumer faisant autorité, Messenger est un choix naturel. On écrit une classe, un handler, on configure un transport, et l\u0026rsquo;infrastructure gère le reste.\nSi ce sont des payloads de données consommés par plusieurs services indépendants, chacun possédant sa propre désérialisation, l\u0026rsquo;abstraction qu\u0026rsquo;ajoute Messenger joue contre vous. La topologie explicite de Swarrot et son modèle de payload brut donnent plus de contrôle là où on en a vraiment besoin.\nUne vraie limitation à garder à l\u0026rsquo;esprit : Swarrot est lié à l\u0026rsquo;extension PECL AMQP, qui n\u0026rsquo;implémente qu\u0026rsquo;AMQP 0-9-1. Ce qui signifie que RabbitMQ (ou un broker compatible) est une dépendance dure. Si l\u0026rsquo;infrastructure migre un jour vers un broker AMQP 1.0 (Azure Service Bus, ActiveMQ Artemis), Swarrot ne peut pas suivre. La couche transport de Messenger abstrait ça proprement : changer de broker signifie changer un DSN, pas réécrire les consumers.\nSi la portabilité de broker est une exigence, ou susceptible de le devenir, ça change significativement le calcul.\nSwarrot n\u0026rsquo;est pas du legacy à migrer. Pour l\u0026rsquo;instant, c\u0026rsquo;est le bon choix : le routing AMQP comme primitive, les messages comme données, RabbitMQ comme choix d\u0026rsquo;infrastructure long terme.\nÇa pourrait changer. Un package de contrats partagé, une nouvelle exigence de broker, un service greenfield qui ne porte pas le poids de la topologie existante : n\u0026rsquo;importe lequel de ces éléments pourrait faire pencher la balance vers Messenger. La bibliothèque n\u0026rsquo;est pas inadaptée à cette plateforme. Elle est peut-être juste la bonne réponse pour une version future de celle-ci.\n","permalink":"https://guillaumedelre.github.io/fr/2022/01/26/swarrot-vs-symfony-messenger-une-comparaison-en-conditions-r%C3%A9elles/","summary":"\u003cp\u003eOn a migré une plateforme de microservices médias vers Symfony 6 début 2022. Douze services, la plupart consommant des messages depuis RabbitMQ via \u003ca href=\"https://github.com/swarrot/swarrot\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eSwarrot\u003c/a\u003e. Symfony 6 a rendu \u003ca href=\"https://symfony.com/doc/current/messenger.html\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eMessenger\u003c/a\u003e plus central que jamais, et pendant la planification de la migration un développeur a posé la question évidente : pourquoi ne pas migrer en même temps ?\u003c/p\u003e\n\u003cp\u003eÇa vient avec le framework. Ça a de la logique de retry, du support AMQP natif, de la documentation first-party. Notre setup ressemblait à de l\u0026rsquo;artisanat par comparaison.\u003c/p\u003e","title":"Swarrot vs Symfony Messenger : une comparaison en conditions réelles"},{"content":"Symfony 6.0 est sorti le 29 novembre 2021. La caractéristique définissante : PHP 8.1 est le minimum. Pas supporté, requis. L\u0026rsquo;équipe de releases a attendu que PHP 8.1 sorte, puis a coupé Symfony 6.0 le lendemain.\nCe n\u0026rsquo;est pas juste un bump de version. C\u0026rsquo;est un engagement à construire contre le langage actuel plutôt que le plancher historique.\nLe système de sécurité, enfin reconstruit Le composant de sécurité Symfony a deux systèmes. L\u0026rsquo;ancien (AnonymousToken, GuardAuthenticatorInterface, un enchevêtrement d\u0026rsquo;interfaces qui vous faisaient implémenter des méthodes dont vous n\u0026rsquo;aviez pas besoin) avait été déprécié. 6.0 le supprime entièrement.\nLe nouveau système de sécurité (security.enable_authenticator_manager: true en 5.x) est maintenant le seul système. C\u0026rsquo;est plus propre : une seule interface à implémenter, séparation claire entre authentification et autorisation, vérification des credentials basée sur des passeports. La migration depuis les anciens guard authenticators n\u0026rsquo;est pas indolore, mais la destination est beaucoup moins confuse.\nLa classe Path du Filesystem Travailler avec des chemins de fichiers en PHP est fondamentalement un problème de manipulation de strings. __DIR__, concaténation, realpath(), séparateurs spécifiques à la plateforme : la bibliothèque standard donne des primitives mais pas vraiment un modèle.\nLa nouvelle classe Path gère ça :\nuse Symfony\\Component\\Filesystem\\Path; Path::join(\u0026#39;/var/www\u0026#39;, \u0026#39;html\u0026#39;, \u0026#39;../uploads\u0026#39;); // /var/www/uploads Path::makeRelative(\u0026#39;/var/www/html\u0026#39;, \u0026#39;/var/www\u0026#39;); // html Path::isAbsolute(\u0026#39;./relative/path\u0026#39;); // false Multiplateforme, sans effets de bord, sans accès au filesystem nécessaire. Aussi dans 6.0 : support des patterns .gitignore imbriqués dans Finder.\nLes enums dans le système de formulaires En s\u0026rsquo;appuyant sur les fondations posées par 5.4, 6.0 pousse le support des enums plus loin. Les valeurs BackedEnum font des allers-retours à travers les formulaires et le sérialiseur sans transformateurs personnalisés. Le composant de formulaire comprend les cases d\u0026rsquo;enum comme options de choix nativement.\nCe que 6.0 supprime La liste des suppressions est extensive : l\u0026rsquo;ancien système de sécurité, le composant Templating, le support des annotations PHP (remplacées par les attributs natifs), le support du cache Doctrine, ContainerAwareTrait. Six années de marqueurs @deprecated accumulés, finalement nettoyés.\nLes applications qui avaient pris au sérieux les warnings de dépréciation de 5.4 avaient un chemin de migration propre. Celles qui ne l\u0026rsquo;avaient pas fait avaient du travail à faire.\nLa complétion automatique était toujours le manque Le composant Console a reçu l\u0026rsquo;autocomplétion shell, et c\u0026rsquo;est proprement intégré : définir une méthode complete() sur sa commande, et Tab dans Bash suggère des valeurs valides pour les options et arguments.\nclass DeployCommand extends Command { public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { if ($input-\u0026gt;mustSuggestOptionValuesFor(\u0026#39;env\u0026#39;)) { $suggestions-\u0026gt;suggestValues([\u0026#39;prod\u0026#39;, \u0026#39;staging\u0026#39;, \u0026#39;dev\u0026#39;]); } } } Toutes les commandes Symfony intégrées ont reçu la complétion aussi : debug:router, cache:pool:clear, lint:yaml, et une quinzaine d\u0026rsquo;autres. Exécuter bin/console completion bash \u0026gt;\u0026gt; ~/.bashrc et c\u0026rsquo;est terminé.\nMessenger, maintenant avec attributs et traitement par lots L\u0026rsquo;attribut #[AsMessageHandler] remplace l\u0026rsquo;ancienne MessageHandlerInterface. Moins de boilerplate, et on peut maintenant configurer l\u0026rsquo;affinité de transport et la priorité directement sur l\u0026rsquo;attribut :\n#[AsMessageHandler(fromTransport: \u0026#39;async\u0026#39;, priority: 10)] class SendWelcomeEmailHandler { public function __invoke(UserRegistered $message): void { ... } } L\u0026rsquo;autre ajout significatif : BatchHandlerInterface. Quand on insère un millier de lignes, traiter les messages un par un est du gaspillage. Les batch handlers collectent les messages et les traitent en groupes. La taille de lot par défaut est 10, contrôlée par BatchHandlerTrait::shouldFlush(). L\u0026rsquo;Acknowledger gère le succès et l\u0026rsquo;échec individuels dans le lot.\nreset_on_message: true dans la config Messenger réinitialise les services du conteneur entre les messages. Auparavant, un buffer Monolog pouvait se remplir à travers la gestion des messages et personne ne s\u0026rsquo;en rendait compte avant la production. Ça évite cette catégorie de bugs de stateful sans nécessiter de nettoyage manuel.\nLe conteneur DI devient plus expressif Trois changements qui comptent en pratique.\nLes types union et intersection s\u0026rsquo;autowire maintenant. PHP 8.1 a ajouté les types intersection, et Symfony 6.0 les câble :\npublic function __construct( private NormalizerInterface\u0026amp;DenormalizerInterface $serializer ) {} Ça fonctionne tant que les deux interfaces pointent vers le même service via les alias d\u0026rsquo;autowiring.\nTaggedIterator et TaggedLocator ont reçu les options defaultPriorityMethod et defaultIndexMethod. On n\u0026rsquo;a plus besoin de YAML pour exprimer l\u0026rsquo;ordonnancement ou l\u0026rsquo;indexation pour les services taggués :\npublic function __construct( #[TaggedIterator(tag: \u0026#39;app.handler\u0026#39;, defaultPriorityMethod: \u0026#39;getPriority\u0026#39;)] private iterable $handlers, ) {} SubscribedService (l\u0026rsquo;attribut qui remplace la magie implicite de ServiceSubscriberTrait) rend l\u0026rsquo;accès paresseux aux services explicite et typable :\n#[SubscribedService] private function mailer(): MailerInterface { return $this-\u0026gt;container-\u0026gt;get(__METHOD__); } La validation reçoit trois nouveaux outils CssColor valide les valeurs de couleurs CSS dans les formats voulus : hex, RGB, HSL, couleurs nommées, ou n\u0026rsquo;importe quel mélange. Utile pour les champs de configuration de thème où on veut accepter #ff0000 mais pas red, ou vice versa.\n#[Assert\\CssColor(formats: Assert\\CssColor::HEX_LONG)] private string $brandColor; Cidr valide la notation CIDR pour IPv4 et IPv6, avec des options pour fixer la version et contraindre la plage de masque réseau. Les outils d\u0026rsquo;infrastructure et les formulaires de config réseau ont enfin une contrainte de première classe.\nLe troisième ajout n\u0026rsquo;est pas une nouvelle contrainte. Ce sont les attributs imbriqués PHP 8.1 qui rendent les contraintes composées existantes utilisables sans XML. AtLeastOneOf, Collection, All, Sequentially : tout ça nécessitait auparavant des contournements d\u0026rsquo;annotation. Maintenant ça fonctionne juste comme attributs :\n#[Assert\\Collection( fields: [ \u0026#39;email\u0026#39; =\u0026gt; new Assert\\Email(), \u0026#39;role\u0026#39; =\u0026gt; [new Assert\\NotBlank(), new Assert\\Choice([\u0026#39;admin\u0026#39;, \u0026#39;user\u0026#39;])], ] )] private array $payload; Le sérialiseur, nettoyé Deux choses. D\u0026rsquo;abord, le contexte de sérialisation est maintenant configurable globalement au lieu d\u0026rsquo;être répété à chaque appel serialize() :\n# config/packages/serializer.yaml serializer: default_context: enable_max_depth: true Ensuite, l\u0026rsquo;option COLLECT_DENORMALIZATION_ERRORS change comment le sérialiseur gère les erreurs de type à la désérialisation. Au lieu de lever une exception au premier problème, il les collecte tous et les expose via PartialDenormalizationException. Si on écrit une API qui désérialise des corps de requête, c\u0026rsquo;est la différence entre retourner \u0026ldquo;le premier champ qui échoue\u0026rdquo; et \u0026ldquo;tous les champs qui échouent\u0026rdquo; dans une seule réponse.\nLes utilitaires de string que personne ne savait vouloir trimPrefix() et trimSuffix() sur les classes UnicodeString / ByteString. Pas glamour, mais supprimer un préfixe connu avec ltrim() est un piège subtil : ça supprime des caractères, pas des strings. Ceux-ci sont corrects :\nuse function Symfony\\Component\\String\\u; u(\u0026#39;file-image-001.png\u0026#39;)-\u0026gt;trimPrefix(\u0026#39;file-\u0026#39;); // \u0026#39;image-001.png\u0026#39; u(\u0026#39;report.html.twig\u0026#39;)-\u0026gt;trimSuffix(\u0026#39;.twig\u0026#39;); // \u0026#39;report.html\u0026#39; Aussi dans cette version : NilUlid pour les ULIDs à valeur zéro, perMonth() et perYear() sur RateLimiter pour quand les limites horaires n\u0026rsquo;ont pas de sens, et appendToFile() dans le composant Filesystem a reçu un paramètre LOCK_EX optionnel pour les écrivains concurrents.\nDéboguer l\u0026rsquo;environnement debug:dotenv est une nouvelle commande console qui montre quels fichiers .env ont été chargés et d\u0026rsquo;où vient chaque valeur. Quand on a .env, .env.local, .env.test, et .env.test.local qui se battent et que quelque chose ne va pas, cette commande dit exactement quel fichier a gagné. Elle n\u0026rsquo;apparaît que quand le composant Dotenv est utilisé, ce qui est le cas pour toute application Symfony standard.\n","permalink":"https://guillaumedelre.github.io/fr/2022/01/12/symfony-6.0-php-8.1-uniquement-et-le-syst%C3%A8me-de-s%C3%A9curit%C3%A9-reconstruit/","summary":"\u003cp\u003eSymfony 6.0 est sorti le 29 novembre 2021. La caractéristique définissante : PHP 8.1 est le minimum. Pas supporté, requis. L\u0026rsquo;équipe de releases a attendu que PHP 8.1 sorte, puis a coupé Symfony 6.0 le lendemain.\u003c/p\u003e\n\u003cp\u003eCe n\u0026rsquo;est pas juste un bump de version. C\u0026rsquo;est un engagement à construire contre le langage actuel plutôt que le plancher historique.\u003c/p\u003e\n\u003ch2 id=\"le-système-de-sécurité-enfin-reconstruit\"\u003eLe système de sécurité, enfin reconstruit\u003c/h2\u003e\n\u003cp\u003eLe composant de sécurité Symfony a deux systèmes. L\u0026rsquo;ancien (\u003ccode\u003eAnonymousToken\u003c/code\u003e, \u003ccode\u003eGuardAuthenticatorInterface\u003c/code\u003e, un enchevêtrement d\u0026rsquo;interfaces qui vous faisaient implémenter des méthodes dont vous n\u0026rsquo;aviez pas besoin) avait été déprécié. 6.0 le supprime entièrement.\u003c/p\u003e","title":"Symfony 6.0 : PHP 8.1 uniquement, et le système de sécurité reconstruit"},{"content":"Symfony 5.4 est sorti le 29 novembre 2021, le même jour que Symfony 6.0 et un jour après PHP 8.1. Pas un hasard.\n5.4 est la version LTS, et son rôle est de porter autant que possible le jeu de fonctionnalités de 6.0 tout en conservant la compatibilité 5.x. C\u0026rsquo;est aussi la première version de Symfony qui comprend réellement les fonctionnalités de PHP 8.1.\nSupport des enums PHP 8.1 a introduit les enums natifs. Symfony 5.4 les embrasse immédiatement :\nenum Status: string { case Active = \u0026#39;active\u0026#39;; case Inactive = \u0026#39;inactive\u0026#39;; } Le type de formulaire EnumType rend les enums sous forme de listes déroulantes, sans transformateurs personnalisés. Le validateur comprend les backed enums. Le sérialiseur mappe les valeurs d\u0026rsquo;enum sur leur type de backing et inversement. Trois composants mis à jour d\u0026rsquo;un coup, ce qui a rendu la migration des bases de code des pseudo-constantes enum vers les vrais enums PHP 8.1 étonnamment fluide.\nCache des voters de sécurité La CacheableVoterInterface permet aux voters qui s\u0026rsquo;abstiennent toujours sur un attribut donné de le signaler au système de sécurité, qui peut alors les ignorer lors des vérifications suivantes. Pour les applications avec de nombreux voters, le gain sur les vérifications de permissions s\u0026rsquo;accumule vite. Petit changement, perceptible en pratique.\nMessenger continue de mûrir Le traitement par batch de Messenger (gérer plusieurs messages en une seule transaction au lieu d\u0026rsquo;un par un) est maintenant stable. Rate limiting par transport. Les dead letter queues bénéficient de meilleurs outils. Après des années en mode « expérimental », Messenger en 5.4 est enfin la fondation async sur laquelle on peut s\u0026rsquo;appuyer pour des charges sérieuses.\nLa Console a eu sa touche Tab Symfony 5.4 embarque l\u0026rsquo;autocomplétion shell pour toutes les commandes. Appuyez sur Tab et le shell suggère les noms de commandes, les valeurs d\u0026rsquo;arguments et les valeurs d\u0026rsquo;options. Pour les commandes intégrées, ça fonctionne sans configuration. Pour les commandes personnalisées, ajoutez une méthode complete() :\nuse Symfony\\Component\\Console\\Completion\\CompletionInput; use Symfony\\Component\\Console\\Completion\\CompletionSuggestions; public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { if ($input-\u0026gt;mustSuggestOptionValuesFor(\u0026#39;format\u0026#39;)) { $suggestions-\u0026gt;suggestValues([\u0026#39;json\u0026#39;, \u0026#39;xml\u0026#39;, \u0026#39;csv\u0026#39;]); } } Pas d\u0026rsquo;interface requise, juste la méthode et Symfony s\u0026rsquo;en charge. La communauté a aussi passé en revue toutes les commandes intégrées (debug:router, cache:pool:clear, secrets:remove, lint:twig, et une dizaine d\u0026rsquo;autres) pour ajouter les compléments avant la sortie.\nLes routes peuvent être des alias maintenant Le composant de routing supporte désormais les alias : une route peut pointer vers une autre. Le cas d\u0026rsquo;usage évident, c\u0026rsquo;est renommer une route sans casser tout ce qui génère encore des URLs avec l\u0026rsquo;ancien nom.\n# config/routes.yaml admin_dashboard: path: /admin # ancien nom conservé pendant la transition dashboard: alias: admin_dashboard deprecated: package: \u0026#39;acme/my-bundle\u0026#39; version: \u0026#39;2.3\u0026#39; Générer une URL avec dashboard fonctionne toujours, mais déclenche un avertissement de dépréciation. Des chemins de renommage propres pour les bundles qui doivent maintenir des noms de routes publics tout en avançant.\nLes exceptions sont mappées aux codes HTTP dans la config Avant 5.4, mapper une classe d\u0026rsquo;exception à un code HTTP signifiait implémenter HttpExceptionInterface ou écrire un listener. Maintenant c\u0026rsquo;est juste une entrée YAML :\n# config/packages/framework.yaml framework: exceptions: App\\Exception\\PaymentRequiredException: status_code: 402 log_level: warning App\\Exception\\MaintenanceException: status_code: 503 log_level: info L\u0026rsquo;exception n\u0026rsquo;a rien à implémenter. Le framework lit la map, définit le code de statut, loggue au niveau configuré. Pratique pour les exceptions métier qui n\u0026rsquo;ont aucune raison de connaître HTTP.\nDeux nouvelles contraintes de validation 5.4 ajoute Cidr et CssColor au composant Validator.\nCidr valide la notation réseau (adresse IP plus masque de sous-réseau) avec un contrôle sur la version IP acceptée et les bornes de la valeur du masque :\n#[Assert\\Cidr(version: 4, netmaskMin: 16, netmaskMax: 28)] private string $allowedSubnet; CssColor valide qu\u0026rsquo;une chaîne est une couleur CSS valide. Utile pour les éditeurs de thème, la config CMS, ou toute interface qui laisse les utilisateurs choisir des couleurs :\n#[Assert\\CssColor( formats: Assert\\CssColor::HEX_LONG, message: \u0026#34;La couleur d\u0026#39;accentuation doit être une valeur hex à 6 chiffres.\u0026#34;, )] private string $accentColor; Attributs PHP imbriqués pour les contraintes de validation Symfony 5.2 avait ajouté les contraintes de validation en attributs PHP, mais PHP 8.0 avait une limitation sur les attributs imbriqués. Les contraintes complexes comme All, Collection, ou AtLeastOneOf étaient impossibles à exprimer avec la syntaxe d\u0026rsquo;attribut seule. PHP 8.1 a levé cette restriction, et 5.4 en tire le meilleur parti :\nuse Symfony\\Component\\Validator\\Constraints as Assert; class CartItem { #[Assert\\All([ new Assert\\NotNull(), new Assert\\Range(min: 1), ])] private array $quantities; #[Assert\\AtLeastOneOf( constraints: [new Assert\\Email(), new Assert\\Url()], message: \u0026#39;Doit être un email ou une URL valide.\u0026#39;, )] private string $contact; } Pas de docblocks d\u0026rsquo;annotations, pas de mapping XML. Des attributs PHP 8.1 purs, de bout en bout.\nInjection de dépendances : trois choses à savoir Les itérateurs taggués peuvent maintenant être injectés dans des service locators, qui n\u0026rsquo;acceptaient auparavant que des listes de services explicites. L\u0026rsquo;autowiring des types union fonctionne quand les deux côtés de l\u0026rsquo;union résolvent vers le même service, ce qui est courant avec les interfaces du serializer :\npublic function __construct( private NormalizerInterface \u0026amp; DenormalizerInterface $serializer ) {} #[SubscribedService] remplace l\u0026rsquo;introspection automatique que ServiceSubscriberTrait faisait implicitement. C\u0026rsquo;est maintenant un attribut explicite sur les méthodes, ce qui rend la dépendance visible sans aucune magie :\nuse Symfony\\Contracts\\Service\\Attribute\\SubscribedService; class SomeService implements ServiceSubscriberInterface { #[SubscribedService] private function router(): RouterInterface { return $this-\u0026gt;container-\u0026gt;get(__METHOD__); } } Messenger : attributs, état des workers et reset de services Les handlers Messenger peuvent abandonner MessageHandlerInterface en faveur de #[AsMessageHandler], qui permet aussi de lier un handler à un transport spécifique et de définir sa priorité, sans toucher au YAML :\n#[AsMessageHandler(fromTransport: \u0026#39;async\u0026#39;, priority: 10)] class ProcessOrderHandler { public function __invoke(ProcessOrder $message): void { /* ... */ } } L\u0026rsquo;état des workers est maintenant inspectable via WorkerMetadata dans les event listeners, utile quand vous avez des workers sur plusieurs transports et avez besoin de savoir lequel a déclenché un événement donné.\nLes workers longue durée accumulent de l\u0026rsquo;état entre les messages : buffers de l\u0026rsquo;entity manager, caches en mémoire, connexions ouvertes. La nouvelle option reset_on_message prend en charge la réinitialisation de tous les services réinitialisables entre les messages :\nframework: messenger: reset_on_message: true Serializer : collecter les erreurs plutôt que lever Désérialiser du JSON externe dans un DTO typé levait une exception dès la première discordance de type. L\u0026rsquo;option COLLECT_DENORMALIZATION_ERRORS change ça : toutes les erreurs de type sont collectées dans une PartialDenormalizationException, pour que vous puissiez retourner un 400 propre avec la liste complète des problèmes par champ :\ntry { $dto = $serializer-\u0026gt;deserialize($request-\u0026gt;getContent(), OrderDto::class, \u0026#39;json\u0026#39;, [ DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS =\u0026gt; true, ]); } catch (PartialDenormalizationException $e) { return $this-\u0026gt;json( array_map(fn($err) =\u0026gt; [\u0026#39;path\u0026#39; =\u0026gt; $err-\u0026gt;getPath(), \u0026#39;expected\u0026#39; =\u0026gt; $err-\u0026gt;getExpectedTypes()], $e-\u0026gt;getErrors()), 400 ); } Le contexte par défaut du serializer peut aussi être défini globalement en YAML, pour ne plus passer les mêmes options à chaque appel.\nNégociation de langue intégrée Deux nouvelles options du framework gèrent l\u0026rsquo;en-tête Accept-Language sans listeners personnalisés :\nframework: enabled_locales: [\u0026#39;en\u0026#39;, \u0026#39;fr\u0026#39;, \u0026#39;de\u0026#39;] set_locale_from_accept_language: true set_content_language_from_locale: true Avec ça en place, Symfony lit la langue préférée du navigateur, choisit la meilleure correspondance parmi enabled_locales, définit la locale de la requête, et ajoute un en-tête Content-Language à la réponse. L\u0026rsquo;attribut de route {_locale} a toujours la priorité quand il est présent.\nTraduction : extraction, pas mise à jour La commande translation:update est renommée en translation:extract. L\u0026rsquo;ancien nom reste comme déprécié. La distinction compte : la commande n\u0026rsquo;écrit jamais dans une base de données, elle extrait les chaînes traduisibles des fichiers source. Le nouveau nom dit enfin ce qu\u0026rsquo;elle fait.\nlint:xliff gagne aussi une option --format=github qui sort les erreurs en annotations GitHub Actions, pour que les échecs de lint de traduction apparaissent en commentaires de revue de PR plutôt que de se noyer dans les logs.\nRaccourcis du contrôleur élagués Trois raccourcis d\u0026rsquo;AbstractController sont dépréciés : getDoctrine(), dispatchMessage(), et la méthode générique get() pour récupérer des services arbitraires du container. La direction, c\u0026rsquo;est l\u0026rsquo;injection par constructeur explicite. Pour getDoctrine() en particulier :\n// avant $em = $this-\u0026gt;getDoctrine()-\u0026gt;getManager(); // après — injecter directement public function __construct(private EntityManagerInterface $em) {} Request::get() est aussi déprécié. Il cherchait dans les attributs de route, la query string et le corps POST dans un ordre non documenté, ce qui était une excellente façon d\u0026rsquo;obtenir des résultats surprenants. Utilisez $request-\u0026gt;query-\u0026gt;get(), $request-\u0026gt;request-\u0026gt;get(), ou $request-\u0026gt;attributes-\u0026gt;get() et soyez explicite sur la provenance de la valeur.\nLa classe utilitaire Path Le composant Filesystem reçoit une classe Path portée depuis webmozart/path-util. Elle gère les cas tordus que dirname() et realpath() ratent :\nuse Symfony\\Component\\Filesystem\\Path; Path::canonicalize(\u0026#39;../config/../config/services.yaml\u0026#39;); // \u0026#39;../config/services.yaml\u0026#39; Path::getDirectory(\u0026#39;C:/\u0026#39;); // \u0026#39;C:/\u0026#39; (dirname() retourne \u0026#39;.\u0026#39;) Path::getLongestCommonBasePath([ \u0026#39;/var/www/project/src/Controller/FooController.php\u0026#39;, \u0026#39;/var/www/project/src/Controller/BarController.php\u0026#39;, \u0026#39;/var/www/project/src/Entity/User.php\u0026#39;, ]); // \u0026#39;/var/www/project/src\u0026#39; Utile dès que votre code manipule des chemins qui traversent les frontières des OS ou qui contiennent des segments relatifs.\nLes petites choses qui s\u0026rsquo;accumulent debug:dotenv montre quels fichiers .env ont été chargés et quelle valeur chaque variable résout. La première chose qu\u0026rsquo;on cherche quand un comportement spécifique à un environnement déraille.\nLe composant String ajoute trimPrefix() et trimSuffix() pour retirer des préfixes ou suffixes connus sans écrire un calcul de substr :\nu(\u0026#39;file-image-0001.png\u0026#39;)-\u0026gt;trimPrefix(\u0026#39;file-\u0026#39;); // \u0026#39;image-0001.png\u0026#39; u(\u0026#39;template.html.twig\u0026#39;)-\u0026gt;trimSuffix(\u0026#39;.twig\u0026#39;); // \u0026#39;template.html\u0026#39; DomCrawler reçoit innerText(), qui retourne uniquement le texte direct d\u0026rsquo;un nœud, à l\u0026rsquo;exclusion des éléments enfants. text() retourne tout y compris le texte imbriqué ; innerText() retourne uniquement le contenu propre du nœud. Petite différence, mais ça compte quand on fait du scraping.\nLe composant RateLimiter étend son support des intervalles avec perMonth() et perYear(), pour les applications qui ont besoin de limiter des événements sur des fenêtres plus longues : envois de newsletters, remises à zéro de quotas API, limites de forfaits annuels.\nLe composant Finder respecte maintenant les fichiers .gitignore dans tous les sous-répertoires quand vous appelez ignoreVCSIgnored(true), pas seulement à la racine. Les règles des répertoires enfants supplantent celles des parents, exactement comme git lui-même.\nLa fenêtre LTS 5.4 reçoit des corrections de bugs jusqu\u0026rsquo;en novembre 2024 et des correctifs de sécurité jusqu\u0026rsquo;en novembre 2025. La migration de 5.4 vers 6.4 (la prochaine LTS) est intentionnellement fluide : corrigez les avertissements de dépréciation de 5.4, et le saut vers 6.x devient mécanique.\nLa couche de dépréciation en 5.4 pointe vers tout ce que 6.0 supprime : les derniers morceaux de l\u0026rsquo;ancien système de sécurité, ContainerAwareTrait, et quelques patterns legacy de formulaires et de serializer.\n","permalink":"https://guillaumedelre.github.io/fr/2022/01/10/symfony-5.4-lts-support-des-enums-alias-de-routes-et-le-pont-vers-php-8.1/","summary":"\u003cp\u003eSymfony 5.4 est sorti le 29 novembre 2021, le même jour que Symfony 6.0 et un jour après PHP 8.1. Pas un hasard.\u003c/p\u003e\n\u003cp\u003e5.4 est la version LTS, et son rôle est de porter autant que possible le jeu de fonctionnalités de 6.0 tout en conservant la compatibilité 5.x. C\u0026rsquo;est aussi la première version de Symfony qui comprend réellement les fonctionnalités de PHP 8.1.\u003c/p\u003e\n\u003ch2 id=\"support-des-enums\"\u003eSupport des enums\u003c/h2\u003e\n\u003cp\u003ePHP 8.1 a introduit les enums natifs. Symfony 5.4 les embrasse immédiatement :\u003c/p\u003e","title":"Symfony 5.4 LTS : support des enums, alias de routes, et le pont vers PHP 8.1"},{"content":"PHP 8.1 est sorti le 25 novembre. Il fait suite à la refonte massive de 8.0 avec quelque chose de différent : moins de fonctionnalités, mais chacune vraiment réfléchie plutôt que greffée à la va-vite.\nLes enums C\u0026rsquo;est la nouveauté qui change les bases de code dès la mise à jour. Avant 8.1, les énumérations en PHP se résumaient à des constantes de classe, des chaînes ou des entiers sans rien pour les faire respecter :\n// avant : rien n\u0026#39;empêche de passer Status::INVALID const ACTIVE = \u0026#39;active\u0026#39;; const INACTIVE = \u0026#39;inactive\u0026#39;; // après enum Status: string { case Active = \u0026#39;active\u0026#39;; case Inactive = \u0026#39;inactive\u0026#39;; } function activate(Status $status): void { ... } Les enums PHP sont des objets, pas des scalaires. Ils supportent les méthodes, les interfaces et les constantes. Les backed enums (avec une valeur string ou int) se sérialisent proprement et se mappent naturellement aux colonnes de base de données. Les pure enums (sans type de backing) expriment des concepts métier sans se soucier de la sérialisation.\nL\u0026rsquo;effet immédiat : chaque champ de statut, chaque ensemble fini d\u0026rsquo;états dans toutes les bases de code que je maintiens est devenu un candidat à l\u0026rsquo;enum. Le système de types a enfin un moyen natif d\u0026rsquo;exprimer ce que chaque projet PHP simulait depuis des années.\nLes fibers Les fibers sont une primitive de concurrence coopérative : vous pouvez suspendre et reprendre l\u0026rsquo;exécution d\u0026rsquo;une fonction, en cédant le contrôle sans recourir aux threads.\n$fiber = new Fiber(function(): void { $value = Fiber::suspend(\u0026#39;first\u0026#39;); echo \u0026#34;Repris avec : {$value}\\n\u0026#34;; }); $result = $fiber-\u0026gt;start(); // \u0026#39;first\u0026#39; $fiber-\u0026gt;resume(\u0026#39;hello\u0026#39;); // \u0026#34;Repris avec : hello\u0026#34; Les fibers sont la fondation dont les bibliothèques async comme ReactPHP et Amp avaient besoin depuis un moment du côté du runtime. Pour la plupart des développeurs d\u0026rsquo;applications, l\u0026rsquo;API directe compte moins que les bibliothèques construites par-dessus, mais comprendre les fibers explique ce que font ces bibliothèques en coulisses.\n:pencil2: Les propriétés readonly 8.0 avait apporté la promotion des paramètres du constructeur. 8.1 ajoute readonly :\nclass User { public function __construct( public readonly int $id, public readonly string $name, ) {} } Une propriété readonly ne peut être écrite qu\u0026rsquo;une seule fois, lors de l\u0026rsquo;initialisation. Après ça, toute écriture lève une Error. Combiné avec la promotion des paramètres, les value objects et les DTOs deviennent concis et signifient réellement ce qu\u0026rsquo;ils annoncent.\nLa syntaxe callable de première classe $fn = strlen(...); $fn = $this-\u0026gt;process(...); $fn = MyClass::create(...); ... après un callable crée une Closure sans le boilerplate de Closure::fromCallable(). Utile quand on passe des méthodes comme callbacks.\n8.1 est précis. Les enums justifient à eux seuls la mise à jour.\nLes types d\u0026rsquo;intersection Les types union ont débarqué en 8.0. Les types d\u0026rsquo;intersection suivent en 8.1. Là où un union dit « l\u0026rsquo;un ou l\u0026rsquo;autre », une intersection dit « tous à la fois » :\nfunction process(Countable\u0026amp;Iterator $collection): void { foreach ($collection as $item) { /* ... */ } echo count($collection); } Une contrainte : les types d\u0026rsquo;intersection ne peuvent pas être mélangés avec les types union dans la même déclaration (ça arrivera en 8.2 avec les DNF types). Mais ça débloque déjà une vérification de types précise pour les objets qui doivent satisfaire plusieurs interfaces à la fois, un pattern que les frameworks utilisent constamment et qui devait rester sans typage jusqu\u0026rsquo;ici.\nLe type de retour never Une fonction qui ne retourne jamais (elle lève toujours une exception ou sort) a maintenant un type pour le dire :\nfunction redirect(string $url): never { header(\u0026#34;Location: {$url}\u0026#34;); exit(); } function fail(string $message): never { throw new \\RuntimeException($message); } L\u0026rsquo;avantage concret : les analyseurs statiques peuvent prouver que le code après une fonction never est inatteignable, et les appelants savent qu\u0026rsquo;il n\u0026rsquo;y a pas de valeur de retour à gérer. Avant ça, ça vivait dans des docblocks sans enforcement.\nLes constantes de classe finales Avant 8.1, n\u0026rsquo;importe quelle sous-classe pouvait silencieusement surcharger la constante de classe d\u0026rsquo;un parent. Maintenant vous pouvez y mettre un terme :\nclass Base { final public const VERSION = \u0026#39;1.0\u0026#39;; } class Child extends Base { // Fatal error: Cannot override final constant Base::VERSION public const VERSION = \u0026#39;2.0\u0026#39;; } Parallèlement, les constantes d\u0026rsquo;interface sont désormais surchargeables par les classes implémentant l\u0026rsquo;interface par défaut. Un correctif de comportement qui était incohérent depuis l\u0026rsquo;introduction des interfaces.\nnew dans les initialiseurs Les valeurs par défaut des paramètres étaient autrefois limitées aux scalaires et aux tableaux. 8.1 lève cette restriction :\nclass Logger { public function __construct( private Handler $handler = new NullHandler(), ) {} } function createUser( Validator $validator = new DefaultValidator(), ): User { /* ... */ } Idem pour les arguments d\u0026rsquo;attributs et les initialiseurs de variables statiques. Ce qui signifie que l\u0026rsquo;injection de dépendances avec des valeurs par défaut sensées ne nécessite plus une vérification de null et une instanciation paresseuse dans le corps de la méthode.\nLe déballage de tableaux avec des clés string Le déballage de tableau via l\u0026rsquo;opérateur spread ne fonctionnait qu\u0026rsquo;avec des tableaux à clés entières avant 8.1. Les clés string fonctionnent aussi maintenant :\n$defaults = [\u0026#39;color\u0026#39; =\u0026gt; \u0026#39;red\u0026#39;, \u0026#39;size\u0026#39; =\u0026gt; \u0026#39;M\u0026#39;]; $custom = [\u0026#39;size\u0026#39; =\u0026gt; \u0026#39;L\u0026#39;, \u0026#39;weight\u0026#39; =\u0026gt; \u0026#39;200g\u0026#39;]; $merged = [...$defaults, ...$custom]; // [\u0026#39;color\u0026#39; =\u0026gt; \u0026#39;red\u0026#39;, \u0026#39;size\u0026#39; =\u0026gt; \u0026#39;L\u0026#39;, \u0026#39;weight\u0026#39; =\u0026gt; \u0026#39;200g\u0026#39;] Les clés ultérieures écrasent les précédentes. Même comportement que array_merge(), mais exprimé inline. La différence de performance est marginale ; la différence de lisibilité, elle, ne l\u0026rsquo;est pas.\nfsync et fdatasync Deux fonctions qui n\u0026rsquo;avaient aucune bonne raison d\u0026rsquo;être absentes d\u0026rsquo;un langage orienté système de fichiers :\n$fp = fopen(\u0026#39;/tmp/important.dat\u0026#39;, \u0026#39;w\u0026#39;); fwrite($fp, $data); fsync($fp); // vide les buffers OS vers le stockage physique fclose($fp); fdatasync() fait la même chose mais saute la synchronisation des métadonnées quand on ne se soucie que de la durabilité des données. Les deux retournent false en cas d\u0026rsquo;échec. Si vous écrivez quoi que ce soit qui nécessite une sécurité en cas de crash, vous aviez besoin de ça.\nPasser null aux paramètres non-nullables des fonctions internes Un changement plus discret mais aux conséquences réelles : les fonctions internes qui acceptent des chaînes, des entiers, etc. ont toujours avalé silencieusement null et l\u0026rsquo;ont coercé. En 8.1, ça commence à émettre un avertissement de dépréciation.\nstr_contains(\u0026#34;foobar\u0026#34;, null); // Deprecated: Passing null to parameter #2 ($needle) of type string is deprecated Ça aligne les fonctions internes sur les fonctions définies par l\u0026rsquo;utilisateur, qui refusaient déjà les arguments nullable pour des paramètres non-nullables. PHP 9.0 transforme ça en erreur fatale. Si vous passez null dans des fonctions de chaînes, c\u0026rsquo;est maintenant un meilleur moment pour le corriger que pendant un incident de production.\nMySQLi lève des exceptions par défaut Avant 8.1, MySQLi échouait silencieusement sauf si vous appeliez explicitement mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT). C\u0026rsquo;est maintenant la valeur par défaut :\n// Ceci lève \\mysqli_sql_exception en cas d\u0026#39;échec de connexion en 8.1 // Auparavant retournait false et définissait une erreur que vous deviez vérifier manuellement $connection = new mysqli(\u0026#39;localhost\u0026#39;, \u0026#39;user\u0026#39;, \u0026#39;wrong_password\u0026#39;, \u0026#39;db\u0026#39;); Toute base de code qui attrape les erreurs MySQLi en vérifiant les valeurs de retour doit être revue. Les échecs silencieux qui causaient des bugs difficiles à diagnostiquer lèvent maintenant des exceptions, ce qui est le bon comportement, même si ça peut surprendre si vous l\u0026rsquo;attrapez en pleine mise à jour.\n","permalink":"https://guillaumedelre.github.io/fr/2022/01/09/php-8.1-enums-fibers-et-un-syst%C3%A8me-de-types-qui-grandit/","summary":"\u003cp\u003ePHP 8.1 est sorti le 25 novembre. Il fait suite à la refonte massive de 8.0 avec quelque chose de différent : moins de fonctionnalités, mais chacune vraiment réfléchie plutôt que greffée à la va-vite.\u003c/p\u003e\n\u003ch2 id=\"les-enums\"\u003eLes enums\u003c/h2\u003e\n\u003cp\u003eC\u0026rsquo;est la nouveauté qui change les bases de code dès la mise à jour. Avant 8.1, les énumérations en PHP se résumaient à des constantes de classe, des chaînes ou des entiers sans rien pour les faire respecter :\u003c/p\u003e","title":"PHP 8.1 : enums, fibers, et un système de types qui grandit"},{"content":"PHP 8.0 est sorti le 26 novembre. Je le fais tourner depuis six semaines sur un projet perso et un nouveau service au boulot. C\u0026rsquo;est la version PHP la plus significative depuis 7.0, et à certains égards plus impactante, parce que les changements se renforcent mutuellement de façon utile.\nJIT Le compilateur Just-In-Time était l\u0026rsquo;annonce principale. La réalité en production est plus nuancée : pour les applications web typiques (requêtes en base, appels HTTP, rendu de templates) les gains sont modestes, parce que ces workloads sont limités par les I/O, pas par le calcul. Là où le JIT brille vraiment, c\u0026rsquo;est le code intensif en CPU : manipulation d\u0026rsquo;images, transformation de données, calcul mathématique.\nPour la plupart des applications web, l\u0026rsquo;amélioration de performance vient du travail sur le moteur en général dans 8.0, pas du JIT spécifiquement. Vaut quand même la peine de l\u0026rsquo;activer : ça ne coûte rien sur les workloads I/O-bound.\nLes expressions match switch a trois problèmes : il utilise la comparaison souple, il tombe en cascade par défaut, et il ne peut pas être utilisé comme expression. match règle les trois :\n$result = match($status) { \u0026#39;active\u0026#39;, \u0026#39;pending\u0026#39; =\u0026gt; \u0026#39;processing\u0026#39;, \u0026#39;done\u0026#39; =\u0026gt; \u0026#39;finished\u0026#39;, default =\u0026gt; throw new \\UnexpectedValueException($status), }; Comparaison stricte. Pas de cascade. Expression qui retourne une valeur. Un match non exhaustif lève une exception. Après une semaine avec match, j\u0026rsquo;ai arrêté d\u0026rsquo;écrire des switch.\nLes arguments nommés array_slice(array: $users, offset: 0, length: 10, preserve_keys: true); Les arguments nommés permettent de passer les arguments dans n\u0026rsquo;importe quel ordre et d\u0026rsquo;en sauter des optionnels. Le gain évident : la lisibilité sur les fonctions avec plusieurs flags booléens. Le gain moins évident : les arguments nommés survivent aux mises à jour de PHP même quand l\u0026rsquo;ordre des paramètres change, parce qu\u0026rsquo;on nomme ce qu\u0026rsquo;on veut dire.\nLes attributs Place aux docblock annotations (le style @Route, @ORM\\Column sur lequel les frameworks se sont appuyés pendant des années), bienvenue à la syntaxe PHP de première classe :\n#[Route(\u0026#39;/users\u0026#39;, methods: [\u0026#39;GET\u0026#39;])] #[IsGranted(\u0026#39;ROLE_ADMIN\u0026#39;)] public function list(): Response { ... } Les attributs sont validés par le moteur, pas parsés depuis des strings. Le support IDE fonctionne directement, sans magie de plugin. Pour les utilisateurs de Symfony et Doctrine, c\u0026rsquo;est le vrai gain quotidien de PHP 8.0.\nLa promotion de constructeur class User { public function __construct( public readonly int $id, public string $name, private ?string $email = null, ) {} } Propriétés déclarées et assignées en une ligne dans la signature du constructeur. Le gain de refactoring le plus immédiat dans 8.0 : chaque classe de données que j\u0026rsquo;ai touchée depuis la mise à jour fait moitié moins de lignes qu\u0026rsquo;avant.\nL\u0026rsquo;opérateur nullsafe $city = $user?-\u0026gt;getAddress()?-\u0026gt;getCity()?-\u0026gt;getName(); null à n\u0026rsquo;importe quel point de la chaîne court-circuite le reste et retourne null. L\u0026rsquo;alternative était des null checks imbriqués ou une chaîne de retours anticipés. Ça se compose naturellement.\nLes types union Les arguments nommés rendent les signatures de fonctions plus explicites au site d\u0026rsquo;appel. Les types union les rendent plus honnêtes au site de déclaration :\nfunction processInput(int|float|string $value): string|int { if (is_string($value)) { return strlen($value); } return (int) round($value); } L\u0026rsquo;union int|float|string est un OU littéral. Le moteur l\u0026rsquo;impose à l\u0026rsquo;entrée et à la sortie. Avant 8.0, \u0026ldquo;ce paramètre accepte int ou float\u0026rdquo; vivait dans un docblock que rien n\u0026rsquo;imposait. Il y a aussi null comme composant de type : ?string est juste du sucre syntaxique pour string|null, les deux sont valides.\nUn cas spécial : false. PHP a un tas de fonctions natives qui retournent une valeur typée en cas de succès et false en cas d\u0026rsquo;échec. Le système de types de 8.0 accommode ça : array|false, string|false. C\u0026rsquo;est une reconnaissance honnête que la codebase ne peut pas être réécrite du jour au lendemain.\nLe type de retour static static comme type de retour était possible de manière informelle via les docblocks, mais 8.0 le rend officiel. La distinction entre self et static compte dans l\u0026rsquo;héritage :\nclass Builder { protected array $config = []; public function set(string $key, mixed $value): static { $this-\u0026gt;config[$key] = $value; return $this; } } class SpecialBuilder extends Builder {} $result = (new SpecialBuilder())-\u0026gt;set(\u0026#39;foo\u0026#39;, \u0026#39;bar\u0026#39;); // $result est SpecialBuilder, pas Builder Avec self comme type de retour, cette chaîne retournerait Builder, cassant les interfaces fluides dans les sous-classes. static fait fonctionner correctement les APIs fluides à travers les hiérarchies d\u0026rsquo;héritage sans surcharges manuelles.\nLe type mixed mixed était une convention de docblock pendant des années. 8.0 en fait un vrai type qui apparaît dans les signatures :\nfunction debug(mixed $value): void { var_dump($value); } Il accepte tout : null, objets, ressources, scalaires, tableaux. Sémantiquement c\u0026rsquo;est la même chose que n\u0026rsquo;avoir aucune déclaration de type, mais c\u0026rsquo;est explicite plutôt qu\u0026rsquo;absent. La différence entre \u0026ldquo;ce paramètre est non typé\u0026rdquo; et \u0026ldquo;ce paramètre accepte intentionnellement n\u0026rsquo;importe quoi.\u0026rdquo; Vaut la peine de l\u0026rsquo;utiliser quand on écrit un utilitaire généraliste qui serait malhonnête avec un type plus étroit.\nthrow comme expression Avant 8.0, throw était une instruction. Ça semble une distinction pédante jusqu\u0026rsquo;à ce qu\u0026rsquo;on tombe sur les endroits où on veut vraiment une expression :\n// Dans un ternaire : $value = $input ?? throw new \\InvalidArgumentException(\u0026#39;input required\u0026#39;); // Dans une arrow function : $getId = fn(User $u) =\u0026gt; $u-\u0026gt;id ?? throw new \\RuntimeException(\u0026#39;no id\u0026#39;); // Dans un bras match (qui est déjà une expression) : $status = match($code) { 200 =\u0026gt; \u0026#39;ok\u0026#39;, 404 =\u0026gt; \u0026#39;not found\u0026#39;, default =\u0026gt; throw new \\UnexpectedValueException(\u0026#34;unknown code: $code\u0026#34;), }; Le dernier est particulièrement utile : un match sans default lancera UnhandledMatchError automatiquement, mais parfois on veut contrôler le type d\u0026rsquo;exception et le message.\ncatch sans variable Petite amélioration de qualité de vie. Quand on attrape une exception mais qu\u0026rsquo;on n\u0026rsquo;utilise pas réellement l\u0026rsquo;objet, 8.0 permet d\u0026rsquo;omettre la variable :\ntry { $result = $cache-\u0026gt;get($key); } catch (CacheMissException) { $result = $this-\u0026gt;compute($key); } Avant 8.0, il fallait écrire catch (CacheMissException $e) et ensuite soit utiliser $e soit vivre avec l\u0026rsquo;avertissement IDE sur la variable inutilisée. Aucune des deux options n\u0026rsquo;était satisfaisante.\nLes fonctions string qui auraient dû exister depuis des années Trois fonctions que chaque développeur PHP a écrites manuellement au moins une fois :\nstr_contains(\u0026#39;hello world\u0026#39;, \u0026#39;world\u0026#39;); // true str_starts_with(\u0026#39;hello world\u0026#39;, \u0026#39;hell\u0026#39;); // true str_ends_with(\u0026#39;hello world\u0026#39;, \u0026#39;world\u0026#39;); // true Avant 8.0, les approches habituelles étaient strpos() !== false, strncmp(), ou substr() ===, qui nécessitent toutes de s\u0026rsquo;arrêter pour se souvenir de la sémantique. Ces nouvelles fonctions sont juste directes et lisibles. Pas de regex, pas d\u0026rsquo;arithmétique d\u0026rsquo;offset.\nUn tri stable Les fonctions de tri de PHP n\u0026rsquo;étaient pas stables avant 8.0. \u0026ldquo;Pas stable\u0026rdquo; signifie que les éléments qui se comparent comme égaux pouvaient se retrouver dans n\u0026rsquo;importe quel ordre les uns par rapport aux autres. En pratique, ça causait des bugs subtils dans le code UI qui avait besoin d\u0026rsquo;un ordre cohérent, une pagination qui changeait entre les chargements, et des tests qui ne passaient que par chance.\n8.0 garantit la stabilité à travers toutes les fonctions de tri : sort(), usort(), array_multisort(), et le reste. Les éléments égaux conservent leur position relative originale. C\u0026rsquo;est le comportement que la plupart des gens supposaient déjà être là.\nWeakMap 7.4 apportait WeakReference pour les objets simples. 8.0 apporte WeakMap : une map où les clés (des objets) et leurs données associées peuvent être ramassées par le GC quand aucune autre référence à l\u0026rsquo;objet-clé n\u0026rsquo;existe :\nclass RequestCache { private WeakMap $cache; public function __construct() { $this-\u0026gt;cache = new WeakMap(); } public function get(Request $request): Response { return $this-\u0026gt;cache[$request] ??= $this-\u0026gt;compute($request); } } Dès que $request n\u0026rsquo;est plus référencé ailleurs, l\u0026rsquo;entrée disparaît de la map. Pas de nettoyage manuel nécessaire. C\u0026rsquo;est le bon pattern pour la mémoïsation et les caches de propriétés calculées où on ne veut pas être la seule raison qu\u0026rsquo;un objet reste vivant.\nLes nouveaux types d\u0026rsquo;exception ValueError est levée quand une fonction reçoit le bon type mais une valeur invalide, par opposition à TypeError qui se déclenche sur les mauvais types :\narray_chunk([], -5); // ValueError: array_chunk(): Argument #2 ($length) must be greater than 0 Avant 8.0, beaucoup de ces cas étaient des warnings qui retournaient false ou null. Maintenant ils lèvent des exceptions. Le moteur est plus strict, ce qui signifie qu\u0026rsquo;on attrape les problèmes plus tôt plutôt que d\u0026rsquo;obtenir des résultats bizarres quelque part en aval.\nget_debug_type() et fdiv() Deux fonctions utilitaires à connaître.\nget_debug_type() retourne une représentation string normalisée de n\u0026rsquo;importe quelle valeur, pratique pour les messages d\u0026rsquo;erreur :\nget_debug_type(1); // \u0026#34;int\u0026#34; get_debug_type(1.0); // \u0026#34;float\u0026#34; get_debug_type(null); // \u0026#34;null\u0026#34; get_debug_type(new Foo()); // \u0026#34;Foo\u0026#34; (pas \u0026#34;object\u0026#34;) get_debug_type([]); // \u0026#34;array\u0026#34; La différence avec gettype() : elle retourne les noms de classes pour les objets et utilise des noms normalisés (\u0026quot;int\u0026quot; pas \u0026quot;integer\u0026quot;). Exactement ce qu\u0026rsquo;on veut pour construire un message d\u0026rsquo;exception qui dit ce qu\u0026rsquo;on a reçu versus ce qu\u0026rsquo;on attendait.\nfdiv() effectue une division en virgule flottante suivant IEEE 754, ce qui signifie que la division par zéro retourne INF, -INF, ou NAN au lieu d\u0026rsquo;un warning :\nfdiv(10, 0); // INF fdiv(-10, 0); // -INF fdiv(0, 0); // NAN Les changements qui cassent des choses 8.0 inclut aussi quelques changements qui ne sont pas des fonctionnalités, ce sont des corrections.\nLe plus important : 0 == \u0026quot;foo\u0026quot; est maintenant false. En PHP 7, comparer un entier à une string non numérique castait la string en 0, donc 0 == \u0026quot;n'importe-quoi-non-numérique\u0026quot; s\u0026rsquo;évaluait à true. C\u0026rsquo;était une source persistante de bugs et de maux de tête de sécurité. PHP 8 l\u0026rsquo;inverse : l\u0026rsquo;entier est casté en string à la place :\nvar_dump(0 == \u0026#34;foo\u0026#34;); // bool(false) en 8.0, bool(true) en 7.x var_dump(0 == \u0026#34;\u0026#34;); // bool(false) en 8.0, bool(true) en 7.x var_dump(0 == \u0026#34;0\u0026#34;); // bool(true) dans les deux (\u0026#34;0\u0026#34; est numérique) Si on s\u0026rsquo;appuyait sur ça intentionnellement, on savait déjà que c\u0026rsquo;était douteux. Si on ne savait pas qu\u0026rsquo;on s\u0026rsquo;en appuyait, 8.0 va trouver ces chemins de code.\nPlusieurs fonctions qui retournaient des ressources retournent maintenant des objets propres : curl_init() retourne un CurlHandle, imagecreate() retourne un GdImage, xml_parser_create() retourne un XMLParser. Le code qui vérifie is_resource($curl) va casser, parce que is_resource() retourne false pour ces objets. La correction consiste à vérifier contre false (la valeur de retour en cas d\u0026rsquo;échec) plutôt que de vérifier le type du cas de succès.\nPHP 8.0 est le genre de version où les fonctionnalités se renforcent mutuellement. Les attributs se marient bien avec la promotion de constructeur. Match s\u0026rsquo;associe naturellement avec les types union. Les fonctions string réduisent le bruit qui cachait l\u0026rsquo;intention. Les corrections sont parfois cassantes, mais elles poussent le langage vers une cohérence qu\u0026rsquo;il aurait dû avoir depuis des années.\n","permalink":"https://guillaumedelre.github.io/fr/2021/01/10/php-8.0-match-arguments-nomm%C3%A9s-attributs-et-jit/","summary":"\u003cp\u003ePHP 8.0 est sorti le 26 novembre. Je le fais tourner depuis six semaines sur un projet perso et un nouveau service au boulot. C\u0026rsquo;est la version PHP la plus significative depuis 7.0, et à certains égards plus impactante, parce que les changements se renforcent mutuellement de façon utile.\u003c/p\u003e\n\u003ch2 id=\"jit\"\u003eJIT\u003c/h2\u003e\n\u003cp\u003eLe compilateur Just-In-Time était l\u0026rsquo;annonce principale. La réalité en production est plus nuancée : pour les applications web typiques (requêtes en base, appels HTTP, rendu de templates) les gains sont modestes, parce que ces workloads sont limités par les I/O, pas par le calcul. Là où le JIT brille vraiment, c\u0026rsquo;est le code intensif en CPU : manipulation d\u0026rsquo;images, transformation de données, calcul mathématique.\u003c/p\u003e","title":"PHP 8.0 : match, arguments nommés, attributs et JIT"},{"content":"Chaque mise à jour de contenu sur la plateforme crée une révision. C\u0026rsquo;est délibéré : les éditeurs ont besoin d\u0026rsquo;un historique sur lequel ils peuvent revenir, et la plateforme a besoin d\u0026rsquo;une piste d\u0026rsquo;audit. Ce que personne n\u0026rsquo;avait anticipé, c\u0026rsquo;était le rythme. Certains articles passent par quarante sauvegardes en un seul après-midi. Une pièce à fort trafic accumule des centaines de révisions sur sa durée de vie. Après quelques mois, la table de révisions avait plusieurs millions de lignes.\nLes supprimer naïvement n\u0026rsquo;était pas une option. \u0026ldquo;Garder les 50 dernières\u0026rdquo; perd tout contexte historique pour les articles qui n\u0026rsquo;ont pas été touchés depuis un an. \u0026ldquo;Garder une par jour\u0026rdquo; perd tous les détails pour le contenu qui est activement édité. Ce dont on avait besoin, c\u0026rsquo;était une distribution qui correspondait à la façon dont les révisions sont réellement utilisées : couverture dense pour l\u0026rsquo;historique récent, couverture clairsemée pour l\u0026rsquo;ancien.\nC\u0026rsquo;est une distribution logarithmique. Et la construire nécessitait du SQL brut.\nPourquoi les stratégies simples échouent L\u0026rsquo;attrait d\u0026rsquo;une fenêtre fixe est évident : garder les N révisions les plus récentes et supprimer le reste. C\u0026rsquo;est une ligne de SQL et zéro maths. Le problème, c\u0026rsquo;est qu\u0026rsquo;elle traite une révision d\u0026rsquo;hier et une révision d\u0026rsquo;il y a trois ans comme également précieuses, ce qu\u0026rsquo;elles ne sont pas. Un éditeur qui ouvre un article de 2017 n\u0026rsquo;a pas besoin de ses 50 dernières versions ; il pourrait avoir besoin d\u0026rsquo;une par trimestre. Un article qui a été publié ce matin pourrait avoir besoin de chaque sauvegarde de la dernière heure.\nUne stratégie temporelle (une révision par jour calendaire) a le problème inverse : elle est trop agressive pour le contenu actif. Si un article reçoit 30 sauvegardes entre 09h00 et 10h00, toutes sauf une disparaissent. Ce n\u0026rsquo;est pas de l\u0026rsquo;histoire, c\u0026rsquo;est de l\u0026rsquo;effacement.\nNi l\u0026rsquo;une ni l\u0026rsquo;autre ne peut exprimer \u0026ldquo;garder plus de détails pour le contenu récent, moins pour le vieux\u0026rdquo;. Cette relation est logarithmique.\nL\u0026rsquo;idée de score L\u0026rsquo;algorithme assigne à chaque révision un score basé sur son âge, puis garde seulement une révision par bucket de score. La formule de score produit des valeurs hautes et bien espacées pour les révisions récentes, et des valeurs petites et regroupées pour les anciennes.\nL\u0026rsquo;expression centrale, simplifiée, ressemble à ça :\n( ln( EXTRACT(epoch FROM (now() - created_at)) ) / ( EXTRACT(epoch FROM (now() - created_at)) / 6000 ) ) * ( 1 / (EXTRACT(epoch FROM (now() - created_at)) / 60 / 1440) ) * 1000 Soit s l\u0026rsquo;âge en secondes. La formule est grossièrement ln(s) / s * C, où le logarithme au numérateur et s au dénominateur font diminuer le résultat rapidement à mesure que s augmente.\nConverti en entier, l\u0026rsquo;effet est le suivant : une révision sauvegardée il y a 10 minutes pourrait scorer 8432, une sauvegardée il y a 11 minutes score 8431. Elles sont dans des buckets différents. Une révision d\u0026rsquo;il y a six mois score 2, une d\u0026rsquo;il y a huit mois score aussi 2. Même bucket. La window function choisit ensuite la révision la plus récente de chaque bucket et supprime le reste.\nLe résultat est automatique : les sauvegardes récentes sont toutes gardées parce que chacune a un score distinct ; les anciennes sont élagées parce que beaucoup partagent le même score.\nLa tentative DQL qui n\u0026rsquo;a pas abouti Les window functions ne font pas partie de DQL. Le langage de requête de Doctrine n\u0026rsquo;a pas de syntaxe pour OVER, PARTITION BY ou ROW_NUMBER(). Avant de passer au SQL brut, l\u0026rsquo;équipe a essayé de les ajouter.\nL\u0026rsquo;approche FunctionNode fonctionne pour les fonctions SQL simples, comme on l\u0026rsquo;avait déjà vu avec la FTS. Un nœud RowNumber émettant ROW_NUMBER() est trivial :\nclass RowNumber extends FunctionNode { public function getSql(SqlWalker $sqlWalker): string { return \u0026#39;ROW_NUMBER()\u0026#39;; } } La partie plus difficile est OVER(PARTITION BY ... ORDER BY ...). Un nœud de fonction Over a été ébauché, avec un nœud AST PartitionByClause personnalisé pour gérer la clause PARTITION BY :\nclass Over extends FunctionNode { protected ?PartitionByClause $partitionByClause = null; protected ?OrderByClause $orderByClause = null; public function getSql(SqlWalker $sqlWalker): string { return \u0026#39;OVER(\u0026#39; .($this-\u0026gt;partitionByClause ? $this-\u0026gt;partitionByClause-\u0026gt;dispatch($sqlWalker) : ($this-\u0026gt;orderByClause ? $this-\u0026gt;orderByClause-\u0026gt;dispatch($sqlWalker) : \u0026#39;\u0026#39;)) .\u0026#39;)\u0026#39;; } } Ça n\u0026rsquo;a jamais été terminé. Les classes ont été livrées marquées @deprecated et \u0026ldquo;NOT TESTED YET\u0026rdquo;. Le problème est la composabilité : FunctionNode de DQL fonctionne bien pour les fonctions qui apparaissent dans les clauses WHERE ou les expressions SELECT. Une window function comme ROW_NUMBER() OVER (PARTITION BY ...) est une structure différente : elle apparaît dans une position SELECT, modifie la sémantique de la requête englobante, et exige que le parseur gère PARTITION BY comme une extension de la grammaire DQL. Rendre ça suffisamment robuste pour être fiable en production est un investissement significatif. Passer à DBAL et écrire le SQL directement a pris un après-midi.\nLa requête, couche par couche L\u0026rsquo;implémentation finale est trois requêtes imbriquées :\nDELETE FROM revision WHERE iri = ? AND id NOT IN ( SELECT id FROM ( SELECT row_number() OVER ( PARTITION BY num, iri ORDER BY num DESC, created_at DESC ) AS lines, * FROM ( SELECT ( ( ln( EXTRACT(epoch FROM (now() - created_at)) ) / ( EXTRACT(epoch FROM (now() - created_at)) / 6000 ) ) * ( 1 / (EXTRACT(epoch FROM (now() - created_at)) / 60 / 1440) ) * 1000 )::numeric::integer AS num, * FROM revision WHERE iri = ? ORDER BY created_at DESC ) AS lst ) AS rst WHERE lines = 1 ); Requête intérieure : calcule num, le score entier, pour chaque révision de l\u0026rsquo;IRI donnée. Les lignes sont triées par created_at DESC à ce stade.\nRequête intermédiaire : exécute ROW_NUMBER() OVER (PARTITION BY num, iri ORDER BY num DESC, created_at DESC). Dans chaque bucket de score (num), les révisions sont numérotées à partir de 1 dans l\u0026rsquo;ordre décroissant d\u0026rsquo;âge. La révision la plus récente de chaque bucket obtient lines = 1.\nFiltre extérieur : ne garde que les lignes lines = 1, une révision par bucket de score.\nDELETE : supprime chaque révision pour cet IRI qui n\u0026rsquo;est pas dans l\u0026rsquo;ensemble gardé.\nLe PARTITION BY num, iri est redondant sur l\u0026rsquo;IRI (toute la requête est déjà filtrée sur un IRI), mais rend l\u0026rsquo;intention explicite et garde la logique correcte si la requête est un jour réutilisée dans un contexte plus large.\nLa méthode est appelée depuis une requête complémentaire qui identifie quels IRIs ont accumulé plus qu\u0026rsquo;un seuil de révisions :\npublic function getIrisWithMoreRevisionThan(int $maxRevisionsCount, int $limit = 0, ?int $retencyDay = null): array { $queryBuilder = $this -\u0026gt;createQueryBuilder(\u0026#39;revision\u0026#39;) -\u0026gt;select(\u0026#39;revision.iri\u0026#39;) -\u0026gt;groupBy(\u0026#39;revision.iri\u0026#39;) -\u0026gt;having(\u0026#39;COUNT(1) \u0026gt; :maxRevisions\u0026#39;) -\u0026gt;orderBy(\u0026#39;COUNT(1)\u0026#39;, Order::Descending-\u0026gt;value) -\u0026gt;setParameter(\u0026#39;maxRevisions\u0026#39;, $maxRevisionsCount); // ... return array_column($queryBuilder-\u0026gt;getQuery()-\u0026gt;getResult(), \u0026#39;iri\u0026#39;); } Les deux méthodes tournent ensemble dans un nettoyage planifié : trouver les IRIs au-dessus du seuil, élaguer chacun.\nLe câbler à une commande planifiée La requête d\u0026rsquo;élagage ne s\u0026rsquo;exécute pas dans une requête HTTP. Elle tourne derrière une commande Symfony, appelée sur un planning.\nLa commande prend quelques options pour contrôler son agressivité :\n#[AsCommand(\u0026#39;app:purge:revision\u0026#39;, \u0026#39;Remove useless revisions\u0026#39;)] final class PurgeRevisionCommand extends Command { protected function configure(): void { $this -\u0026gt;addOption(\u0026#39;max-revisions\u0026#39;, \u0026#39;m\u0026#39;, InputOption::VALUE_REQUIRED, \u0026#39;Seuil de révisions au-dessus duquel un IRI est élaguée\u0026#39;, 30) -\u0026gt;addOption(\u0026#39;limit\u0026#39;, \u0026#39;l\u0026#39;, InputOption::VALUE_REQUIRED, \u0026#39;Nombre max d\\\u0026#39;IRIs à traiter par exécution\u0026#39;) -\u0026gt;addOption(\u0026#39;delay\u0026#39;, \u0026#39;w\u0026#39;, InputOption::VALUE_REQUIRED, \u0026#39;Délai en secondes entre chaque IRI\u0026#39;) -\u0026gt;addOption(\u0026#39;retencyDay\u0026#39;, \u0026#39;r\u0026#39;, InputOption::VALUE_OPTIONAL, \u0026#39;Ne traiter que les IRIs dont la dernière révision est plus vieille que N jours\u0026#39;); } protected function execute(InputInterface $input, OutputInterface $output): int { $iris = $this-\u0026gt;revisionRepository-\u0026gt;getIrisWithMoreRevisionThan( (int) $input-\u0026gt;getOption(\u0026#39;max-revisions\u0026#39;), (int) $input-\u0026gt;getOption(\u0026#39;limit\u0026#39;), (int) $input-\u0026gt;getOption(\u0026#39;retencyDay\u0026#39;), ); foreach ($iris as $iri) { $totalDeleted += $this-\u0026gt;revisionRepository-\u0026gt;deleteOldRevisionForIri($iri); usleep((int) $input-\u0026gt;getOption(\u0026#39;delay\u0026#39;) * 1_000_000); } return Command::SUCCESS; } } L\u0026rsquo;option --delay mérite attention : sur une base de données chargée, marteler une centaine d\u0026rsquo;instructions DELETE dos à dos peut provoquer de la contention de verrous. Un petit sleep entre les itérations empêche l\u0026rsquo;élagage d\u0026rsquo;entrer en concurrence avec le trafic de production.\nLa commande tourne derrière deux entrées crontab avec des seuils différents :\n# Horaire : garder 30 révisions par IRI, traiter 100 IRIs par exécution 0 * * * * php bin/console app:purge:revision --max-revisions 30 --limit 100 # Nocturne : pour le contenu non touché depuis un an, garder seulement 3 0 0 * * * php bin/console app:purge:revision --max-revisions 3 --limit 100 --retencyDay 365 La stratégie à deux niveaux est importante. Le job horaire garde 30 révisions par IRI, ce qui est un plafond raisonnable pour le contenu activement édité. Le job nocturne cible seulement les IRIs non mis à jour depuis plus d\u0026rsquo;un an et n\u0026rsquo;en garde que 3. Un article qui n\u0026rsquo;a pas bougé depuis douze mois n\u0026rsquo;a pas besoin de trente versions dans son historique.\nCe que ça donne en pratique Un article sauvegardé 200 fois gardera typiquement 20 à 30 révisions après élagage : la plupart des sauvegardes récentes, quelques-unes du mois dernier, une ou deux de chaque trimestre de l\u0026rsquo;année précédente. Le décompte exact dépend de la distribution d\u0026rsquo;âge des sauvegardes, pas d\u0026rsquo;un plafond arbitraire.\nUn article mis à jour pour la dernière fois il y a deux ans pourrait se retrouver avec 5 ou 6 révisions. Les modifications récentes sont toutes là ; l\u0026rsquo;ancien historique est compressé mais pas effacé.\nCe n\u0026rsquo;est pas un historique parfait. C\u0026rsquo;est un historique utile.\nLa frontière entre DQL et SQL brut La tentative window function n\u0026rsquo;est pas un échec à cacher. C\u0026rsquo;est une donnée utile : FunctionNode fonctionne bien pour les fonctions scalaires dans les positions WHERE et SELECT, mais composer une expression complète ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) en DQL est plus difficile qu\u0026rsquo;il n\u0026rsquo;y paraît. L\u0026rsquo;extension de grammaire, les nœuds AST, l\u0026rsquo;intégration du SQL walker : c\u0026rsquo;est une quantité non triviale de code pour quelque chose que le SQL natif gère en trois lignes.\nLa frontière pratique est grossièrement celle-ci : si une fonctionnalité PostgreSQL correspond à un appel de fonction d\u0026rsquo;arité fixe, le DQL personnalisé convient. Si elle nécessite une nouvelle syntaxe de clause (frames de fenêtre, CTEs, lateral joins), le DBAL natif est généralement le meilleur compromis.\n","permalink":"https://guillaumedelre.github.io/fr/2020/09/27/%C3%A9lagage-des-r%C3%A9visions-avec-des-window-functions-et-des-logarithmes-quand-dql-ne-suffisait-plus/","summary":"\u003cp\u003eChaque mise à jour de contenu sur la plateforme crée une révision. C\u0026rsquo;est délibéré : les éditeurs ont besoin d\u0026rsquo;un historique sur lequel ils peuvent revenir, et la plateforme a besoin d\u0026rsquo;une piste d\u0026rsquo;audit. Ce que personne n\u0026rsquo;avait anticipé, c\u0026rsquo;était le rythme. Certains articles passent par quarante sauvegardes en un seul après-midi. Une pièce à fort trafic accumule des centaines de révisions sur sa durée de vie. Après quelques mois, la table de révisions avait plusieurs millions de lignes.\u003c/p\u003e","title":"Élagage des révisions avec des window functions et des logarithmes, quand DQL ne suffisait plus"},{"content":"PHP 7.4 est sorti le 28 novembre. C\u0026rsquo;est la dernière version 7.x avant PHP 8.0, et ça se sent. Les fonctionnalités sont suffisamment substantielles pour tenir debout seules, mais elles ressemblent aussi à des fondations pour ce qui arrive.\nLes propriétés typées C\u0026rsquo;est la grande nouveauté. Depuis PHP 7.0, on pouvait typer les paramètres de fonctions et les valeurs de retour. Mais les propriétés de classe ? Toujours non typées :\nclass User { public int $id; public string $name; public ?DateTimeInterface $deletedAt; } 7.4 change ça. Les propriétés typées font respecter les types à l\u0026rsquo;affectation, pas seulement au niveau des sites d\u0026rsquo;appel. Les classes deviennent auto-documentées d\u0026rsquo;une façon que les docblocks n\u0026rsquo;ont jamais vraiment réussi à faire, et le moteur attrape les erreurs de type avant qu\u0026rsquo;elles ne se propagent dans la moitié de la stack.\nUne subtilité : les propriétés typées sont uninitialized par défaut (pas null). Accéder à une propriété non initialisée lève une Error. C\u0026rsquo;est un piège classique : ?string n\u0026rsquo;implique pas un défaut de null. Il faut encore un = null explicite pour ça.\nLes arrow functions Les closures en PHP ont toujours nécessité d\u0026rsquo;importer explicitement les variables de la portée extérieure avec use :\n$multiplier = 3; $fn = fn($x) =\u0026gt; $x * $multiplier; // pas besoin de use() Les arrow functions capturent automatiquement la portée englobante. Expression unique, retour implicite, pas de boilerplate. Elles ne remplacent pas les closures complètes pour la logique complexe, mais pour les callbacks courts, elles éliminent une catégorie de bruit qui s\u0026rsquo;accumulait depuis des années.\nLe préchargement opcache Pour les setups PHP-FPM à longue durée de vie, le préchargement permet à un script de charger et compiler des fichiers PHP en mémoire opcache au démarrage du serveur. Ces fichiers sont disponibles pour toutes les requêtes sans overhead de compilation.\nLe gain varie selon l\u0026rsquo;application. Sur les grands frameworks où les mêmes fichiers sont chargés à chaque requête, c\u0026rsquo;est réel. Sur les petites applications, négligeable. Vaut la peine de benchmarker avant d\u0026rsquo;ajouter la complexité de configuration.\nLes petites choses qui s\u0026rsquo;accumulent Les fonctionnalités mentionnées en passant méritent plus qu\u0026rsquo;une ligne. L\u0026rsquo;opérateur d\u0026rsquo;affectation null-coalescente ??= résout un pattern suffisamment agaçant à écrire à chaque fois, mais jamais assez pour se donner la peine de l\u0026rsquo;abstraire :\n$config[\u0026#39;timeout\u0026#39;] ??= 30; // équivalent à : $config[\u0026#39;timeout\u0026#39;] = $config[\u0026#39;timeout\u0026#39;] ?? 30; L\u0026rsquo;opérateur spread dans les littéraux de tableau fait ce qu\u0026rsquo;on attend de la version pour les appels de fonctions — dépacker un itérable dans un littéral de tableau :\n$defaults = [\u0026#39;color\u0026#39; =\u0026gt; \u0026#39;blue\u0026#39;, \u0026#39;size\u0026#39; =\u0026gt; \u0026#39;M\u0026#39;]; $options = [\u0026#39;size\u0026#39; =\u0026gt; \u0026#39;L\u0026#39;, ...$defaults, \u0026#39;weight\u0026#39; =\u0026gt; 1.2]; // [\u0026#39;size\u0026#39; =\u0026gt; \u0026#39;M\u0026#39;, \u0026#39;color\u0026#39; =\u0026gt; \u0026#39;blue\u0026#39;, \u0026#39;weight\u0026#39; =\u0026gt; 1.2] Note : les clés string n\u0026rsquo;étaient pas supportées dans 7.4 pour le dépaquet de tableau. Ça viendra plus tard.\nLes types de retour covariants et les types de paramètres contravariants comblent un vide qui rendait certains patterns d\u0026rsquo;héritage inutilement maladroits. Une classe enfant peut maintenant affiner son type de retour vers un sous-type de celui du parent, sans erreur fatale :\nclass Producer { public function get(): Iterator {} } class ChildProducer extends Producer { public function get(): ArrayIterator {} // ArrayIterator implémente Iterator } Lire des nombres à 3h du matin Le séparateur de littéraux numériques est une de ces fonctionnalités dont on ne sait pas qu\u0026rsquo;on la voulait jusqu\u0026rsquo;à la première fois qu\u0026rsquo;on écrit une grande constante et qu\u0026rsquo;on perd immédiatement le sens de l\u0026rsquo;ordre de grandeur :\n$earthMass = 5_972_168_000_000_000_000_000_000; // kg $lightSpeed = 299_792_458; // m/s $planck = 6.626_070_15e-34; // J·s $hexMask = 0xFF_EC_D5_08; $binaryFlags = 0b0001_1111_0010_0000; L\u0026rsquo;underscore est purement syntaxique. Le moteur le supprime avant de parser la valeur. On peut le mettre n\u0026rsquo;importe où entre les chiffres, bien que la convention suive le groupement naturel du système numérique utilisé.\nRéférencer sans posséder WeakReference permet de tenir une référence à un objet sans empêcher le ramasse-miettes de le détruire. Le cas d\u0026rsquo;usage : les caches et registres — on veut savoir qu\u0026rsquo;un objet est vivant, mais on ne veut pas être la raison qu\u0026rsquo;il reste vivant :\n$object = new HeavyObject(); $ref = WeakReference::create($object); var_dump($ref-\u0026gt;get()); // object(HeavyObject) unset($object); var_dump($ref-\u0026gt;get()); // NULL — le GC l\u0026#39;a collecté Avant 7.4, il y avait WeakRef via une extension, et certains frameworks faisaient des tours de passe-passe avec SplObjectStorage qui ne se comportaient pas tout à fait pareil. La classe native est juste directe.\nLa sérialisation sans surprise La sérialisation personnalisée d\u0026rsquo;objets avant 7.4 passait par l\u0026rsquo;interface Serializable : implémenter serialize() et unserialize(), retourner une string, reconstruire depuis elle. Le problème est que serialize() déclenchait __sleep(), unserialize() déclenchait __wakeup(), et l\u0026rsquo;interaction entre ces hooks était fragile, surtout dans les hiérarchies d\u0026rsquo;héritage.\n7.4 introduit __serialize() et __unserialize(), qui travaillent avec des tableaux plutôt que des strings et n\u0026rsquo;interagissent pas avec les anciens hooks :\nclass Session { private string $token; private \\DateTime $createdAt; public function __serialize(): array { return [\u0026#39;token\u0026#39; =\u0026gt; $this-\u0026gt;token, \u0026#39;created\u0026#39; =\u0026gt; $this-\u0026gt;createdAt-\u0026gt;getTimestamp()]; } public function __unserialize(array $data): void { $this-\u0026gt;token = $data[\u0026#39;token\u0026#39;]; $this-\u0026gt;createdAt = (new \\DateTime())-\u0026gt;setTimestamp($data[\u0026#39;created\u0026#39;]); } } Quand les nouvelles et anciennes méthodes coexistent sur la même classe, __serialize() gagne. L\u0026rsquo;ancienne interface Serializable est dépréciée dans 8.1.\nCe que la bibliothèque standard a discrètement reçu mb_str_split() fait ce que str_split() fait mais correctement pour les strings multibyte. Le manque était franchement embarrassant pour un langage utilisé dans autant de locales que PHP :\nmb_str_split(\u0026#39;héllo\u0026#39;, 1); // [\u0026#39;h\u0026#39;, \u0026#39;é\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39;] str_split(\u0026#39;héllo\u0026#39;, 1); // [\u0026#39;h\u0026#39;, \u0026#39;Ã\u0026#39;, \u0026#39;©\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39;] — cassé strip_tags() accepte maintenant un tableau de tags autorisés, ce qui est plus propre que le format string qu\u0026rsquo;il fallait passer auparavant :\nstrip_tags($html, [\u0026#39;p\u0026#39;, \u0026#39;br\u0026#39;, \u0026#39;strong\u0026#39;]); // était : \u0026#39;\u0026lt;p\u0026gt;\u0026lt;br\u0026gt;\u0026lt;strong\u0026gt;\u0026#39; proc_open() accepte maintenant un tableau de commandes, contournant complètement l\u0026rsquo;interprétation par le shell. Même idée que subprocess de Python avec shell=False. À retenir quand on passe des arguments fournis par l\u0026rsquo;utilisateur à un processus externe.\nLe chapitre FFI L\u0026rsquo;extension Foreign Function Interface a atterri dans 7.4 après avoir passé du temps dans une branche feature. Elle permet à PHP d\u0026rsquo;appeler des fonctions C natives en chargeant une bibliothèque partagée et en déclarant les signatures :\n$ffi = FFI::cdef(\u0026#34;int strlen(const char *s);\u0026#34;, \u0026#34;libc.so.6\u0026#34;); var_dump($ffi-\u0026gt;strlen(\u0026#34;hello\u0026#34;)); // int(5) Les applications pratiques sont étroites mais réelles : appeler des API de plateforme sans binding PHP, wrapper du code C critique pour les performances sans écrire une extension complète, ou juste jouer avec des bibliothèques natives directement. Ce n\u0026rsquo;est pas un remplacement des extensions propres en production, mais ça supprime la barrière \u0026ldquo;écrire une extension C\u0026rdquo; pour l\u0026rsquo;exploration.\nCe qui a été déprécié Quelques choses qui auraient dû être nettoyées depuis longtemps ont finalement reçu le traitement dépréciation dans 7.4.\nLes ternaires imbriqués sans parenthèses ont toujours été ambigus. PHP les évaluait de gauche à droite alors que pratiquement tous les autres langages avec un ternaire évaluent de droite à gauche :\n// Était ambigu, maintenant déprécié : $a ? $b : $c ? $d : $e; // Rendre explicite : ($a ? $b : $c) ? $d : $e; L\u0026rsquo;accès par offset avec accolades pour les strings et tableaux — $str{0} au lieu de $str[0] — est déprécié et supprimé dans 8.0. C\u0026rsquo;était toujours un alias, jamais une fonctionnalité distincte.\nimplode() avec l\u0026rsquo;ordre d\u0026rsquo;arguments inversé (tableau en premier, colle en second) est déprécié. La fonction a accepté les deux ordres depuis le début, ce qui était une erreur. L\u0026rsquo;ordre correct est implode(string $separator, array $array).\nCe qui arrive ensuite 7.4 est la dernière version 7.x. Les dépréciations sont principalement du déblayage pour les suppressions dans 8.0. Le backlog de RFCs pour 8.0 est substantiel : JIT, attributs, arguments nommés, expressions match. 7.4 est un bon endroit où atterrir en attendant que tout ça arrive.\n","permalink":"https://guillaumedelre.github.io/fr/2020/01/12/php-7.4-les-propri%C3%A9t%C3%A9s-typ%C3%A9es-et-les-arrow-functions-quon-attendait/","summary":"\u003cp\u003ePHP 7.4 est sorti le 28 novembre. C\u0026rsquo;est la dernière version 7.x avant PHP 8.0, et ça se sent. Les fonctionnalités sont suffisamment substantielles pour tenir debout seules, mais elles ressemblent aussi à des fondations pour ce qui arrive.\u003c/p\u003e\n\u003ch2 id=\"les-propriétés-typées\"\u003eLes propriétés typées\u003c/h2\u003e\n\u003cp\u003eC\u0026rsquo;est la grande nouveauté. Depuis PHP 7.0, on pouvait typer les paramètres de fonctions et les valeurs de retour. Mais les propriétés de classe ? Toujours non typées :\u003c/p\u003e","title":"PHP 7.4 : les propriétés typées et les arrow functions qu'on attendait"},{"content":"Symfony 5.0 est sorti le 21 novembre 2019, le même jour que la 4.4. Là où la 4.4 mise sur la stabilité et une longue fenêtre de support, la 5.0 ouvre un nouveau chapitre : plus de code déprécié, PHP 7.2.5 minimum, et quelques nouveaux composants qui comblent enfin des lacunes accumulées depuis des années.\nLe composant String La gestion des chaînes en PHP est notoirement éparpillée : des fonctions avec préfixe par-ci (str_), avec suffixe par-là (strpos), un support d\u0026rsquo;encodage incohérent, et rien d\u0026rsquo;orienté objet en vue. Le composant String enveloppe tout ça dans une API fluide orientée objet avec support Unicode :\nuse Symfony\\Component\\String\\UnicodeString; $str = new UnicodeString(\u0026#39; Hello World \u0026#39;); echo $str-\u0026gt;trim()-\u0026gt;lower()-\u0026gt;replace(\u0026#39; \u0026#39;, \u0026#39;-\u0026#39;); // hello-world L\u0026rsquo;ajout pratique, c\u0026rsquo;est le Slugger, un générateur de slug locale-aware qui gère vraiment correctement les caractères accentués :\n$slug = $slugger-\u0026gt;slug(\u0026#39;L\\\u0026#39;été à Montréal\u0026#39;); // l-ete-a-montreal Avant, il fallait intégrer une bibliothèque tierce ou en écrire une soi-même. Maintenant ça ship avec FrameworkBundle, disponible par défaut.\nNotifier Le courrier électronique est géré par Mailer. SMS, notifications push, messages de chat : pas de solution first-party, jusqu\u0026rsquo;à maintenant. Le composant Notifier en ajoute une : une interface unifiée sur des dizaines de canaux et fournisseurs.\nLa même notification peut atterrir sur Slack, déclencher un SMS via Twilio, ou finir comme notification push, tout configuré via des DSN. Ajouter un nouveau canal, c\u0026rsquo;est un changement de config, pas un changement de code.\nLe coffre-fort de secrets Stocker des secrets dans des fichiers .env fonctionne, mais les valeurs sont en clair, les environnements partagés sont une galère, et il n\u0026rsquo;y a aucun moyen natif de chiffrer quoi que ce soit au repos.\nSymfony 5.0 ajoute une famille de commandes secrets: et un mécanisme de coffre-fort. Les secrets sont chiffrés avec une paire de clés stockée hors du dépôt. Les fichiers chiffrés sont commités ; la clé de déchiffrement ne l\u0026rsquo;est pas. En production, la clé arrive comme variable d\u0026rsquo;environnement ou est injectée depuis un gestionnaire de secrets.\nphp bin/console secrets:set DATABASE_PASSWORD php bin/console secrets:decrypt-to-local --force Pas une solution de gestion de secrets à part entière, mais un vrai pas en avant par rapport à un fichier .env en clair qui traîne non chiffré dans le dépôt.\nMailer reçoit une couche de notification Le composant Mailer est arrivé en 4.4. Ce que la 5.0 ajoute par-dessus, c\u0026rsquo;est la NotificationEmail : un email pré-stylisé et responsive construit sur Foundation for Emails, avec une API explicite pour les niveaux d\u0026rsquo;importance et les boutons d\u0026rsquo;appel à l\u0026rsquo;action :\nuse Symfony\\Bridge\\Twig\\Mime\\NotificationEmail; $email = (new NotificationEmail()) -\u0026gt;from(\u0026#39;alerts@example.com\u0026#39;) -\u0026gt;to(\u0026#39;admin@example.com\u0026#39;) -\u0026gt;subject(\u0026#39;Disk usage critical\u0026#39;) -\u0026gt;markdown(\u0026#39;The disk on **prod-01** is at 94%. Check it now.\u0026#39;) -\u0026gt;action(\u0026#39;Open dashboard\u0026#39;, \u0026#39;https://example.com/servers\u0026#39;) -\u0026gt;importance(NotificationEmail::IMPORTANCE_URGENT); Pas de template à écrire, pas de CSS inline à dompter. Pour les alertes transactionnelles, les notifications de facturation et les emails système, ça couvre 80 % de ce dont on a besoin sans toucher à quoi que ce soit.\nLes firewalls paresseux et le problème de cache Chaque firewall stateful dans Symfony charge l\u0026rsquo;utilisateur depuis la session à chaque requête, que l\u0026rsquo;action en ait besoin ou non. Ce qui signifie que toute réponse est non-cacheable par défaut, même pour des pages qui ne touchent jamais à $this-\u0026gt;getUser().\nLa 5.0 ajoute le mode lazy pour les firewalls, qui diffère l\u0026rsquo;accès à la session jusqu\u0026rsquo;à ce que le code appelle réellement is_granted() ou accède au token utilisateur :\n# config/packages/security.yaml security: firewalls: main: pattern: ^/ anonymous: lazy Les pages qui n\u0026rsquo;ont pas besoin de l\u0026rsquo;utilisateur redeviennent cacheables. Les nouveaux projets obtiennent ça par défaut via la recette Flex ; les existants ont besoin d\u0026rsquo;un changement de config en une ligne.\nLes migrations de mots de passe sans grand soir Migrer une app en production de bcrypt vers argon2id impliquait jusqu\u0026rsquo;ici de forcer une réinitialisation du mot de passe pour chaque utilisateur. Le PasswordUpgraderInterface rend ça progressif : à la connexion, Symfony vérifie si le hash stocké correspond à l\u0026rsquo;algorithme courant. Sinon, il le re-hash sur place et appelle votre upgrader pour le sauvegarder :\n// src/Repository/UserRepository.php class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface { public function upgradePassword(UserInterface $user, string $newHashedPassword): void { $user-\u0026gt;setPassword($newHashedPassword); $this-\u0026gt;getEntityManager()-\u0026gt;flush(); } } Associez ça à algorithm: auto dans la config de l\u0026rsquo;encodeur, et les anciens hashs migrent silencieusement à mesure que les utilisateurs se connectent. Pas de script de migration, pas de downtime, pas de friction pour l\u0026rsquo;utilisateur.\nErrorHandler remplace Debug Le composant Debug est parti. Son remplaçant, ErrorHandler, fait le même travail (convertir les erreurs PHP en exceptions, afficher de belles pages d\u0026rsquo;erreur) mais sans nécessiter Twig. Pour les apps API qui ne rendent jamais de HTML, ça compte : ErrorHandler génère les erreurs dans le format de la requête (JSON, XML, texte) en suivant RFC 7807 :\n{ \u0026#34;title\u0026#34;: \u0026#34;Not Found\u0026#34;, \u0026#34;status\u0026#34;: 404, \u0026#34;detail\u0026#34;: \u0026#34;Sorry, the page you are looking for could not be found\u0026#34; } La config de routing passe de TwigBundle à FrameworkBundle, et c\u0026rsquo;est la seule étape de migration pour la plupart des projets. Une ligne, c\u0026rsquo;est fait.\nLes listeners d\u0026rsquo;événements, enfin moins verbeux Enregistrer un listener d\u0026rsquo;événement kernel impliquait auparavant de nommer explicitement l\u0026rsquo;événement dans le tag de service. Symfony 5.0 l\u0026rsquo;infère depuis la signature de méthode :\n// Pas de configuration de tag au-delà de kernel.event_listener final class SecurityListener { public function onKernelRequest(RequestEvent $event): void { // Symfony lit le type hint et détermine l\u0026#39;événement } } # config/services.yaml App\\EventListener\\SecurityListener: tags: [kernel.event_listener] Utilisez __invoke() et ça fonctionne de la même façon. Enregistrez en masse tout un répertoire de listeners avec un seul bloc resource, et Symfony détermine quel événement chacun gère.\nHttpClient grandit Le composant HttpClient est arrivé en 4.4 comme stable. La 5.0 ajoute quelques choses utiles par-dessus :\nL\u0026rsquo;authentification NTLM pour les environnements d\u0026rsquo;entreprise, le buffering conditionnel via un callback (bufferiser les grandes réponses seulement quand le content-type correspond), une option max_duration qui plafonne le temps total de requête indépendamment des conditions réseau, et toStream() pour transformer n\u0026rsquo;importe quelle réponse en un flux PHP standard pour le code qui attend du fread() :\n$response = $client-\u0026gt;request(\u0026#39;GET\u0026#39;, \u0026#39;https://api.example.com/large-export\u0026#39;, [ \u0026#39;max_duration\u0026#39; =\u0026gt; 30.0, \u0026#39;buffer\u0026#39; =\u0026gt; fn(array $headers): bool =\u0026gt; str_contains($headers[\u0026#39;content-type\u0026#39;][0] ?? \u0026#39;\u0026#39;, \u0026#39;json\u0026#39;), ]); // Le streamer plutôt que de tout charger en mémoire $stream = $response-\u0026gt;toStream(); Le client a aussi obtenu une interopérabilité complète avec PSR-18 et HTTPlug v1/v2, donc toute bibliothèque qui dépend de ces abstractions fonctionne directement avec lui.\nCe que la 5.0 supprime La 5.0 abandonne tout ce qui était déprécié en 4.4. Les plus notables :\nWebServerBundle (utilisez symfony server:start depuis l\u0026rsquo;outil CLI à la place) L\u0026rsquo;AnonymousToken de l\u0026rsquo;ancien système de sécurité (remplacé par NullToken) Les anciens noms d\u0026rsquo;événements de formulaire Le ClassLoader interne de Symfony Le composant Debug (remplacé par ErrorHandler) Si vous avez fait tourner votre app 4.4 avec les notices de dépréciation actives et corrigé les avertissements, la mise à niveau vers la 5.0 ne nécessite aucun changement de code.\n","permalink":"https://guillaumedelre.github.io/fr/2020/01/06/symfony-5.0-string-notifier-et-le-coffre-fort-de-secrets/","summary":"\u003cp\u003eSymfony 5.0 est sorti le 21 novembre 2019, le même jour que la 4.4. Là où la 4.4 mise sur la stabilité et une longue fenêtre de support, la 5.0 ouvre un nouveau chapitre : plus de code déprécié, PHP 7.2.5 minimum, et quelques nouveaux composants qui comblent enfin des lacunes accumulées depuis des années.\u003c/p\u003e\n\u003ch2 id=\"le-composant-string\"\u003eLe composant String\u003c/h2\u003e\n\u003cp\u003eLa gestion des chaînes en PHP est notoirement éparpillée : des fonctions avec préfixe par-ci (\u003ccode\u003estr_\u003c/code\u003e), avec suffixe par-là (\u003ccode\u003estrpos\u003c/code\u003e), un support d\u0026rsquo;encodage incohérent, et rien d\u0026rsquo;orienté objet en vue. Le composant String enveloppe tout ça dans une API fluide orientée objet avec support Unicode :\u003c/p\u003e","title":"Symfony 5.0 : String, Notifier et le coffre-fort de secrets"},{"content":"Symfony 4.4 et 5.0 sont tous les deux sortis le 21 novembre 2019. La 4.4 est la LTS : même ensemble de fonctionnalités que la 5.0, couche de dépréciation intégrée, et une longue fenêtre de support pour les équipes qui ne peuvent pas suivre chaque release.\nLa fonctionnalité qui mérite d\u0026rsquo;être mise en avant est arrivée en 4.2 et a mûri tout au long des 4.3 et 4.4 : HttpClient.\nHttpClient Les options HTTP natives de PHP (file_get_contents avec des contextes de flux, cURL, Guzzle) ont chacune leur propre modèle, leurs propres bizarreries et leur propre coût d\u0026rsquo;abstraction. Symfony 4.2 a introduit HttpClient, un client HTTP first-party avec une seule API pour plusieurs transports.\nL\u0026rsquo;interface est claire :\n$response = $client-\u0026gt;request(\u0026#39;GET\u0026#39;, \u0026#39;https://api.example.com/users\u0026#39;); $users = $response-\u0026gt;toArray(); L\u0026rsquo;implémentation est asynchrone par défaut. Les réponses sont paresseuses : la requête réseau n\u0026rsquo;a pas lieu tant qu\u0026rsquo;on ne lit pas réellement la réponse. Plusieurs requêtes peuvent être initiées et résolues au fil de l\u0026rsquo;arrivée des données, sans threads ni callbacks.\nLe transport mock intégré (MockHttpClient) rend les tests d\u0026rsquo;appels HTTP indolores sans avoir à démarrer des serveurs ou patcher des fonctions globales.\nMailer Également stabilisé en 4.4 : le composant Mailer, qui remplace SwiftMailerBundle comme solution d\u0026rsquo;email recommandée. Le transport se configure via DSN :\nMAILER_DSN=smtp://user:pass@smtp.example.com:587 L\u0026rsquo;approche DSN signifie que changer de fournisseur (Mailgun, Postmark, SES, SMTP local) est un changement de config, pas un changement de code. Les tests d\u0026rsquo;emails utilisent un spooler par défaut dans les environnements hors production.\nMessenger se mâture Le composant Messenger a atterri en 3.4 à titre expérimental. En 4.4, il est stable et éprouvé en production : gestion de messages asynchrones avec logique de retry, transport d\u0026rsquo;échec, et adaptateurs pour AMQP, Redis, Doctrine, et les transports in-process.\nLe pattern qu\u0026rsquo;il permet (traiter une requête de façon synchrone, dispatcher du travail de façon asynchrone, réessayer en cas d\u0026rsquo;échec) remplace toute une classe de setups Gearman/RabbitMQ qui nécessitaient des bibliothèques tierces et une configuration conséquente.\nLa fenêtre LTS La 4.4 est supportée pour les bugs jusqu\u0026rsquo;en novembre 2022 et pour les correctifs de sécurité jusqu\u0026rsquo;en novembre 2023. Si vous êtes sur la 4.x et recherchez la stabilité, c\u0026rsquo;est un endroit confortable où rester. Les avertissements de dépréciation qu\u0026rsquo;elle introduit pointent directement vers ce que la 5.0 exigera.\nLe composant Messenger, de l\u0026rsquo;expérimental à la production Messenger est arrivé en 4.1 comme une expérience. Le concept était simple : dispatcher un objet message vers un bus, le traiter immédiatement ou le router vers un transport pour un traitement asynchrone. En 4.3 et 4.4, l\u0026rsquo;expérience était devenue de l\u0026rsquo;infrastructure.\nLa release 4.3 a ajouté un transport d\u0026rsquo;échec dédié. Quand un message échoue après toutes les tentatives de retry, il va quelque part de récupérable plutôt que de simplement disparaître :\nframework: messenger: failure_transport: failed transports: async: \u0026#39;%env(MESSENGER_TRANSPORT_DSN)%\u0026#39; failed: \u0026#39;doctrine://default?queue_name=failed\u0026#39; routing: App\\Message\\SendEmail: async Les messages qui atterrissent dans failed peuvent être inspectés et retentés manuellement. Avant ça, les messages en échec étaient une entrée de log et un mal de tête. Après, c\u0026rsquo;est une file qu\u0026rsquo;on peut vraiment travailler.\nLe dispatching d\u0026rsquo;événements, avec les objets en première place Depuis le début, le système d\u0026rsquo;événements de Symfony utilisait des noms d\u0026rsquo;événements en chaîne comme identifiant principal. On définissait OrderEvents::NEW_ORDER = 'order.new_order', on écoutait cette chaîne, et on passait l\u0026rsquo;objet événement comme paramètre secondaire.\nLa 4.3 a inversé ça. L\u0026rsquo;objet événement passe en premier, et le nom de l\u0026rsquo;événement devient optionnel :\n// Avant $dispatcher-\u0026gt;dispatch(OrderEvents::NEW_ORDER, $event); // 4.3+ $dispatcher-\u0026gt;dispatch($event); Omettez le nom et Symfony utilise le nom de classe comme identifiant. Les listeners et subscribers peuvent maintenant référencer la classe directement :\npublic static function getSubscribedEvents(): array { return [ OrderPlacedEvent::class =\u0026gt; \u0026#39;onOrderPlaced\u0026#39;, ]; } Les événements HttpKernel ont été renommés en conséquence : GetResponseEvent est devenu RequestEvent, FilterResponseEvent est devenu ResponseEvent. Les anciens noms sont restés comme alias pendant toute la 4.x.\nVarDumper obtient un serveur Un dump() dans un contrôleur qui retourne du JSON, et votre sortie de debug se retrouve injectée directement dans le corps de la réponse. Pour le développement d\u0026rsquo;API, c\u0026rsquo;est suffisamment agaçant pour que les gens désactivent le dumping complètement.\nLa 4.1 a ajouté un serveur VarDumper qui capture les dumps séparément :\nbin/console server:dump Configurez la destination du dump dans config/packages/dev/debug.yaml :\ndebug: dump_destination: \u0026#34;tcp://%env(VAR_DUMPER_SERVER)%\u0026#34; Maintenant, dump() dans votre contrôleur API envoie les données vers la console du serveur au lieu de polluer la réponse. Le serveur affiche le dump avec le fichier source, la requête HTTP qui l\u0026rsquo;a déclenché, et le timestamp.\nVarExporter, pour quand var_export() vous déçoit var_export() a deux problèmes : il ignore la sémantique de sérialisation et sa sortie n\u0026rsquo;est pas conforme à PSR-2. Le composant VarExporter de la 4.2 corrige les deux.\n$exported = VarExporter::export([123, [\u0026#39;abc\u0026#39;, true]]); // Retourne : // [ // 123, // [ // \u0026#39;abc\u0026#39;, // true, // ], // ] Plus important encore, il gère correctement les objets implémentant Serializable, __sleep, et __wakeup. Là où var_export() abandonne silencieusement les méthodes de sérialisation et exporte les propriétés brutes, VarExporter produit du code qui appelle les mêmes hooks qu\u0026rsquo;unserialize() utiliserait. Le cas d\u0026rsquo;usage pratique est le préchauffage du cache : générer des fichiers PHP que OPcache peut charger sans ré-exécuter des calculs coûteux.\nDes mots de passe vérifiés contre les bases de données de violations La contrainte NotCompromisedPassword est arrivée en 4.3. Elle vérifie les mots de passe soumis contre la base de données de violations d\u0026rsquo;haveibeenpwned.com sans envoyer le vrai mot de passe nulle part.\nuse Symfony\\Component\\Validator\\Constraints as Assert; class User { #[Assert\\NotCompromisedPassword] public string $plainPassword; } L\u0026rsquo;implémentation utilise la k-anonymité : on hash le mot de passe en SHA-1, on envoie seulement les cinq premiers caractères à l\u0026rsquo;API, on récupère tous les hashs correspondants, on vérifie localement. Le mot de passe ne quitte jamais votre serveur. Pour les formulaires d\u0026rsquo;inscription, ajouter cette contrainte c\u0026rsquo;est une ligne et un signal de sécurité vraiment utile.\nWorkflow obtient du contexte Le composant Workflow existait avant la 4.x, mais la 4.3 a ajouté la propagation de contexte : la possibilité de passer des données arbitraires à travers une transition et d\u0026rsquo;y accéder dans les listeners.\n$workflow-\u0026gt;apply($article, \u0026#39;publish\u0026#39;, [ \u0026#39;user\u0026#39; =\u0026gt; $user-\u0026gt;getUsername(), \u0026#39;reason\u0026#39; =\u0026gt; $request-\u0026gt;request-\u0026gt;get(\u0026#39;reason\u0026#39;), ]); Le contexte arrive dans TransitionEvent et est stocké aux côtés du marquage. Pour les pistes d\u0026rsquo;audit, c\u0026rsquo;est la différence entre savoir qu\u0026rsquo;une transition s\u0026rsquo;est produite et savoir qui l\u0026rsquo;a déclenchée et pourquoi. On peut aussi injecter du contexte depuis un subscriber sans toucher à chaque appel apply(), ce qui est pratique pour les préoccupations transversales comme les timestamps ou l\u0026rsquo;utilisateur courant.\nL\u0026rsquo;autowiring est devenu plus intelligent La 4.2 a ajouté la liaison par type et par nom simultanément. Avant, on pouvait lier par type (LoggerInterface) ou par nom ($logger), mais pas les deux en même temps. Ça posait problème quand un service a besoin de deux implémentations différentes de la même interface :\nservices: _defaults: bind: Psr\\Log\\LoggerInterface $orderLogger: \u0026#39;@monolog.logger.orders\u0026#39; Psr\\Log\\LoggerInterface $paymentLogger: \u0026#39;@monolog.logger.payments\u0026#39; class OrderService { public function __construct( private LoggerInterface $orderLogger, // gets monolog.logger.orders private LoggerInterface $paymentLogger, // gets monolog.logger.payments ) {} } La correspondance exige que le type et le nom de l\u0026rsquo;argument s\u0026rsquo;alignent, donc pas de risque d\u0026rsquo;injecter accidentellement le mauvais logger.\nErrorHandler remplace le composant Debug Le composant Debug, inchangé depuis 2013, avait une dépendance maladroite sur TwigBundle même pour les apps API-only. Toute exception non attrapée dans une API JSON rendait une page d\u0026rsquo;erreur HTML à moins d\u0026rsquo;écrire des listeners d\u0026rsquo;exception personnalisés.\nLa 4.4 extrait ça dans un composant ErrorHandler dédié. Pour les requêtes non-HTML, les réponses d\u0026rsquo;erreur suivent désormais RFC 7807 nativement :\n{ \u0026#34;title\u0026#34;: \u0026#34;Not Found\u0026#34;, \u0026#34;status\u0026#34;: 404, \u0026#34;detail\u0026#34;: \u0026#34;Sorry, the page you are looking for could not be found\u0026#34; } Pas besoin de Twig. Le format suit l\u0026rsquo;en-tête Accept : JSON pour les requêtes JSON, XML pour les requêtes XML. Pour personnaliser davantage, on fournit un normalizer via le composant Serializer plutôt qu\u0026rsquo;un template Twig.\nLe préchargement PHP 7.4, câblé automatiquement PHP 7.4 a introduit le préchargement OPcache : charger des fichiers en mémoire partagée avant l\u0026rsquo;arrivée de toute requête, pour qu\u0026rsquo;ils soient disponibles sous forme d\u0026rsquo;opcodes compilés dès la toute première requête. Le gain pratique est de 30 à 50 % de temps de réponse en moins sans changer une ligne de code.\nLe bémol c\u0026rsquo;est la configuration : il faut spécifier exactement quels fichiers précharger dans php.ini. Symfony 4.4 génère ce fichier automatiquement dans le répertoire de cache :\n; php.ini opcache.preload=/path/to/project/var/cache/prod/App_KernelProdContainer.preload.php opcache.preload_user=www-data Lancez cache:warmup en production et pointez OPcache vers le fichier généré. Symfony précharge le container, les routes compilées et les templates Twig : les fichiers lus à chaque requête et qui ne changent jamais entre les déploiements.\nConsole : codes de retour et NO_COLOR Deux petites choses en 4.4 qui auraient honnêtement dû exister plus tôt. Les commandes qui ne retournent pas d\u0026rsquo;entier depuis execute() déclenchent maintenant un avertissement de dépréciation. En 5.0, le type de retour devient obligatoire. Retourner 0 pour le succès, non-zéro pour l\u0026rsquo;échec : comportement Unix standard, et ça rend l\u0026rsquo;intégration avec les superviseurs de processus et les pipelines CI sans ambiguïté.\nprotected function execute(InputInterface $input, OutputInterface $output): int { // ... return Command::SUCCESS; // = 0 } Le deuxième point : le support de la variable d\u0026rsquo;environnement NO_COLOR, suivant la convention de no-color.org. Activez-la et toutes les commandes console de Symfony abandonnent les codes d\u0026rsquo;échappement ANSI quelle que soit la capacité déclarée par le terminal. Utile pour les environnements CI qui capturent la sortie en texte et qui s\u0026rsquo;étranglent sur les codes couleur intégrés dans les logs.\n","permalink":"https://guillaumedelre.github.io/fr/2020/01/04/symfony-4.4-lts-httpclient-mailer-messenger-et-les-fonctionnalit%C3%A9s-qui-ont-tenu-bon/","summary":"\u003cp\u003eSymfony 4.4 et 5.0 sont tous les deux sortis le 21 novembre 2019. La 4.4 est la LTS : même ensemble de fonctionnalités que la 5.0, couche de dépréciation intégrée, et une longue fenêtre de support pour les équipes qui ne peuvent pas suivre chaque release.\u003c/p\u003e\n\u003cp\u003eLa fonctionnalité qui mérite d\u0026rsquo;être mise en avant est arrivée en 4.2 et a mûri tout au long des 4.3 et 4.4 : \u003ccode\u003eHttpClient\u003c/code\u003e.\u003c/p\u003e","title":"Symfony 4.4 LTS : HttpClient, Mailer, Messenger et les fonctionnalités qui ont tenu bon"},{"content":"La question était simple : quelle est la température et l\u0026rsquo;humidité dans mon bureau à domicile en ce moment ? Pas la météo dehors, pas une moyenne de ville — les conditions réelles dans la pièce où je passe la majeure partie de ma journée. Ouvrir une application météo pour ça semblait mal.\nUn Raspberry Pi tournait déjà sur l\u0026rsquo;étagère. Un capteur BME280 coûte environ 10€. Ça aurait dû être un projet de week-end.\nC\u0026rsquo;était globalement le cas, à l\u0026rsquo;exception de la partie où j\u0026rsquo;ai supposé que lire un capteur de température signifiait lire un registre.\nQuatre fils et une puce Le Bosch BME280 mesure la température, l\u0026rsquo;humidité et la pression atmosphérique par I²C. Quatre fils vers les pins GPIO du Raspberry Pi, activer l\u0026rsquo;I²C dans raspi-config, et le capteur apparaît à l\u0026rsquo;adresse 0x77 sur le bus :\ni2cdetect -y 1 C\u0026rsquo;est la partie facile. Le piège, c\u0026rsquo;est ce qui se passe ensuite.\nOn ne lit pas juste la température Le BME280 ne vous donne pas 21,5°C. Il vous donne des valeurs ADC brutes : des entiers 20 bits qui ne signifient absolument rien par eux-mêmes. Pour obtenir une vraie température, il faut :\nLire les coefficients de calibration que Bosch a gravés dans l\u0026rsquo;EEPROM de la puce à l\u0026rsquo;usine (registres 0x88, 0xA1, 0xE1) Appliquer les formules de compensation Bosch : de l\u0026rsquo;arithmétique en virgule flottante double précision qui utilise ces coefficients pour transformer les valeurs brutes en vraies mesures Attendre que la mesure soit terminée en scrutant le registre de statut La compensation de température seule prend la valeur brute, applique une correction quadratique avec trois constantes de calibration, et crache une valeur en centièmes de degrés Celsius. La pression dépend de la température corrigée. L\u0026rsquo;humidité dépend des deux.\nTout est directement tiré de la datasheet Bosch, rien d\u0026rsquo;inventé. Mais ça signifie que le driver n\u0026rsquo;est pas un programme de cinq lignes. C\u0026rsquo;est implémenter une spec, pas importer une bibliothèque.\nLe rendre accessible par le réseau Une fois le driver fonctionnel, la question suivante était de savoir comment amener ces valeurs dans Home Assistant. Le chemin le plus simple : une API Flask avec deux endpoints.\nGET /bme280 retourne la lecture courante en JSON. GET /bme280/publish lit le capteur et pousse les trois valeurs vers un broker MQTT. Un cron job sur le Pi appelle l\u0026rsquo;endpoint publish toutes les quelques minutes, et Home Assistant récupère les valeurs en temps réel.\nLe mécanisme de découverte MQTT a rendu la partie Home Assistant presque sans friction. Une commande mosquitto_pub par type de capteur — publier un payload JSON de config vers le bon topic — et les entités apparaissent automatiquement dans l\u0026rsquo;UI. Pas d\u0026rsquo;édition de configuration.yaml, pas de redémarrage requis.\nBME280 ──I²C──► bme280.py ──► Flask API ──MQTT──► Home Assistant Le guide d\u0026rsquo;installation complet est dans le repo.\nCe à quoi je ne m\u0026rsquo;attendais pas La calibration Bosch n\u0026rsquo;est pas négociable. J\u0026rsquo;ai commencé par lire le registre de température brute directement et le scaler naïvement. Le résultat était des nombres qui avaient l\u0026rsquo;air presque plausibles et qui étaient complètement faux. L\u0026rsquo;algorithme de compensation n\u0026rsquo;est pas une décoration optionnelle, c\u0026rsquo;est ce qui rend la sortie significative.\nLe polling bat les événements ici. Le capteur ne pousse pas de données, on lui demande une lecture. Un cron job toutes les minutes est tout ce dont on a besoin pour surveiller une pièce. Le streaming en temps réel serait excessif et userait probablement le capteur plus vite.\nLa découverte MQTT est sous-estimée. Déclarer manuellement les capteurs dans configuration.yaml fonctionne, mais l\u0026rsquo;auto-découverte semble simplement juste. Publier un payload de config une fois, et Home Assistant s\u0026rsquo;en occupe. Ajouter un nouveau type de capteur plus tard prend environ trente secondes.\nLa pièce est maintenant à 21,4°C et 47% d\u0026rsquo;humidité. Je le sais sans rien ouvrir.\nUne note sur le SensorAPI officiel Bosch En écrivant le driver, j\u0026rsquo;ai jeté un œil au SensorAPI officiel Bosch pour référence. Deux choses ont retenu mon attention.\nL\u0026rsquo;exemple userspace Linux ne fonctionne pas vraiment sur Raspberry Pi sans modifications : ioctl est appelé avant que dev_addr soit assigné, donc l\u0026rsquo;adresse du périphérique I²C n\u0026rsquo;est jamais correctement définie. Le correctif est évident une fois qu\u0026rsquo;on le voit, et plusieurs contributeurs ont buté sur le même bug indépendamment, mais ils attendaient en PR depuis des années. Certains attendent encore.\nIl y a aussi la PR #94 (toujours ouverte début 2025), signalant un comportement indéfini dans bme280_get_sensor_mode() : l\u0026rsquo;opérande gauche d\u0026rsquo;un \u0026amp; bit à bit est une variable non initialisée, détecté par analyse statique.\nLa puce elle-même est excellente. Mais le code de référence du fabricant est un point de départ, pas un évangile. Implémenter l\u0026rsquo;algorithme de compensation directement depuis la datasheet signifiait que je comprenais chaque ligne. Quand une lecture paraît bizarre, il n\u0026rsquo;y a pas de mystérieuse bibliothèque C à blâmer.\nguillaumedelre/bme280 Driver Python pour le capteur BME280 — température, humidité et pression par I²C, avec publication MQTT et intégration Home Assistant.\n","permalink":"https://guillaumedelre.github.io/fr/2019/11/17/dun-capteur-%C3%A0-10-%C3%A0-un-tableau-de-bord-home-assistant-avec-raspberry-pi-et-mqtt/","summary":"\u003cp\u003eLa question était simple : quelle est la température et l\u0026rsquo;humidité dans mon bureau à domicile en ce moment ? Pas la météo dehors, pas une moyenne de ville — les conditions réelles dans la pièce où je passe la majeure partie de ma journée. Ouvrir une application météo pour ça semblait mal.\u003c/p\u003e\n\u003cp\u003eUn Raspberry Pi tournait déjà sur l\u0026rsquo;étagère. Un capteur BME280 coûte environ 10€. Ça aurait dû être un projet de week-end.\u003c/p\u003e","title":"D'un capteur à 10€ à un tableau de bord Home Assistant avec Raspberry Pi et MQTT"},{"content":"PHP 7.3 est sorti le 6 décembre. Pas de fonctionnalité phare. C\u0026rsquo;est une collection d\u0026rsquo;améliorations du quotidien qui, individuellement, semblent mineures, mais qui ensemble rendent le travail de tous les jours nettement moins agaçant.\nHeredoc et nowdoc flexibles Jusqu\u0026rsquo;à 7.3, le marqueur de fermeture d\u0026rsquo;un heredoc devait être en colonne zéro. Ce qui forçait une désindentation maladroite dans du code par ailleurs bien formaté :\n// avant $html = \u0026lt;\u0026lt;\u0026lt;HTML \u0026lt;div\u0026gt; \u0026lt;p\u0026gt;Hello\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; HTML; // devait être en colonne 0, moche // après $html = \u0026lt;\u0026lt;\u0026lt;HTML \u0026lt;div\u0026gt; \u0026lt;p\u0026gt;Hello\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; HTML; Le marqueur de fermeture peut désormais être indenté pour correspondre au code environnant, et cette indentation est retirée du contenu. Ça paraît cosmétique. Ce n\u0026rsquo;est pas le cas. Les heredocs dans des contextes imbriqués (méthodes de classe, conditions) étaient visuellement dissonants avant. Maintenant ils s\u0026rsquo;intègrent.\narray_key_first() et array_key_last() Ce contournement existait depuis toujours :\n$first = array_keys($array)[0]; 7.3 ajoute les helpers évidents :\n$first = array_key_first($array); $last = array_key_last($array); Et is_countable() pour vérifier proprement avant d\u0026rsquo;appeler count() sur quelque chose qui n\u0026rsquo;implémente peut-être pas Countable. Des fonctions qui auraient dû exister depuis des années.\nPCRE2 Le moteur d\u0026rsquo;expressions régulières a migré de PCRE vers PCRE2. Largement invisible pour les patterns existants, mais PCRE2 est activement maintenu et gère mieux les cas limites. L\u0026rsquo;impact pratique principal : certains patterns qui produisaient auparavant un comportement indéfini lancent maintenant des erreurs. C\u0026rsquo;est le bon comportement, même si ça surprend lors du premier upgrade.\nVirgules finales dans les appels de fonctions 7.2 autorisait les virgules finales dans les imports de namespaces groupés. 7.3 étend ça aux appels de fonctions et de méthodes :\n$result = array_merge( $defaults, $overrides, $extras, // plus besoin de retirer cette virgule avant la parenthèse fermante ); Ça compte surtout pour les appels multiligne. Ajouter ou retirer un argument ne nécessite plus de toucher à la ligne adjacente. Les diffs restent honnêtes, les rebases deviennent un peu moins douloureux.\nAssignments par référence dans la déstructuration de tableaux La déstructuration de tableaux a gagné la capacité de capturer des références plutôt que des copies :\n$data = [\u0026#39;Alice\u0026#39;, 42]; [\u0026amp;$name, $age] = $data; $name = \u0026#39;Bob\u0026#39;; var_dump($data[0]); // string(3) \u0026#34;Bob\u0026#34; Les références imbriquées fonctionnent aussi :\n[$a, [\u0026amp;$b]] = [1, [2]]; Plus de niche que les virgules finales, mais le bon outil quand on a besoin d\u0026rsquo;aliaser profondément dans une structure sans un tas d\u0026rsquo;assignations intermédiaires.\ninstanceof avec des littéraux est maintenant légal Avant, utiliser instanceof avec un littéral à gauche était une erreur de parsing. 7.3 le rend valide :\nvar_dump(null instanceof stdClass); // bool(false) Ça retourne toujours false, ce qui est exactement correct. L\u0026rsquo;avantage, c\u0026rsquo;est que du code qui construit conditionnellement une valeur puis vérifie son type n\u0026rsquo;a plus besoin d\u0026rsquo;extraire la valeur dans une variable au préalable. Utile dans le code généré et les helpers de test.\njson_decode() et json_encode() peuvent maintenant lever des exceptions Avant 7.3, les erreurs JSON étaient silencieuses à moins de penser à vérifier json_last_error(). Facile à oublier, facile à rater :\n$data = json_decode($response); if (json_last_error() !== JSON_ERROR_NONE) { // la plupart des gens oubliaient cette partie } 7.3 ajoute JSON_THROW_ON_ERROR :\n$data = json_decode($response, true, 512, JSON_THROW_ON_ERROR); // lève JsonException sur une entrée malformée JsonException étend RuntimeException. Attrapez-la spécifiquement ou laissez-la se propager. Ça aurait dû fonctionner comme ça dès le début.\nsetcookie() avec un tableau d\u0026rsquo;options L\u0026rsquo;ancienne signature de setcookie() est un vestige : sept arguments positionnels, dont la plupart qu\u0026rsquo;on laisse à leurs valeurs par défaut juste pour atteindre celui qu\u0026rsquo;on veut vraiment. 7.3 ajoute une forme alternative qui prend un tableau associatif :\nsetcookie(\u0026#39;session\u0026#39;, $token, [ \u0026#39;expires\u0026#39; =\u0026gt; time() + 3600, \u0026#39;path\u0026#39; =\u0026gt; \u0026#39;/\u0026#39;, \u0026#39;secure\u0026#39; =\u0026gt; true, \u0026#39;httponly\u0026#39; =\u0026gt; true, \u0026#39;samesite\u0026#39; =\u0026gt; \u0026#39;Lax\u0026#39;, ]); L\u0026rsquo;option samesite est la vraie raison pour laquelle ça a été ajouté — l\u0026rsquo;ancienne signature positionnelle n\u0026rsquo;avait pas de slot pour elle. session_set_cookie_params() a reçu le même traitement, et une nouvelle directive ini session.cookie_samesite couvre la valeur par défaut.\nhrtime() pour un benchmarking qui mesure vraiment le temps microtime() lit l\u0026rsquo;horloge murale. Très bien pour la plupart des cas. Mais elle est affectée par les ajustements NTP, et sa résolution dépend de l\u0026rsquo;implémentation. hrtime() lit l\u0026rsquo;horloge monotone haute résolution :\n$start = hrtime(true); // nanosecondes sous forme d\u0026#39;entier doWork(); $elapsed = hrtime(true) - $start; echo $elapsed / 1e6 . \u0026#34; ms\\n\u0026#34;; Sans l\u0026rsquo;argument true, elle retourne [secondes, nanosecondes] sous forme d\u0026rsquo;un tableau à deux éléments. Utilisez ça pour les microbenchmarks, ou partout où la dérive d\u0026rsquo;horloge corromprait silencieusement vos mesures.\ngc_status() — regarder à l\u0026rsquo;intérieur du ramasse-miettes Le ramasse-miettes cyclique de PHP se déclenche quand un buffer de cycles potentiels se remplit. Jusqu\u0026rsquo;à 7.3 il n\u0026rsquo;y avait pas de moyen simple de voir ce qu\u0026rsquo;il faisait réellement. gc_status() expose l\u0026rsquo;état interne :\n$status = gc_status(); // [ // \u0026#39;runs\u0026#39; =\u0026gt; 3, // \u0026#39;collected\u0026#39; =\u0026gt; 127, // \u0026#39;threshold\u0026#39; =\u0026gt; 10001, // \u0026#39;roots\u0026#39; =\u0026gt; 42, // ] Pas quelque chose que la plupart du code applicatif a besoin. Utile quand on essaie de comprendre pourquoi la mémoire continue de grimper sous des charges de travail spécifiques.\nCompileError rejoint la hiérarchie des exceptions Les erreurs de parsing sont catchables en tant que ParseError depuis PHP 7.0. 7.3 introduit CompileError comme classe parente pour les échecs à la compilation, avec ParseError qui devient une sous-classe :\nError └── CompileError └── ParseError En pratique, le code qui catch ParseError continue de fonctionner. La nouvelle classe donne juste aux futures erreurs de compilation (qui ne sont pas des erreurs de parsing) une place correcte dans la hiérarchie.\nbcscale() comme getter L\u0026rsquo;échelle BC Math était toujours settable via bcscale($n). Obtenir l\u0026rsquo;échelle actuelle nécessitait de la suivre soi-même. 7.3 fait fonctionner bcscale() sans arguments :\nbcscale(4); echo bcscale(); // 4 Mineur. Utile à savoir si vous écrivez du code de bibliothèque qui doit respecter ou restaurer le paramètre d\u0026rsquo;échelle de l\u0026rsquo;appelant.\nL\u0026rsquo;avertissement pour continue dans un switch Celui-là est un correctif d\u0026rsquo;exactitude qui ressemble à une dépréciation. En PHP, continue dans un switch s\u0026rsquo;est toujours comporté comme break — il sort du switch, pas de la boucle englobante. Les développeurs venant d\u0026rsquo;autres langages écrivent souvent ça en espérant passer à l\u0026rsquo;itération suivante de la boucle :\nforeach ($items as $item) { switch ($item-\u0026gt;type) { case \u0026#39;skip\u0026#39;: continue; // FAUX : sort du switch, pas du foreach } } 7.3 ajoute un warning pour ce pattern. Le correctif, c\u0026rsquo;est continue 2 pour cibler explicitement la boucle englobante. Le comportement n\u0026rsquo;a pas changé. Le silence, si.\nDépréciations Les constantes insensibles à la casse déclarées via define() sont dépréciées :\ndefine(\u0026#39;MY_CONST\u0026#39;, 42, true); // troisième argument déprécié Passer une needle non-chaîne à strpos(), strstr(), et fonctions similaires est déprécié. En PHP 8, ces fonctions interpréteront la needle comme une chaîne, pas comme un codepoint ASCII. Si vous passez intentionnellement des entiers à ces fonctions, chr($n) est la forme explicite.\nfgetss() est déprécié — c\u0026rsquo;était fgets() avec les balises HTML/PHP retirées. Utilisez fgets() et retirez les balises explicitement si nécessaire. Le filtre de stream string.strip_tags disparaît avec lui.\n7.3 est le genre de version qu\u0026rsquo;on apprécie avec du recul. Rien d\u0026rsquo;individuellement dramatique, mais après six mois avec elle, la correction des heredocs seule a amorti le coût de la migration en lisibilité. Parfois le banal est exactement ce qu\u0026rsquo;il faut.\n","permalink":"https://guillaumedelre.github.io/fr/2019/01/20/php-7.3-des-petites-victoires-qui-saccumulent/","summary":"\u003cp\u003ePHP 7.3 est sorti le 6 décembre. Pas de fonctionnalité phare. C\u0026rsquo;est une collection d\u0026rsquo;améliorations du quotidien qui, individuellement, semblent mineures, mais qui ensemble rendent le travail de tous les jours nettement moins agaçant.\u003c/p\u003e\n\u003ch2 id=\"heredoc-et-nowdoc-flexibles\"\u003eHeredoc et nowdoc flexibles\u003c/h2\u003e\n\u003cp\u003eJusqu\u0026rsquo;à 7.3, le marqueur de fermeture d\u0026rsquo;un heredoc devait être en colonne zéro. Ce qui forçait une désindentation maladroite dans du code par ailleurs bien formaté :\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// avant\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$html \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026lt;\u0026lt;\u0026lt;\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003eHTML\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e    \u0026lt;div\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e        \u0026lt;p\u0026gt;Hello\u0026lt;/p\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e    \u0026lt;/div\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003eHTML; // devait être en colonne 0, moche\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e// après\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e$html = \u0026lt;\u0026lt;\u0026lt;HTML\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e    \u0026lt;div\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e        \u0026lt;p\u0026gt;Hello\u0026lt;/p\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e    \u0026lt;/div\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003eHTML\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eLe marqueur de fermeture peut désormais être indenté pour correspondre au code environnant, et cette indentation est retirée du contenu. Ça paraît cosmétique. Ce n\u0026rsquo;est pas le cas. Les heredocs dans des contextes imbriqués (méthodes de classe, conditions) étaient visuellement dissonants avant. Maintenant ils s\u0026rsquo;intègrent.\u003c/p\u003e","title":"PHP 7.3 : des petites victoires qui s'accumulent"},{"content":"PHP 7.2 est sorti le 30 novembre. La grande nouvelle n\u0026rsquo;est pas une nouvelle fonctionnalité, c\u0026rsquo;est une suppression. mcrypt disparaît.\nC\u0026rsquo;est une bonne chose, même si ça ne le semble pas quand c\u0026rsquo;est vous qui devez faire la migration.\nLe problème mcrypt mcrypt n\u0026rsquo;est plus maintenu depuis 2007. Plus d\u0026rsquo;une décennie de stagnation dans une bibliothèque cryptographique. Dépréciée en 7.1, elle est retirée définitivement en 7.2. Son remplaçant : sodium, désormais intégré comme extension core.\nSodium est le binding PHP pour libsodium, une bibliothèque cryptographique moderne conçue autour de valeurs par défaut sûres. Là où mcrypt vous demandait de choisir le bon algorithme, le bon mode et le bon padding (et vous laissait vous planter silencieusement), l\u0026rsquo;API de sodium rend les choix dangereux structurellement difficiles. sodium_crypto_secretbox() pour le chiffrement symétrique, sodium_crypto_box() pour l\u0026rsquo;asymétrique, sodium_crypto_sign() pour les signatures. Les noms disent ce qu\u0026rsquo;on fait.\nSi vous avez du code mcrypt en production, la migration est inévitable. Et ça vaut le coup de le faire soigneusement : du code cryptographique qui \u0026ldquo;fonctionne encore\u0026rdquo; peut être silencieusement cassé d\u0026rsquo;une façon que vous ne remarquerez qu\u0026rsquo;il sera trop tard.\nLe type hint object 7.2 ajoute object comme type de paramètre et de retour :\nfunction serialize(object $data): string { // accepte n\u0026#39;importe quel objet } C\u0026rsquo;est large — n\u0026rsquo;importe quel objet le satisfait — mais c\u0026rsquo;est mieux qu\u0026rsquo;aucun type du tout quand vous ne vous souciez vraiment pas de la classe spécifique. Complète les types existants array, callable et les hints par classe concrète.\nArgon2 dans password_hash $hash = password_hash($password, PASSWORD_ARGON2I); PASSWORD_BCRYPT était la valeur par défaut et reste raisonnable, mais Argon2 a remporté le Password Hashing Competition pour une bonne raison : il est memory-hard, ce qui rend le craquage par GPU significativement plus coûteux. Vaut la peine de basculer pour les nouvelles apps.\n7.2 est davantage une version sécurité qu\u0026rsquo;une version langage. Supprimez mcrypt, ajoutez sodium, et vous placez la plateforme dans un état où on peut lui faire confiance avec des données sensibles. Les fonctionnalités du langage sont incrémentales. Le changement d\u0026rsquo;infrastructure, lui, ne l\u0026rsquo;est pas.\nDes types de paramètres qu\u0026rsquo;on peut désormais omettre intentionnellement 7.2 formalise quelque chose qui était jusqu\u0026rsquo;ici juste une odeur de code : quand vous implémentez une interface ou surchargez une méthode, vous pouvez maintenant omettre complètement le type du paramètre. C\u0026rsquo;est de la contravariance valide au sens du principe de substitution de Liskov.\ninterface Serializable { public function serialize(array $data): string; } class JsonSerializer implements Serializable { public function serialize($data): string { // pas de type — accepte plus, toujours valide return json_encode($data); } } Ça paraît bizarre au premier abord. Mais c\u0026rsquo;est logiquement correct : une méthode qui accepte tout est strictement plus permissive qu\u0026rsquo;une qui n\u0026rsquo;accepte que des tableaux. Le système de types est d\u0026rsquo;accord, même si votre relecteur de code lève un sourcil.\nDes méthodes abstraites qui évoluent Quand une classe abstraite étend une autre classe abstraite, elle peut désormais surcharger la méthode abstraite avec une signature différente. La contrainte est directionnelle : les paramètres peuvent être élargis (contravariants), les types de retour peuvent être resserrés (covariants).\nabstract class BaseProcessor { abstract function process(string $input); } abstract class TypedProcessor extends BaseProcessor { abstract function process($input): int; // paramètre élargi, type de retour ajouté } C\u0026rsquo;était refusé avant 7.2. Ça débloque des abstractions intermédiaires sans forcer chaque classe feuille à répéter la même signature.\nVirgule finale dans les imports groupés Petit, mais je remarque son absence quand elle manque. Les imports de namespaces groupés peuvent désormais avoir une virgule finale après le dernier élément :\nuse App\\Services\\{ UserService, OrderService, NotificationService, // virgule ici — enfin }; Ça signifie qu\u0026rsquo;on peut réordonner ou ajouter des lignes sans toucher à la ligne précédente. Les diffs git deviennent plus propres, les conflits de merge plus rares.\ncount() a développé une conscience Avant 7.2, count(null) retournait 0. Silencieusement. Sans warning. C\u0026rsquo;est exactement le genre de chose qui enterre un bug pendant des mois. Maintenant, ça émet un E_WARNING quand vous passez quelque chose qui n\u0026rsquo;est ni un tableau ni un objet Countable.\ncount(null); // Warning: count(): Parameter must be an array or an object that implements Countable count(42); // pareil count(\u0026#34;hi\u0026#34;); // pareil Le comportement n\u0026rsquo;a pas changé pour les entrées valides. Seul le silence a été brisé. C\u0026rsquo;est la bonne direction.\nspl_object_id() — ce que vous émuliez avec SplObjectStorage Si vous avez déjà construit une map indexée par identité d\u0026rsquo;objet, vous avez écrit quelque chose comme ça :\n$storage = new SplObjectStorage(); $storage[$obj] = true; 7.2 ajoute spl_object_id(), qui retourne un entier unique pour la durée de vie d\u0026rsquo;un objet. C\u0026rsquo;est le même handle interne qu\u0026rsquo;utilise SplObjectStorage, rendu directement accessible :\n$id = spl_object_id($obj); // ex. 42 $map[$id] = \u0026#39;something\u0026#39;; L\u0026rsquo;entier est réutilisé après la destruction de l\u0026rsquo;objet, donc ne le conservez pas au-delà de la durée de vie de l\u0026rsquo;objet. Dans un contexte bien délimité cependant, c\u0026rsquo;est une clé d\u0026rsquo;identité peu coûteuse.\nPDO : les chaînes de caractères nationales Quand on travaille avec des bases de données qui distinguent les types de chaînes régulières et nationales (Oracle, SQL Server), 7.2 ajoute les flags nécessaires :\n$stmt = $pdo-\u0026gt;prepare(\u0026#34;SELECT * FROM users WHERE name = ?\u0026#34;); $stmt-\u0026gt;bindValue(1, \u0026#39;Ångström\u0026#39;, PDO::PARAM_STR | PDO::PARAM_STR_NATL); Ou définir une valeur par défaut au niveau de la connexion :\n$pdo-\u0026gt;setAttribute(PDO::ATTR_DEFAULT_STR_PARAM, PDO::PARAM_STR_NATL); PDO::PARAM_STR_NATL indique que la chaîne est un type caractère national (NCHAR/NVARCHAR). Obscur, certes. Indispensable si vous avez déjà vu vos données Unicode ressortir déformées parce que le driver ne faisait pas la différence.\nGD supporte les BMP et les rectangles de clipping Deux choses à connaître. D\u0026rsquo;abord, les fichiers BMP sont désormais des citoyens de première classe dans l\u0026rsquo;extension GD :\n$image = imagecreatefrombmp(\u0026#39;photo.bmp\u0026#39;); imagebmp($image, \u0026#39;output.bmp\u0026#39;); Ensuite, on peut maintenant définir un rectangle de clipping pour que les opérations de dessin n\u0026rsquo;affectent qu\u0026rsquo;une portion de l\u0026rsquo;image :\nimagesetclip($image, 10, 10, 200, 150); // x1, y1, x2, y2 // tout ce qui est dessiné en dehors de ce rectangle est silencieusement ignoré Aucune de ces fonctionnalités ne transforme la façon dont la plupart des apps fonctionnent, mais les deux remplacent \u0026ldquo;installer une bibliothèque supplémentaire\u0026rdquo; par \u0026ldquo;c\u0026rsquo;est juste dans le core maintenant.\u0026rdquo;\nmb_chr() et mb_ord() — le chr() et ord() d\u0026rsquo;Unicode PHP a chr() et ord() depuis toujours. Ils travaillent sur des octets. Pour les points de code Unicode, vous étiez livré à vous-même. 7.2 ajoute les équivalents multibyte :\n$char = mb_chr(0x1F600); // retourne l\u0026#39;emoji 😀 $code = mb_ord(\u0026#39;é\u0026#39;); // retourne 233 Et mb_scrub(), qui supprime les séquences d\u0026rsquo;octets invalides d\u0026rsquo;une chaîne plutôt que d\u0026rsquo;échouer silencieusement ou de lancer une exception :\n$clean = mb_scrub($untrustedInput, \u0026#39;UTF-8\u0026#39;); Pratique à toute frontière externe : réponses API, uploads de fichiers, lectures en base depuis des systèmes legacy.\nDépréciations à connaître avant l\u0026rsquo;arrivée de 7.4 Plusieurs choses ont été mollement dépréciées en 7.2 et deviendront des erreurs dans les versions ultérieures. Celles qui risquent le plus de piquer :\n__autoload() est déprécié. Si vous enregistrez encore une fonction d\u0026rsquo;autoload globale au lieu d\u0026rsquo;utiliser spl_autoload_register(), corrigez ça avant que ça devienne fatal.\ncreate_function() est déprécié. C\u0026rsquo;est un wrapper autour de eval() et ça a toujours été dangereux. Utilisez une closure :\n// avant $fn = create_function(\u0026#39;$x\u0026#39;, \u0026#39;return $x * 2;\u0026#39;); // après $fn = fn($x) =\u0026gt; $x * 2; each() est déprécié. Le pattern de boucle qu\u0026rsquo;il permettait s\u0026rsquo;écrit mieux avec foreach. Aucune perte ici.\nparse_str() sans second argument déverse les valeurs parsées dans la table des symboles locale — un problème de sécurité qui n\u0026rsquo;aurait jamais dû être autorisé. Passez toujours la variable de sortie :\nparse_str($queryString, $params); // correct Le cast (unset) est déprécié parce qu\u0026rsquo;il retourne toujours null, que vous pouvez simplement écrire null. Une syntaxe complètement inutile qui n\u0026rsquo;aurait jamais dû exister.\n","permalink":"https://guillaumedelre.github.io/fr/2018/01/14/php-7.2-adieu-mcrypt-bonjour-sodium/","summary":"\u003cp\u003ePHP 7.2 est sorti le 30 novembre. La grande nouvelle n\u0026rsquo;est pas une nouvelle fonctionnalité, c\u0026rsquo;est une suppression. \u003ccode\u003emcrypt\u003c/code\u003e disparaît.\u003c/p\u003e\n\u003cp\u003eC\u0026rsquo;est une bonne chose, même si ça ne le semble pas quand c\u0026rsquo;est vous qui devez faire la migration.\u003c/p\u003e\n\u003ch2 id=\"le-problème-mcrypt\"\u003eLe problème mcrypt\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003emcrypt\u003c/code\u003e n\u0026rsquo;est plus maintenu depuis 2007. Plus d\u0026rsquo;une décennie de stagnation dans une bibliothèque cryptographique. Dépréciée en 7.1, elle est retirée définitivement en 7.2. Son remplaçant : \u003ccode\u003esodium\u003c/code\u003e, désormais intégré comme extension core.\u003c/p\u003e","title":"PHP 7.2 : adieu mcrypt, bonjour sodium"},{"content":"Symfony 4.0 est sorti le 30 novembre 2017, le même jour que la 3.4. La date de sortie commune est à peu près la seule chose qu\u0026rsquo;ils ont en commun.\n4.0, c\u0026rsquo;est une philosophie différente. La Symfony Standard Edition, ce point de départ monolithique qui embarquait tout et vous laissait retirer ce dont vous n\u0026rsquo;aviez pas besoin, a disparu. À sa place : un microframework qui grandit.\nFlex Symfony Flex est un plugin Composer qui change la façon dont on installe les packages Symfony. Avant Flex, ajouter un bundle impliquait : l\u0026rsquo;installer via Composer, l\u0026rsquo;enregistrer dans AppKernel.php, ajouter la config dans config/, mettre à jour le routing si nécessaire. Quatre étapes, toutes manuelles.\nAvec Flex, installer un package exécute une \u0026ldquo;recette\u0026rdquo; : un ensemble d\u0026rsquo;étapes automatisées qui enregistre le bundle, génère un squelette de config et câble le routing. Installer Doctrine :\ncomposer require symfony/orm-pack Cette commande installe les packages, crée config/packages/doctrine.yaml, ajoute les stubs de variables d\u0026rsquo;environnement dans .env, et enregistre tout. Une commande, zéro étape manuelle.\nLes recettes sont contribuées par la communauté et hébergées sur un serveur central. La qualité varie, mais pour les packages majeurs elles sont maintenues en parallèle des packages eux-mêmes.\nLa nouvelle structure de projet Le layout de la Standard Edition (app/, src/, web/) est remplacé par une structure plus légère. La config se trouve dans config/, découpée par environnement. Le répertoire public s\u0026rsquo;appelle désormais public/, plus web/. Le kernel est plus petit. Les controllers sont des classes ordinaires, plus besoin d\u0026rsquo;extends Controller.\nPlus important encore, le services.yaml par défaut utilise les conventions d\u0026rsquo;autowiring de la 3.3 qui rendent la configuration explicite des services largement inutile. Les nouveaux projets démarrent minimaux et grossissent en ajoutant ce dont ils ont réellement besoin.\nServices privés par défaut La plus grosse rupture de compatibilité de la 4.0 pour les apps existantes : tous les services sont privés par défaut. On ne peut plus récupérer un service directement depuis le container, il doit être injecté. C\u0026rsquo;est le bon choix du point de vue de l\u0026rsquo;injection de dépendances, mais ça casse tout ce qui utilisait $this-\u0026gt;get('service_id') dans les controllers.\nLe chemin de migration, c\u0026rsquo;est AbstractController, qui fournit les mêmes méthodes pratiques via des service locators lazy plutôt qu\u0026rsquo;un accès direct au container.\nCe qui a été supprimé 4.0 est propre parce qu\u0026rsquo;il supprime tout ce qui était déprécié en 3.4 :\nLes anciens événements de formulaire, les anciennes interfaces de sécurité, les anciens formats de configuration Le support de PHP \u0026lt; 7.1.3 Le composant ClassLoader Le support ACL dans le SecurityBundle Les suppressions sont musclées. Les apps qui ont sauté la correction de leurs dépréciations 3.4 vont souffrir. Celles qui ont fait le ménage avant ont une migration tranquille.\nSymfony 4.0, c\u0026rsquo;est le reset dont le framework avait besoin. La Standard Edition avait accumulé des années de \u0026ldquo;c\u0026rsquo;est comme ça qu\u0026rsquo;on fait\u0026rdquo; que Flex balaie d\u0026rsquo;un coup.\nDes variables d\u0026rsquo;environnement qui connaissent leur type Avant 3.4 et 4.0, les variables d\u0026rsquo;environnement étaient des chaînes. Toujours. Essayer d\u0026rsquo;injecter DATABASE_PORT dans un paramètre de type int plantait silencieusement ou explosait avec une erreur de type. Le correctif était laid : caster en PHP ou éviter les paramètres typés.\n4.0 embarque des processeurs de variables d\u0026rsquo;environnement qui gèrent la conversion au niveau du container :\nparameters: app.connection.port: \u0026#39;%env(int:DATABASE_PORT)%\u0026#39; app.debug_mode: \u0026#39;%env(bool:APP_DEBUG)%\u0026#39; Au-delà du casting, les processeurs peuvent décoder du base64, charger depuis des fichiers, parser du JSON, ou résoudre des paramètres du container dans une valeur. La combinaison json:file: est devenue un pattern propre pour charger des secrets depuis des fichiers montés dans des déploiements conteneurisés :\nparameters: env(SECRETS_FILE): \u0026#39;/run/secrets/app.json\u0026#39; app.secrets: \u0026#39;%env(json:file:SECRETS_FILE)%\u0026#39; Vous pouvez aussi écrire des processeurs personnalisés en implémentant EnvVarProcessorInterface et en taguant le service. Ça ressemble à de la sur-ingénierie jusqu\u0026rsquo;au jour où vous en avez besoin.\nDes services taggués sans boilerplate Avant 4.0, rassembler tous les services portant un tag donné dans un service signifiait écrire un compiler pass. Quarante lignes de PHP pour dire \u0026ldquo;donne-moi tout ce qui est tagué app.handler.\u0026rdquo;\n3.4 a introduit le raccourci YAML !tagged, et 4.0 l\u0026rsquo;emporte avec lui :\nservices: App\\HandlerCollection: arguments: [!tagged app.handler] La collection est lazy par défaut quand elle est type-hintée en iterable, donc les services ne sont pas instanciés tant qu\u0026rsquo;on n\u0026rsquo;itère pas dessus. Ça a remplacé toute une catégorie de compiler passes qui n\u0026rsquo;existaient que pour construire des listes.\nPHP comme format de configuration YAML est la valeur par défaut depuis si longtemps que ça semble obligatoire. Ce n\u0026rsquo;est pas le cas. 4.0 embarque une configuration en PHP via une interface fluent :\n// config/services.php return function (ContainerConfigurator $container) { $services = $container-\u0026gt;services() -\u0026gt;defaults() -\u0026gt;autowire() -\u0026gt;autoconfigure(); $services-\u0026gt;load(\u0026#39;App\\\\\u0026#39;, \u0026#39;../src/\u0026#39;) -\u0026gt;exclude(\u0026#39;../src/{Entity,Repository}\u0026#39;); }; La même approche fonctionne pour les routes. L\u0026rsquo;avantage pratique : autocomplétion de l\u0026rsquo;IDE, vérification des types, et vraie logique PHP dans la configuration sans la syntaxe d\u0026rsquo;interpolation %. YAML n\u0026rsquo;est pas près de disparaître, mais maintenant vous avez le choix.\nArgon2i, parce que bcrypt vieillissait déjà Symfony 3.4/4.0 a ajouté le support d\u0026rsquo;Argon2i, vainqueur du Password Hashing Competition 2015. La configuration tient en une ligne :\nsecurity: encoders: App\\Entity\\User: algorithm: argon2i Argon2i est intégré à PHP 7.2+ et disponible via l\u0026rsquo;extension sodium sur les versions antérieures. Comme bcrypt, il se sale lui-même, inutile de gérer des colonnes de sel. Contrairement à bcrypt, il est conçu pour résister aux attaques par GPU avec une utilisation mémoire configurable. Si vous démarrez un nouveau projet sur 4.0, il n\u0026rsquo;y a vraiment aucune raison de choisir bcrypt.\nLa couche formulaire reçoit un thème Bootstrap 4 Le thème de formulaire Bootstrap 3 existant remonte à Symfony 2.x. Bootstrap 4 arrive comme option de premier ordre en 4.0 :\ntwig: form_themes: [\u0026#39;bootstrap_4_layout.html.twig\u0026#39;] Plus utile en pratique : les types d\u0026rsquo;input HTML5 tel et color sont désormais disponibles comme types de formulaire TelType et ColorType. Avant, il fallait écrire des types personnalisés ou surcharger des widgets bruts pour ça.\nBinding de service local Les bindings _defaults globaux s\u0026rsquo;appliquent à tous les services. Parfois on a besoin d\u0026rsquo;un binding limité à une classe ou un namespace spécifique, comme des instances de logger différentes pour des sous-systèmes différents.\n4.0 supporte bind par service exactement pour ça :\nservices: App\\Service\\OrderService: bind: Psr\\Log\\LoggerInterface: \u0026#39;@monolog.logger.orders\u0026#39; App\\Service\\PaymentService: bind: Psr\\Log\\LoggerInterface: \u0026#39;@monolog.logger.payments\u0026#39; Même interface, deux implémentations différentes, pas de factory, pas de configuration supplémentaire. Petite fonctionnalité, mais elle élimine toute une catégorie de bidouilles bancales.\n","permalink":"https://guillaumedelre.github.io/fr/2018/01/14/symfony-4.0-flex-et-la-fin-de-la-standard-edition/","summary":"\u003cp\u003eSymfony 4.0 est sorti le 30 novembre 2017, le même jour que la 3.4. La date de sortie commune est à peu près la seule chose qu\u0026rsquo;ils ont en commun.\u003c/p\u003e\n\u003cp\u003e4.0, c\u0026rsquo;est une philosophie différente. La Symfony Standard Edition, ce point de départ monolithique qui embarquait tout et vous laissait retirer ce dont vous n\u0026rsquo;aviez pas besoin, a disparu. À sa place : un microframework qui grandit.\u003c/p\u003e\n\u003ch2 id=\"flex\"\u003eFlex\u003c/h2\u003e\n\u003cp\u003eSymfony Flex est un plugin Composer qui change la façon dont on installe les packages Symfony. Avant Flex, ajouter un bundle impliquait : l\u0026rsquo;installer via Composer, l\u0026rsquo;enregistrer dans \u003ccode\u003eAppKernel.php\u003c/code\u003e, ajouter la config dans \u003ccode\u003econfig/\u003c/code\u003e, mettre à jour le routing si nécessaire. Quatre étapes, toutes manuelles.\u003c/p\u003e","title":"Symfony 4.0 : Flex et la fin de la Standard Edition"},{"content":"Symfony 3.4 et 4.0 sont sortis le même jour : le 30 novembre 2017. Ce n\u0026rsquo;est pas une coïncidence, c\u0026rsquo;est la stratégie.\n3.4 n\u0026rsquo;est pas une version de fonctionnalités. Elle livre exactement les mêmes fonctionnalités que 3.3, plus chaque avertissement de dépréciation que 4.0 va rendre obligatoire. Son seul objectif est d\u0026rsquo;être l\u0026rsquo;outil de migration : monter de 3.3 à 3.4, corriger ce qui apparaît dans les logs, puis passer à 4.0 proprement.\nPourquoi les versions LTS comptent dans le modèle Symfony Symfony publie une nouvelle version mineure tous les six mois. Ce rythme serait brutal pour les applications en production, donc le projet désigne chaque quatrième mineure comme LTS : trois ans de corrections de bugs, quatre de correctifs de sécurité. Ce qui signifie que les équipes peuvent cibler 3.4 et arrêter de penser aux mises à jour pendant un moment.\n3.4 est la dernière LTS de la ligne 3.x. Si on est encore sur 2.x ou un 3.x ancien, c\u0026rsquo;est la zone d\u0026rsquo;atterrissage.\nLa couche de dépréciations Chaque fonctionnalité supprimée par 4.0 est dépréciée dans 3.4. Faire tourner son application sur 3.4 avec les notices de dépréciation activées transforme les logs en une liste de tâches. Les plus courantes :\nLes services sans visibilité explicite (public/private) génèrent des warnings — 4.0 rend tous les services privés par défaut ControllerTrait est déprécié au profit de AbstractController Les anciennes interfaces d\u0026rsquo;authentificateur de sécurité sont marquées pour suppression La configuration de services YAML seule sans annotations d\u0026rsquo;autowiring déclenche des warnings Le workflow prévu : monter sur 3.4, faire tourner la suite de tests avec les notices de dépréciation comme erreurs (SYMFONY_DEPRECATIONS_HELPER=max[self]=0 dans PHPUnit), corriger tout ce qui échoue. Après ça, la montée vers 4.0 est essentiellement mécanique.\nLa fenêtre de support 3.4 LTS reçoit des corrections de bugs jusqu\u0026rsquo;en novembre 2020 et des correctifs de sécurité jusqu\u0026rsquo;en novembre 2021. C\u0026rsquo;est une marge confortable pour les applications qui ne peuvent pas suivre chaque version. Le coût : rester sur l\u0026rsquo;architecture 3.x, sans Flex, sans structure micro-framework, sans autowiring zéro-config par défaut.\nLe pont est là. Savoir si et quand on le traverse est une décision business, pas technique.\nLes services passent privés 3.4 a inversé la visibilité par défaut des services de public à privé. Avant, $container-\u0026gt;get('app.my_service') était du code parfaitement normal. Après, c\u0026rsquo;est un anti-pattern qui génère un warning de dépréciation dans 3.4 et casse complètement dans 4.0.\nLa raison est simple : récupérer des services directement depuis le conteneur masque les dépendances et déjoue l\u0026rsquo;analyse statique. En injectant via le constructeur, le conteneur peut optimiser le graphe, supprimer les services inutilisés, et détecter les erreurs à la compilation. En les récupérant à l\u0026rsquo;exécution, il ne peut pas.\nPour les applications qui utilisent déjà l\u0026rsquo;autowiring, la migration est généralement légère. Le point délicat ce sont les contrôleurs qui étendent Controller et appellent $this-\u0026gt;get('quelque-chose'). La correction consiste à passer à AbstractController, qui fournit les mêmes raccourcis mais via des service locators paresseux plutôt que l\u0026rsquo;accès direct au conteneur.\nPour les services qui ont vraiment besoin d\u0026rsquo;être publics (accédés depuis du code legacy ou des tests fonctionnels), les marquer explicitement :\nservices: App\\Service\\LegacyAdapter: public: true Lier les arguments scalaires une seule fois Un point de friction classique avec l\u0026rsquo;autowiring : les arguments de constructeur scalaires. Si dix services ont tous besoin de $projectDir, il fallait configurer chacun individuellement. La clé bind sous _defaults règle ça :\nservices: _defaults: autowire: true autoconfigure: true bind: $projectDir: \u0026#39;%kernel.project_dir%\u0026#39; $mailerDsn: \u0026#39;%env(MAILER_DSN)%\u0026#39; Psr\\Log\\LoggerInterface $auditLogger: \u0026#39;@monolog.logger.audit\u0026#39; Tout service avec un paramètre de constructeur nommé $projectDir reçoit la valeur liée automatiquement. On peut aussi lier par type-hint, ce qui gère le cas courant où plusieurs canaux de logger existent et on en a besoin d\u0026rsquo;un spécifique. Les liaisons dans _defaults s\u0026rsquo;appliquent à tous les services du fichier ; on peut surcharger par service si nécessaire.\nInjecter les services taggués sans compiler pass Avant 3.4, collecter tous les services avec un tag donné nécessitait d\u0026rsquo;écrire un compiler pass. Il y a maintenant un raccourci YAML :\nservices: App\\Chain\\TransformerChain: arguments: $transformers: !tagged app.transformer class TransformerChain { public function __construct(private iterable $transformers) {} } La notation !tagged crée un IteratorArgument : les services sont instanciés paresseusement au fil de l\u0026rsquo;itération, donc les transformers non utilisés ne sont jamais construits. Pour l\u0026rsquo;ordonnancement, ajouter un attribut priority à la définition du tag sur chaque service.\nUn logger livré avec le framework Pas de Monolog ? Pas de problème. Symfony 3.4 inclut un logger PSR-3 qui écrit sur php://stderr par défaut. On l\u0026rsquo;injecte avec Psr\\Log\\LoggerInterface :\nuse Psr\\Log\\LoggerInterface; class MyService { public function __construct(private LoggerInterface $logger) {} public function doSomething(): void { $this-\u0026gt;logger-\u0026gt;warning(\u0026#39;Quelque chose de douteux s\\\u0026#39;est produit\u0026#39;, [\u0026#39;context\u0026#39; =\u0026gt; \u0026#39;ici\u0026#39;]); } } Le niveau minimum par défaut est warning. La cible est les workloads container et Kubernetes où stderr est le puits de logs naturel. C\u0026rsquo;est délibérément minimal : pas de handlers, pas de processors, pas de channels. Quand on en a besoin, on installe Monolog.\nLes Guard authenticators ont reçu une méthode supports() La méthode getCredentials() du composant Guard jouait un double rôle : décider si l\u0026rsquo;authentificateur devait gérer la requête, et extraire les credentials. Retourner null était le signal pour passer. Ça rendait le contrat confus.\n3.4 a ajouté supports() pour séparer ces responsabilités :\nclass ApiTokenAuthenticator extends AbstractGuardAuthenticator { public function supports(Request $request): bool { return $request-\u0026gt;headers-\u0026gt;has(\u0026#39;X-API-TOKEN\u0026#39;); } public function getCredentials(Request $request): array { // N\u0026#39;est appelé que quand supports() retourne true. // Doit toujours retourner des credentials maintenant. return [\u0026#39;token\u0026#39; =\u0026gt; $request-\u0026gt;headers-\u0026gt;get(\u0026#39;X-API-TOKEN\u0026#39;)]; } } L\u0026rsquo;ancienne GuardAuthenticatorInterface est dépréciée. L\u0026rsquo;avantage pratique : les classes de base peuvent implémenter la logique partagée getUser() et checkCredentials(), tandis que les sous-classes ne surchargent que supports() et getCredentials(). Une responsabilité chacune.\nDeux nouvelles commandes de debug debug:autowiring remplace l\u0026rsquo;ancien debug:container --types pour découvrir quels type-hints fonctionnent avec l\u0026rsquo;autowiring :\n$ bin/console debug:autowiring log Autowirable Services ==================== Psr\\Log\\LoggerInterface alias to monolog.logger Psr\\Log\\LoggerInterface $auditLogger alias to monolog.logger.audit Passer un mot-clé pour filtrer. Fini de deviner si c\u0026rsquo;est LoggerInterface ou Logger.\ndebug:form donne la même capacité d\u0026rsquo;introspection pour les types de formulaires :\n$ bin/console debug:form App\\Form\\OrderType label_attr Option: label_attr Required: false Default: [] Allowed types: array Sans arguments, il liste tous les types de formulaires enregistrés, extensions et guessers. Avec un nom de type et un nom d\u0026rsquo;option, il montre toutes les contraintes sur cette option. Avant ça, on lisait le source ou on tâtonnait.\nLes sessions sont devenues plus strictes par défaut 3.4 implémente SessionUpdateTimestampHandlerInterface de PHP 7.0, ce qui apporte deux choses : les écritures de session paresseuses (écrites seulement quand les données ont vraiment changé) et la validation stricte des ID de session (les IDs qui n\u0026rsquo;existent pas dans le store sont rejetés plutôt que créés silencieusement, ce qui bloque une classe d\u0026rsquo;attaques de fixation de session).\nLes anciennes classes WriteCheckSessionHandler, NativeSessionHandler et NativeProxy sont dépréciées. Le MemcacheSessionHandler (note : pas Memcached) est supprimé, puisque l\u0026rsquo;extension PECL sous-jacente a arrêté de recevoir des mises à jour pour PHP 7.\nLes thèmes de formulaires Twig peuvent maintenant être scopés Les thèmes de formulaires globaux s\u0026rsquo;appliquent à tous les formulaires dans l\u0026rsquo;application. Si un formulaire a besoin d\u0026rsquo;un look complètement différent, il n\u0026rsquo;y avait pas de moyen propre de se désinscrire. Le mot-clé only gère ça :\n{% raw %}{% form_theme orderForm with [\u0026#39;form/order_layout.html.twig\u0026#39;] only %}{% endraw %} Le mot-clé only désactive tous les thèmes globaux pour ce formulaire, y compris le form_div_layout.html.twig de base. Le thème personnalisé doit alors soit fournir tous les blocs qu\u0026rsquo;il utilise, soit les importer explicitement avec {% raw %}{% use 'form_div_layout.html.twig' %}{% endraw %}.\nSurcharger les templates de bundle sans boucles infinies Surcharger un template de bundle qu\u0026rsquo;on avait aussi besoin d\u0026rsquo;étendre causait autrefois une erreur de référence circulaire. Surcharger @TwigBundle/Exception/error404.html.twig et essayer aussi d\u0026rsquo;en hériter ? L\u0026rsquo;ancienne résolution de namespace suivait la surcharge et bouclait indéfiniment.\n3.4 a introduit le préfixe @! pour référencer explicitement le template de bundle original, en contournant toutes les surcharges :\n{% raw %}{# templates/bundles/TwigBundle/Exception/error404.html.twig #} {% extends \u0026#39;@!Twig/Exception/error404.html.twig\u0026#39; %} {% block title %}Page non trouvée{% endblock %}{% endraw %} @TwigBundle résout vers la surcharge si elle existe. @!TwigBundle résout toujours vers l\u0026rsquo;original. Surcharger-et-étendre, sans les acrobaties.\n","permalink":"https://guillaumedelre.github.io/fr/2018/01/12/symfony-3.4-lts-le-pont-quon-a-vraiment-envie-de-traverser/","summary":"\u003cp\u003eSymfony 3.4 et 4.0 sont sortis le même jour : le 30 novembre 2017. Ce n\u0026rsquo;est pas une coïncidence, c\u0026rsquo;est la stratégie.\u003c/p\u003e\n\u003cp\u003e3.4 n\u0026rsquo;est pas une version de fonctionnalités. Elle livre exactement les mêmes fonctionnalités que 3.3, plus chaque avertissement de dépréciation que 4.0 va rendre obligatoire. Son seul objectif est d\u0026rsquo;être l\u0026rsquo;outil de migration : monter de 3.3 à 3.4, corriger ce qui apparaît dans les logs, puis passer à 4.0 proprement.\u003c/p\u003e","title":"Symfony 3.4 LTS : le pont qu'on a vraiment envie de traverser"},{"content":"Symfony 3.3 est sorti le 29 mai. C\u0026rsquo;est la version qui a changé ma façon de penser la configuration des services. Avec le recul, c\u0026rsquo;était une prévisualisation de ce que 4.0 allait adopter comme nouveau standard.\nLe problème de l\u0026rsquo;autowiring Avant 3.3, le DI de Symfony était puissant mais verbeux. Chaque service devait être déclaré explicitement dans services.yml avec ses arguments listés. L\u0026rsquo;autowiring existait depuis 3.1, mais il était opt-in par service et avait assez de cas limites pour vous mordre. Les équipes écrivaient soit des montagnes de YAML, soit s\u0026rsquo;appuyaient sur des bundles tiers pour réduire le bruit.\n3.3 a réécrit les defaults. Avec autoconfigure: true et autowire: true définis une seule fois dans la section defaults, chaque classe dans src/ devient automatiquement un service, et ses dépendances de constructeur sont résolues par type. Ce qui prenait vingt lignes de YAML ne prend maintenant plus rien :\nservices: _defaults: autowire: true autoconfigure: true App\\: resource: \u0026#39;../src/\u0026#39; Ce bloc unique est toute la configuration de services pour la plupart des applications. Le framework découvre les services, injecte les dépendances, et applique les tags (command, event subscriber, voter\u0026hellip;) en fonction des interfaces que chaque classe implémente.\nLes conditionnels instanceof Le mot-clé instanceof dans la configuration des services gère le tagging qui nécessitait auparavant une déclaration explicite :\nservices: _instanceof: Symfony\\Component\\EventDispatcher\\EventSubscriberInterface: tags: [\u0026#39;kernel.event_subscriber\u0026#39;] Tout service implémentant EventSubscriberInterface reçoit le tag automatiquement. Même chose pour Command, Voter, MessageHandlerInterface. Le boilerplate s\u0026rsquo;évapore.\nLe composant Dotenv Avant 3.3, Symfony n\u0026rsquo;avait aucun moyen natif de charger des fichiers .env. La réponse standard était un package tiers. Le nouveau composant Dotenv lit .env et peuple $_ENV et $_SERVER, faisant de la configuration basée sur l\u0026rsquo;environnement un citoyen de première classe enfin.\nDécouverte des services depuis le filesystem L\u0026rsquo;option resource rassemble tout. Au lieu d\u0026rsquo;enregistrer chaque classe individuellement, on pointe le conteneur vers un répertoire et il scanne les classes PSR-4 :\nservices: App\\: resource: \u0026#39;../src/\u0026#39; exclude: \u0026#39;../src/{Entity,Migrations}\u0026#39; Chaque classe trouvée devient un service avec son FQCN comme service ID. L\u0026rsquo;option exclude gère les cas comme les entités Doctrine qu\u0026rsquo;on ne veut pas que le conteneur touche. Et non, ce n\u0026rsquo;est pas de la magie : c\u0026rsquo;est un scan filesystem à la compilation, donc le coût est payé une fois pendant le cache warmup, pas par requête.\nQuand on a besoin d\u0026rsquo;un sous-ensemble du conteneur Les service locators résolvent une tension spécifique : certains services ont légitimement besoin d\u0026rsquo;accéder de manière paresseuse à un ensemble variable d\u0026rsquo;autres services, mais injecter le conteneur entier est un anti-pattern — ça masque les dépendances et déjoue l\u0026rsquo;analyse statique. La solution est un locator qui déclare explicitement ce qu\u0026rsquo;il contient.\nservices: App\\Handler\\HandlerLocator: class: Symfony\\Component\\DependencyInjection\\ServiceLocator tags: [\u0026#39;container.service_locator\u0026#39;] arguments: - App\\Command\\CreateOrder: \u0026#39;@App\\Handler\\CreateOrderHandler\u0026#39; App\\Command\\CancelOrder: \u0026#39;@App\\Handler\\CancelOrderHandler\u0026#39; App\\Bus\\CommandBus: arguments: [\u0026#39;@App\\Handler\\HandlerLocator\u0026#39;] Le locator implémente ContainerInterface de PSR-11, donc la classe réceptrice type-hinte contre Psr\\Container\\ContainerInterface. Les services à l\u0026rsquo;intérieur sont instanciés de manière paresseuse : si un handler donné n\u0026rsquo;est jamais appelé pendant une requête, il n\u0026rsquo;est jamais construit.\nEt à propos de PSR-11 : Symfony 3.3 a fait implémenter ce standard à son conteneur. Ce qui signifie que toute bibliothèque attendant un conteneur PSR-11 fonctionne maintenant directement avec le conteneur Symfony, sans adaptateur.\nLe routing est devenu plus rapide Le composant de routing a réécrit comment il génère les fichiers dump. Dans une application avec 900 routes, la correspondance d\u0026rsquo;URL est passée de 7,5 ms à 2,5 ms par correspondance : une réduction de 66%. Les optimisations vivent dans la sortie compilée, pas dans le chemin d\u0026rsquo;exécution, donc les définitions de routes existantes en bénéficient automatiquement après un cache clear.\nTrouver la racine du projet sans compter les séparateurs de répertoires Avant 3.3, obtenir la racine du projet nécessitait le pattern délicieusement maladroit %kernel.root_dir%/../, parce que getRootDir() pointait vers le répertoire app/. La nouvelle méthode getProjectDir() remonte depuis le fichier kernel jusqu\u0026rsquo;à trouver composer.json et retourne ce répertoire.\n// Avant $path = $this-\u0026gt;getParameter(\u0026#39;kernel.root_dir\u0026#39;) . \u0026#39;/../var/data.db\u0026#39;; // Après $path = $this-\u0026gt;getParameter(\u0026#39;kernel.project_dir\u0026#39;) . \u0026#39;/var/data.db\u0026#39;; Le paramètre correspondant est %kernel.project_dir%. Si vous déployez sans composer.json, vous pouvez surcharger la méthode dans votre classe kernel et retourner n\u0026rsquo;importe quel chemin qui fait sens.\nLes messages flash sans toucher l\u0026rsquo;objet session L\u0026rsquo;ancienne façon d\u0026rsquo;itérer les messages flash dans Twig nécessitait de passer par app.session.flashbag, ce qui forçait aussi le démarrage de la session qu\u0026rsquo;il y ait des messages ou non. Le nouveau helper app.flashes évite les deux :\n{% raw %}{% for label, messages in app.flashes %} {% for message in messages %} \u0026lt;div class=\u0026#34;flash-{{ label }}\u0026#34;\u0026gt;{{ message }}\u0026lt;/div\u0026gt; {% endfor %} {% endfor %}{% endraw %} S\u0026rsquo;il n\u0026rsquo;y a pas de messages flash, la session ne démarre jamais. On peut aussi filtrer par type : app.flashes('error') ne retourne que les messages d\u0026rsquo;erreur.\nLa commande encode-password est devenue intelligente La commande console security:encode-password est devenue plus futée. Au lieu d\u0026rsquo;exiger qu\u0026rsquo;on passe la classe utilisateur en argument, elle liste maintenant les classes utilisateur configurées et laisse choisir :\n$ bin/console security:encode-password For which user class would you like to encode a password? [0] App\\Entity\\User [1] App\\Entity\\AdminUser Elle normalise aussi la configuration des encodeurs pour gérer les cas limites avec des noms d\u0026rsquo;utilisateur au format email que la version précédente corrompait silencieusement en remplaçant @ par des underscores. Beau rattrapage.\nHTTP/2 push et resource hints Le composant WebLink gère l\u0026rsquo;en-tête HTTP Link, qui dit aux navigateurs (et aux proxies HTTP/2) de précharger, prérécupérer ou se préconnecter à des ressources avant même que la page les demande. Il se présente sous forme de fonctions Twig :\n{% raw %}{{ preload(\u0026#39;/fonts/custom.woff2\u0026#39;, { as: \u0026#39;font\u0026#39;, crossorigin: true }) }} {{ prefetch(\u0026#39;/api/next-page-data.json\u0026#39;) }} {{ dns_prefetch(\u0026#39;https://fonts.googleapis.com\u0026#39;) }}{% endraw %} Chaque appel ajoute un en-tête Link correspondant à la réponse. Pour les applications derrière un proxy capable HTTP/2, ça peut déclencher un server push avant même que le navigateur ait parsé le HTML. On l\u0026rsquo;active dans config.yml :\nframework: web_link: enabled: true Des dépréciations auxquelles on peut vraiment faire confiance La compilation du conteneur générait des avertissements de dépréciation qui disparaissaient au prochain chargement de page parce que le conteneur mis en cache était déjà construit. 3.3 persiste ces messages sur disque et les affiche dans la barre de débogage web aux côtés des dépréciations de la phase de requête. Si une classe est dépréciée pendant la compilation des services, vous le verrez sans avoir à vider le cache d\u0026rsquo;abord.\nCe que ça a signifié pour 4.0 Les defaults d\u0026rsquo;autowiring de 3.3 sont exactement ce que Symfony 4.0 a adopté comme nouvelle structure de projet standard. Le services.yaml de chaque nouveau projet Symfony 4 est essentiellement le snippet ci-dessus. Si on avait déjà assimilé ce que 3.3 introduisait, le \u0026ldquo;nouveau mode\u0026rdquo; de 4.0 semblait familier plutôt qu\u0026rsquo;étranger.\nLa direction était claire : moins de configuration, plus de convention. Laisser PHP comprendre ce qu\u0026rsquo;il faut câbler ensemble.\n","permalink":"https://guillaumedelre.github.io/fr/2017/07/13/symfony-3.3-quand-les-services-ont-arr%C3%AAt%C3%A9-d%C3%AAtre-un-cauchemar-de-configuration/","summary":"\u003cp\u003eSymfony 3.3 est sorti le 29 mai. C\u0026rsquo;est la version qui a changé ma façon de penser la configuration des services. Avec le recul, c\u0026rsquo;était une prévisualisation de ce que 4.0 allait adopter comme nouveau standard.\u003c/p\u003e\n\u003ch2 id=\"le-problème-de-lautowiring\"\u003eLe problème de l\u0026rsquo;autowiring\u003c/h2\u003e\n\u003cp\u003eAvant 3.3, le DI de Symfony était puissant mais verbeux. Chaque service devait être déclaré explicitement dans \u003ccode\u003eservices.yml\u003c/code\u003e avec ses arguments listés. L\u0026rsquo;autowiring existait depuis 3.1, mais il était opt-in par service et avait assez de cas limites pour vous mordre. Les équipes écrivaient soit des montagnes de YAML, soit s\u0026rsquo;appuyaient sur des bundles tiers pour réduire le bruit.\u003c/p\u003e","title":"Symfony 3.3 : quand les services ont arrêté d'être un cauchemar de configuration"},{"content":"La règle était simple : celui qui casse le build CI offre le café à l\u0026rsquo;équipe. Ça a marché un moment. Puis quelqu\u0026rsquo;un a proposé qu\u0026rsquo;on ait un retour plus immédiat. Quelque chose de physique. Quelque chose qui tire.\nUn Dream Cheeky Thunder a atterri sur un bureau peu après. Quatre missiles en mousse, un câble USB, et un consensus d\u0026rsquo;équipe très clair : le brancher au cluster, le câbler au pipeline de build, et laisser le CI décider qui mérite une volée.\nLe lanceur devait répondre à des appels HTTP depuis n\u0026rsquo;importe où sur le réseau. Sans driver, sans GUI, sans visée manuelle. Juste un endpoint qui le fait tirer dans la direction du bureau du coupable.\nVoilà l\u0026rsquo;histoire de dream-cheeky-thunder.\nPas de SDK, pas de docs, pas de problème Dream Cheeky n\u0026rsquo;a jamais publié de spec de protocole. Le lanceur parle USB HID brut, et le seul point de départ était un script Python vendorisé de 2012 qui traînait dans des fils de forum. Vendor ID 0x2123, product ID 0x1010, et une poignée d\u0026rsquo;octets de contrôle que quelqu\u0026rsquo;un avait rétro-ingénié des années auparavant.\nC\u0026rsquo;était suffisant. Le protocole est simple : envoyer une séquence d\u0026rsquo;octets pour bouger les moteurs, en envoyer une autre pour tirer. La partie délicate : le lanceur n\u0026rsquo;a aucun retour de position. Pas d\u0026rsquo;encodeurs, pas de fins de course en dehors des butées physiques aux extrémités. On le pilote à l\u0026rsquo;aveugle.\nDu USB au HTTP Le pipeline CI devait déclencher le lanceur par le réseau. Un script local ne suffisait pas — le lanceur devait être accessible depuis n\u0026rsquo;importe quelle machine du cluster, y compris le serveur de build. Donc : une API REST.\nFastAPI était le choix évident. Le flux de ciblage côté CI se résume à trois appels HTTP :\ncurl -X POST http://localhost:8000/park # reset vers une position connue curl -X POST http://localhost:8000/yaw/20 # rotation vers le bureau du coupable curl -X POST \u0026#34;http://localhost:8000/fire?shots=2\u0026#34; L\u0026rsquo;appel /park est plus important qu\u0026rsquo;il n\u0026rsquo;y paraît. Puisque le lanceur n\u0026rsquo;a pas de retour de position, le serveur estime l\u0026rsquo;angle courant en suivant le temps de rotation des moteurs. Cette estimation dérive. Un choc sur le hardware, une commande interrompue, ou simplement l\u0026rsquo;imprécision du tracking temporel — tout s\u0026rsquo;accumule. Le parking pousse les deux moteurs contre les butées physiques en balayage complet, ce qui garantit l\u0026rsquo;alignement quelle que soit la représentation interne du serveur. Sans ça, la visée est une approximation.\nLa référence complète de l\u0026rsquo;API est dans le repo. Il y a aussi une UI web si vous préférez cliquer plutôt que curl.\nDocker ne connaît pas l\u0026rsquo;USB Faire tourner ça dans un conteneur Docker sur le cluster, c\u0026rsquo;est là que les choses ont commencé à devenir intéressantes : les conteneurs ne voient pas les périphériques USB par défaut.\nLe mount devices dans compose.yaml expose le bus USB au conteneur :\ndevices: - /dev/bus/usb:/dev/bus/usb Pas suffisant. Première exécution : USBError: [Errno 13] Access denied. Le nœud de device est bien là dans le conteneur, mais il hérite des permissions du host, et sur le host seul root peut l\u0026rsquo;ouvrir par défaut.\nLa solution : une règle udev. Déposer un fichier dans /etc/udev/rules.d/, et le kernel applique le bon groupe et les bonnes permissions quand le device se branche. Après ça, l\u0026rsquo;utilisateur du conteneur peut l\u0026rsquo;ouvrir sans privilèges élevés. La règle est fournie avec le projet, les instructions d\u0026rsquo;installation sont dans la doc.\nWSL2 a rendu ça intéressant La moitié de l\u0026rsquo;équipe tourne sous Windows avec Docker Desktop sur WSL2. C\u0026rsquo;est là que ça devient créatif.\nWSL2 n\u0026rsquo;a pas accès aux périphériques USB par défaut : le kernel Windows les détient, et le mount devices seul ne fait rien parce que WSL2 ne voit simplement pas le hardware. La solution est usbipd-win, qui transfère le périphérique USB de Windows vers le kernel WSL2 par IP. Une fois ça fait, le chemin Linux fonctionne à l\u0026rsquo;identique : règle udev, mount devices, terminé.\nL\u0026rsquo;attachement ne survit pas aux redémarrages, cependant. usbipd v4+ a ajouté un mécanisme de policy qui automatise la reconnexion, ce qui a mis fin au mystère du \u0026ldquo;ça marchait hier\u0026rdquo; qui nous agaçait depuis des jours.\nCe qui nous a vraiment surpris Le positionnement temporel fonctionne suffisamment bien. Sans encodeurs, on s\u0026rsquo;attendait à ce que le tracking d\u0026rsquo;angle soit quasi-inutilisable. En pratique, le parking avant chaque séquence le maintenait assez précis pour viser un bureau spécifique de manière fiable. Pas au millimètre, mais la précision missile en mousse, ça convient.\nLe mount devices est nécessaire mais pas suffisant. L\u0026rsquo;erreur de permission était déroutante précisément parce que le device était clairement visible dans le conteneur. La règle udev est la partie que la plupart des tutoriels passent discrètement sous silence.\nLa règle café n\u0026rsquo;a plus jamais été la même après ça. Une fois le lanceur câblé au pipeline, les builds cassés sont devenus beaucoup plus motivants à corriger.\nguillaumedelre/dream-cheeky-thunder FastAPI + Docker + PyUSB — contrôle HTTP pour le lance-missiles USB Dream Cheeky Thunder. Pull requests bienvenus, surtout si vous avez une meilleure approche de calibration d'angle.\n","permalink":"https://guillaumedelre.github.io/fr/2017/02/21/contr%C3%B4ler-un-lance-missiles-usb-en-http-avec-fastapi-et-docker/","summary":"\u003cp\u003eLa règle était simple : celui qui casse le build CI offre le café à l\u0026rsquo;équipe. Ça a marché un moment. Puis quelqu\u0026rsquo;un a proposé qu\u0026rsquo;on ait un retour plus immédiat. Quelque chose de physique. Quelque chose qui tire.\u003c/p\u003e\n\u003cp\u003eUn \u003ca href=\"http://www.dreamcheeky.com/thunder-missile-launcher\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eDream Cheeky Thunder\u003c/a\u003e a atterri sur un bureau peu après. Quatre missiles en mousse, un câble USB, et un consensus d\u0026rsquo;équipe très clair : le brancher au cluster, le câbler au pipeline de build, et laisser le CI décider qui mérite une volée.\u003c/p\u003e","title":"Contrôler un lance-missiles USB en HTTP avec FastAPI et Docker"},{"content":"Un timestamp qui revient de la base de données avec une heure de décalage. Pas à chaque fois. Uniquement quand le serveur de dev tourne en Europe/Paris et que la CI tourne en UTC. Le genre de bug qui disparaît quand on le cherche et qui revient en production un vendredi soir.\nLe problème n\u0026rsquo;est pas dans la logique métier. Il est dans ce que Doctrine fait discrètement avec les dates.\nCe que Doctrine fait par défaut Quand on déclare un champ datetime dans une entité Doctrine, la conversion entre PHP et la base de données passe par DateTimeType. Cette classe appelle format() sur l\u0026rsquo;objet DateTime pour écrire en base, et DateTime::createFromFormat() pour le relire. Aucune mention de timezone nulle part.\nSi l\u0026rsquo;objet PHP est en Europe/Paris, Doctrine formate 2017-01-15 11:30:00 et l\u0026rsquo;écrit tel quel. Si le serveur qui lit ce champ est en UTC, il obtient 2017-01-15 11:30:00 et l\u0026rsquo;interprète comme UTC. Une heure s\u0026rsquo;est évaporée dans l\u0026rsquo;aller-retour, sans le moindre message d\u0026rsquo;erreur.\nLa doc Doctrine couvre ce sujet et suggère des types personnalisés comme solution. Ce qu\u0026rsquo;elle mentionne en passant, c\u0026rsquo;est qu\u0026rsquo;on peut donner à ces types personnalisés le même nom que les types natifs. Ce détail change tout.\nRemplacer, pas ajouter La plupart des exemples de types Doctrine personnalisés introduisent un nouveau nom : utc_datetime, app_date, et ainsi de suite. On annote ensuite chaque champ avec type: 'utc_datetime' dans les entités. Ça fonctionne, mais c\u0026rsquo;est fastidieux et ça ne protège pas contre un type: 'datetime' oublié.\nL\u0026rsquo;autre option : enregistrer le type personnalisé sous le nom datetime. Doctrine remplace sa propre implémentation par la nôtre, partout, sans exception. Chaque champ datetime de toutes les entités passe par notre logique, sans changer une seule annotation.\nC\u0026rsquo;est ce qu\u0026rsquo;on vient de déployer sur notre plateforme de microservices PHP. Voici à quoi ça ressemble.\nLe trait partagé Les deux types (date et datetime) partagent la même logique de conversion via un trait :\ntrait UTCDate { private \\DateTimeZone $utc; public function convertToPHPValue($value, AbstractPlatform $platform): ?\\DateTime { if (null === $value || $value instanceof \\DateTime) { return $value; } $format = $this-\u0026gt;getFormat($platform); $converted = \\DateTime::createFromFormat($format, $value, $this-\u0026gt;getUtc()); if (!$converted) { throw new \\RuntimeException( sprintf(\u0026#39;Could not convert database value \u0026#34;%s\u0026#34; to DateTime using format \u0026#34;%s\u0026#34;.\u0026#39;, $value, $format) ); } $this-\u0026gt;postConvert($converted); return $converted; } abstract protected function getFormat(AbstractPlatform $platform): string; private function getUtc(): \\DateTimeZone { if (empty($this-\u0026gt;utc)) { $this-\u0026gt;utc = new \\DateTimeZone(\u0026#39;UTC\u0026#39;); } return $this-\u0026gt;utc; } } Le point clé : \\DateTime::createFromFormat() reçoit une timezone UTC explicite. La valeur brute issue de la base est interprétée comme UTC, peu importe la timezone configurée sur le serveur PHP.\nUTCDateTimeType Pour les champs datetime, le chemin d\u0026rsquo;écriture doit aussi imposer l\u0026rsquo;UTC :\nclass UTCDateTimeType extends DateTimeType { use UTCDate; #[\\Override] public function convertToPHPValue($value, AbstractPlatform $platform): ?\\DateTime { if (null === $value || $value instanceof \\DateTimeInterface) { return parent::convertToPHPValue($value, $platform); } return parent::convertToPHPValue(\u0026#34;$value+0000\u0026#34;, $platform); } #[\\Override] public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string { if ($value instanceof \\DateTime) { $value-\u0026gt;setTimezone($this-\u0026gt;getUtc()); } return parent::convertToDatabaseValue($value, $platform); } #[\\Override] protected function getFormat(AbstractPlatform $platform): string { return $platform-\u0026gt;getDateTimeFormatString(); } protected function postConvert(\\DateTime $converted): void {} } En lecture (convertToPHPValue), si la valeur est une chaîne brute, on y ajoute +0000 avant de déléguer au parent. Le parent utilise ensuite ce suffixe de timezone pour créer correctement l\u0026rsquo;objet PHP.\nEn écriture (convertToDatabaseValue), on force le DateTime en UTC avant de le formater. Ce qui va en base est toujours en UTC.\nUTCDateType Pour les colonnes date (sans composante horaire), même approche avec une étape supplémentaire :\nclass UTCDateType extends DateType { use UTCDate; #[\\Override] protected function getFormat(AbstractPlatform $platform): string { return $platform-\u0026gt;getDateFormatString(); } protected function postConvert(\\DateTime $converted): void { $converted-\u0026gt;setTime(0, 0, 0); } } La méthode postConvert() remet l\u0026rsquo;heure à 00:00:00 après le parsing. Sans elle, un champ date pourrait revenir avec 23:59:59 ou 00:00:00+02:00 selon la timezone du serveur, ce qui casse les comparaisons et le tri.\nEnregistrement dans Symfony La partie décisive : déclarer les types sous leurs noms natifs dans config/packages/doctrine.yaml.\ndoctrine: dbal: types: date: class: App\\Doctrine\\DBAL\\Types\\UTCDateType datetime: class: App\\Doctrine\\DBAL\\Types\\UTCDateTimeType C\u0026rsquo;est tout. Doctrine échange ses propres implémentations contre les nôtres. Les entités existantes ne changent pas, les migrations ne bougent pas, les annotations restent type: Types::DATETIME_MUTABLE. Le comportement change globalement, sans friction.\n12 microservices, 89 colonnes, un bloc de config Ces deux types tournent maintenant sur 12 microservices indépendants, chacun avec sa propre config Doctrine, couvrant 89 colonnes de production. Les serveurs CI tournent en UTC, les machines de dev en Europe/Paris, les données voyagent entre eux sans dériver. Ce n\u0026rsquo;est pas spectaculaire. C\u0026rsquo;est juste fiable.\nLa vraie leçon n\u0026rsquo;est pas technique : un problème de timezone non résolu est un problème d\u0026rsquo;intégrité des données. Les décalages s\u0026rsquo;accumulent silencieusement, les comparaisons se trompent, les exports deviennent inexacts. Deux lignes de config et trois classes peuvent prévenir ça définitivement.\n","permalink":"https://guillaumedelre.github.io/fr/2017/02/19/forcer-lutc-dans-doctrine-sans-toucher-aux-entit%C3%A9s/","summary":"\u003cp\u003eUn timestamp qui revient de la base de données avec une heure de décalage. Pas à chaque fois. Uniquement quand le serveur de dev tourne en \u003ccode\u003eEurope/Paris\u003c/code\u003e et que la CI tourne en UTC. Le genre de bug qui disparaît quand on le cherche et qui revient en production un vendredi soir.\u003c/p\u003e\n\u003cp\u003eLe problème n\u0026rsquo;est pas dans la logique métier. Il est dans ce que Doctrine fait discrètement avec les dates.\u003c/p\u003e","title":"Forcer l'UTC dans Doctrine sans toucher aux entités"},{"content":"PHP 7.1 est sorti le 1er décembre. Pas de titre \u0026ldquo;2x plus rapide\u0026rdquo;, pas de réécriture du moteur. Il comble les lacunes que la 7.0 avait laissées dans le système de types, et ces lacunes étaient vraiment agaçantes.\nLes types nullables La 7.0 permettait de déclarer string $name comme type de paramètre. Ce qu\u0026rsquo;elle ne permettait pas, c\u0026rsquo;était de dire \u0026ldquo;ça peut aussi être null\u0026rdquo;. On devait soit abandonner le type hint complètement, soit bricoler autour. La 7.1 ajoute le préfixe ? :\nfunction findUser(?int $id): ?User { if ($id === null) return null; return $this-\u0026gt;repository-\u0026gt;find($id); } Ça semble mineur. Ce n\u0026rsquo;est pas le cas. Les types nullables font la différence entre une signature qui dit ce que fait une fonction et une qui ment par omission. Chaque codebase sur lequel j\u0026rsquo;ai travaillé a des fonctions qui peuvent retourner null. Maintenant on peut vraiment le dire plutôt que de le cacher dans un docblock.\nLe type de retour void Le complément du nullable : une fonction qui ne retourne intentionnellement rien :\npublic function process(Order $order): void { $this-\u0026gt;dispatcher-\u0026gt;dispatch(new OrderProcessed($order)); } void rend l\u0026rsquo;intention explicite et empêche de retourner accidentellement une valeur depuis une fonction qui ne devrait pas. Combiné aux types nullables, le système de types de PHP en 7.1 est bien plus expressif qu\u0026rsquo;en 7.0.\nLa visibilité des constantes de classe Un petit correctif mais bienvenu. Les constantes dans les classes étaient toujours publiques avant la 7.1. Maintenant :\nclass Config { private const DB_PASSWORD = \u0026#39;secret\u0026#39;; protected const VERSION = \u0026#39;2.0\u0026#39;; public const MAX_RETRIES = 3; } Garder les détails d\u0026rsquo;implémentation privés, ça compte. Ça aurait dû exister depuis le début.\nAttraper plusieurs exceptions try { // ... } catch (InvalidArgumentException | RuntimeException $e) { // gérer les deux } Évite un bloc catch dupliqué quand deux exceptions nécessitent un traitement identique. Simple, utile.\nDéstructurer des tableaux sans list() list() est dans PHP depuis la 4.0 et a toujours semblé un peu à côté syntaxiquement. La 7.1 ajoute un raccourci avec [] qui se lit bien plus naturellement :\n[$first, $second] = $coordinates; foreach ($rows as [$id, $name, $email]) { // ... } Elle gagne aussi le support des clés, ce qui rend la déstructuration de tableaux associatifs enfin utilisable :\n[\u0026#39;id\u0026#39; =\u0026gt; $id, \u0026#39;name\u0026#39; =\u0026gt; $name] = $user; foreach ($records as [\u0026#39;id\u0026#39; =\u0026gt; $id, \u0026#39;status\u0026#39; =\u0026gt; $status]) { // ... } Avant ça, extraire des clés nommées d\u0026rsquo;un tableau signifiait soit extract() (qui déverse tout dans le scope et invite les collisions) soit un tas d\u0026rsquo;assignations individuelles. C\u0026rsquo;est juste plus propre.\nLe type iterable Si on écrit une fonction qui accepte soit un tableau soit un générateur, il n\u0026rsquo;y avait pas de type hint propre pour ça en 7.0. On typait soit en array et on excluait silencieusement les générateurs, soit on abandonnait le hint complètement :\nfunction processItems(iterable $items): void { foreach ($items as $item) { $this-\u0026gt;handle($item); } } iterable accepte tout ce sur quoi on peut faire un foreach : les tableaux et les implémentations de Traversable. Ça fonctionne aussi comme type de retour. Pas dramatique, mais ça comble un vrai manque.\nLes offsets négatifs sur les chaînes L\u0026rsquo;indexation de chaînes avec [] ou {} accepte maintenant les valeurs négatives, en comptant depuis la fin :\n$str = \u0026#39;hello\u0026#39;; echo $str[-1]; // \u0026#34;o\u0026#34; echo $str[-2]; // \u0026#34;l\u0026#34; Plusieurs fonctions de chaînes ont reçu le même traitement : strpos(), substr(), substr_count() et d\u0026rsquo;autres acceptent maintenant un offset négatif. Cohérent avec ce que Python fait depuis toujours. Mieux vaut tard que jamais.\nClosure::fromCallable() Avant ça, convertir un callable (comme [$object, 'method'] ou une chaîne nom de fonction) en une vraie Closure nécessitait Closure::bind() ou bindTo() avec une gestion de portée délicate. La 7.1 ajoute une méthode de fabrique statique :\nclass Processor { private function transform(string $value): string { return strtoupper($value); } public function getTransformer(): Closure { return Closure::fromCallable([$this, \u0026#39;transform\u0026#39;]); } } La closure résultante capture le bon $this et la bonne portée. C\u0026rsquo;est particulièrement utile quand on passe des méthodes comme callbacks à des fonctions qui attendent un callable, ou quand on construit des pipelines.\nArgumentCountError En PHP 7.0, appeler une fonction définie par l\u0026rsquo;utilisateur avec trop peu d\u0026rsquo;arguments générait un warning et l\u0026rsquo;exécution continuait avec des paramètres remplis à null. En 7.1, ça lève une ArgumentCountError :\nfunction connect(string $host, int $port): void { /* ... */ } try { connect(\u0026#39;localhost\u0026#39;); // Lève ArgumentCountError } catch (\\ArgumentCountError $e) { // ... } ArgumentCountError étend TypeError, qui étend Error. Les call sites qui se dégradaient silencieusement auparavant échouent maintenant bruyamment. C\u0026rsquo;est un risque de migration si on a du code qui comptait sur le comportement permissif, mais honnêtement, c\u0026rsquo;est la bonne décision.\nLa 7.1 est le genre de version qui fait davantage faire confiance à une plateforme. La core team regardait clairement les frictions, pas seulement les titres à faire valoir.\n","permalink":"https://guillaumedelre.github.io/fr/2017/01/15/php-7.1-un-syst%C3%A8me-de-types-plus-rigoureux-et-les-petits-gains-autour/","summary":"\u003cp\u003ePHP 7.1 est sorti le 1er décembre. Pas de titre \u0026ldquo;2x plus rapide\u0026rdquo;, pas de réécriture du moteur. Il comble les lacunes que la 7.0 avait laissées dans le système de types, et ces lacunes étaient vraiment agaçantes.\u003c/p\u003e\n\u003ch2 id=\"les-types-nullables\"\u003eLes types nullables\u003c/h2\u003e\n\u003cp\u003eLa 7.0 permettait de déclarer \u003ccode\u003estring $name\u003c/code\u003e comme type de paramètre. Ce qu\u0026rsquo;elle ne permettait pas, c\u0026rsquo;était de dire \u0026ldquo;ça peut aussi être null\u0026rdquo;. On devait soit abandonner le type hint complètement, soit bricoler autour. La 7.1 ajoute le préfixe \u003ccode\u003e?\u003c/code\u003e :\u003c/p\u003e","title":"PHP 7.1 : un système de types plus rigoureux et les petits gains autour"},{"content":"PHP 7.0 est sorti le 3 décembre. Un mois et demi plus tard, j\u0026rsquo;ai migré deux projets et les résultats sont difficiles à ignorer.\nLe chiffre phare : 2x plus rapide que PHP 5.6. Ce n\u0026rsquo;est pas un benchmark cherry-pick — c\u0026rsquo;est la médiane sur des applications réelles. Le Zend Engine a été réécrit pour utiliser une nouvelle représentation interne des valeurs, ce qui réduit significativement l\u0026rsquo;utilisation mémoire et diminue les allocations. Sur un projet, le temps de réponse moyen a chuté de 40% sans aucune modification du code. On met à jour, et ça va plus vite.\nMais les performances ne sont pas la partie la plus intéressante.\nLes types, enfin PHP a eu les type hints pour les objets depuis la 5.0, pour les tableaux depuis la 5.1. En 7.0, on peut enfin déclarer des types scalaires pour les paramètres de fonctions et les valeurs de retour :\nfunction add(int $a, int $b): int { return $a + $b; } En mode strict (declare(strict_types=1)), passer un float à cette fonction lève une TypeError. En mode coercitif par défaut, PHP convertit la valeur. Cette distinction compte : le mode strict est par fichier, on peut donc l\u0026rsquo;adopter progressivement sans tout casser d\u0026rsquo;un coup.\nLes déclarations de type de retour constituent l\u0026rsquo;autre moitié. Placer l\u0026rsquo;intention dans la signature plutôt que dans un docblock signifie que c\u0026rsquo;est le moteur qui l\u0026rsquo;applique, pas un code reviewer à moitié endormi.\nL\u0026rsquo;opérateur null coalescent ?? est petit mais utilisé en permanence :\n$username = $_GET[\u0026#39;user\u0026#39;] ?? \u0026#39;guest\u0026#39;; Ça remplace isset($_GET['user']) ? $_GET['user'] : 'guest'. Il se chaîne aussi : $a ?? $b ?? $c. Après des années de bruit avec isset(), ça seul valait la mise à jour.\nLa partie qui casse La refonte de la gestion des erreurs est le vrai risque lors de la migration. Beaucoup d\u0026rsquo;erreurs fatales sont maintenant des exceptions Error, attrapables mais différentes des Exception. Le code qui comptait sur les erreurs fatales pour stopper l\u0026rsquo;exécution silencieusement a maintenant besoin d\u0026rsquo;une gestion explicite. La suppression d\u0026rsquo;erreurs avec @ fonctionne aussi différemment par endroits.\nLire le guide de migration avant de toucher une appli en production. Le gain est réel, mais le fossé entre 5.6 et 7.0 est le plus large que PHP ait jamais eu.\nL\u0026rsquo;opérateur vaisseau spatial \u0026lt;=\u0026gt; est un opérateur de comparaison combiné qui retourne -1, 0 ou 1. Il est surtout là pour le tri :\nusort($users, function ($a, $b) { return $a-\u0026gt;age \u0026lt;=\u0026gt; $b-\u0026gt;age; }); Avant ça, un comparateur de tri personnalisé était un petit exercice de mémoire arithmétique. $a - $b fonctionne pour les entiers mais plante silencieusement pour les flottants. \u0026lt;=\u0026gt; fait ce qu\u0026rsquo;il faut pour chaque type comparable.\nLes classes anonymes On peut maintenant instancier une classe définie en ligne, sur le moment, sans lui donner de nom :\n$logger = new class($config) implements LoggerInterface { public function __construct(private array $config) {} public function log(string $message): void { file_put_contents($this-\u0026gt;config[\u0026#39;path\u0026#39;], $message . PHP_EOL, FILE_APPEND); } }; Le cas d\u0026rsquo;usage canonique, ce sont les doublures de test et les implémentations d\u0026rsquo;interface ponctuelles qui ne méritent pas un fichier. Ça supprime une vraie friction : le fossé entre \u0026ldquo;j\u0026rsquo;ai besoin d\u0026rsquo;un objet\u0026rdquo; et \u0026ldquo;je dois créer un fichier de classe pour un truc de 10 lignes\u0026rdquo;.\nAléatoire cryptographiquement sûr Les rand() et mt_rand() de PHP 5 n\u0026rsquo;ont jamais été conçus pour la sécurité. La 7.0 ajoute deux fonctions qui le sont :\n$token = bin2hex(random_bytes(32)); // token hexadécimal de 64 caractères $pin = random_int(100000, 999999); random_bytes() puise dans le CSPRNG du système d\u0026rsquo;exploitation. random_int() enveloppe ça pour les entiers. Ces fonctions remplacent tous les schémas de génération de tokens maison qui faisaient ça mal en silence, ce qui représente la majorité d\u0026rsquo;entre eux.\nLes déclarations use groupées Avant 7.0, importer cinq éléments depuis le même namespace nécessitait cinq instructions use. Maintenant :\nuse App\\Model\\{User, Order, Product}; use function App\\Helpers\\{formatDate, slugify}; use const App\\Config\\{MAX_RETRIES, TIMEOUT}; Petite amélioration ergonomique, mais qui réduit le bruit visuel en haut des fichiers avec des hiérarchies de namespaces profondes.\nLes générateurs ont grandi Les générateurs en 5.5 étaient intéressants mais incomplets. La 7.0 ajoute deux choses. Premièrement, un générateur peut maintenant avoir une valeur de retour, accessible après la fin de l\u0026rsquo;itération :\nfunction process(): Generator { yield \u0026#39;step 1\u0026#39;; yield \u0026#39;step 2\u0026#39;; return \u0026#39;done\u0026#39;; } $gen = process(); foreach ($gen as $step) { /* ... */ } echo $gen-\u0026gt;getReturn(); // \u0026#34;done\u0026#34; Deuxièmement, yield from délègue à un autre générateur ou itérable, en transmettant transparemment les valeurs et valeurs de retour :\nfunction inner(): Generator { yield 1; yield 2; return \u0026#39;inner done\u0026#39;; } function outer(): Generator { $result = yield from inner(); echo $result; // \u0026#34;inner done\u0026#34; yield 3; } Ça rend la composition de générateurs pratique sans avoir à câbler manuellement les valeurs entre eux.\nClosure::call() Une façon plus directe de lier une closure à un objet et de l\u0026rsquo;appeler immédiatement :\nclass Counter { private int $count = 0; } $increment = function (int $by): void { $this-\u0026gt;count += $by; }; $increment-\u0026gt;call(new Counter(), 5); bindTo() existait avant mais nécessitait deux étapes. call() les fusionne et est plus rapide à l\u0026rsquo;exécution car il évite la création d\u0026rsquo;une closure intermédiaire.\nSyntaxe d\u0026rsquo;échappement Unicode dans les chaînes On peut maintenant intégrer des caractères Unicode directement dans les chaînes entre guillemets doubles ou les heredocs via un point de code :\necho \u0026#34;\\u{1F418}\u0026#34;; // 🐘 echo \u0026#34;\\u{00E9}\u0026#34;; // é C\u0026rsquo;est mieux que de copier-coller des caractères depuis une table Unicode dans les fichiers sources, ce que les gens faisaient vraiment.\nUn unserialize() plus sûr unserialize() a une longue histoire d\u0026rsquo;être un vecteur d\u0026rsquo;attaques par injection d\u0026rsquo;objets. La 7.0 ajoute une option allowed_classes :\n$data = unserialize($input, [\u0026#39;allowed_classes\u0026#39; =\u0026gt; false]); $data = unserialize($input, [\u0026#39;allowed_classes\u0026#39; =\u0026gt; [User::class, Order::class]]); Passer false empêche toute instanciation d\u0026rsquo;objet pendant la désérialisation. C\u0026rsquo;est le comportement par défaut à adopter quand on désérialise des données non fiables.\n:1234: Division entière intdiv() est une division entière explicite sans intermédiaire flottant :\n$pages = intdiv(count($items), $perPage); // int, pas besoin de cast Oui, on pourrait caster le résultat d\u0026rsquo;une division. intdiv() rend l\u0026rsquo;intention claire et évite les cas limites de précision flottante que le cast introduit pour les grands nombres.\nLes constantes en tableaux Avant 7.0, define() n\u0026rsquo;acceptait que les valeurs scalaires. Les tableaux fonctionnaient avec const au niveau de la classe ou du namespace mais pas avec define(). Maintenant si :\ndefine(\u0026#39;HTTP_METHODS\u0026#39;, [\u0026#39;GET\u0026#39;, \u0026#39;POST\u0026#39;, \u0026#39;PUT\u0026#39;, \u0026#39;DELETE\u0026#39;, \u0026#39;PATCH\u0026#39;]); Utile pour la configuration qui doit être une constante mais qui vit en dehors d\u0026rsquo;une classe.\nDes assertions avec des dents assert() a reçu une vraie refonte. En PHP 5, les assertions étaient un eval de chaînes à l\u0026rsquo;exécution. Maintenant elles peuvent lever des exceptions et être complètement supprimées en production avec zéro overhead :\n// Dans php.ini ou au bootstrap : // assert.active = 1 (dev), 0 (prod) // assert.exception = 1 assert($user-\u0026gt;isVerified(), new \\LogicException(\u0026#39;Unverified user reached checkout\u0026#39;)); Quand assert.active = 0, l\u0026rsquo;expression n\u0026rsquo;est jamais évaluée. Quand c\u0026rsquo;est activé, une assertion qui échoue lève directement l\u0026rsquo;exception fournie. C\u0026rsquo;est enfin un outil qu\u0026rsquo;on peut utiliser sans honte.\nLa refonte de session_start() session_start() accepte maintenant un tableau d\u0026rsquo;options qui surchargent les directives php.ini pour cet appel :\nsession_start([ \u0026#39;cookie_lifetime\u0026#39; =\u0026gt; 86400, \u0026#39;cookie_secure\u0026#39; =\u0026gt; true, \u0026#39;cookie_httponly\u0026#39; =\u0026gt; true, \u0026#39;cookie_samesite\u0026#39; =\u0026gt; \u0026#39;Lax\u0026#39;, ]); Avant ça, on définissait soit les options globalement dans php.ini, soit on appelait ini_set() avant session_start(). Aucune des deux n\u0026rsquo;était top quand on avait besoin de configurations de session différentes dans différentes parties d\u0026rsquo;une appli.\n","permalink":"https://guillaumedelre.github.io/fr/2016/01/17/php-7.0-performances-types-et-les-fonctionnalit%C3%A9s-qui-ont-marqu%C3%A9/","summary":"\u003cp\u003ePHP 7.0 est sorti le 3 décembre. Un mois et demi plus tard, j\u0026rsquo;ai migré deux projets et les résultats sont difficiles à ignorer.\u003c/p\u003e\n\u003cp\u003eLe chiffre phare : 2x plus rapide que PHP 5.6. Ce n\u0026rsquo;est pas un benchmark cherry-pick — c\u0026rsquo;est la médiane sur des applications réelles. Le Zend Engine a été réécrit pour utiliser une nouvelle représentation interne des valeurs, ce qui réduit significativement l\u0026rsquo;utilisation mémoire et diminue les allocations. Sur un projet, le temps de réponse moyen a chuté de 40% sans aucune modification du code. On met à jour, et ça va plus vite.\u003c/p\u003e","title":"PHP 7.0 : performances, types, et les fonctionnalités qui ont marqué"}]