Image #

Almost every web application that accepts image uploads needs image processing capabilities — resizing thumbnails so you don’t store 10MB camera-phone images, adding brand watermarks, converting to WebP for performance, or dynamically generating OG (Open Graph) images for social media. PHP provides the GD (Graphics Draw) extension, which is installed on almost all hosting and needs no additional software. The Imagick extension (the PHP binding for ImageMagick) delivers higher quality but requires a separate installation. This article focuses on GD because of its portability — all the code runs on almost any PHP server as-is.

Making Sure GD Is Available #

<?php
// Check whether GD is active
if (!extension_loaded('gd')) {
    throw new \RuntimeException("The GD extension is not active");
}

// GD info — supported formats
$info = gd_info();
echo "GD version: " . $info['GD Version'] . "\n";
echo "JPEG: " . ($info['JPEG Support'] ? 'Yes' : 'No') . "\n";
echo "PNG: "  . ($info['PNG Support']  ? 'Yes' : 'No') . "\n";
echo "WebP: " . ($info['WebP Support'] ? 'Yes' : 'No') . "\n";
echo "GIF: "  . ($info['GIF Create Support'] ? 'Yes' : 'No') . "\n";
echo "AVIF: " . ($info['AVIF Support'] ? 'Yes' : 'No') . "\n";

// Useful format constants
// IMG_JPEG, IMG_PNG, IMG_GIF, IMG_WEBP, IMG_AVIF

Opening and Creating Images #

<?php
// Open an existing image — format auto-detected
$image = imagecreatefromjpeg('photo.jpg');
$image = imagecreatefrompng('logo.png');
$image = imagecreatefromgif('animation.gif');
$image = imagecreatefromwebp('modern.webp');

// Universal way — detect from the MIME type
function openImage(string $path): \GdImage
{
    $info = getimagesize($path);
    if ($info === false) {
        throw new \InvalidArgumentException("Not an image file: $path");
    }

    $image = match($info[2]) {
        IMAGETYPE_JPEG => imagecreatefromjpeg($path),
        IMAGETYPE_PNG  => imagecreatefrompng($path),
        IMAGETYPE_GIF  => imagecreatefromgif($path),
        IMAGETYPE_WEBP => imagecreatefromwebp($path),
        IMAGETYPE_AVIF => imagecreatefromavif($path),
        default        => throw new \InvalidArgumentException(
            "Unsupported format: " . image_type_to_mime_type($info[2])
        ),
    };

    if ($image === false) {
        throw new \RuntimeException("Failed to open image: $path");
    }

    return $image;
}

// Create a new image (empty canvas)
$width  = 800;
$height = 600;
$canvas = imagecreatetruecolor($width, $height);

// imagecreate — 8-bit (256 colors), avoid unless for GIF
// imagecreatetruecolor — 24-bit (16 million colors), use this

// Colors — imagecolorallocate() for each color
$white  = imagecolorallocate($canvas, 255, 255, 255); // RGB
$black  = imagecolorallocate($canvas, 0, 0, 0);
$red    = imagecolorallocate($canvas, 255, 0, 0);
$blue   = imagecolorallocate($canvas, 0, 100, 200);
$gray   = imagecolorallocate($canvas, 128, 128, 128);

// Colors with alpha (transparency) — 0=opaque, 127=transparent
$semiTransparentWhite = imagecolorallocatealpha($canvas, 255, 255, 255, 64);

// Fill the background with a color
imagefill($canvas, 0, 0, $white);

// Don't forget to destroy the image after you're done to free memory
imagedestroy($canvas);

Resize — Changing Dimensions #

