Live This site runs Joomla 6.1.2
JoomClub

News, security and craft for the Joomla ecosystem

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.