PHP File Upload

Su Subhalakshmi Chandran Updated 17 Oct 2026
7 min read ·Lesson 46 of 248
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 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 User-defined Functions Slicing Strings Function Arguments 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 $_SESSION $_ENV PHP RegEx RegEx Patterns Heredoc and Nowdoc RegEx Modifiers preg_match preg_replace preg_split PHP RegEx Functions Nullable Types Mixed and Never Types Nullsafe Operator PHP Attributes PHP Fibers Readonly Classes Property Hooks (PHP 8.4) Asymmetric Visibility (PHP 8.4) Reflection API SPL Data Structures Composer PHP Form Handling PDO Transactions PDO Error Handling PHP XML Parsers PHP SimpleXML Parser PHP SimpleXML - Get PHP XML Expat Parser PHP DOM Parser PHP XMLReader PHP XMLWriter AJAX Intro PHP Class Constants Encryption with Sodium Recursive Functions Variable Functions Anonymous Functions Closures Arrow Functions First-class Callable Syntax PHP Arrays Indexed Arrays Associative Arrays Create Arrays Variables Scope Static Variables Variable Variables PHP Echo / Print PHP printf / sprintf PHP Data Types PHP Booleans PHP NULL PHP Strings String Functions array_map / array_filter / array_reduce Array Functions PHP Superglobals $GLOBALS What Is `$_SERVER` ? $_REQUEST $_POST $_GET $_FILES $_COOKIE PHP Type Juggling MySQL Order By MySQL Delete Data MySQL Update Data MySQL Limit Data MySQL Joins with PHP Pagination with PHP PDO Introduction PDO Connect PDO Prepared Statements PDO Fetch Modes PSR-4 Autoloading Installing Extensions (PECL / PIE) OPcache & Performance PHP-FPM Project: Contact Form with Email Project: Login & Registration System Project: CRUD App with MySQL Project: Shopping Cart with Sessions Project: REST API with PHP DateTime Class Timezones PHP Include Include vs Require PHP File Handling PHP File Open/Read PHP File Create/Write File Delete / Rename / Copy PHP Directories PHP CSV Files
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.