<?php
function resize(
    string $inputPath,
    string $outputPath,
    int    $newWidth,
    int    $newHeight = 0,   // 0 = auto-calculate keeping the aspect ratio
    int    $quality   = 85,  // for JPEG: 0-100
): void {
    $source     = openImage($inputPath);
    $origWidth  = imagesx($source);
    $origHeight = imagesy($source);

    // Calculate the new dimensions while keeping the aspect ratio
    if ($newHeight === 0) {
        $ratio      = $newWidth / $origWidth;
        $newHeight  = (int) round($origHeight * $ratio);
    } elseif ($newWidth === 0) {
        $ratio    = $newHeight / $origHeight;
        $newWidth = (int) round($origWidth * $ratio);
    }

    // Create a new canvas
    $result = imagecreatetruecolor($newWidth, $newHeight);

    // Preserve transparency for PNG and GIF
    $extension = strtolower(pathinfo($outputPath, PATHINFO_EXTENSION));
    if (in_array($extension, ['png', 'gif'])) {
        imagealphablending($result, false);
        imagesavealpha($result, true);
        $transparent = imagecolorallocatealpha($result, 0, 0, 0, 127);
        imagefill($result, 0, 0, $transparent);
    }

    // Resize with high-quality resampling
    imagecopyresampled(
        $result,  $source,
        0, 0,     0, 0,                  // destination position, source position
        $newWidth, $newHeight,            // destination size
        $origWidth, $origHeight           // source size
    );

    // Save according to the output format
    saveImage($result, $outputPath, $quality);

    imagedestroy($source);
    imagedestroy($result);
}

function saveImage(\GdImage $image, string $path, int $quality = 85): void
{
    $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));

    match($extension) {
        'jpg', 'jpeg' => imagejpeg($image, $path, $quality),
        'png'         => imagepng($image, $path, (int) round((100 - $quality) / 10)),
        'gif'         => imagegif($image, $path),
        'webp'        => imagewebp($image, $path, $quality),
        'avif'        => imageavif($image, $path, $quality),
        default       => throw new \InvalidArgumentException("Unsupported format: $extension"),
    };
}

// Usage
resize('large_photo.jpg', 'thumbnail.jpg', newWidth: 300);
resize('large_photo.jpg', 'medium.webp',   newWidth: 800, newHeight: 600);

Crop — Cutting Images #

<?php
// Crop a specific area
function crop(
    string $inputPath,
    string $outputPath,
    int $x, int $y,
    int $width, int $height,
    int $quality = 85,
): void {
    $source = openImage($inputPath);
    $result = imagecreatetruecolor($width, $height);

    imagecopyresampled(
        $result, $source,
        0, 0,           // position on the output canvas
        $x, $y,         // where to start cropping in the source
        $width, $height, $width, $height
    );

    saveImage($result, $outputPath, $quality);
    imagedestroy($source);
    imagedestroy($result);
}

// Smart crop — center crop
function centerCrop(
    string $inputPath,
    string $outputPath,
    int $targetWidth,
    int $targetHeight,
    int $quality = 85,
): void {
    $source     = openImage($inputPath);
    $origWidth  = imagesx($source);
    $origHeight = imagesy($source);

    // Calculate the ratio to fit the target without distortion
    $ratioX = $targetWidth / $origWidth;
    $ratioY = $targetHeight / $origHeight;
    $ratio  = max($ratioX, $ratioY); // take the larger so the image fills the target

    $resizeWidth  = (int) round($origWidth * $ratio);
    $resizeHeight = (int) round($origHeight * $ratio);

    // Resize first
    $resized = imagecreatetruecolor($resizeWidth, $resizeHeight);
    imagecopyresampled($resized, $source, 0, 0, 0, 0, $resizeWidth, $resizeHeight, $origWidth, $origHeight);
    imagedestroy($source);

    // Then crop from the center
    $offsetX = (int) round(($resizeWidth - $targetWidth) / 2);
    $offsetY = (int) round(($resizeHeight - $targetHeight) / 2);

    $result = imagecreatetruecolor($targetWidth, $targetHeight);
    imagecopyresampled($result, $resized, 0, 0, $offsetX, $offsetY, $targetWidth, $targetHeight, $targetWidth, $targetHeight);
    imagedestroy($resized);

    saveImage($result, $outputPath, $quality);
    imagedestroy($result);
}

// Crop into a square (for social media thumbnails)
centerCrop('landscape_photo.jpg', 'square_thumbnail.jpg', 400, 400);

