Templates & Frontend
Generate and Cache Derived Images in a Joomla Template
Occasionally a listing needs a thumbnail that CSS cannot produce: a fixed crop with a shaped overlay burned into it, generated from whatever the editor uploaded. That is a job for the image library — and the important part is not the…
Check whether you need this at all
Most "we need to process the image" requirements turn out to be CSS. A fixed crop is aspect-ratio plus object-fit: cover. A tint or gradient is a pseudo-element. A rounded or shaped mask is border-radius, clip-path or mask-image, all of which are ordinary CSS now.
Generate files on the server when the result has to exist as a file — a share image, a PDF, an email — or when the transformation genuinely cannot be expressed in the browser.
Generate once, serve forever
The pattern that matters:
use Joomla\CMS\Filesystem\Folder;
function derivedImage(string $source, int $width, int $height): ?string
{
$cacheDir = JPATH_ROOT . '/images/derived';
$key = substr(sha1($source . $width . $height . filemtime(JPATH_ROOT . '/' . $source)), 0, 16);
$relative = 'images/derived/' . $key . '.webp';
if (is_file(JPATH_ROOT . '/' . $relative)) {
return $relative;
}
if (!is_dir($cacheDir)) {
Folder::create($cacheDir);
}
// ... generate, then write to JPATH_ROOT . '/' . $relative
return $relative;
}
Two details make this work in practice. The cache key includes the source file's modification time, so replacing the original produces a new derivative instead of serving a stale one forever. And the check comes first — without it every page view regenerates every thumbnail, and on a catalogue of any size that is the whole of your CPU budget.
The generation itself
$src = imagecreatefromjpeg(JPATH_ROOT . '/' . $source);
$dst = imagecreatetruecolor($width, $height);
imagecopyresampled(
$dst, $src,
0, 0, 0, 0,
$width, $height,
imagesx($src), imagesy($src)
);
imagewebp($dst, JPATH_ROOT . '/' . $relative, 82);
imagedestroy($src);
imagedestroy($dst);
Use imagecopyresampled, not imagecopyresized — the resampled version interpolates and the resized one does not, which is the difference between a clean thumbnail and a jagged one.
For an overlay, load the mask as PNG and composite it with imagecopy, keeping alpha:
imagealphablending($dst, true);
imagesavealpha($dst, true);
$mask = imagecreatefrompng(JPATH_ROOT . '/templates/yourtemplate/images/mask.png');
imagecopy($dst, $mask, 0, 0, 0, 0, imagesx($mask), imagesy($mask));
Avoid the older trick of compositing a flat colour and then declaring that colour transparent with imagecolortransparent(). It removes every matching pixel, including pixels in the photograph itself — a black mask takes the shadows with it. A PNG mask with a real alpha channel does not have that problem.
Do not trust the path
The source path usually comes from a custom field or an article's image data, which means it came from a form. Two guards before touching the filesystem:
use Joomla\Filesystem\Path;
$full = Path::check(JPATH_ROOT . '/' . $source);
if (!str_starts_with($full, JPATH_ROOT . '/images/')) {
return null;
}
if (!in_array(strtolower(pathinfo($full, PATHINFO_EXTENSION)), ['jpg', 'jpeg', 'png', 'webp'], true)) {
return null;
}
Without the prefix check, a path containing ../ reaches outside the images directory. Without the extension check, you hand arbitrary files to an image decoder.
Where to put the code
Not in a layout. Layouts are re-rendered per item and per view, and image generation in one is how the same file ends up being produced by three different templates.
Put it in a helper the template calls, or better, in a content plugin that runs once per article and stores the result. Then the layout just prints a path.
If GD is not available
Check before relying on it — some shared hosts still ship without it, and the failure mode is a blank image rather than an error:
if (!extension_loaded('gd')) { /* fall back to the original file */ }
Imagick, where available, produces better resampling and handles more formats. The structure above does not change; only the drawing calls do.