Me

Laravel Glide Helper : optimisation d'images à la volée dans Blade

:: Aucun commentaire

laravel-glide-helper est un petit package Composer qui ajoute une seule fonction à une application Laravel : glide(). Vous lui donnez une image et la taille voulue, elle renvoie l'URL d'une copie redimensionnée et compressée en WebP — ou dans le format de votre choix —, générée à la première requête puis livrée comme un simple fichier à toutes les suivantes. Elle traite exactement de la même façon une image versionnée avec le thème et une image qu'un client a envoyée depuis le back-office il y a une heure.

L'histoire derrière le package

Je l'ai écrit quand j'étais CTO d'une société de développement qui livre des projets clients à un rythme mensuel. Une partie du poste consistait à réduire en continu le temps de développement des projets.

Un développeur terminait une page — l'intégration faite, les tests au vert — puis passait le reste de l'après-midi sur ses images. Les photos de banques d'images arrivent en cinq mille pixels de large. Celles des clients arrivent telles que le téléphone ou l'appareil photo les a produites. Il fallait ouvrir chacune, la recadrer, la redimensionner aux deux ou trois largeurs de la maquette, l'exporter, la renommer et la committer.

Sur une équipe de quatre à six développeurs et un nouveau projet chaque mois, cela représente des jours de temps senior par an passés à faire à la main ce qu'une machine fait automatiquement.

Les images que personne dans l'équipe ne voit

Le second problème commence après la livraison. Presque tout ce que nous livrions comportait un back-office. Le client y envoie donc une photo d'actualité sortie de l'appareil ou une photo produit tirée du catalogue d'un fournisseur, et la page pèse désormais plusieurs mégaoctets de plus que celle que nous avions livrée.

Rien ne casse, et c'est ce qui coûte cher. La page a toujours l'air correcte. Elle charge simplement plus lentement, surtout sur téléphone : la plus grande image en haut d'une page est généralement son élément Largest Contentful Paint, et c'est la Core Web Vital que Google retient comme vitesse de chargement de cette page. Un site livré avec un bon score de performance se dégrade envoi après envoi, et le référencement que le client paie se dégrade avec lui.

Un client ne va pas apprendre à exporter du WebP à la bonne largeur, et il ne devrait pas avoir à le faire. La correction devait se trouver dans le code, appliquée à chaque image sur son chemin vers la page, quelle que soit sa provenance.

Ce que Statamic faisait déjà bien

J'utilisais déjà la solution de Statamic, qui fournit une balise Glide : le template désigne une image et la taille à laquelle elle sera affichée, Statamic génère cette version à la première demande puis renvoie le fichier stocké les fois suivantes.

<img src="{{ glide:hero_image width='1200' height='600' fit='crop_focal' format='webp' }}"
     width="1200" height="600" alt="{{ hero_image:alt }}">
Antlers

La décision qui compte, c'est l'endroit où vit la taille. Elle est écrite dans le template, à côté du balisage qui affiche l'image — le seul endroit qui sait à quelle taille l'image sera réellement affichée. Le fichier d'origine n'est jamais modifié, personne ne redimensionne quoi que ce soit à la main, et peu importe que le fichier vienne du commit d'un développeur ou du formulaire d'envoi d'un client.

Blade n'avait rien d'équivalent. spatie/laravel-glide encapsule bien la bibliothèque Glide de The PHP League, mais il vous donne un objet image à manipuler puis enregistrer, pas une URL à placer dans un attribut src. Le helper a donc commencé comme une simple fonction dans le fichier de helpers d'un projet client, et une fois qu'il y a fait ses preuves, je l'ai extrait en package et ajouté au starter kit de la société, pour que chaque nouveau projet l'ait dès son premier commit.

Installer laravel-glide-helper

Le package nécessite PHP 8.1 ou plus et Laravel 10 ou plus, et installe spatie/laravel-glide avec lui.

composer require mehdismekouar/laravel-glide-helper

# Facultatif : publier config/glide-helper.php pour modifier les valeurs par défaut
php artisan vendor:publish --tag="glide-helper-config"

# Obligatoire une fois par environnement, si le lien n'existe pas déjà
php artisan storage:link
Shell

Utiliser glide() dans les templates Blade

La signature est glide(string $src, array $params = []): string. Les paramètres reprennent les noms courts de Glide — w et h pour la taille, fit pour la façon dont l'image remplit ce cadre, fm pour le format, q pour la qualité —, donc tout ce que liste la documentation de Glide fonctionne ici tel quel.

Les images livrées avec le thème

Pour une image qui vit dans le dépôt, passez son URL publique : Vite::asset() pour les images du dossier /resources, asset() pour celles du dossier /public. Cela fonctionne de la même façon dans un style en ligne :

<!-- Avec Vite::asset() -->
<img src="{{ glide(Vite::asset('resources/images/home/desert-sunset.jpg'), ['w' => 800]) }}"
     width="800" height="533" alt="Coucher de soleil sur les dunes" loading="lazy">

<!-- Avec asset() -->
<section style="background-image: url('{{ glide(asset('images/hero-bg.jpg'), ['w' => 1600, 'q' => 75]) }}')">
    ...
</section>
Blade

L'original reste dans le dépôt en pleine résolution, et c'est ce que vous voulez : le jour où la maquette demande une version plus grande, vous changez un nombre au lieu de repartir à la recherche de la photo source. Pendant npm run dev, Vite::asset() pointe vers le serveur de développement, que le helper traite comme une URL externe et renvoie telle quelle : vous voyez donc l'original en local et la copie optimisée sur le build.

Les images qu'un client envoie depuis le back-office

C'est le cas le plus intéressant, et celui pour lequel le package a été écrit. Quel que soit ce qui stocke l'envoi — un simple champ fichier, Filament, la Media Library de Spatie —, on finit avec un chemin sur le disque public ou une URL sous /storage, et glide() résout les deux :

