On shared hosting, system command execution functions are usually blocked. That means any image solution that relies on calling an external binary does not exist for you. We explained this restriction in full in the disable_functions post.

That leaves GD: the library built into PHP itself. This post is the pipeline we built with GD for this very site, and the three safeguards without which the work breaks silently.

First, see what you have#

PHP
var_dump(extension_loaded('gd'));
var_dump(function_exists('imagewebp'));
var_dump(function_exists('imagecreatefromavif'));
print_r(gd_info());

Note: the extension being present does not mean every format is supported. Some builds have WebP and no AVIF, or the reverse. Always check the specific function, not just the extension.

Guard one: memory, before opening the file#

This is the most important part of this post.

When GD opens a JPEG, it converts it to a pixel array. The memory needed depends on the number of pixels, not on the file size. A one-megabyte JPEG can be twelve megapixels and take tens of times its file size in memory when opened.

The result is the worst kind of failure: the process dies with no understandable message. The user clicks the button, the page goes white, and no error is logged anywhere.

The solution: estimate before opening, and refuse explicitly if it will not fit.

PHP
private static function assertCanOpen(string $path): void
{
    $info = @getimagesize($path);

    if ($info === false) {
        throw new RuntimeException('The image file is not readable.');
    }

    [$width, $height] = $info;
    $channels = $info['channels'] ?? 4;
    $bits = $info['bits'] ?? 8;

    // conservative estimate with a safety factor
    $needed = (int) ($width * $height * ($bits / 8) * $channels * 1.8);
    $limit = self::memoryLimitBytes();
    $free = $limit - memory_get_usage(true);

    if ($limit > 0 && $needed > $free) {
        throw new RuntimeException('The image dimensions are too large for the available memory.');
    }
}

The getimagesize function reads only the file header and takes almost no memory, so this check is safe.

Reading the memory limit has several cases you must all cover:

PHP
private static function memoryLimitBytes(): int
{
    $raw = trim((string) ini_get('memory_limit'));

    if ($raw === '' || $raw === '-1') {
        return 0; // no limit
    }

    $unit = strtolower(substr($raw, -1));
    $value = (int) $raw;

    return match ($unit) {
        'g' => $value * 1024 * 1024 * 1024,
        'm' => $value * 1024 * 1024,
        'k' => $value * 1024,
        default => $value,
    };
}

Guard two: EXIF rotation#

Phone photos are usually stored in landscape orientation, and the correct orientation is written in the EXIF metadata. The browser reads that metadata, GD does not.

The result: a photo that looks right in the phone gallery comes out rotated after processing.

PHP
private static function applyExifRotation($image, string $path)
{
    if (! function_exists('exif_read_data')) {
        return $image;
    }

    $exif = @exif_read_data($path);
    $orientation = (int) ($exif['Orientation'] ?? 1);

    return match ($orientation) {
        3 => imagerotate($image, 180, 0),
        6 => imagerotate($image, -90, 0),
        8 => imagerotate($image, 90, 0),
        default => $image,
    };
}

There is a positive side effect too: GD output does not carry metadata, so the GPS coordinates and device name stored in a phone photo are removed automatically. That is a good feature for your users' privacy.

Guard three: transparency#

If you resize a transparent PNG or WebP image with default settings, the transparent background turns black. Two lines are needed and they are easy to forget:

PHP
$canvas = imagecreatetruecolor($newWidth, $newHeight);

imagealphablending($canvas, false);
imagesavealpha($canvas, true);

$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefilledrectangle($canvas, 0, 0, $newWidth, $newHeight, $transparent);

imagecopyresampled(
    $canvas, $source,
    0, 0, 0, 0,
    $newWidth, $newHeight, $srcWidth, $srcHeight
);

Always use imagecopyresampled, not imagecopyresized; the second does not interpolate and the result is jagged.

Choose quality by image type#

We did not see this point in any tutorial, but it is vital for a technical blog.

Your images fall into two groups and their quality needs are not the same:

  • Real photos — soft gradients, no sharp edges. They tolerate lossy compression well.
  • Screenshots and graphics — small text, one-pixel lines, sharp edges. The same compression here blurs the letters and makes code inside the image unreadable.

If your blog is full of terminal screenshots — like this one — one quality for everything means either needlessly large files or crushed text. We set two separate quality levels and left the choice to the uploader:

PHP
$quality = $isScreenshot ? 93 : 82;
imagewebp($canvas, $destination, $quality);

Tune the numbers on your own real images; what matters is that you have two levels, not that they are exactly these numbers.

Multiple versions and srcset#

Create several widths for every image so that mobile gets a small file:

PHP
$widths = [400, 800, 1200, 1600];

foreach ($widths as $width) {
    if ($width >= $sourceWidth) {
        continue; // never upscale
    }

    // build and save the version
}

Do not upscale. Creating a 1600 version from an 800 source only adds size with no quality gain.

A trap we fell into#

The rule above has a side effect that we did not see for a while. Suppose the source is 784 pixels wide:

  • WebP versions: only 400 is built, because 800 is larger than the source.
  • The JPEG fallback: is built at the native width, that is 784.

The result? A modern browser that understands WebP gets the 400 image and an old browser gets the 784 image. The better the browser, the blurrier the picture.

The fix: when the source is narrower than the largest width in the list, add the native width to the WebP versions too.

On the HTML side#

