Project: Image Upload Gallery
What You Will Build A photo gallery where users upload images with a title. The application validates each upload, re-encodes it into a safe WebP image, creates a thumbnail, stores the files outside t
What You Will Build
A photo gallery where users upload images with a title. The application validates each upload, re-encodes it into a safe WebP image, creates a thumbnail, stores the files outside the web root, records them in the database, and shows them in a paginated grid. This project puts PHP File Upload, File Upload Security, image processing with GD, and database work together.
Features
| Feature | Technique | Lesson |
|---|---|---|
| Reliable upload validation | Error codes, size limits, finfo MIME detection | PHP File Upload |
| Protection against malicious files | Re-encoding with GD, random names, private storage | File Upload Security |
| Protection against huge images | Dimension limits before decoding | File Upload Security |
| Thumbnails | Aspect-ratio-preserving resizing | — |
| Serving private files | Delivery script with caching and safe headers | HTTP Headers and Redirects |
| Gallery listing | Pagination | Pagination with PHP |
Requirements and Structure
The GD extension with WebP support is needed (php -m | grep gd; on Ubuntu, apt install php8.4-gd).
gallery/
├── public/
│ ├── index.php ← gallery grid
│ ├── upload.php ← upload form and handler
│ ├── image.php ← serves images (?id=…&size=thumb|full)
│ └── delete.php ← POST only
├── src/
│ ├── bootstrap.php ← session, e(), csrf, flash(), pdo(), paginate()
│ └── images.php ← validation and processing
└── storage/gallery/ ← image files (not web-accessible; writable by PHP)
Database Table
CREATE TABLE gallery_images (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
stored_name CHAR(32) NOT NULL,
title VARCHAR(120) NOT NULL,
original_name VARCHAR(150) NOT NULL,
width SMALLINT UNSIGNED NOT NULL,
height SMALLINT UNSIGNED NOT NULL,
size_bytes INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_gallery_stored_name (stored_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Step 1: Calculating Sizes
Images are scaled down to fit within a maximum width and height while keeping their aspect ratio. This small pure function is easy to test:
<?php
// src/images.php
function fitWithin(int $width, int $height, int $max): array
{
if ($width <= $max && $height <= $max) {
return [$width, $height]; // never enlarge
}
$ratio = min($max / $width, $max / $height);
return [max(1, (int) round($width * $ratio)), max(1, (int) round($height * $ratio))];
}
foreach ([[4000, 3000, 1600], [800, 600, 1600], [1000, 4000, 1600], [4000, 3000, 400]] as [$w, $h, $max]) {
[$newW, $newH] = fitWithin($w, $h, $max);
echo "{$w}×{$h} within {$max} → {$newW}×{$newH}\n";
}
Output
4000×3000 within 1600 → 1600×1200
800×600 within 1600 → 800×600
1000×4000 within 1600 → 400×1600
4000×3000 within 400 → 400×300
The scale factor is determined by whichever side exceeds the limit more; small images are never enlarged.
Step 2: Validating and Processing an Upload
<?php
// src/images.php (continued)
const GALLERY_DIR = __DIR__ . '/../storage/gallery';
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const MAX_SOURCE_PIXELS = 40_000_000; // e.g. 8000×5000
const ALLOWED_IMAGE_TYPES = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP];
final class InvalidImageException extends RuntimeException {}
function processUploadedImage(array $file): array
{
if (!isset($file['error']) || is_array($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
throw new InvalidImageException('The upload failed. Please choose a file and try again.');
}
if ($file['size'] > MAX_UPLOAD_BYTES || !is_uploaded_file($file['tmp_name'])) {
throw new InvalidImageException('Images may be at most 5 MB.');
}
$info = getimagesize($file['tmp_name']);
if ($info === false || !in_array($info[2], ALLOWED_IMAGE_TYPES, true)) {
throw new InvalidImageException('Please upload a JPEG, PNG, or WebP image.');
}
[$width, $height] = $info;
if ($width < 200 || $height < 200 || $width * $height > MAX_SOURCE_PIXELS) {
throw new InvalidImageException('Images must be at least 200×200 pixels and at most 40 megapixels.');
}
$source = imagecreatefromstring(file_get_contents($file['tmp_name']));
if ($source === false) {
throw new InvalidImageException('The image could not be read.');
}
$storedName = bin2hex(random_bytes(16));
[$fullW, $fullH] = fitWithin($width, $height, 1600);
saveWebp($source, $fullW, $fullH, GALLERY_DIR . "/$storedName.webp");
[$thumbW, $thumbH] = fitWithin($width, $height, 400);
saveWebp($source, $thumbW, $thumbH, GALLERY_DIR . "/{$storedName}_thumb.webp");
return [
'stored_name' => $storedName,
'width' => $fullW,
'height' => $fullH,
'size_bytes' => filesize(GALLERY_DIR . "/$storedName.webp"),
'original_name' => mb_substr(basename((string) $file['name']), 0, 150),
];
}
function saveWebp(GdImage $source, int $width, int $height, string $path): void
{
$resized = imagescale($source, $width, $height, IMG_BICUBIC);
imagealphablending($resized, false);
imagesavealpha($resized, true);
if (!imagewebp($resized, $path, 82)) {
throw new RuntimeException("Could not write $path");
}
chmod($path, 0640);
}
Security and Robustness Decisions
- Upload checks: the error code, the size, and
is_uploaded_file()are verified before touching the file. - Real type detection:
getimagesize()reads the image header and returns the actual format (IMAGETYPE_*) and dimensions — the browser's MIME type and the file extension are ignored. - Dimension limits before decoding: a 1 MB file can claim to be 50,000 × 50,000 pixels; decoding it would require gigabytes of memory (a "decompression bomb"). Checking width × height first prevents this.
- Re-encoding: the image is decoded into pixels and written as a new WebP file. Anything hidden in the original — scripts in metadata, polyglot payloads, private EXIF data such as GPS coordinates — is discarded.
- Random file names (32 hex characters) and storage outside the web root make it impossible to execute or guess uploaded files.
- Transparency from PNG/WebP sources is preserved (
imagesavealpha).
Note: Phone cameras often store photos sideways with an EXIF "orientation" flag. For JPEG uploads, read it with
exif_read_data()and rotate withimagerotate()before resizing, or photos may appear rotated.
Step 3: The Upload Page
<?php
// public/upload.php
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/images.php';
$title = '';
$error = null;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$title = is_string($_POST['title'] ?? null) ? trim($_POST['title']) : '';
if (!csrfValid($_POST['_csrf'] ?? null)) {
$error = 'Your session expired. Please try again.';
} elseif (mb_strlen($title) < 2 || mb_strlen($title) > 120) {
$error = 'Please enter a title (2–120 characters).';
} elseif (!isset($_FILES['image'])) {
$error = 'Please choose an image (the file may exceed the server limit).';
} else {
try {
$image = processUploadedImage($_FILES['image']);
pdo()->prepare(
'INSERT INTO gallery_images (stored_name, title, original_name, width, height, size_bytes)
VALUES (?, ?, ?, ?, ?, ?)'
)->execute([
$image['stored_name'], $title, $image['original_name'],
$image['width'], $image['height'], $image['size_bytes'],
]);
flash('Your image was uploaded.');
header('Location: /', true, 303);
exit;
} catch (InvalidImageException $e) {
$error = $e->getMessage();
}
}
}
?>
<h1>Upload an image</h1>
<?php if ($error !== null): ?><p class="error" role="alert"><?= e($error) ?></p><?php endif; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="<?= e(csrfToken()) ?>">
<label>Title <input name="title" value="<?= e($title) ?>" required maxlength="120"></label>
<label>Image (JPEG, PNG, or WebP, max. 5 MB)
<input type="file" name="image" accept="image/jpeg,image/png,image/webp" required>
</label>
<button type="submit">Upload</button>
</form>
InvalidImageException messages are written for users and can be shown directly; any other exception reaches the global error handler and is logged. Remember to set upload_max_filesize and post_max_size in php.ini slightly above 5 MB — when a request exceeds post_max_size, PHP discards all form data, which is why the page also handles a missing $_FILES['image'].
Step 4: Serving Images
<?php
// public/image.php?id=12&size=thumb
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/images.php';
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
$suffix = ($_GET['size'] ?? 'full') === 'thumb' ? '_thumb' : '';
$stmt = pdo()->prepare('SELECT stored_name FROM gallery_images WHERE id = ?');
$stmt->execute([$id ?: 0]);
$storedName = $stmt->fetchColumn();
$path = $storedName === false ? null : GALLERY_DIR . "/{$storedName}{$suffix}.webp";
if ($path === null || !is_file($path)) {
http_response_code(404);
exit;
}
header('Content-Type: image/webp');
header('Content-Length: ' . filesize($path));
header('Cache-Control: public, max-age=31536000, immutable');
header('X-Content-Type-Options: nosniff');
readfile($path);
- The file path is built only from values the application generated (the stored name from the database and a fixed suffix) — user input never becomes part of a path (see Filesystem Security).
- Because a stored image never changes, browsers may cache it for a year (
immutable). - This is the place to add access checks for private galleries. For high-traffic public galleries, files can instead be served directly by the web server or a CDN.
Step 5: The Gallery Page
<?php
// public/index.php
require __DIR__ . '/../src/bootstrap.php';
$total = (int) pdo()->query('SELECT COUNT(*) FROM gallery_images')->fetchColumn();
$p = paginate($total, 12, filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1);
$stmt = pdo()->prepare('SELECT id, title, width, height FROM gallery_images ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?');
$stmt->bindValue(1, $p['per_page'], PDO::PARAM_INT);
$stmt->bindValue(2, $p['offset'], PDO::PARAM_INT);
$stmt->execute();
$message = flash();
?>
<h1>Gallery</h1>
<?php if ($message !== null): ?><p class="info"><?= e($message) ?></p><?php endif; ?>
<p><a href="/upload.php">Upload an image</a></p>
<div class="grid">
<?php foreach ($stmt as $image): ?>
<figure>
<a href="/image.php?id=<?= (int) $image['id'] ?>">
<img src="/image.php?id=<?= (int) $image['id'] ?>&size=thumb"
alt="<?= e($image['title']) ?>" loading="lazy">
</a>
<figcaption><?= e($image['title']) ?> (<?= (int) $image['width'] ?>×<?= (int) $image['height'] ?>)</figcaption>
<form method="post" action="/delete.php" onsubmit="return confirm('Delete this image?');">
<input type="hidden" name="_csrf" value="<?= e(csrfToken()) ?>">
<input type="hidden" name="id" value="<?= (int) $image['id'] ?>">
<button type="submit">Delete</button>
</form>
</figure>
<?php endforeach; ?>
</div>
<?= paginationLinks($p) ?>
Titles are escaped in both the alt attribute and the caption. loading="lazy" lets the browser load thumbnails only when they scroll into view.
public/delete.php (Essentials)
<?php
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/images.php';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || !csrfValid($_POST['_csrf'] ?? null)) {
http_response_code(405);
exit;
}
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
$stmt = pdo()->prepare('SELECT stored_name FROM gallery_images WHERE id = ?');
$stmt->execute([$id ?: 0]);
if (($storedName = $stmt->fetchColumn()) !== false) {
pdo()->prepare('DELETE FROM gallery_images WHERE id = ?')->execute([$id]);
@unlink(GALLERY_DIR . "/$storedName.webp");
@unlink(GALLERY_DIR . "/{$storedName}_thumb.webp");
flash('The image was deleted.');
}
header('Location: /', true, 303);
exit;
The database row is deleted first, so the gallery never shows an image whose files are missing; a leftover file (if unlink() fails) is harmless and can be cleaned up by a maintenance script. In a multi-user gallery, check that the current user owns the image before deleting it.
Testing the Project
| Test | Expected Result |
|---|---|
| Upload a 4000×3000 JPEG | Stored as 1600×1200 WebP with a 400×300 thumbnail |
Rename a .php file to photo.jpg and upload it | "Please upload a JPEG, PNG, or WebP image." |
| Upload an SVG or GIF | Rejected |
| Upload a 100×100 image | Rejected (minimum size) |
| Upload a 7 MB image | Rejected (size limit) |
Open /storage/gallery/... in the browser | Not reachable (outside the document root) |
Request image.php?id=99999 | 404 |
Use a title with <b> tags | Displayed as text |
Common Mistakes
Saving Uploads Unchanged
Effect: Malicious payloads and private metadata are stored and served.
Fix: Re-encode images with GD or Imagick.
Decoding Before Checking Dimensions
Effect: Memory exhaustion from decompression bombs.
Fix: Check width × height with getimagesize() first.
Building File Paths From Request Parameters
Effect: Path traversal.
Fix: Look up stored names in the database; use fixed suffixes.
Forgetting enctype="multipart/form-data"
Effect: $_FILES is empty.
Fix: Always set it on upload forms.
Best Practices
- Validate uploads by size, real type, and dimensions; re-encode every image.
- Store files with random names outside the web root and serve them through a script or CDN.
- Generate thumbnails once at upload time instead of on every request.
- Escape titles everywhere and protect deletions with POST, CSRF tokens, and ownership checks.
- Configure PHP's upload limits consistently with the application's limits.
Official reference: https\://www.php.net/manual/en/features.file-upload.php