ZetCode

PHP File Uploads

last modified October 22, 2026

In this article, we show how to receive uploaded files in PHP with the $_FILES superglobal and move_uploaded_file, and how to validate them safely.

Accepting files from users is one of the riskiest features of a web application. Everything the client sends about a file — its name, its declared type — can be forged.

A server that stores an uploaded .php file inside the web root and serves it hands attackers a remote code execution hole. The examples below therefore check the real size and content of each file and never trust the original file name.

The examples run with the built-in web server (php -S 127.0.0.1:8000) and are tested with curl.

Main Features

File uploads involve an HTML form, a superglobal, a few functions, and several configuration directives.

The upload form

The browser sends files through an HTML form. Our form lets the user pick a PNG or JPEG image.

form.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Upload</title></head>
<body>
<form action="upload.php" method="post" enctype="multipart/form-data">
    <input type="hidden" name="MAX_FILE_SIZE" value="2097152">
    <input type="file" name="image" accept="image/png,image/jpeg">
    <button type="submit">Upload</button>
</form>
</body>
</html>

The form must use the POST method and the multipart/form-data encoding.

The accept attribute and the hidden MAX_FILE_SIZE field are only hints for the browser. They are easy to bypass, so the server must repeat every check.

Handling the upload

The upload.php script receives the file, validates it, and stores it under a new name. The uploads directory is created next to the script.

upload.php
<?php

declare(strict_types=1);

const UPLOAD_DIR = __DIR__ . '/uploads/';
const MAX_SIZE = 2 * 1024 * 1024;
const ALLOWED = [
    'image/png'  => 'png',
    'image/jpeg' => 'jpg',
];

function uploadError(int $code): string
{
    return match ($code) {
        UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File is too large.',
        UPLOAD_ERR_PARTIAL  => 'File was only partially uploaded.',
        UPLOAD_ERR_NO_FILE  => 'No file was uploaded.',
        UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE, UPLOAD_ERR_EXTENSION => 'Server error.',
        default => 'Unknown error.',
    };
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit("POST only\n");
}

$file = $_FILES['image'] ?? null;

if ($file === null || $file['error'] !== UPLOAD_ERR_OK) {
    http_response_code(400);
    exit(uploadError($file['error'] ?? UPLOAD_ERR_NO_FILE) . "\n");
}

if ($file['size'] > MAX_SIZE) {
    http_response_code(413);
    exit("File exceeds 2 MB.\n");
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);

if (!isset(ALLOWED[$mime])) {
    http_response_code(415);
    exit("Unsupported type: $mime\n");
}

$name = bin2hex(random_bytes(8)) . '.' . ALLOWED[$mime];

if (!move_uploaded_file($file['tmp_name'], UPLOAD_DIR . $name)) {
    http_response_code(500);
    exit("Cannot save the file.\n");
}

echo "Saved as $name ({$file['size']} bytes, $mime)\n";

First, the error element of the file must be UPLOAD_ERR_OK. The other codes tell us what went wrong: for example, UPLOAD_ERR_INI_SIZE when the file is bigger than upload_max_filesize (2 MB by default), or UPLOAD_ERR_NO_FILE when the user chose nothing.

Then we check the size ourselves. This matters when the ini limit is higher than the limit we want.

The type is detected with finfo from the bytes of the file, not from the type value sent by the client, and compared with an allow-list. The file extension comes from our allow-list too.

Finally, the file is stored under a random name generated with random_bytes. The user cannot choose a path, overwrite another file, or smuggle in a name like ../../index.php.

$ curl -s -w "[%{http_code}]\n" -F image=@small.png 127.0.0.1:8000/upload.php
Saved as 08dabe1781c261ec.png (70 bytes, image/png)
[200]
$ curl -s -w "[%{http_code}]\n" -F image=@evil.png 127.0.0.1:8000/upload.php
Unsupported type: text/x-php
[415]
$ curl -s -w "[%{http_code}]\n" -F image=@big.png 127.0.0.1:8000/upload.php
File is too large.
[400]
$ curl -s -w "[%{http_code}]\n" -F other=x 127.0.0.1:8000/upload.php
No file was uploaded.
[400]
$ curl -s -w "[%{http_code}]\n" 127.0.0.1:8000/upload.php
POST only
[405]

The file evil.png contains PHP source with a harmless .png name; finfo sees through the name and the upload is rejected.

The 3 MB big.png never reaches the script as a file. PHP refuses it first and reports UPLOAD_ERR_INI_SIZE.

PHP configuration

Several php.ini directives control uploads. The defaults are listed below.

DirectiveDefaultMeaning
file_uploadsOnAllows HTTP file uploads at all.
upload_max_filesize2MMaximum size of one uploaded file.
post_max_size8MMaximum size of the whole request; keep it larger than upload_max_filesize.
max_file_uploads20Maximum number of files in one request.
upload_tmp_dirsystem tempWhere files are held before move_uploaded_file.

When a request exceeds post_max_size, PHP discards the whole body, and both $_POST and $_FILES are empty. A form that suddenly "loses" all its fields is often hitting this limit.

To upload several files, use the same field name with brackets (name="photos[]") and the multiple attribute. $_FILES then holds arrays for each key, and the checks above are repeated in a loop.

Security checklist

The following rules summarize how to accept files safely.

Summary

In this article, we have received uploaded files with $_FILES, validated their size and real MIME type, and stored them under random names with move_uploaded_file.

Author

My name is Jan Bodnar and I am a passionate programmer with many years of programming experience. I have been writing programming articles since 2007. So far, I have written over 1400 articles and 8 e-books. I have over eight years of experience in teaching programming.

List all PHP tutorials.