{{-- Un chemin enregistré par $request->file('cover')->store('posts', 'public') --}}
<img src="{{ glide($post->cover, ['w' => 1200, 'h' => 630, 'fit' => 'crop']) }}"
     width="1200" height="630" alt="{{ $post->title }}">

{{-- Une URL issue de Spatie Media Library --}}
<img src="{{ glide($circuit->getFirstMediaUrl('featured-image'), ['w' => 500]) }}"
     width="500" alt="{{ $circuit->name }}" loading="lazy">
Blade

Le client peut envoyer une photo de huit mégaoctets tout droit sortie de son téléphone. La page reçoit un WebP optimisé — ou le format que vous avez choisi — à la largeur prévue par la mise en page, et personne dans l'équipe n'a à intervenir.

Des images responsives avec srcset

Chaque appel produit une seule taille : un srcset n'est donc que le même appel à plusieurs largeurs, et le navigateur choisit la plus petite qui remplit l'emplacement :

@php
    $srcset = collect([480, 800, 1200, 1600])
        ->map(fn ($width) => glide($product->image, ['w' => $width]).' '.$width.'w')
        ->implode(', ');
@endphp

<img src="{{ glide($product->image, ['w' => 800]) }}"
     srcset="{{ $srcset }}"
     sizes="(min-width: 1024px) 50vw, 100vw"
     alt="{{ $product->name }}" loading="lazy">
Blade

Pas de loading="lazy" sur l'image en haut de page. C'est généralement elle l'élément Largest Contentful Paint, et elle demande plutôt loading="eager" et fetchpriority="high" — une image hero en lazy est la façon la plus courante de perdre tout ce que le redimensionnement vient de faire gagner.

Comment le helper fonctionne en interne

Tout le package tient en une fonction d'une quarantaine de lignes, et il vaut la peine de savoir ce qu'elle fait à chaque appel :

  • Elle fusionne vos paramètres avec les valeurs par défaut de config/glide-helper.php.

  • Une URL sur un autre domaine est renvoyée intacte. Cela couvre un CDN, un bucket S3 et le serveur de développement Vite.

  • Sinon, elle cherche d'abord le fichier sur le disque.

  • Si elle ne trouve rien, elle renvoie ce que vous lui avez passé. Un fichier manquant s'affiche comme l'image cassée qu'il était déjà, au lieu d'une exception qui fait tomber la page.

  • Elle nomme le résultat d'après un MD5 du chemin du fichier, de sa date de modification et des paramètres, et ne génère l'image que si ce fichier n'existe pas encore.

  • Elle renvoie l'URL publique du fichier généré.

$hash = md5($sourcePath.filemtime($sourcePath).json_encode($params));
$hashedName = "{$hash}.{$extension}";

// ...

if (! file_exists($outputPath)) {
    GlideImage::create($sourcePath)
        ->modify($params)
        ->save($outputPath);
}

return Storage::disk('public')->url($outputRelativePath);
src/helpers.php

La date de modification dans ce hash est le détail que je garderais si je réécrivais tout le reste. Un client qui remplace une photo par un nouveau fichier du même nom obtient une nouvelle version à la requête suivante, sans cache à vider.

Après la première requête, l'image est un fichier statique. Le serveur web la livre directement depuis le disque, et à chaque rendu PHP se contente de calculer un hash et de vérifier qu'un fichier existe.

Régler les valeurs par défaut une fois pour toutes

Ce sont les valeurs par défaut qui font qu'un simple glide($src, ['w' => 800]) produit un WebP en qualité 90. Modifiez-les dans la configuration publiée plutôt qu'appel par appel.

return [
    'defaults' => [
        'q' => 90,      // qualité, 1-100
        'fm' => 'webp', // format de sortie
        'fit' => 'max', // tenir dans le cadre, sans jamais agrandir
    ],
    'output_dir' => 'manipulated', // sur le disque public
];
config/glide-helper.php

Pour les grandes images de fond, dont personne n'examine le détail, un 'q' => 75 par appel est généralement indiscernable à l'écran et nettement plus léger.

Ce que ça change pour les développeurs et les équipes

Un développeur dépose dans le dépôt le fichier fourni par le designer, écrit la taille dont la mise en page a besoin, et passe à la suite ; presque plus personne n'ouvre un éditeur d'images pour un projet web. La revue de code s'est simplifiée aussi : un appel à glide() indique la taille et le format dans le template, là où un relecteur les lit.

Le gain le plus important est celui que personne ne voit. Une page garde le poids qu'elle avait à la mise en ligne, quel que soit le nombre de photos que le client envoie ensuite, parce que ce n'est jamais son fichier d'origine qui est livré au visiteur, mais sa version optimisée.

Les limites à connaître

  • La première requête pour chaque taille paie sa génération. Un original très lourd peut ajouter un délai perceptible à ce chargement-là ; toutes les requêtes suivantes reçoivent un fichier statique.

  • Les anciennes versions ne sont jamais supprimées. Remplacer un fichier produit un nouveau hash et laisse le résultat précédent dans storage/app/public/manipulated (ou dans l'output_dir que vous avez défini) : sur un site qui reçoit beaucoup d'envois, ce dossier demande un nettoyage de temps en temps. Le vider est sans risque, tout se régénère à la demande.

  • Seuls les fichiers locaux sont traités. Les images sur S3 ou sur tout autre disque distant passent sans modification.

Où le trouver

Le package est sur GitHub et Packagist, sous licence MIT.

Si vous préparez un projet Laravel et voulez que la performance et le SEO fassent partie du développement, c'est exactement ce que je propose.

Qu’en pensez-vous ?
Aucun fichier choisi