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.
<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
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:
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--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
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:
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--Imported 2 prices
KBD-01 => 89.50
MUG-04 => 18.75Now 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:
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--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.phporphoto.phpcannot 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
$_FILESis 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
$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.