Project: Image Upload Gallery

sa sahaya jerish Updated 20 Oct 2026
9 min read ·Lesson 155 of 155
In this post

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

FeatureTechniqueLesson
Reliable upload validationError codes, size limits, finfo MIME detectionPHP File Upload
Protection against malicious filesRe-encoding with GD, random names, private storageFile Upload Security
Protection against huge imagesDimension limits before decodingFile Upload Security
ThumbnailsAspect-ratio-preserving resizing—
Serving private filesDelivery script with caching and safe headersHTTP Headers and Redirects
Gallery listingPaginationPagination 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

  1. Upload checks: the error code, the size, and is_uploaded_file() are verified before touching the file.
  2. 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.
  3. 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.
  4. 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.
  5. Random file names (32 hex characters) and storage outside the web root make it impossible to execute or guess uploaded files.
  6. 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 with imagerotate() 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.

<?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

TestExpected Result
Upload a 4000×3000 JPEGStored 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 GIFRejected
Upload a 100×100 imageRejected (minimum size)
Upload a 7 MB imageRejected (size limit)
Open /storage/gallery/... in the browserNot reachable (outside the document root)
Request image.php?id=99999404
Use a title with <b> tagsDisplayed 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

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Home PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else Slicing Strings User-defined Functions Slicing Strings PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning MySQL Database MySQL Connect MySQL Create DB MySQL Create Table MySQL Insert Data MySQL Get Last ID MySQL Insert Multiple MySQL Prepared MySQL Select Data MySQL Where Debugging PHP Project: Image Upload Gallery
Ask about this post
AI Ask about this post

Ask questions about Project: Image Upload Gallery and get answers drawn from it.

Signed-in readers only.