Himanshu SharmaSoftware engineer
Let’s talk

PHP · Lesson 38 of 54

File uploads

Accept file uploads with $_FILES, check errors, size and the real file type, store files safely under new names, and avoid classic upload vulnerabilities.

14 minPHP on the webUpdated 22 October 2026

Awaiting technical review. This lesson is readable but not yet listed in search engines.

By the end of this lessonAccept an uploaded file, validate it properly and store it safely with move_uploaded_file.

How an upload arrives

To send a file, a form needs two things: method="post" and enctype="multipart/form-data". Without the second, the browser sends only the file's name.

php
<form method="post" action="/import.php" enctype="multipart/form-data">
  <input type="file" name="pricelist" accept=".csv">
  <button>Upload</button>
</form>

PHP saves the uploaded data to a temporary file and describes it in $_FILES, under the field's name:

php
<?php
header('Content-Type: text/plain');
$file = $_FILES['pricelist'];
echo "name: {$file['name']}\n";
echo "type: {$file['type']}\n";
echo "size: {$file['size']}\n";
echo "error: {$file['error']}\n";
echo "tmp_name exists: " . (is_uploaded_file($file['tmp_name']) ? 'yes' : 'no') . "\n";

This is what the browser actually sends for a small CSV file. Each part of a multipart body starts with the boundary string from the header:

http
POST /inspect.php
Content-Type: multipart/form-data; boundary=----upload

------upload
Content-Disposition: form-data; name="pricelist"; filename="prices.csv"
Content-Type: text/csv

sku,price
KBD-01,89.50
MUG-04,18.75
------upload--
result
name: prices.csv
type: text/csv
size: 37
error: 0
tmp_name exists: yes
Key Meaning
name the original file name, chosen by the uploader
type the MIME type, claimed by the browser
size size in bytes
tmp_name where PHP stored the file temporarily
error UPLOAD_ERR_OK (0) on success, or an error code

PHP deletes the temporary file when the request ends, so you must move it somewhere permanent during the request.

The upload error codes

Always check error first. The common values:

Constant Meaning
UPLOAD_ERR_OK the upload succeeded
UPLOAD_ERR_INI_SIZE larger than upload_max_filesize in php.ini
UPLOAD_ERR_FORM_SIZE larger than the form's MAX_FILE_SIZE field
UPLOAD_ERR_PARTIAL only part of the file arrived
UPLOAD_ERR_NO_FILE no file was chosen

If a request is larger than post_max_size, PHP discards it entirely and $_FILES and $_POST are both empty, so treat an empty $_FILES on a POST as "too large or missing". Raise the two settings in php.ini if your users need bigger uploads; the defaults are small (2 MB per file and 8 MB per request).

Validate the real file

Everything in $_FILES except tmp_name, size and error comes from the uploader. A file called cat.jpg with the type image/jpeg can really be a PHP script. Check what the file really is with the finfo extension, which reads the file's contents:

php
<?php
header('Content-Type: text/plain');

const MAX_BYTES = 1024 * 1024; // 1 MB
const ALLOWED = ['text/csv' => 'csv', 'text/plain' => 'csv'];

$file = $_FILES['pricelist'] ?? null;
if (!$file || $file['error'] === UPLOAD_ERR_NO_FILE) {
    exit("Please choose a file.");
}
if ($file['error'] !== UPLOAD_ERR_OK) {
    exit("Upload failed (error {$file['error']}).");
}
if ($file['size'] > MAX_BYTES) {
    exit("The file is larger than 1 MB.");
}

$type = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset(ALLOWED[$type])) {
    exit("Only CSV files are accepted (this looks like $type).");
}

$folder = __DIR__ . '/../storage/imports';
if (!is_dir($folder)) {
    mkdir($folder, 0775, true);
}
$target = $folder . '/' . bin2hex(random_bytes(8)) . '.' . ALLOWED[$type];

if (!move_uploaded_file($file['tmp_name'], $target)) {
    exit("Could not save the file.");
}

$rows = array_map('str_getcsv', file($target, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
echo "Imported " . (count($rows) - 1) . " prices\n";
foreach (array_slice($rows, 1) as [$sku, $price]) {
    echo "$sku => $price\n";
}

The same CSV upload, now validated and stored:

http
POST /import.php
Content-Type: multipart/form-data; boundary=----upload

------upload
Content-Disposition: form-data; name="pricelist"; filename="prices.csv"
Content-Type: text/csv

sku,price
KBD-01,89.50
MUG-04,18.75
------upload--
result
Imported 2 prices
KBD-01 => 89.50
MUG-04 => 18.75

Now a file that is really PHP code, uploaded with a harmless-looking name and type. A real attacker would put something dangerous in it; this one only prints a line, but to the server it is just as much a script:

http
POST /import.php
Content-Type: multipart/form-data; boundary=----upload

------upload
Content-Disposition: form-data; name="pricelist"; filename="prices.csv"
Content-Type: text/csv

<?php echo 'This file is a PHP script'; ?>
------upload--
result
Only CSV files are accepted (this looks like text/x-php).

finfo looked at the contents and saw through the disguise. Note the other defences in that script, which together matter more than any single check:

  • A new file name from random_bytes(), with an extension chosen from the validated type. The uploader's name is never used for the path, so names such as ../../index.php or photo.php cannot do harm.
  • A folder outside the web root (../storage), so even a file that slipped through cannot be requested and run by a browser.
  • move_uploaded_file(), which refuses to move anything that was not really uploaded in this request.

Images

For image uploads, check the type with finfo and also confirm that PHP can read the image with getimagesize(), which returns its width and height or false. Store images under a generated name with an extension from your own list (jpg, png, webp), and never accept SVG files from the public: an SVG is XML that can contain scripts. If images must be shown on the site, serve them from a folder where the web server is configured not to run PHP, like the uploads/tools/ folder this site's admin uses for tool images, or resize and re-save them with an image library, which strips anything hidden inside.

Common mistakes

  • Forgetting enctype="multipart/form-data", so $_FILES is empty.
  • Trusting the file name, the extension or $_FILES[...]['type'], all of which the uploader controls.
  • Saving uploads inside the web root under their original names, the classic way websites get taken over.
  • Not checking the error code and size before using the file.

Exercise

Change the import script so it also rejects CSV files with more than 1,000 data rows, and so that every price must be a valid number of at least 0. Write just the extra checks that go after the file has been read into $rows.

Show one possible answer
php
$data = array_slice($rows, 1);
if (count($data) > 1000) {
    exit("Please upload at most 1,000 prices at a time.");
}
foreach ($data as $line => $row) {
    $price = filter_var($row[1] ?? '', FILTER_VALIDATE_FLOAT, ['options' => ['min_range' => 0]]);
    if ($price === false) {
        exit("Row " . ($line + 2) . " has an invalid price.");
    }
}

$line + 2 turns the array index into the line number a person sees in the file: one for counting from 1 and one for the header row.

Key points

  • A form needs method="post" and enctype="multipart/form-data" to send files; PHP describes each file in $_FILES.
  • Check $_FILES['field']['error'] === UPLOAD_ERR_OK, then the size, then the real type with finfo, never the browser's type or the file name.
  • Save with move_uploaded_file() under a name you generate, in a folder where files cannot be run as code.
  • upload_max_filesize and post_max_size in php.ini limit how large uploads can be.

Check yourself

1. Which form attribute is required for file uploads?
2. Why should you not trust $_FILES['photo']['type']?
3. Why save uploads under a generated name instead of the original file name?
4. What does move_uploaded_file() check that rename() does not?