File Uploads

Profile pictures, documents, CSV imports — many applications accept files from users. PHP receives uploads automatically and describes them in the $_FILES array. Because an upload is a file chosen by a stranger, checking it properly is essential: size, type, name and where it's stored all matter.

The upload form

An upload form must use method="post" and enctype="multipart/form-data", with an <input type="file">:

HTML
<form method="post" action="upload.php" enctype="multipart/form-data">
  <input type="file" name="photo" accept="image/png,image/jpeg">
  <input name="caption">
  <button>Upload</button>
</form>

What PHP receives

PHP saves the file to a temporary location and fills $_FILES['photo'] with details about it. The temporary file is deleted at the end of the request unless you move it:

TXT notes.txt
Meeting notes: launch on Friday.
PHP upload.php
<?php
header('Content-Type: text/plain');

$file = $_FILES['document'];
echo "name:     {$file['name']}\n";
echo "type:     {$file['type']}\n";
echo "size:     {$file['size']} bytes\n";
echo "error:    {$file['error']} (", $file['error'] === UPLOAD_ERR_OK ? "OK" : "failed", ")\n";
echo "tmp file: ", is_uploaded_file($file['tmp_name']) ? "exists" : "missing", "\n";
echo "caption:  {$_POST['caption']}\n";
HTTP
POST /upload.php caption=Notes
FILE document=notes.txt
Responses
> POST /upload.php
name:     notes.txt
type:     text/plain
size:     33 bytes
error:    0 (OK)
tmp file: exists
caption:  Notes

Ordinary fields sent with the upload still arrive in $_POST.

Important: name and type come from the browser, so an attacker controls them completely. A file called cat.jpg with type image/jpeg could be a PHP script. Only size, error and the file's actual contents can be trusted.

Handling uploads safely

A robust upload handler checks, in order:

  1. that the upload succeeded (error === UPLOAD_ERR_OK);
  2. the size, against your own limit;
  3. the real type, by inspecting the contents with finfo;
  4. and then stores it under a name you generate, with an extension you choose, outside the code folder or in a folder where scripts can't run.
TXT shell.png
<?php system($_GET['cmd']); ?>
PHP upload.php
<?php
header('Content-Type: text/plain');

const MAX_BYTES = 2 * 1024 * 1024;
const ALLOWED = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'text/plain' => 'txt'];

function storeUpload(array $file, string $dir): string
{
    if ($file['error'] !== UPLOAD_ERR_OK) {
        throw new RuntimeException(match ($file['error']) {
            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File is too large.',
            UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
            default => 'Upload failed.',
        });
    }
    if ($file['size'] > MAX_BYTES) {
        throw new RuntimeException('File is larger than 2 MB.');
    }

    $mime = new finfo(FILEINFO_MIME_TYPE)->file($file['tmp_name']);
    if (!isset(ALLOWED[$mime])) {
        throw new RuntimeException("Files of type $mime are not allowed.");
    }

    @mkdir($dir, 0755, true);
    $name = bin2hex(random_bytes(8)) . '.' . ALLOWED[$mime];
    if (!move_uploaded_file($file['tmp_name'], "$dir/$name")) {
        throw new RuntimeException('Could not save the file.');
    }
    return $name;
}

try {
    $saved = storeUpload($_FILES['file'] ?? ['error' => UPLOAD_ERR_NO_FILE], __DIR__ . '/storage/uploads');
    echo "Saved as a ", strlen($saved), "-character random name with extension .", pathinfo($saved, PATHINFO_EXTENSION), "\n";
} catch (RuntimeException $e) {
    http_response_code(400);
    echo "Error: ", $e->getMessage(), "\n";
}
HTTP
POST /upload.php
FILE file=notes.txt
POST /upload.php
FILE file=shell.png
POST /upload.php
Responses
> POST /upload.php
Saved as a 20-character random name with extension .txt
> POST /upload.php
< 400 Bad Request
Error: Files of type text/x-php are not allowed.
> POST /upload.php
< 400 Bad Request
Error: No file was uploaded.

The second request is the interesting one: an attacker uploaded a PHP script named shell.png, and their browser (or script) can claim it's image/png. Checking the name or the claimed type would have let it through — and if it were saved as shell.png, or worse shell.php, in a public folder, the attacker could run commands on your server. finfo looks at the actual bytes, sees PHP source code, and the upload is rejected. Even accepted files get a random name and an extension chosen from the detected type.

Upload limits

Several php.ini settings cap uploads, and your web server may have its own limits too:

SettingDefaultMeaning
file_uploadsOnUploads enabled
upload_max_filesize2MMaximum size of one file
post_max_size8MMaximum size of the whole request
max_file_uploads20Maximum number of files per request

If a request exceeds post_max_size, PHP discards it entirely: $_POST and $_FILES are both empty. Check for that case before validating individual fields.

Multiple files

With name="photos[]" and the multiple attribute, $_FILES['photos'] holds arrays: $_FILES['photos']['name'][0], $_FILES['photos']['name'][1] and so on. A small loop rearranges it into one array per file:

PHP
<?php
$files = [];
foreach ($_FILES['photos']['name'] as $i => $name) {
    $files[] = [
        'name' => $name,
        'tmp_name' => $_FILES['photos']['tmp_name'][$i],
        'size' => $_FILES['photos']['size'][$i],
        'error' => $_FILES['photos']['error'][$i],
    ];
}

Serving uploaded files

  • Store uploads outside the public web folder, and serve them through a PHP script that checks permissions and sends the right Content-Type — or in a folder configured so the server never executes scripts there.
  • Keep the user's original filename in your database if you want to display it, but never use it as the stored filename.
  • For images, consider re-encoding them with an image library, which strips anything hidden in the file.
  • Large applications often store uploads in object storage such as Amazon S3 instead of on the web server's disk.

Try it yourself

Write a handler that accepts a CSV file and reports how many rows and columns it has.

Try it yourself · import.php
Edit the code, copy it into import.php, then start PHP's built-in web server with php -S localhost:8000 in that folder and open http://localhost:8000/import.php (PHP 8.5). How to install PHP