Watermarks #

<?php
// Text watermark
function textWatermark(
    string $inputPath,
    string $outputPath,
    string $text,
    int    $fontSize = 20,
    int    $opacity  = 70,   // 0-100
    string $position = 'bottom-right', // bottom-right, center, etc.
    int    $quality  = 85,
): void {
    $image = openImage($inputPath);
    $width  = imagesx($image);
    $height = imagesy($image);

    // GD built-in fonts (must use TTF for custom fonts)
    // Built-in fonts: 1-5 (fixed size, can't be adjusted)
    $font = 5; // largest built-in font

    $textWidth  = imagefontwidth($font) * strlen($text);
    $textHeight = imagefontheight($font);

    // Determine the position
    $padding = 15;
    [$x, $y] = match($position) {
        'bottom-right' => [$width - $textWidth - $padding, $height - $textHeight - $padding],
        'bottom-left'  => [$padding, $height - $textHeight - $padding],
        'top-right'    => [$width - $textWidth - $padding, $padding],
        'top-left'     => [$padding, $padding],
        'center'       => [(int)(($width - $textWidth) / 2), (int)(($height - $textHeight) / 2)],
        default        => [$padding, $height - $textHeight - $padding],
    };

    // Color with alpha for transparency
    $alpha  = (int) round(127 * (1 - $opacity / 100));
    $white  = imagecolorallocatealpha($image, 255, 255, 255, $alpha);
    $shadow = imagecolorallocatealpha($image, 0, 0, 0, $alpha);

    // Draw the shadow first (1px offset) then the main text
    imagestring($image, $font, $x + 1, $y + 1, $text, $shadow);
    imagestring($image, $font, $x, $y, $text, $white);

    saveImage($image, $outputPath, $quality);
    imagedestroy($image);
}

// Watermark with a TTF font (better quality)
function textWatermarkTTF(
    string $inputPath,
    string $outputPath,
    string $text,
    string $fontPath,   // path to the .ttf file
    int    $fontSize = 24,
    int    $quality  = 85,
): void {
    $image = openImage($inputPath);
    $width  = imagesx($image);
    $height = imagesy($image);

    // Calculate the text bounding box
    $bbox = imagettfbbox($fontSize, 0, $fontPath, $text);
    $textWidth  = abs($bbox[2] - $bbox[0]);
    $textHeight = abs($bbox[7] - $bbox[1]);

    $x = $width  - $textWidth  - 20;
    $y = $height - 20;

    // Shadow
    $shadow = imagecolorallocatealpha($image, 0, 0, 0, 64);
    imagettftext($image, $fontSize, 0, $x + 2, $y + 2, $shadow, $fontPath, $text);

    // Main text
    $white = imagecolorallocatealpha($image, 255, 255, 255, 32);
    imagettftext($image, $fontSize, 0, $x, $y, $white, $fontPath, $text);

    saveImage($image, $outputPath, $quality);
    imagedestroy($image);
}

// Image watermark (logo overlay)
function imageWatermark(
    string $inputPath,
    string $logoPath,
    string $outputPath,
    int    $opacity  = 50,
    string $position = 'bottom-right',
    int    $quality  = 85,
): void {
    $image = openImage($inputPath);
    $logo  = openImage($logoPath);

    $imageWidth  = imagesx($image);
    $imageHeight = imagesy($image);
    $logoWidth   = imagesx($logo);
    $logoHeight  = imagesy($logo);

    $padding = 15;
    [$x, $y] = match($position) {
        'bottom-right' => [$imageWidth - $logoWidth - $padding, $imageHeight - $logoHeight - $padding],
        'bottom-left'  => [$padding, $imageHeight - $logoHeight - $padding],
        'center'       => [(int)(($imageWidth - $logoWidth) / 2), (int)(($imageHeight - $logoHeight) / 2)],
        default        => [$imageWidth - $logoWidth - $padding, $imageHeight - $logoHeight - $padding],
    };

    // Merge with opacity
    imagecopymerge($image, $logo, $x, $y, 0, 0, $logoWidth, $logoHeight, $opacity);

    saveImage($image, $outputPath, $quality);
    imagedestroy($image);
    imagedestroy($logo);
}

