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 useimagedestroy()when done to free memory.imagecopyresampled()(notimagecopyresized()) 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-Typeheader then call the output function withnullas 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. UseIMAGETYPE_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.