La vraie motivation de ce chantier n'était pas le SEO. Non, ce qui m'a décidé, c'est le refus d'illustrer mes billets avec des images laides générées par IA ou des photos sans âme de bibliothèque d'images libres de droits. Je voulais une image originale, qui se construise sur l'existant : les couleurs du site, sa typo, son point rose.
Partager un lien sur LinkedIn, dans un chan Slack ou sur Bluesky : avant le moindre mot du billet, c'est une image de 1200 × 630 pixels qui s'affiche. Cette image, presque tout le monde la délègue : au thème WordPress, à un SaaS, à un screenshot de navigateur headless ou pire, à une image générée par IA. Ici, elle est dessinée en PHP, au pixel près, par le serveur qui héberge le billet.
Voici comment je m'y suis pris, de A à Z. À la fin, vous saurez pourquoi deux bibliothèques d'images (GD et Imagick) cohabitent dans le même container, ce que fait Glide et à quoi sert le token s= qui signe chaque URL de resize, et où vivent les caches. Avec deux pièges en prime (sinon ce n'est pas amusant) : la fuite mémoire du worker mode FrankenPHP et les coders fantômes d'ImageMagick dans une image Docker slim.
L'affiche avant le film
L'image OG, c'est l'affiche du billet : elle est placardée dans le hall (le feed LinkedIn, le chan Slack…) pendant que le film, lui, reste dans la salle. Et comme au cinéma, la plupart des passants ne verront jamais que l'affiche.
Côté HTML, ça tient en quelques balises, rendues par le composant Seo:Seo sur chaque page :
<meta property="og:image" content="https://lecodeestdanslepre.fr/og/billets/mon-slug.png?v=3f9c1a2b4d5e6f70">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="…la même URL…">Détail amusant : je n'ai plus de compte X ou Facebook, mais le markup twitter:* / og:* reste. LinkedIn, Discord et Slack le consomment pour leurs previews riches. Le nom de la balise a survécu à la plateforme.
Le 1200 × 630 n'est pas un choix : c'est le format summary_large_image (ratio 1,91:1) recommandé par Facebook et repris partout. On ne débattra pas de ce chiffre ici. Borne posée d'entrée : ce billet parle de la fabrication des pixels, pas du contenu des balises, ni de JSON-LD, ni de SEO au sens large.
Deux bibliothèques d'images dans le même container
PHP a deux façons historiques de toucher une image. GD, l'extension raster embarquée presque partout : rapide, frugale, API minimale. Imagick, le binding ImageMagick : environ 80 Mo de dépendances dans l'image de prod, mais un vrai moteur de rendu, avec ce qui nous intéresse ici : des métriques de texte fiables.
Sur le même sujet
Docker : images, layers et isolation kernel expliqués
Le blog utilise les deux, et chacune n'a qu'un seul travail :
- GD encode. Toutes les variantes des photos de billets (resize, conversion WebP) sortent de GD, piloté par Glide. Un seul pipeline d'encodage pour les médias, assumé dans
GlideServerFactory. - Imagick dessine. La carte OG est du texte posé sur un fond, et rien d'autre. Or poser du texte proprement demande de le mesurer :
queryFontMetrics()rend ça trivial côté Imagick, quandimagettfbbox()côté GD transforme l'exercice en artisanat pénible.
Reste le troisième larron, celui que tout l'écosystème JS choisit (vercel/og, Browsershot) : rendre un template HTML dans un Chromium headless et screenshoter. Réponse courte : il n'y a pas de navigateur dans l'image de prod, et en installer un pour produire 630 pixels de haut, c'est inviter un orchestre symphonique pour jouer une note.
Pour aller plus loin
Comment FrankenPHP a relégué PHP-FPM et Nginx au stade de reliques
Pour aller plus loin
FrankenPHP en prod : un binaire, quatre sous-domaines, un seul worker
Glide : le resize à la volée, sous signature
Glide (league/glide, passé en v4 fin juin) fait une chose et la fait bien : on lui donne un fichier source et des paramètres (w=640, fm=webp, q=…), il encode la variante demandée, la met en cache disque, et la ressert telle quelle au hit suivant. Les images responsives du blog reposent entièrement dessus : le srcset de chaque bloc image aligne trois ou quatre largeurs calibrées sur la colonne de lecture (~820 px) et les DPR 2-3 des mobiles.
Une URL de variante ressemble à ça :
https://media.lecodeestdanslepre.fr/media/{sha256}.jpg?w=640&fm=webp&s={token}Trois choses à y voir.
Le nom de fichier est un SHA-256 du contenu. L'URL est content-addressed : changez l'image, l'URL change. Conséquence directe : Cache-Control: public, max-age=31536000, immutable. Le navigateur peut garder la réponse un an, elle ne mentira jamais.
Le s={token}, c'est la signature. Un HMAC-SHA256 calculé sur le chemin plus l'ensemble des paramètres de query, avec une clé serveur (MEDIA_URL_SIGNATURE_KEY, stockée dans l'Ansible Vault en prod). Côté serving, la signature est validée avant que Glide ne touche à quoi que ce soit : URL non signée ou bidouillée, 403 sec. Sans ça, n'importe qui peut demander ?w=3999&h=3999&q=100 en boucle sur chaque image du site. Un endpoint de resize non signé, c'est un broyeur de CPU en libre-service.
Le reste est de la ceinture et des bretelles. max_image_size plafonne la sortie à 4 000 × 4 000 pixels, au cas où une URL signée mal calibrée traînerait quelque part. La route n'accepte que [a-f0-9]{64}\.[a-z0-9]+, ce qui coupe court à toute traversée de chemin. Une source disparue donne un 404 propre et une variante non encodable un 400, jamais un 500 public.
Détail d'implémentation qui a son importance : Glide v3+ n'embarque plus de response factory Symfony. Plutôt que de tirer league/glide-symfony, le controller appelle makeImage() puis sert le fichier caché via un BinaryFileResponse maison : streaming, ETag, 304, exactement comme pour le fichier original.
La carte OG : quatre lignes de texte et un point rose
Revenons à l'affiche. La chaîne tient en quatre classes, sous src/Service/Seo/OpenGraph/ :
OgImageContentMapperréduit n'importe quel contenu à quatre lignes de texte : eyebrow, titre, résumé, meta. Un billet a droit à la ligne complète (Pierre · 4 juillet 2026 · 6 min de lecture), une catégorie ou une page reste sobre : le type de contenu en eyebrow, et c'est tout.OgImageGeneratorest un renderer pur : quatre chaînes en entrée, un blob PNG en sortie. Moins de 200 lignes.OgImageProvidergère le cache disque et le hash de contenu.OgContentResolverrésout le{type}/{slug}de l'URL en entité publiée, pour que le controller ne touche jamais un repository (deptrac veille).
Le générateur reprend la direction artistique du site : mêmes couleurs (les tokens oklch du thème, convertis une fois en sRGB), même hiérarchie (fil d'Ariane, titre ultra-bold, chapô, meta), et le point rose, collé au dernier mot du titre à 4 pixels près. Seule entorse : le site s'affiche en fonts système Apple, inexistantes sur un serveur Linux. La carte est donc composée en Inter Black et Inter Regular, embarquées dans le repo sous licence OFL.
Le morceau le plus intéressant est l'auto-fit du titre :
foreach ([78, 70, 62, 54] as $candidate) {
$lines = $this->wrap($image, $this->draw($font, $candidate, self::COLOR_INK), $title, $maxWidth);
if (\count($lines) <= 3) {
break;
}
}On essaie corps 78, et on descend jusqu'à ce que le titre tienne en trois lignes maximum. Le wrap est un glouton classique, mot à mot, qui mesure chaque ligne candidate avec queryFontMetrics(). C'est exactement le genre de boucle qui rend GD douloureux et Imagick confortable.
Un cache qui survit à cache:clear
Générer l'affiche coûte un rendu Imagick. On ne le paie qu'une fois : le PNG est écrit dans var/storage/og-cache/, sous un nom qui embarque le type, le slug et un hash de contenu :
billets-mon-slug-3f9c1a2b4d5e6f70.pngLe hash est un SHA-256 (tronqué à 16 caractères) des quatre lignes rendues plus la date de modification. Éditez le titre : nouvelles lignes, nouveau hash, nouveau fichier, et surtout nouvelle URL, puisque SeoImageBuilder suffixe ?v={hash} partout où l'image est référencée. Le cache HTTP peut donc être agressif (max-age un jour, s-maxage sept jours, ETag) : une carte périmée n'est jamais resservie, elle est simplement abandonnée à son ancienne URL.
Deux choix discrets mais structurants :
var/storage/, pasvar/cache/. Uncache:clearau déploiement ne jette pas les affiches. Le dossier vit dans le volume persistant de prod, à côté des médias.- La génération est chaude, pas seulement paresseuse. Un listener Doctrine (
postPersist/postUpdate) regarde passer chaque sauvegarde de contenu publié et dispatche un message async. Le worker imprime l'affiche pendant que l'admin reprend sa relecture : au premier partage, elle attend déjà. Et si le message s'est perdu, le premier hit du crawler la génère à la demande. Deux chemins, même fichier.
Deux pièges, sinon ce n'est pas amusant
Le worker mode d'abord. FrankenPHP garde le process PHP vivant entre les requêtes. Un objet Imagick qui n'est pas libéré ne meurt donc jamais avec la requête : il s'empile. D'où le finally du générateur :
} finally {
// Worker mode (FrankenPHP) : libérer les handles entre les requêtes.
$image->clear();
$image->destroy();
}En PHP-FPM classique, personne n'écrit ça : le process meurt et emporte tout. En worker mode, l'oublier marche parfaitement en dev et fuit en prod.
Les coders fantômes ensuite. ImageMagick 7 charge ses encodeurs (PNG, JPEG, WebP…) comme des modules dlopen() au runtime, accompagnés de leur config XML. L'image Docker de prod étant un stage slim où les bibliothèques sont collectées par analyse des dépendances de liens, ces modules sont invisibles : ils ne sont liés nulle part. Résultat constaté sur #1110 : Imagick::queryFormats() renvoie une liste vide, setImageFormat('png') throw, et /og/{type}/{slug}.png répond 500. En prod. Uniquement en prod.
Le fix tient en deux temps : copier explicitement les modules et leur config XML dans le stage final, et surtout verrouiller le build avec un smoke test qui encode un PNG de 2 × 2 pixels :
RUN php -r 'try { $i = new Imagick(); $i->newImage(2, 2, new ImagickPixel("red")); … }' \
|| (echo "ERROR: Imagick ne peut pas encoder de PNG dans le stage slim" >&2 && exit 1)Le build casse à cet endroit précis, plutôt que de shipper une image qui répond 500 sur chaque carte.
Le mot de la fin
Ce qui me plaît dans cette chaîne, ce n'est pas la carte elle-même : c'est que le site se décrit tout seul. Les couleurs de l'affiche sont les tokens du thème, le titre vient de l'entité, le hash suit chaque édition. Aucun service tiers ne sait à quoi ressemble un billet avant nous.
Le réflexe dominant devant un problème de rendu serveur, en 2026, c'est d'embarquer un navigateur complet, d'appeler un SaaS ou d'utiliser des images générées par IA. Pour certains cas, c'est le bon choix, pour d'autres c'est rédhibitoire. Mais chaque fois qu'on le fait, une partie du site cesse d'être du code qu'on lit et devient un contrat qu'on subit.
Une coquille, une erreur dans ce billet ? Signale-la-moi.