PicassoBundle : le composant image qui manquait à Symfony

Une ligne de Twig, et vos images sortent en AVIF, WebP et srcset responsive. Next.js n'a qu'à bien se tenir.

PicassoBundle : le composant image qui manquait à Symfony

Il y a quelques années, je vous parlais de la gestion des miniatures avec Glide. L'article tient toujours la route, mais le Web a bougé depuis. AVIF est arrivé. Les Core Web Vitals comptent pour le référencement, et le LCP vous tape sur les doigts dès qu'une image de 3 Mo traîne en haut de page. Côté React, Next.js a popularisé son composant <Image>, qui fait à peu près tout le boulot à votre place.

Pendant ce temps, côté Symfony, on écrit toujours ce genre de joyeuseté à la main quand on veut bien faire les choses :

<picture>
    <source
        type="image/avif"
        srcset="/images/hero-640.avif 640w, /images/hero-1080.avif 1080w, /images/hero-1920.avif 1920w"
        sizes="100vw"
    />
    <source
        type="image/webp"
        srcset="/images/hero-640.webp 640w, /images/hero-1080.webp 1080w, /images/hero-1920.webp 1920w"
        sizes="100vw"
    />
    <img
        src="/images/hero-1080.jpg"
        srcset="/images/hero-640.jpg 640w, /images/hero-1080.jpg 1080w, /images/hero-1920.jpg 1920w"
        sizes="100vw"
        width="1920"
        height="1080"
        loading="lazy"
        alt="Hero"
    />
</picture>

Et encore, là on a fait l'effort ! Dans la vraie vie de la vraie prod avec de vrais humains dessus, on ne fait (presque) jamais tout ça. Un <img> tout seul, pas de dimensions (bonjour le CLS), pas d'AVIF ni de WebP parce que "c'est déjà bien d'avoir un JPG". Et un Lighthouse à 60 qui vous rappelle gentiment que vous pourriez faire mieux. Le fameux "je le ferai plus tard", sauf que plus tard n'arrive jamais : il y a une feature à livrer la semaine prochaine.

Bref, à force de recopier ce bloc de projet en projet, j'ai fini par en faire un bundle. Open source, évidemment : je vous présente PicassoBundle.

En une ligne de Twig

Voilà ce que vous écrivez dans votre template :

<twig:Picasso:Image
    src="hero.jpg"
    width="1920"
    height="1080"
    sizes="100vw"
    alt="Hero"
/>

Et voilà ce que vous obtenez : le gros bloc HTML du début de l'article, sans en écrire une ligne. Avec en prime les bonnes valeurs de loading, les dimensions détectées depuis le fichier source si vous ne les passez pas, et des URLs signées pour éviter qu'un petit malin vous spamme de transformations en 9000x9000 pixels.

L'idée est piquée sans complexe à next/image côté React : un seul composant, et c'est lui qui se tape le boulot.

La magnifique démo de cet article : 👉 https://labs.silarhi.fr/picasso

🤓 « Encore un bundle image ?! »

Bien vu. Mais regardez ce qui existe : Glide transforme les images et s'arrête là. LiipImagineBundle est puissant, mais il ne génère pas le HTML à votre place. Et on a tous un bundle maison qui traîne dans un placard. Il manquait la brique du dessus : celle qui va de l'image source jusqu'au HTML final, en passant par les formats modernes et les placeholders.

En résumé, avant / après :

Sans PicassoBundle Avec PicassoBundle
Formats Les <source> AVIF/WebP/JPEG à la main Automatique depuis la config
Srcset responsive Fabriqué à la main par breakpoint Généré depuis l'attribut sizes
Placeholders flous DIY ou on zappe Intégrés (LQIP, BlurHash, ou maison)
Dimensions En dur ou oubliées Détectées depuis l'image
LCP loading et fetchpriority à la main Un seul prop priority
Sources d'images Filesystem uniquement Filesystem, Flysystem, Vich, URL
CDN À construire soi-même Imgix intégré, ou votre CDN maison

C'est parti

Rien d'exotique, c'est un bundle Symfony classique :

composer require silarhi/picasso-bundle

Il vous faut ensuite au moins un transformer, c'est-à-dire ce qui va générer les variantes de vos images. Deux sont fournis :

# Option A : Glide (transformation locale avec GD ou Imagick)
composer require league/glide

# Option B : Imgix (transformation par le CDN)
# Rien à installer, juste l'URL de base à configurer