Outputting to the Browser #

<?php
// Send an image directly to the browser without saving to a file
function outputImageToBrowser(\GdImage $image, string $format = 'jpeg', int $quality = 85): void
{
    $mimeType = match($format) {
        'jpeg', 'jpg' => 'image/jpeg',
        'png'         => 'image/png',
        'gif'         => 'image/gif',
        'webp'        => 'image/webp',
        default       => 'image/jpeg',
    };

    header("Content-Type: $mimeType");
    header('Cache-Control: public, max-age=86400'); // 24-hour cache

    match($format) {
        'jpeg', 'jpg' => imagejpeg($image, null, $quality), // null = output to the browser
        'png'         => imagepng($image),
        'gif'         => imagegif($image),
        'webp'        => imagewebp($image, null, $quality),
    };
}

// Example: on-demand thumbnail generation
// URL: /thumbnail.php?id=42&w=300&h=200
$id     = filter_input(INPUT_GET, 'id',   FILTER_VALIDATE_INT);
$width  = filter_input(INPUT_GET, 'w',    FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 2000]]);
$height = filter_input(INPUT_GET, 'h',    FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 2000]]);

if (!$id || !$width) {
    http_response_code(400);
    exit;
}

$originalPath = "/uploads/products/$id.jpg";
$cachePath    = "/cache/thumbs/{$id}_{$width}x{$height}.webp";

if (!file_exists($originalPath)) {
    http_response_code(404);
    exit;
}

// Serve from the cache if it exists
if (file_exists($cachePath)) {
    header('Content-Type: image/webp');
    header('X-Cache: HIT');
    readfile($cachePath);
    exit;
}

// Generate and cache
$source = openImage($originalPath);
$thumb  = imagecreatetruecolor($width, $height ?: imagesx($source));
// ... resize logic ...
imagewebp($thumb, $cachePath, 80);
imagedestroy($source);

// Serve the result
header('Content-Type: image/webp');
header('X-Cache: MISS');
imagewebp($thumb, null, 80);
imagedestroy($thumb);
exit;

Safe Image Upload Patterns #

