PHP File Upload

Su Subhalakshmi Chandran Updated 17 Oct 2026
7 min read ·Lesson 47 of 102
In this post

The $_FILES lesson explained how PHP receives uploaded files and how to validate and store a single file safely. This lesson builds a complete image upload feature on that foundation: checking upload

The $_FILES lesson explained how PHP receives uploaded files and how to validate and store a single file safely. This lesson builds a complete image upload feature on that foundation: checking upload limits, validating real images, resizing them into thumbnails with the GD extension, storing a record of each upload, and displaying the results — the typical requirements for profile pictures, product photos, or a gallery.


The Upload Flow

Browser form (multipart/form-data)
        │
        ▼
PHP stores the file temporarily  →  $_FILES['photo']
        │
        ▼
Validate: error code, size, real MIME type, image dimensions
        │
        ▼
Process: re-encode and resize (GD)  →  storage/uploads/…
        │
        ▼
Record: save metadata (database or index file)
        │
        ▼
Redirect and display

Every step can fail, so each one either succeeds or produces a clear message for the user.


Knowing the Limits Before Uploading

Several settings limit uploads. A good form tells users the maximum size before they choose a large file:

<?php
function iniToBytes(string $value): int
{
    $value = trim($value);
    $number = (int) $value;

    return match (strtoupper(substr($value, -1))) {
        'G' => $number * 1024 ** 3,
        'M' => $number * 1024 ** 2,
        'K' => $number * 1024,
        default => $number,
    };
}

foreach (['2M', '512K', '1G', '1048576'] as $setting) {
    echo str_pad($setting, 8) . iniToBytes($setting) . " bytes\n";
}

$maxUpload = min(iniToBytes(ini_get('upload_max_filesize')), iniToBytes(ini_get('post_max_size')));

Output

2M      2097152 bytes
512K    524288 bytes
1G      1073741824 bytes
1048576 1048576 bytes
  • php.ini sizes use shorthand (2M, 512K, 1G); the function converts them to bytes.
  • The effective maximum is the smaller of upload_max_filesize (per file) and post_max_size (whole request).
  • Display it in the form ("Maximum size: 2 MB") and also set your own application limit, which may be lower.

Validating That a File Is a Real Image

Besides checking the MIME type (see $_FILES), image uploads should verify that the file can actually be read as an image and has sensible dimensions:

<?php
function validateImage(array $file, int $maxBytes, int $maxPixels = 40_000_000): array
{
    if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        return [null, 'The upload failed. Please try again.'];
    }

    if ($file['size'] > $maxBytes) {
        return [null, 'The image is too large.'];
    }

    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
    if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
        return [null, 'Only JPEG, PNG, and WebP images are allowed.'];
    }

    $info = getimagesize($file['tmp_name']);
    if ($info === false) {
        return [null, 'The file is not a valid image.'];
    }

    [$width, $height] = $info;
    if ($width * $height > $maxPixels) {
        return [null, 'The image dimensions are too large.'];
    }

    return [['mime' => $mime, 'width' => $width, 'height' => $height], null];
}

Why Check Dimensions?

A small compressed file can describe a gigantic image (for example, 30,000 × 30,000 pixels). Decoding it for resizing would need gigabytes of memory — a so-called decompression bomb that can crash the server. getimagesize() reads only the image header, so the dimensions can be checked cheaply before decoding the image.


Calculating Thumbnail Sizes

Resizing should keep the aspect ratio and never enlarge small images:

<?php
function fitWithin(int $width, int $height, int $maxSide): array
{
    $scale = min(1, $maxSide / max($width, $height));

    return [(int) round($width * $scale), (int) round($height * $scale)];
}

foreach ([[4000, 3000], [1080, 1920], [300, 200]] as [$w, $h]) {
    [$newW, $newH] = fitWithin($w, $h, 400);
    echo "{$w}×{$h} → {$newW}×{$newH}\n";
}

Output

4000×3000 → 400×300
1080×1920 → 225×400
300×200 → 300×200
  • The longest side is scaled to 400 pixels; the other side follows the same ratio.
  • min(1, ...) prevents enlarging images that are already small, which would only make them blurry.

Resizing and Re-Encoding With GD

The GD extension (ext-gd, enabled in most PHP installations) can read, resize, and write images:

<?php
function createResizedCopy(string $source, string $mime, string $destination, int $maxSide): void
{
    $image = match ($mime) {
        'image/jpeg' => imagecreatefromjpeg($source),
        'image/png' => imagecreatefrompng($source),
        'image/webp' => imagecreatefromwebp($source),
    };

    if ($image === false) {
        throw new RuntimeException('Cannot read the image.');
    }

    [$newWidth, $newHeight] = fitWithin(imagesx($image), imagesy($image), $maxSide);

    $resized = imagecreatetruecolor($newWidth, $newHeight);
    imagealphablending($resized, false);   // keep transparency from PNG/WebP
    imagesavealpha($resized, true);

    imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, imagesx($image), imagesy($image));

    if (!imagewebp($resized, $destination, 82)) {
        throw new RuntimeException('Cannot save the resized image.');
    }
}

How It Works

  1. The image is decoded with the function matching its detected MIME type.
  2. A new true-color canvas of the target size is created; alpha settings preserve transparency.
  3. imagecopyresampled() scales the image smoothly into the new canvas.
  4. The result is saved as WebP with quality 82, a good balance between quality and file size for the web.

(In PHP 8, GD images are GdImage objects that are freed automatically when they go out of scope; calling imagedestroy() is no longer necessary.)

Why Re-Encoding Improves Security and Privacy

Writing a new image from the decoded pixels:

  • discards anything hidden in the original file, such as embedded scripts or extra data appended to the image,
  • removes metadata such as EXIF GPS coordinates, which smartphone photos often contain (the location where a photo was taken can reveal a user's home address),
  • produces consistent formats and sizes for your website.

Putting It Together: An Upload Handler

<?php
// upload.php (uses validateImage(), fitWithin(), and createResizedCopy() from above)
declare(strict_types=1);

session_start();

$storage = __DIR__ . '/../storage/uploads';
$index = $storage . '/index.json';

if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
    $file = $_FILES['photo'] ?? [];

    if (!isset($file['error']) || is_array($file['error'])) {
        $_SESSION['flash'] = 'Please upload exactly one image.';
        header('Location: /upload.php', true, 303);
        exit;
    }

    [$image, $error] = validateImage($file, 5 * 1024 * 1024);

    if ($error !== null) {
        $_SESSION['flash'] = $error;
        header('Location: /upload.php', true, 303);
        exit;
    }

    $id = bin2hex(random_bytes(8));
    createResizedCopy($file['tmp_name'], $image['mime'], "$storage/$id-large.webp", 1600);
    createResizedCopy($file['tmp_name'], $image['mime'], "$storage/$id-thumb.webp", 400);

    $records = is_file($index) ? json_decode(file_get_contents($index), true) : [];
    $records[] = [
        'id' => $id,
        'original_name' => mb_substr(basename($file['name']), 0, 100),
        'uploaded_at' => date(DATE_ATOM),
    ];
    file_put_contents($index, json_encode($records, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX);

    $_SESSION['flash'] = 'Image uploaded successfully.';
    header('Location: /upload.php', true, 303);
    exit;
}

Step-by-Step Explanation

  1. The handler accepts exactly one file and rejects array uploads to a single-file field.
  2. The image is validated (error code, size, MIME type, dimensions).
  3. Two versions are generated: a large display image and a thumbnail. The original upload is not kept, which saves space and removes hidden content (keep it only if your application needs originals, stored privately).
  4. A random ID names the files; the original name is stored only as text metadata, shortened and stripped of any path.
  5. A record is added to an index file with a lock. In a real application, this would be a database INSERT.
  6. The user is redirected with a flash message (Post/Redirect/Get).

The Form

<form method="post" action="/upload.php" enctype="multipart/form-data">
    <input type="hidden" name="MAX_FILE_SIZE" value="5242880">
    <label>Photo (JPEG, PNG or WebP, max 5 MB)
        <input type="file" name="photo" accept="image/jpeg,image/png,image/webp" required>
    </label>
    <button type="submit">Upload</button>
</form>

The hidden MAX_FILE_SIZE field lets PHP reject oversized files early (error code UPLOAD_ERR_FORM_SIZE), but it can be changed by the client, so the server-side size check remains essential. A CSRF token should be added as for every POST form.


Displaying Uploaded Images

If uploads are stored outside the web root (recommended), a small script serves them after validating the requested name:

<?php
// image.php?id=3f9a1c0e8b7d4a2e&size=thumb
$id = $_GET['id'] ?? '';
$size = $_GET['size'] ?? 'thumb';

if (!is_string($id) || !preg_match('/^[a-f0-9]{16}$/', $id) || !in_array($size, ['thumb', 'large'], true)) {
    http_response_code(404);
    exit;
}

$path = __DIR__ . "/../storage/uploads/$id-$size.webp";

if (!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');
readfile($path);
  • The ID must match the exact format generated by the upload handler, and the size must be one of two allowed values, so no other file can be requested.
  • Because each upload has a unique, never-changing file name, browsers can cache images for a long time (immutable).
  • For large sites, images are often stored in object storage (such as Amazon S3) and delivered through a CDN instead.

Common Mistakes

Trusting the Extension or Browser MIME Type

Effect: Non-image or malicious files are accepted.

Fix: Check with finfo and getimagesize().

Decoding Images Without Checking Dimensions

Effect: Huge images exhaust server memory.

Fix: Read dimensions with getimagesize() first and enforce a pixel limit.

Serving Original Photos

Effect: Large files slow pages down, and EXIF metadata can leak users' locations.

Fix: Re-encode and resize every image.

Upscaling Small Images

Effect: Blurry, unnecessarily large files.

Fix: Never scale above 100%.

Storing Uploads Where Scripts Can Run

Effect: A crafted upload could be executed.

Fix: Store outside the web root or disable script execution for the upload folder.


Best Practices

  • Show users the maximum upload size and enforce your own limit on the server.
  • Validate the error code, size, real MIME type, and image dimensions.
  • Re-encode images into web-friendly sizes and formats, and discard metadata.
  • Name files with random IDs and keep original names only as escaped metadata.
  • Serve stored images through a script with strict validation, or through dedicated storage and a CDN.

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
Ask about this post
AI Ask about this post

Ask questions about PHP File Upload and get answers drawn from it.

Signed-in readers only.