Skip to content

Image Upload and Resize in PHP

Resizing an uploaded image is four lines with GD and about forty once you handle transparency, phone photos that arrive sideways, and files crafted to exhaust your memory. Here is the version that survives real users.

Validate that it is actually an image

Before decoding anything, confirm the file is what it claims. The reported MIME type comes from the browser and means nothing:

$info = @getimagesize($file['tmp_name']);
if ($info === false) {
    throw new RuntimeException('Not a valid image.');
}

[$width, $height, $type] = $info;

$supported = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_WEBP];
if (!in_array($type, $supported, true)) {
    throw new RuntimeException('Unsupported image type.');
}

getimagesize() reads the header only, so it is cheap — and crucially it gives you the dimensions before you allocate memory for the pixels.

Cap the dimensions, not just the file size

A "decompression bomb" is a small file describing an enormous image. A 5 MB PNG can decode to 40,000 × 40,000 pixels, and GD needs roughly width × height × 4 bytes — 6.4 GB — which takes the process down. File size alone will not protect you:

const MAX_PIXELS = 50_000_000;      // 50 MP, generous for real photos

if ($width * $height > MAX_PIXELS) {
    throw new RuntimeException('Image dimensions are too large.');
}

This check is the difference between a resize endpoint and a denial-of-service endpoint.

The resize itself

Use imagecopyresampled(), not imagecopyresized() — the former interpolates and the latter does not, which is why so many PHP thumbnails look jagged.

function resize_to(string $src, string $dest, int $maxW, int $maxH, int $quality = 82): void
{
    $info = getimagesize($src);
    if ($info === false) { throw new RuntimeException('Not an image.'); }
    [$w, $h, $type] = $info;

    // load according to the detected type
    switch ($type) {
        case IMAGETYPE_JPEG: $img = imagecreatefromjpeg($src); break;
        case IMAGETYPE_PNG:  $img = imagecreatefrompng($src);  break;
        case IMAGETYPE_GIF:  $img = imagecreatefromgif($src);  break;
        case IMAGETYPE_WEBP: $img = imagecreatefromwebp($src); break;
        default: throw new RuntimeException('Unsupported type.');
    }
    if ($img === false) { throw new RuntimeException('Could not decode image.'); }

    // scale to fit inside the box, never enlarging
    $ratio = min($maxW / $w, $maxH / $h, 1);
    $newW  = max(1, (int) round($w * $ratio));
    $newH  = max(1, (int) round($h * $ratio));

    $out = imagecreatetruecolor($newW, $newH);

    // preserve transparency for PNG and GIF
    if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_GIF) {
        imagealphablending($out, false);
        imagesavealpha($out, true);
        $transparent = imagecolorallocatealpha($out, 0, 0, 0, 127);
        imagefilledrectangle($out, 0, 0, $newW, $newH, $transparent);
    }

    imagecopyresampled($out, $img, 0, 0, 0, 0, $newW, $newH, $w, $h);

    switch ($type) {
        case IMAGETYPE_JPEG: imagejpeg($out, $dest, $quality); break;
        case IMAGETYPE_PNG:  imagepng($out, $dest, 6); break;
        case IMAGETYPE_GIF:  imagegif($out, $dest); break;
        case IMAGETYPE_WEBP: imagewebp($out, $dest, $quality); break;
    }

    imagedestroy($img);
    imagedestroy($out);
}

Three details that are easy to miss:

Phone photos arrive rotated

Cameras usually store the image in sensor orientation plus an EXIF tag saying how to rotate it. GD ignores that tag, so portrait photos come out sideways after a resize. Apply it yourself before resizing:

function apply_exif_orientation($img, string $path)
{
    if (!function_exists('exif_read_data')) { return $img; }

    $exif = @exif_read_data($path);
    if (!isset($exif['Orientation'])) { return $img; }

    switch ((int) $exif['Orientation']) {
        case 3: return imagerotate($img, 180, 0);
        case 6: return imagerotate($img, -90, 0);
        case 8: return imagerotate($img, 90, 0);
    }
    return $img;
}

Only JPEG (and some TIFF) carry EXIF, and exif_read_data() emits a warning on files without it — hence the @. Orientations 2, 4, 5 and 7 are mirrored variants, rare enough that most sites ignore them; add imageflip() if you need full coverage.

Re-encoding also strips EXIF entirely, which is usually what you want: phone photos routinely carry GPS coordinates, and publishing those is a privacy leak.

Generating several sizes

$sizes = ['thumb' => [200, 200], 'medium' => [800, 800], 'large' => [1600, 1600]];
$base  = bin2hex(random_bytes(16));

foreach ($sizes as $name => [$w, $h]) {
    resize_to($tmpPath, "{$dir}/{$base}-{$name}.jpg", $w, $h);
}

Decode once and resize repeatedly if throughput matters — the decode is the expensive part. For a busy site, do this in a queue worker rather than during the upload request, so the user is not waiting on image processing.

Re-encoding is also a security measure

A file can be a valid JPEG and contain PHP code appended after the image data. Decoding the pixels and writing a fresh file discards everything that is not image content — appended payloads, EXIF comments, trailing archives. The output is entirely PHP's own bytes.

That is a strong defence, but not the only one you need: store outside the web root, generate the filename, and never let the upload directory execute code. The security checklist covers the rest.

Do not accept SVG as an "image" on this path. It is XML rather than pixels, GD will not decode it, and served inline it can execute script.

GD or Imagick?

GDImagick
AvailabilityBundled with almost every PHP buildPECL extension, needs ImageMagick installed
QualityGood with imagecopyresampledBetter resampling filters
FormatsJPEG, PNG, GIF, WebP, AVIF (8.1+)Those plus TIFF, PSD, PDF, HEIC and more
MemoryWhole bitmap in PHP's memoryCan stream; respects its own resource limits

The Imagick equivalent is shorter, and thumbnailImage with one zero argument preserves the aspect ratio:

$im = new Imagick($src);
$im->autoOrient();                    // handles EXIF rotation for you
$im->thumbnailImage(800, 0);          // width 800, height proportional
$im->stripImage();                    // drop EXIF/GPS
$im->setImageCompressionQuality(82);
$im->writeImage($dest);
$im->clear();

Use GD when you need code that runs anywhere; use Imagick when quality, formats or memory behaviour matter.

Memory limits

Receiving an upload costs almost no memory — PHP streams it to disk. Decoding it is what allocates. Roughly width × height × 4 bytes for the source, plus the same for the destination. A 6000 × 4000 photo needs about 96 MB for the source bitmap alone, which is why a default memory_limit of 128M fails on a modern camera image while the file itself is only 8 MB. Cap dimensions, process one image at a time, and call imagedestroy() as soon as you are done.

Getting the file in the first place

Everything above assumes the upload arrived intact. PHP File Uploader handles that half — multiple selection, progress, validation before transfer — and hands you the file server-side to process exactly as above:

<?php
$uploader = new PhpUploader();
$uploader->MultipleFilesUpload = true;
$uploader->AllowedFileExtensions = "jpg,jpeg,png,gif,webp";
$uploader->MaxSizeKB = 20480;
$uploader->Render();
?>

See the multiple-file demo for the front end, and the custom handler demo for taking control of each file as it lands.