<?php
class ImageUploader
{
    private const MAX_SIZE_BYTES    = 10 * 1024 * 1024; // 10MB
    private const ALLOWED_TYPES     = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP, IMAGETYPE_GIF];
    private const MAX_WIDTH         = 5000;
    private const MAX_HEIGHT        = 5000;

    public function __construct(
        private string $uploadDir,
        private int    $thumbWidth = 400,
        private int    $outputQuality = 82,
    ) {
        if (!is_dir($this->uploadDir)) {
            mkdir($this->uploadDir, 0755, true);
        }
    }

    public function upload(array $file): array
    {
        // 1. Validate the PHP upload error
        if ($file['error'] !== UPLOAD_ERR_OK) {
            throw new \RuntimeException("Upload error: " . $this->errorMessage($file['error']));
        }

        // 2. Validate the file size
        if ($file['size'] > self::MAX_SIZE_BYTES) {
            throw new \InvalidArgumentException("File too large (max 10MB)");
        }

        // 3. Validate the image type — DON'T trust $_FILES['type']!
        // Use getimagesize() on the uploaded file
        $imageInfo = @getimagesize($file['tmp_name']);
        if ($imageInfo === false) {
            throw new \InvalidArgumentException("File is not a valid image");
        }

        if (!in_array($imageInfo[2], self::ALLOWED_TYPES, strict: true)) {
            throw new \InvalidArgumentException(
                "Unsupported format: " . image_type_to_mime_type($imageInfo[2])
            );
        }

        // 4. Validate the dimensions
        if ($imageInfo[0] > self::MAX_WIDTH || $imageInfo[1] > self::MAX_HEIGHT) {
            throw new \InvalidArgumentException("Image too large (max 5000x5000px)");
        }

        // 5. Generate a safe filename (don't use the original name!)
        $fileName    = bin2hex(random_bytes(16)) . '.webp';
        $thumbName   = 'thumb_' . $fileName;

        $fullPath  = $this->uploadDir . '/' . $fileName;
        $thumbPath = $this->uploadDir . '/thumbnails/' . $thumbName;

        if (!is_dir(dirname($thumbPath))) {
            mkdir(dirname($thumbPath), 0755, true);
        }

        // 6. Process the image (convert to WebP, resize the thumbnail)
        $source = openImage($file['tmp_name']);

        // Save the full size as WebP
        imagewebp($source, $fullPath, $this->outputQuality);

        // Create the thumbnail
        $origWidth  = imagesx($source);
        $origHeight = imagesy($source);
        $ratio      = $this->thumbWidth / $origWidth;
        $thumbHeight = (int) round($origHeight * $ratio);

        $thumb = imagecreatetruecolor($this->thumbWidth, $thumbHeight);
        imagecopyresampled($thumb, $source, 0, 0, 0, 0, $this->thumbWidth, $thumbHeight, $origWidth, $origHeight);
        imagewebp($thumb, $thumbPath, $this->outputQuality);

        imagedestroy($source);
        imagedestroy($thumb);

        return [
            'file_name'  => $fileName,
            'thumb_name' => $thumbName,
            'width'      => $imageInfo[0],
            'height'     => $imageInfo[1],
            'size'       => $file['size'],
            'mime'       => 'image/webp',
        ];
    }

    private function errorMessage(int $code): string
    {
        return match($code) {
            UPLOAD_ERR_INI_SIZE   => "File exceeds upload_max_filesize in php.ini",
            UPLOAD_ERR_FORM_SIZE  => "File exceeds MAX_FILE_SIZE in the HTML form",
            UPLOAD_ERR_PARTIAL    => "File was only partially uploaded",
            UPLOAD_ERR_NO_FILE    => "No file was uploaded",
            UPLOAD_ERR_NO_TMP_DIR => "The temporary directory doesn't exist",
            UPLOAD_ERR_CANT_WRITE => "Failed to write the file to disk",
            UPLOAD_ERR_EXTENSION  => "Upload stopped by a PHP extension",
            default               => "Unknown error: $code",
        };
    }
}

// Usage
$uploader = new ImageUploader('/var/www/uploads', thumbWidth: 300);

try {
    $result = $uploader->upload($_FILES['photo']);
    echo json_encode(['success' => true, 'data' => $result]);
} catch (\InvalidArgumentException $e) {
    http_response_code(422);
    echo json_encode(['error' => $e->getMessage()]);
} catch (\RuntimeException $e) {
    http_response_code(500);
    echo json_encode(['error' => 'Failed to process the image']);
    error_log($e->getMessage());
}

Summary #

  • imagecreatefromjpeg/png/webp() for opening, imagecreatetruecolor() for creating a new canvas. Always use imagedestroy() when done to free memory.
  • imagecopyresampled() (not imagecopyresized()) for high-quality resizing — it performs bilinear interpolation producing far smoother images.
  • Preserve PNG/GIF transparency with imagealphablending(false) + imagesavealpha(true) + imagecolorallocatealpha() before resize or crop operations.
  • Output to the browser: send the correct Content-Type header then call the output function with null as the second argument (e.g. imagejpeg($img, null, 85)).
  • Validate image uploads with getimagesize() on the tmp file — don’t trust $_FILES['type'] because it can be forged. Use IMAGETYPE_JPEG, IMAGETYPE_PNG, etc. for type checks.
  • Use random filenames (bin2hex(random_bytes(16))) for uploaded files — never use the user’s original name because it can contain dangerous characters or overwrite important files.
  • Convert everything to WebP on upload — files are 25-35% smaller than JPEG at the same quality. PHP 7.0+ supports imagewebp().
  • Cache generated thumbnails — don’t regenerate them on every request. Save them to disk and serve directly on subsequent requests.

← Previous: XML   Next: SPL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact