PHP File Uploads
File uploads let a website accept a file, an image, a PDF, a spreadsheet, straight from a visitor’s browser and store it on the server for later use. PHP exposes every uploaded file through a special superglobal array called $_FILES, plus a small set of built-in functions to validate, inspect, and safely move that file. Handling uploads correctly matters because this is one of the few places where a web application lets a stranger put arbitrary bytes onto your server’s disk, so getting the validation and security details right is essential, not optional.
Overview: How File Uploads Work in PHP
A file upload starts with an HTML form whose method is POST and whose enctype is multipart/form-data. That encoding tells the browser to split the request body into named parts separated by a boundary string, one part per form field, with file fields carrying the file’s binary content plus a filename and a content type. A normal application/x-www-form-urlencoded POST cannot reliably carry binary file data, which is why the enctype attribute is required.
When that request reaches the server, PHP parses the multipart body before your script’s first line even runs. During this bootstrap phase PHP streams each uploaded file to a temporary location on disk, controlled by the upload_tmp_dir directive, or the system’s default temp directory, and records metadata about each file: its original name, its client-reported MIME type, the temporary path, an error code, and its size, into the $_FILES superglobal. By the time your code executes, the file already exists on disk; your job is only to validate it and decide whether to keep it.
That temporary file is deleted automatically when the request finishes. If you want to keep the upload, you must explicitly move it with move_uploaded_file() before your script ends. Uploads are also governed by several php.ini settings, and if one of them rejects the request, the file is too big, there are too many files, or uploads are disabled entirely, PHP will not throw an exception. Instead $_FILES reflects the failure through the file’s error key, so you must always check it.
Syntax
An upload form needs three things: a POST method, enctype="multipart/form-data", and at least one input field of type="file". On the PHP side, every uploaded field becomes an entry in $_FILES keyed by that field’s name attribute. The snippet below builds that structure by hand so you can see its shape:
<?php
// Structure of $_FILES after submitting a form field named "avatar"
$exampleStructure = [
'avatar' => [
'name' => 'profile.jpg',
'type' => 'image/jpeg',
'tmp_name' => '/tmp/phpXXXXXX',
'error' => 0,
'size' => 204800,
],
];
print_r($exampleStructure);
Output:
Array
(
[avatar] => Array
(
[name] => profile.jpg
[type] => image/jpeg
[tmp_name] => /tmp/phpXXXXXX
[error] => 0
[size] => 204800
)
)
Each uploaded field gives you these five keys. Only tmp_name, error, and size are trustworthy, since name and type come from the visitor’s browser and can be forged.
$_FILES sub-array keys
| Key | Meaning |
|---|---|
name |
Original file name on the visitor’s computer. Untrusted; never use it directly as a save path. |
type |
MIME type reported by the browser. Sent by the client, so it can be spoofed; never trust it for security decisions. |
tmp_name |
Full path to the temporary file PHP created on the server. This is the actual uploaded data. |
error |
An integer code from the UPLOAD_ERR_* constants. 0 means success. |
size |
Size of the uploaded file in bytes, as reported during upload. |
Relevant php.ini directives
| Directive | Purpose |
|---|---|
file_uploads |
Must be On for PHP to accept uploads at all (default is On). |
upload_max_filesize |
Maximum size of a single uploaded file, e.g. 2M. |
post_max_size |
Maximum size of the entire POST body; must be at least as large as the total of all uploaded files combined. |
max_file_uploads |
Maximum number of files accepted in one request, default 20. |
upload_tmp_dir |
Directory where PHP stores temporary uploaded files before your script moves them. |
Upload error codes
| Constant | Value | Meaning |
|---|---|---|
UPLOAD_ERR_OK |
0 | No error, upload succeeded. |
UPLOAD_ERR_INI_SIZE |
1 | File exceeds upload_max_filesize. |
UPLOAD_ERR_FORM_SIZE |
2 | File exceeds the form’s MAX_FILE_SIZE field. |
UPLOAD_ERR_PARTIAL |
3 | File was only partially uploaded. |
UPLOAD_ERR_NO_FILE |
4 | No file was submitted. |
UPLOAD_ERR_NO_TMP_DIR |
6 | Missing a temporary folder on the server. |
UPLOAD_ERR_CANT_WRITE |
7 | Failed to write the file to disk. |
Examples
Example 1: A basic single-file upload
The simplest handler checks the error code, then moves the temporary file into a permanent uploads folder using the original name.
<?php
$uploadDir = __DIR__ . '/uploads/';
$fileName = basename($_FILES['avatar']['name']);
$targetPath = $uploadDir . $fileName;
if ($_FILES['avatar']['error'] === UPLOAD_ERR_OK) {
if (move_uploaded_file($_FILES['avatar']['tmp_name'], $targetPath)) {
echo "File uploaded successfully: {$fileName}";
} else {
echo "Failed to move the uploaded file.";
}
} else {
echo "Upload error code: {$_FILES['avatar']['error']}";
}
Output:
File uploaded successfully: profile.jpg
Assuming the visitor picked a file named profile.jpg and the upload succeeded, move_uploaded_file() copies the temporary file to uploads/profile.jpg and returns true. Note that basename() strips any directory information from the original name, which is the bare minimum protection against a crafted filename trying to write outside the uploads folder.
Example 2: Validating size, extension, and real file content
Real applications must verify more than just “did an upload happen.” This example enforces a size limit, an allow-list of extensions, and, most importantly, checks the file’s actual content using the fileinfo extension rather than trusting the browser.
<?php
function validateUpload(array $file): array
{
$errors = [];
$maxSize = 2 * 1024 * 1024; // 2 MB
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif'];
$allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif'];
if ($file['error'] !== UPLOAD_ERR_OK) {
$errors[] = 'Upload failed with error code ' . $file['error'];
return $errors;
}
if ($file['size'] > $maxSize) {
$errors[] = 'File exceeds the 2 MB size limit.';
}
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions, true)) {
$errors[] = 'File extension is not allowed.';
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedMimeTypes, true)) {
$errors[] = 'File content does not match an allowed image type.';
}
return $errors;
}
$errors = validateUpload($_FILES['photo']);
echo empty($errors) ? 'Validation passed.' : implode("\n", $errors);
Output:
Validation passed.
For a genuine 500 KB PNG file, every check passes and $errors stays empty. The key line is the call to finfo_file(), which inspects the file’s actual bytes (its “magic number”) rather than the filename or the client-supplied type field. A file renamed from shell.php to photo.png would fail this check even though its extension looks fine.
Example 3: Handling multiple files from one field
Giving a file input the name documents[] lets a visitor select several files at once. PHP then gives you parallel arrays instead of a single flat structure, so you loop over the indexes.
<?php
$uploadDir = __DIR__ . '/uploads/';
$uploadedCount = 0;
foreach ($_FILES['documents']['name'] as $index => $name) {
if ($_FILES['documents']['error'][$index] === UPLOAD_ERR_OK) {
$tmpName = $_FILES['documents']['tmp_name'][$index];
$safeName = uniqid('doc_', true) . '_' . basename($name);
if (move_uploaded_file($tmpName, $uploadDir . $safeName)) {
$uploadedCount++;
}
}
}
echo "Uploaded {$uploadedCount} file(s) successfully.";
Output:
Uploaded 3 file(s) successfully.
If the visitor selected three valid files and all three moved successfully, the counter ends at 3. Each file gets a unique prefix from uniqid() so that two visitors uploading files with the same original name never overwrite each other.
Under the Hood: What Happens Step by Step
- The browser encodes the form as
multipart/form-data, splitting the request body into boundary-separated parts, one per field, with file parts carrying binary content plus a filename and content type header. - The web server hands the raw request body to PHP as part of starting the script’s request lifecycle.
- Before executing a single line of your code, PHP’s request startup routine parses the multipart body, enforcing
post_max_sizeandupload_max_filesizeas it goes, and writes each file’s contents to a uniquely named temporary file insideupload_tmp_dir. - PHP populates
$_FILESwith the metadata for every file part it processed, including anUPLOAD_ERR_*code for each one, whether it succeeded or not. - Your script runs. At this point the temporary file already exists on disk; nothing you do in PHP affects whether the upload itself succeeded.
- If you never call
move_uploaded_file(), PHP deletes every temporary upload file automatically once the request finishes, so an upload you don’t move is effectively discarded. move_uploaded_file()internally verifies, viais_uploaded_file(), that the source path really was created by PHP’s own upload mechanism during this request. This prevents a malicious script from tricking your code into “moving” an arbitrary file elsewhere on the server just by controlling thetmp_namevalue.
Common Mistakes
Mistake 1: Trusting the client-supplied MIME type
It’s tempting to check $_FILES['file']['type'] because it looks like an authoritative content type. It isn’t: the browser sets it based on the file’s extension or its own guess, and an attacker sending a raw HTTP request can set it to anything at all.
<?php
if ($_FILES['file']['type'] === 'image/png') {
move_uploaded_file($_FILES['file']['tmp_name'], '/uploads/' . $_FILES['file']['name']);
echo "Image uploaded.";
}
This code trusts a header the visitor fully controls. Someone can upload a PHP script, label it image/png, and if the uploads directory is inside the webroot and executes PHP, they now have a working backdoor. The fix is to inspect the file’s real content with the fileinfo extension and to store the file under a name your code generates, not the original one:
<?php
$file = $_FILES['file'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if ($mimeType === 'image/png') {
$safeName = bin2hex(random_bytes(8)) . '.png';
move_uploaded_file($file['tmp_name'], '/uploads/' . $safeName);
echo "Image uploaded as {$safeName}.";
} else {
echo "Rejected: file content is not a PNG image.";
}
Mistake 2: Skipping the error check and the original filename
Calling move_uploaded_file() without first checking the error key, and without sanitizing name, causes two separate problems at once.
<?php
move_uploaded_file($_FILES['file']['tmp_name'], '/uploads/' . $_FILES['file']['name']);
echo "Upload complete.";
If no file was selected, or the upload failed, tmp_name is empty and move_uploaded_file() emits a warning and returns false, yet the script still prints “Upload complete.”, misleading the user. Separately, using the raw name value means a crafted filename containing path segments could try to write outside the intended folder. The corrected version checks the error code, confirms the file genuinely came from an HTTP upload, and strips directory information from the name:
<?php
if ($_FILES['file']['error'] === UPLOAD_ERR_OK && is_uploaded_file($_FILES['file']['tmp_name'])) {
move_uploaded_file($_FILES['file']['tmp_name'], '/uploads/' . basename($_FILES['file']['name']));
echo "Upload complete.";
} else {
echo "Upload failed or no file was selected.";
}
Best Practices
- Never trust
$_FILES[...]['type']or['name']for security decisions; verify real content withfinfo_file()and use an allow-list of MIME types and extensions, never a deny-list. - Always check the
errorkey before doing anything else with an uploaded file. - Generate your own filename for stored files (a random string or UUID plus a validated extension) instead of trusting the original name.
- Store uploaded files outside the public webroot when possible, or in a directory configured to never execute scripts, so an uploaded file can never be run as code.
- Enforce size limits in both
php.ini(upload_max_filesize,post_max_size) and in your own validation code, since ini limits alone give unhelpful, hard-to-customize error handling. - Use
move_uploaded_file(), never a manualcopy()orrename(), since it internally verifies the source really was an HTTP upload. - When accepting many files at once, remember
post_max_sizemust comfortably exceedupload_max_filesizetimes the expected file count, and raisemax_file_uploadsif needed. - Avoid echoing raw server file paths or error internals back to the visitor; log details server-side and show a generic message instead.
Practice Exercises
- Write a script that accepts a single PDF upload through a field named
resume. Reject anything larger than 5 MB or whose real content type (viafinfo_file()) is notapplication/pdf, and echo a clear success or rejection message. - Extend the multiple-file example from this lesson so that each file is also checked for size and extension before being moved. Track and separately report how many files were uploaded successfully versus how many were skipped for failing validation.
- Write a function
generateSafeFilename(string $originalName): stringthat keeps only the validated extension from the original name and replaces everything else with a random string fromrandom_bytes(). Explain in a comment why this is safer than reusing the visitor’s original filename.
Summary
- Uploaded files arrive in the
$_FILESsuperglobal, populated by PHP before your script code runs. - The file behind
tmp_nameonly exists for the current request; callmove_uploaded_file()to keep it permanently. - The client-supplied
nameandtypefields are untrusted input; validate real file content with thefileinfoextension and sanitize or replace the filename. - Always check the
errorkey against theUPLOAD_ERR_*constants before processing a file. upload_max_filesize,post_max_size, andmax_file_uploadsinphp.inigovern what PHP will accept before your code even runs.- Store validated uploads outside the webroot or in a non-executable directory whenever possible to prevent an uploaded file from ever being run as a script.
