Fonctionne avec PHP 8.0 ou version ultérieure. Le support AVIF requiert PHP 8.1+ et libgd compilé avec libavc.
Oui, on sait, ça existe déjà. Les petits chats et autre faune, c'est rigolo, mais c'est un peu consommateur de CO2.
Pour faire du remplissage en environnement de développement, est-ce bien utile ?
Donc, partage de classes PHP de génération d'images fictives pour le développement en local.
Elles permettent de générer des images en couleur ou à fond transparent, avec un texte reprenant les dimensions intrinsèques de l'image ou un texte personnalisé.
Note : en dessous de 100 pixels de large, le texte ne s'affiche pas.
En premier lieu, il faut créer un fichier .htaccess qui va gérer les règles de ré-écriture des url d'images.
Nous aurons besoin également de quelques autres fichiers :
Dummies.class.php— la classe principale, images colorées.DummiesGhost.class.php— extension deDummies, images à fond transparent.index.phpqui renverra les images colorées.ghost/index.phpqui renverra les images transparentes.- un fichier
.ttfpour la police de caractères. À vous de choisir le vôtre.
Le dossier de stockage des images est créé automatiquement à la première requête.
Arborescence serveur
placeholder.server ├── stock/ ← créé automatiquement ├── ghost/ │ ├── .htaccess │ └── index.php ├── .htaccess ├── Dummies.class.php ├── DummiesGhost.class.php ├── favicon.ico ← pour faire joli mais pas obligatoire ├── font-name.ttf ← à configurer dans Dummies └── index.php
Il suffit d'installer tout ça dans un même dossier sur un serveur local, mais ça fonctionne aussi en production.
Par exemple https://placeholder.kortic.com
.htaccess
# Pages d'erreur silencieuses — évite d'exposer la configuration serveur
ErrorDocument 403 " "
ErrorDocument 404 " "
# Encodage UTF-8 explicite pour les fichiers PHP
AddCharset UTF-8 .php
# Interdit la liste des fichiers si pas d'index — sécurité de base
Options -Indexes
# Masque la signature Apache (version, OS) dans les headers et pages d'erreur
ServerSignature Off
<IfModule mod_headers.c>
# Supprime le header Pragma (obsolète, remplacé par Cache-Control)
Header unset Pragma
# Keep-alive limité pour les assets statiques : 5s, 100 requêtes max par connexion
<FilesMatch "\.(ico|webp|avif)$">
Header append Connection "timeout=5, max=100"
</FilesMatch>
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine on
RewriteBase /
RewriteCond %{HTTP_HOST} !^localhost
# Force HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}/$1 [R=301,L]
# Exclut le sous-dossier ghost/. Pourquoi ? Parce que…
# Redirection vers images transparentes.
RewriteRule ^ghost/ - [L]
# Pattern : /[largeur[xhauteur]?[/hexa]?][.webp|.avif]?
# $1 → largeur (2 à 4 chiffres)
# $3 → hauteur (2 à 4 chiffres, optionnelle)
# $5 → couleur hex 6 caractères (optionnelle)
# UnsafeAllow3F : autorise le ? dans la cible de réécriture
# QSA : fusionne la query string existante (?t=...) avec les paramètres réécrits
RewriteRule ^(\d{2,4})(x(\d{2,4}))?(/([a-fA-F0-9]{6})?)?(?:\.webp|\.avif)?$ /?width=$1&height=$3&color=$5& [L,UnsafeAllow3F,QSA]
</IfModule>
Classes PHP
Au nombre de deux.
Dummies.class.php
Il n'y a pas grand chose à raconter là…
Au mieux, modifier quelques bricoles comme le nom du fichier .ttf, les dimensions minimale et maximale.
La classe génère elle-même le dossier de stockage et supprime les fichiers générés quand il y en a 1000 (configurable). Autant éviter de saturer son disque.
Les images sont servies en AVIF si le serveur et le navigateur le supportent, sinon c'est du WebP. Autant dire que si vous êtes sur un serveur mutualisé, c'est WebP d'office…
<?php
use Random\RandomException;
/**
* Classe Dummies — Générateur d'images placeholder pour le développement.
*
* Génère des images de remplacement colorées avec leurs dimensions en texte,
* sans dépendance externe hormis l'extension GD de PHP et une police .ttf.
*
* Les images sont mises en cache sur disque et servies en AVIF (si supporté)
* ou WebP. Un cache HTTP 304 évite les transferts inutiles.
*
* Utilisation typique via index.php :
* $dummies = new Dummies();
* $dummies->generateImage($_GET['color'] ?? null, $_GET['width'] ?? null, $_GET['height'] ?? null, $_GET['t'] ?? null);
*
* Les URLs sont réécrites par le .htaccess :
* /300 → image carrée 300×300, couleur aléatoire, dimensions affichées
* /300x200 → image 300×200, couleur aléatoire, dimensions affichées
* /300/ff8100 → image carrée 300×300, couleur orange, dimensions affichées
* /300x200/ff8100 → image 300×200, couleur orange, dimensions affichées
* /300x200?t=Hello → image 300×200, couleur aléatoire, texte "Hello"
* /300x200/ff8100?t=Hello → image 300×200, couleur orange, texte "Hello"
*
* Prérequis :
* - PHP 8.0+ (PHP 8.1+ pour le support AVIF)
* - Extension GD activée (php -m | grep gd)
* - libgd compilé avec libavc pour AVIF
* - Une police .ttf dans le même dossier que la classe
*/
/**
* Gestionnaire de log optionnel
*/
if (file_exists(__DIR__ . '/DummiesLogger.class.php')) {
require_once __DIR__ . '/DummiesLogger.class.php';
}
class Dummies
{
protected string $dummiesDirectory;
protected string $font;
protected int $stockLimit = 1000;
protected string $poweredBy = 'DUMMY-1968W296';
protected int $cacheMaxAge = 365 * 86400;
protected string $filenameSuffix = '';
protected int $clampMin = 32;
protected int $clampMax = 4000;
public function __construct()
{
$this->dummiesDirectory = __DIR__ . '/__stock';
$this->font = __DIR__ . '/font-impact.ttf';
}
/**
* Supprime le contenu du répertoire de stock.
*
* @param bool $remove Supprime également le répertoire racine si vrai.
* @return bool Faux si le répertoire n'existe pas, vrai sinon.
*/
public function cleanDirectory(bool $remove = false): bool
{
if (!is_dir($this->dummiesDirectory)) {
return false;
}
// RecursiveIteratorIterator avec CHILD_FIRST garantit que les fichiers
// sont supprimés avant leurs dossiers parents (rmdir exige un dossier vide)
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->dummiesDirectory, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
if ($remove) {
rmdir($this->dummiesDirectory);
}
return true;
}
/**
* Génère une couleur hexadécimale aléatoire (sans #).
*
* Utilise random_int() plutôt que rand() pour un générateur
* cryptographique sûr, sans biais modulo.
*
* @throws RandomException
* @return string Ex. "ff8100"
*/
public static function generateRandomColor(): string
{
return sprintf('%06x', random_int(0, 0xFFFFFF));
}
/**
* Convertit un code hexadécimal en tableau RGB.
*
* Accepte avec ou sans # en préfixe ("ff8100" ou "#ff8100").
* Retourne un tableau vide si la valeur est invalide.
*
* @param string $hex Code hexadécimal de la couleur.
* @return array{r: int, g: int, b: int}|array{}
*/
public static function hex2rgb(string $hex): array
{
$hex = ltrim($hex, '#');
if (strlen($hex) !== 6) {
return [];
}
return [
'r' => hexdec(substr($hex, 0, 2)),
'g' => hexdec(substr($hex, 2, 2)),
'b' => hexdec(substr($hex, 4, 2)),
];
}
/**
* Retourne une date GMT formatée pour les headers HTTP.
*
* @param int|null $timestamp Timestamp Unix. Utilise time() si null.
* @return string Ex. "Tue, 01 Jan 2026 00:00:00 GMT"
*/
public static function GMTDate(?int $timestamp = null): string
{
return gmdate('D, d M Y H:i:s \G\M\T', $timestamp ?? time());
}
/**
* Crée le dossier de stock si nécessaire et purge si > $stockLimit fichiers.
*/
protected function initStock(): void
{
if (!is_dir($this->dummiesDirectory)) {
mkdir($this->dummiesDirectory, 0777, true);
}
if (count(glob($this->dummiesDirectory . '/*')) > $this->stockLimit) {
$this->cleanDirectory();
}
}
/**
* Résout la couleur depuis le paramètre hex ou en génère une aléatoire.
*
* @throws RandomException
* @return array{r: int, g: int, b: int}
*/
protected function resolveColor(?string $color): array
{
return (!empty($color) && preg_match('/^[a-fA-F0-9]{6}$/', $color))
? self::hex2rgb($color)
: self::hex2rgb(self::generateRandomColor());
}
/**
* Résout les dimensions en pixels avec clamp.
*
* @return array{w: int, h: int}
*/
protected function resolveDimensions(?string $width, ?string $height): array
{
$w = filter_var($width ?? 300, FILTER_VALIDATE_INT, ['options' => ['min_range' => $this->clampMin, 'max_range' => $this->clampMax]]) ?: 300;
$h = filter_var($height ?? $w, FILTER_VALIDATE_INT, ['options' => ['min_range' => $this->clampMin, 'max_range' => $this->clampMax]]) ?: $w;
return ['w' => $w, 'h' => $h];
}
/**
* Détecte le format de sortie AVIF ou WebP selon le support serveur et client.
*
* @return array{avif: bool, ext: string}
*/
protected function resolveFormat(): array
{
$avif = function_exists('imageavif')
&& (imagetypes() & IMG_AVIF)
&& str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'image/avif');
return ['avif' => $avif, 'ext' => $avif ? 'avif' : 'webp'];
}
/**
* Peint le fond de l'image — coloré dans Dummies, transparent dans DummiesGhost.
*
* @param \GdImage $im Image GD.
* @param int $w Largeur.
* @param int $h Hauteur.
* @param int $r Composante rouge.
* @param int $g Composante verte.
* @param int $b Composante bleue.
*/
protected function renderBackground(\GdImage $im, int $w, int $h, int $r, int $g, int $b): void
{
imagefilledrectangle($im, 0, 0, $w, $h, imagecolorallocate($im, $r, $g, $b));
}
/**
* Retourne la couleur du texte selon le fond — noir ou blanc sur Dummies,
* couleur de l'image sur DummiesGhost.
*
* Luminosité perçue selon la formule W3C : le vert contribue plus que le rouge,
* qui contribue plus que le bleu.
* Résultat > 0.5 = fond clair → texte noir ; ≤ 0.5 = fond sombre → texte blanc.
*
* @return array{r: int, g: int, b: int}
*/
protected function resolveTextColor(int $r, int $g, int $b): array
{
$v = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255 > 0.5 ? 0 : 255;
return ['r' => $v, 'g' => $v, 'b' => $v];
}
/**
* Rend le texte centré sur l'image, réduit proportionnellement si nécessaire.
*
* @param \GdImage $im Image GD.
* @param int $w Largeur.
* @param int $h Hauteur.
* @param int $tr Composante rouge du texte.
* @param int $tg Composante verte du texte.
* @param int $tb Composante bleue du texte.
* @param string $label Texte à afficher.
*/
protected function renderText(\GdImage $im, int $w, int $h, int $tr, int $tg, int $tb, string $label): void
{
$fontsize = min($w / 10, $this->clampMin);
$box = imagettfbbox($fontsize, 0, $this->font, $label);
$textW = abs($box[4] - $box[0]);
$textH = abs($box[5] - $box[1]);
// Réduction proportionnelle si le texte dépasse 90% de la largeur
if ($textW > $w * 0.9) {
$fontsize *= ($w * 0.9) / $textW;
$box = imagettfbbox($fontsize, 0, $this->font, $label);
$textW = abs($box[4] - $box[0]);
$textH = abs($box[5] - $box[1]);
}
// Centrage : Y est la baseline en GD, pas le haut du texte
imagettftext($im, $fontsize, 0,
intval(($w - $textW) / 2),
intval(($h + $textH) / 2),
imagecolorallocate($im, $tr, $tg, $tb),
$this->font, $label
);
}
/**
* Sauvegarde l'image sur disque au format AVIF ou WebP.
* Qualité 80 en AVIF (≈ qualité 90 WebP en perception visuelle).
*/
protected function saveImage(\GdImage $im, string $filename, bool $avif): void
{
$avif ? imageavif($im, $filename, 80) : imagewebp($im, $filename, 90);
chmod($filename, 0644);
}
/**
* Envoie les headers HTTP, gère le cache 304.
*
* @param string $ext Format de l'image (avif ou webp).
* @param string $filename Chemin absolu du fichier généré.
* @param string $etag Hash SHA1 du fichier.
*/
protected function sendHeaders(string $ext, string $filename, string $etag): void
{
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag) {
header('HTTP/1.1 304 Not Modified');
exit;
}
header("ETag: W/\"{$etag}\"");
header('Access-Control-Allow-Origin: *');
header('Cache-Control: no-transform, max-age=' . $this->cacheMaxAge);
header('Content-Type: image/' . $ext);
header('Cross-Origin-Opener-Policy: same-origin-allow-popups');
header('Cross-Origin-Resource-Policy: cross-origin');
header('Date: ' . self::GMTDate());
header('Expires: ' . self::GMTDate(strtotime('+' . $this->cacheMaxAge . ' seconds')));
header('Last-Modified: ' . self::GMTDate(filemtime($filename)));
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Server: Default web server');
header('Strict-Transport-Security: max-age=' . $this->cacheMaxAge . '; includeSubDomains; preload');
header('Vary: Accept-Encoding');
header('X-Content-Type-Options: nosniff');
header('X-Powered-By: ' . $this->poweredBy);
header('X-Robots-Tag: noindex, noarchive, nocache, nosnippet, noimageindex');
}
/**
* Génère et envoie une image placeholder en AVIF ou WebP selon le support.
*
* @param string|null $color Couleur en hexadécimal sans # (ex. "ff8100"). Aléatoire si absent ou invalide.
* @param string|null $width Largeur en pixels ($this->clampMin–$this->clampMax). 300 par défaut.
* @param string|null $height Hauteur en pixels ($this->clampMin–$this->clampMax). Égale à $width par défaut (image carrée).
* @param string|null $text Texte à afficher. Dimensions affichées si absent.
* @throws RandomException
*/
public function generateImage(?string $color = null, ?string $width = null, ?string $height = null, ?string $text = null): void
{
/**
* Logs d'appels des images (non disponible au téléchargement)
*/
if (class_exists('DummiesLogger')) {
DummiesLogger::log();
}
$this->initStock();
['r' => $r, 'g' => $g, 'b' => $b] = $this->resolveColor($color);
['w' => $w, 'h' => $h] = $this->resolveDimensions($width, $height);
['avif' => $avif, 'ext' => $ext] = $this->resolveFormat();
// md5 du texte pour nommage safe ; 'sizes' si affichage des dimensions par défaut
$cacheText = $text !== null ? md5($text) : 'sizes';
$filename = "{$this->dummiesDirectory}/{$w}x{$h}-{$r}-{$g}-{$b}-{$cacheText}{$this->filenameSuffix}.{$ext}";
if (!file_exists($filename)) {
$im = imagecreatetruecolor($w, $h);
$this->renderBackground($im, $w, $h, $r, $g, $b);
if ($w >= 100) {
['r' => $tr, 'g' => $tg, 'b' => $tb] = $this->resolveTextColor($r, $g, $b);
$label = $text !== null ? urldecode($text) : $w . '×' . $h;
$this->renderText($im, $w, $h, $tr, $tg, $tb, $label);
}
$this->saveImage($im, $filename, $avif);
}
$this->sendHeaders($ext, $filename, sha1_file($filename));
readfile($filename);
}
}
DummiesGhost.class.php
Extension de Dummies pour générer des images à fond transparent. C'est le texte qui récupère la couleur passée en paramètre — l'image elle-même reste transparente et s'intègre à n'importe quel fond.
<?php
/**
* Classe DummiesGhost — Variante transparente de Dummies.
*
* Génère des images placeholder à fond transparent où seul le texte
* est coloré. S'intègre à n'importe quel fond sans tache de couleur.
*
* Partage le stock de Dummies.
* Les fichiers sont suffixés "-ghost" pour éviter les collisions de cache.
*
* Utilisation et URLs identiques à Dummies, via /ghost/ :
* /ghost/300 → 300×300, texte couleur aléatoire
* /ghost/300x200/ff8100 → 300×200, texte orange
* /ghost/300x200?t=Hello → 300×200, texte "Hello" couleur aléatoire
*
* Prérequis : identiques à Dummies.
*/
class DummiesGhost extends Dummies
{
protected string $filenameSuffix = '-ghost';
/**
* Fond transparent — peint avant tout dessin.
* imagealphablending à false pour écrire directement les valeurs alpha,
* imagesavealpha pour conserver le canal alpha à la sauvegarde.
*/
protected function renderBackground(\GdImage $im, int $w, int $h, int $r, int $g, int $b): void
{
imagealphablending($im, false);
imagesavealpha($im, true);
imagefilledrectangle($im, 0, 0, $w, $h, imagecolorallocatealpha($im, 0, 0, 0, 127));
// Réactivation du blending pour le rendu du texte TTF
imagealphablending($im, true);
}
/**
* Texte coloré avec la couleur de l'image — pas de calcul de luminance.
*
* @return array{r: int, g: int, b: int}
*/
protected function resolveTextColor(int $r, int $g, int $b): array
{
return ['r' => $r, 'g' => $g, 'b' => $b];
}
}
index.php racine
Pourquoi ne pas avoir tout mis dans un seul fichier ? Parce qu'ici on a d'autres utilisations…
<?php
require_once __DIR__ . '/Dummies.class.php';
$dummies = new Dummies();
$dummies->generateImage($_GET['color'] ?? null, $_GET['width'] ?? null, $_GET['height'] ?? null, $_GET['t'] ?? null);
index.php pour les images transparentes
Dans ghost/
<?php
require_once dirname(__DIR__) . "/Dummies.class.php";
require_once dirname(__DIR__) . "/DummiesGhost.class.php";
(new DummiesGhost())->generateImage($_GET["color"] ?? null, $_GET["width"] ?? null, $_GET["height"] ?? null, $_GET["t"] ?? null);
.htaccess dans ghost/
# Pages d'erreur silencieuses
ErrorDocument 403 " "
ErrorDocument 404 " "
Options -Indexes
ServerSignature Off
<IfModule mod_headers.c>
Header unset Pragma
<FilesMatch "\.(ico|webp|avif)$">
Header append Connection "timeout=5, max=100"
</FilesMatch>
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine on
RewriteBase /ghost/
# Même pattern que la racine, RewriteBase adapté au sous-dossier
RewriteRule ^(\d{2,4})(x(\d{2,4}))?(/([a-fA-F0-9]{6})?)?(?:\.webp|\.avif)?$ /ghost/?width=$1&height=$3&color=$5& [L,UnsafeAllow3F,QSA]
</IfModule>
Exemples
Là, il s'agit de code HTML simple.
<img src="https://placeholder.kortic.com" alt="" width="300" height="300" loading="lazy" decoding="async">
<img src="https://placeholder.kortic.com/480x300/ff8100" alt="" width="480" height="300" loading="lazy" decoding="async">
<img src="https://placeholder.kortic.com/1280x300.avif?t=AVIF%20Hello%20world%20!" alt="" width="1280" height="300" loading="lazy" decoding="async">
<img src="https://placeholder.kortic.com/1280x300.webp?t=WEBP%20Hello%20world%20!" alt="" width="1280" height="300" loading="lazy" decoding="async">
<img src="https://placeholder.kortic.com/ghost/1100x200/bbccdd?t=Avec%20alpha,%20texte%23bbccdd" alt="" width="1100" height="200" loading="lazy" decoding="async">
Ce qui va afficher quelque chose comme ça.
Appel avif explicite : si le serveur ou le navigateur ne prennent pas en charge ce format, une image webp est envoyée.
Appel webp explicite : si le serveur et le navigateur prennent en charge le format avif, une image avif est envoyée quand même.
Image à fond transparent avec texte coloré (bordure ajoutée pour visualiser les limites de l'image).
Les attributs width, height, loading et decoding ne sont pas obligatoires, mais si vous envisagez de faire un peu de qualité, n'hésitez surtout pas à les utiliser.
Les paramètres d'appel
Le pattern est le suivant : //serveur:port/[ghost/]?[largeur[xhauteur]?[/hexa]?[?t=texte]?[.webp|.avif]?]?
//serveur/
appelle une image carrée de 300×300 pixels, couleur aléatoire//serveur/480
appelle une image carrée de 480×480 pixels, couleur aléatoire//serveur/480x320
appelle une image de 480×320 pixels, couleur aléatoire//serveur/480/ff8100
appelle une image carrée de 480×480 pixels, couleur orange//serveur/480x320/000000
appelle une image de 480×320 pixels, couleur noire//serveur/480x320?t=Hello
appelle une image de 480×320 pixels, couleur aléatoire, texte "Hello"//serveur/480x320/ff8100?t=Hello
appelle une image de 480×320 pixels, couleur orange, texte "Hello"//serveur/ghost/480x320/bbccdd?t=Hello
appelle une image transparente de 480×320 pixels, texte couleur#bbccdd
Serveur direct
Vous ne voulez pas vous embêter à monter votre serveur local ?
On a ce qu'il vous faut directement ici. C'est cadeau.
Utilitaire PHP
Un petit helper à intégrer dans une classe utilitaire pour générer des uri toutes belles.
<?php
class Machin {
/**
* Génère une URL d'image placeholder vers le serveur Dummies.
*
* @param int $width Largeur en pixels. 300 par défaut.
* @param int|null $height Hauteur en pixels. Égale à $width si absent (image carrée).
* @param string|null $color Couleur de fond en hexadécimal sans # (ex. "ff8100"). Aléatoire si absent.
* @param string|null $text Texte à afficher sur l'image. Dimensions affichées si absent.
* @param bool $ghost Si vrai, génère une image à fond transparent via /ghost/.
* @return string URL complète de l'image placeholder.
*/
public static function dummy(int $width = 300, ?int $height = null, ?string $color = null, ?string $text = null, bool $ghost = false): string
{
$color ??= self::color();
$height ??= $width;
$base = (DUMMIES_SERVER . ($ghost ? '/ghost' : '') . "/{$width}x{$height}/{$color}.avif");
return $text !== null ? $base . '?t=' . rawurlencode($text) : $base;
}
/**
* Génère une couleur hexadécimale aléatoire (sans #).
*
* Note : rand() est utilisé ici à la place de random_int() — suffisant
* pour un usage purement visuel sans contrainte cryptographique.
*
* @return string Ex. "ff8100"
*/
public static function color(): string
{
return sprintf("%06x", rand(0, 16777215));
}
}
That's all Folks !