Si vous hébergez tout vous-même, Glide est le choix le plus simple. Si vous avez déjà un compte Imgix, branchez-le et profitez de leur cache. Cloudinary, ImageKit ou votre CDN maison ? Rien ne vous en empêche, il suffit d'écrire votre propre transformer.

La configuration minimale, avec un loader qui lit les images dans public/uploads et Glide qui signe les URLs :

# config/packages/picasso.yaml
picasso:
    loaders:
        filesystem:
            path: '%kernel.project_dir%/public/uploads'
    transformers:
        glide:
            sign_key: '%env(PICASSO_SIGN_KEY)%'

Avec un seul loader et un seul transformer, pas besoin de default_loader ni de default_transformer : le bundle les devine tout seul.

Pour que Glide serve les images transformées, il faut aussi importer ses routes :

# config/routes/picasso.yaml
picasso:
    resource: '@PicassoBundle/config/routes.php'

Le plus dur est fait ! Le premier exemple vu plus haut génère maintenant :

  • un <picture> avec une source AVIF et une source WebP
  • un <img> de secours en JPEG, avec son srcset calculé depuis les breakpoints
  • loading="lazy", sauf si vous lui passez priority
  • les dimensions, détectées depuis le fichier si vous ne les fournissez pas

Le tout sans écrire une ligne de HTML. TOP.

Les détails qui m'ont donné du fil à retordre

Sur ce genre de composant, l'essentiel du temps part dans les détails. En voici quelques-uns.

La détection des dimensions