HTML
<picture>
  <source type="image/webp" srcset="a-400.webp 400w, a-800.webp 800w" sizes="(max-width: 48rem) 100vw, 48rem">
  <img src="a.jpg" width="1200" height="800" alt="..." loading="lazy" decoding="async">
</picture>

Always write width and height. Without them the browser does not know the height and the page jumps while the image loads. This is one of the three core page experience metrics at Google, and fixing it is free.

Do not put loading="lazy" on the first image on the page; it is usually the largest visible element and lazy loading it makes speed worse.

AVIF input support#

If a user uploads an AVIF and you only know JPEG and PNG, they get a vague error. It is better to accept the input even if you do not produce AVIF output:

PHP
$image = match ($mime) {
    'image/jpeg' => imagecreatefromjpeg($path),
    'image/png' => imagecreatefrompng($path),
    'image/webp' => imagecreatefromwebp($path),
    'image/avif' => function_exists('imagecreatefromavif')
        ? imagecreatefromavif($path)
        : throw new RuntimeException('This server does not support AVIF input.'),
    default => throw new RuntimeException('Image format is not supported.'),
};

We deliberately do not produce AVIF output: on shared hosting the CPU is expensive and the gain over WebP is marginal.

Better than rejecting: downscale#

The first version of our pipeline rejected images over the limit. It was a bad user experience: the site admin uploaded an ordinary phone photo and got an error message.

The better version: if the dimensions are over the limit, shrink it automatically and carry on. Reject only when it does not fit in memory even after shrinking.

File validation: do not trust the extension#

Before any processing, check the incoming file seriously. The file extension and even the type declared by the browser can both be forged.

PHP
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($path);

$allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];

if (! in_array($mime, $allowed, true)) {
    throw new RuntimeException('File format is not allowed.');
}

And more important: build the final file name yourself, never use the name the user sent directly. A user's name can contain path characters or try a double extension to create an executable file.

The pattern we use: a slug from the image title, plus a short random hash for uniqueness, plus an extension that we choose based on the output format.

Also split the destination folder by year and month:

Code
storage/app/public/media/2026/08/sample-image-a1b2c3-1200.webp

The reason is file system performance: one folder with tens of thousands of files slows down every listing operation.

Browser-side checks: user experience, not security#

Server-side validation is mandatory. But if it is all you have, the user has to upload a ten-megabyte file completely to find out it is not accepted. On a slow connection that means minutes of waiting for an error message.

With a few lines of plain JavaScript you can check before sending:

JavaScript
input.addEventListener('change', () => {
  const file = input.files[0];
  if (!file) return;

  if (file.size > 8 * 1024 * 1024) {
    show('The file is larger than eight megabytes.');
    input.value = '';
    return;
  }

  const img = new Image();
  img.onload = () => {
    if (img.naturalWidth * img.naturalHeight > 40_000_000) {
      show('The image dimensions are too large.');
      input.value = '';
    }
    URL.revokeObjectURL(img.src);
  };
  img.src = URL.createObjectURL(file);
});

And add a real progress bar too. For a multi-megabyte upload on a slow connection, no feedback means the user thinks the site hung and clicks the button again.

These checks do not replace server validation. Anything that runs in the browser can be bypassed.

What not to go for#

There are a few things that are possible with GD but that you should not attempt on shared hosting:

  • Processing several large images at once in one request. Each image's memory is not freed unless you explicitly imagedestroy, and even then memory fragmentation can be a problem. Process images one by one.
  • Generating versions at request time. That means an image processing job on every visit. Build versions at upload time and then serve only the static file.
  • Working on very large images. Set a reasonable limit and shrink anything above it.

All three come down to one principle: on shared hosting, heavy work must be done once and its result saved — not repeated for every visit.

A tool you should definitely build#

Image versioning changes over time: you add a new width, you change the quality. A command that rebuilds all versions from the source without re-uploading is worth having:

Bash
php84 artisan media:reprocess --id=42
php84 artisan media:reprocess --all

Always keep the source file. Without the source, any change in the versioning policy means re-uploading everything.

Measure: actually see before and after#

Without measuring, every quality decision is a guess. After each settings change, look at the real size of the outputs:

Bash
find ~/app/storage/app/public/media -name '*.webp' -printf '%s %p\n' | sort -rn | head -20

It shows the twenty largest files. If one is much bigger than the others, it usually means an image was processed with unusual dimensions or the wrong quality.

And the total space used:

Bash
du -sh ~/app/storage/app/public/media

On shared hosting space is limited and multiple versions multiply it. If you build four WebP widths plus a fallback JPEG for every image, each upload is five files.

A conscious decision: we do not produce AVIF output#

AVIF gives better quality than WebP at equal size. So why do we not produce it?

Because its CPU cost on shared hosting is high and its extra gain over WebP is marginal. WebP is supported almost everywhere today and its size difference from AVIF for ordinary web images is not enough to justify the extra processing time and complexity.

But we do accept AVIF input, because phones and tools today increasingly output this format and rejecting it only confuses the user.

We recommend this decision pattern: be generous in what you accept, conservative in what you produce. Read every format you can, but only generate formats that justify their cost.

Summary#

  • Estimate the memory needed before opening an image. getimagesize is cheap.
  • Apply EXIF rotation yourself.
  • For transparency, do not forget imagealphablending and imagesavealpha.
  • Choose quality by image type, not one fixed number for all.
  • Do not upscale, but watch out for the native-width trap.
  • Write width and height in the HTML.