Les attributs width et height d'un <img> ne servent pas à dimensionner l'image (ça, c'est le rôle du CSS). Ils disent au navigateur quelle forme a l'image avant qu'elle soit chargée, pour qu'il lui réserve la bonne place. Sans eux, la page "saute" quand l'image arrive : c'est le CLS (Cumulative Layout Shift), l'un des trois Core Web Vitals.

Le problème, c'est que personne ne renseigne les dimensions à la main sur chaque image. Et le ticket "corriger le CLS" traîne dans le backlog depuis des mois.

PicassoBundle peut lire les dimensions directement depuis l'image, avec l'option resolve_metadata. Elle est activée par défaut pour le loader filesystem (lire un fichier local ne coûte rien), mais pas pour les loaders distants : inutile de taper le réseau à chaque génération de page. Vous pouvez la forcer au cas par cas, ou fournir les dimensions en dur si vous les connaissez déjà :

{# Hauteur calculée depuis le ratio de l'image source #}
<twig:Picasso:Image src="photo.jpg" width="800" sizes="100vw" alt="Photo" />

{# Dimensions source fournies en dur, l'image n'est pas lue #}
<twig:Picasso:Image
    src="photo.jpg"
    :sourceWidth="4000"
    :sourceHeight="3000"
    width="800"
    height="600"
    alt="Photo"
/>

Petite subtilité : width et height ne sont rendus que si les deux sont connus. Si vous n'en fournissez qu'un et que le bundle ne trouve pas l'autre, aucun n'est posé sur le <img>. Une mauvaise dimension, c'est un CLS garanti. Autant ne rien mettre.

Les images prioritaires et le LCP

Pour une image en haut de page, typiquement une bannière, il faut dire au navigateur "celle-là, charge-la tout de suite". Sinon elle attend gentiment son tour derrière les vingt autres images de la page, et votre LCP (Largest Contentful Paint) fait pleurer Google.

Ici, ça tient en un prop :

<twig:Picasso:Image
    src="hero-banner.jpg"
    width="1920"
    height="1080"
    sizes="100vw"
    :priority="true"
    alt="Hero banner"
/>

priority pose loading="eager" et fetchpriority="high", et désactive le placeholder sur cette image. Une image prioritaire doit s'afficher le plus vite possible : afficher une version floue pendant une demi-seconde, c'est exactement l'inverse de ce qu'on veut.

Les placeholders

Les placeholders, c'est le chargement progressif des connexions 56k, version 2026. Pendant que l'image se charge, on affiche une version minuscule et floutée qui donne une idée de ce qui arrive. Medium et Unsplash le font, et la page paraît plus rapide même quand les Core Web Vitals ne bougent pas.

Ils ne sont pas activés par défaut. Le plus simple est le placeholder "transformer" (aussi appelé LQIP, pour Low Quality Image Placeholder) : une version en 10x10 pixels de l'image, floutée, générée par votre transformer. Aucune dépendance en plus :

# config/packages/picasso.yaml
picasso:
    default_placeholder: blur
    placeholders:
        blur:
            type: transformer
            size: 10
            blur: 5
            quality: 30

Si vous préférez un rendu en dégradé, il y a aussi un placeholder BlurHash (il demande kornrunner/blurhash et imagine/imagine). Et pour ThumbHash, une couleur dominante ou n'importe quoi qui génère une data URI, vous écrivez le vôtre avec l'attribut #[AsPlaceholder].

Dans tous les cas, le placeholder est inliné en background-image sur le <img> et retiré une fois l'image chargée. Il ne joue pas sur le CLS (ça, c'est le rôle de width et height) : il occupe juste la place en attendant la vraie image.

Des loaders pour la vraie vie

Un bundle qui ne lit que dans public/uploads, ça ne tient pas longtemps sur un vrai projet. Les loaders fournis :

  • Filesystem, le classique, un dossier local par loader
  • Flysystem, donc S3, GCS, Azure, FTP et tout ce qui ressemble à du stockage objet
  • VichUploaderBundle, parce que dès qu'un projet Symfony a des uploads, c'est quasi toujours Vich derrière
  • URL, pour des images hébergées ailleurs dont vous voulez quand même un srcset

Avec Vich, le loader porte le nom du mapping, et vous lui passez l'entité :

# config/packages/picasso.yaml
picasso:
    loaders:
        product_image: { type: vich }
<twig:Picasso:Image
    loader="product_image"
    :context="{ entity: product }"
    width="400"
    height="300"
    alt="Photo produit"
/>

Le bundle retrouve tout seul le fichier depuis le mapping. Et si votre source d'images est trop exotique pour tout ça, vous écrivez votre loader avec l'attribut #[AsImageLoader].

Et pour les frontends headless ?

Si votre Symfony sert une API JSON à du React, du Vue ou une app mobile, ImageHelperInterface::imageData() renvoie tout ce que génère le composant : URLs par format, srcset, placeholder, dimensions. Le front n'a plus qu'à reconstruire son <picture>. Et pour une simple URL (balise Open Graph, background CSS, email), il y a la fonction Twig picasso_image_url(). Les détails sont dans le README.

La purge du cache

Votre client change sa photo de profil (ça arrive toujours) : il faut invalider toutes les variantes générées depuis l'ancienne. Les deux transformers fournis savent le faire. Glide nettoie son cache local, Imgix appelle son API de purge (avec une clé d'API dans la config). Côté code :

// src/Service/ImageManager.php
use Silarhi\PicassoBundle\Service\ImagePipeline;

class ImageManager
{
    public function __construct(private ImagePipeline $pipeline) {}

    public function deleteImage(string $path): void
    {
        $this->pipeline->purge($path);
    }
}

Ça a l'air anecdotique. Jusqu'au jour où votre client vous appelle parce que sa nouvelle photo ne s'affiche pas en prod. Cache invalidation, toujours elle : Phil Karlton vous avait prévenu.

Ça donne quoi au final ?

La démo de cet article : 👉 https://labs.silarhi.fr/picasso

Vous y trouverez une grille responsive avec sizes, les différents modes de fit sur la même image, une bannière en priority, et quelques réglages par image (qualité, placeholder désactivé…). Le tout servi par Glide avec des URLs signées. Le code est sur silarhi/symfony-docker-ci, le projet qui me sert depuis des années pour les démos de ce blog.

La page historique qui utilise Glide directement est toujours là : https://labs.silarhi.fr/images. Comparez la quantité de Twig nécessaire entre les deux (spoiler : ce n'est pas vraiment la même).

Pour les curieux, sous le capot :

  • PHP 8.2+, Symfony 6.4, 7.x et 8.x
  • Loaders, transformers et placeholders déclarés avec des attributs PHP (#[AsImageLoader], #[AsImageTransformer], #[AsPlaceholder]) : zéro XML, zéro YAML de service, tout est autowiré
  • Un cache PSR-6 pour les dimensions détectées et les BlurHash
  • Compatible FrankenPHP en mode worker

Voilà ! Vos images n'ont plus d'excuse pour plomber votre LCP. N'hésitez pas à laisser un commentaire si vous l'utilisez, ou si quelque chose coince. Et si le bundle vous fait gagner du temps, une petite ⭐ sur GitHub fait toujours plaisir.

Pour aller plus loin