Handling a file upload in plain PHP, without any framework, is built on the $_FILES superglobal and move_uploaded_file() — understanding this directly is genuinely useful even for framework-based work, since it's exactly what Laravel's own upload handling wraps under a friendlier API.
The upload form
enctype="multipart/form-data" is required on the form itself — without it, the browser doesn't actually encode and send the file content at all, only its filename as plain text.
The $_FILES superglobal's structure
print_r($_FILES);
/*
[document] => Array
(
[name] => report.pdf
[type] => application/pdf
[tmp_name] => /tmp/phpXXXXXX
[error] => 0
[size] => 245678
)
*/
tmp_name is where PHP has already saved the uploaded file temporarily — it needs to be explicitly moved to a permanent location before the request finishes, or it's automatically deleted.
Validating the upload before moving it
$maxSize) {
die('File too large.');
}
Checking the error field first matters — a value other than UPLOAD_ERR_OK (0) means something went wrong during upload (exceeded size limit, partial upload, no file selected), and attempting to process tmp_name without this check first is a common source of confusing failures.
Why checking the client-reported MIME type isn't fully trustworthy
The type field in $_FILES comes from the browser and can be spoofed by anyone crafting a request directly — for a genuine security check (not just a UX convenience), verifying the file's actual content with finfo_file() or by checking file signature bytes directly is more reliable than trusting this client-supplied value alone.
Moving the uploaded file to a permanent location
$uploadDir = __DIR__ . '/uploads/';
$filename = uniqid() . '_' . basename($_FILES['document']['name']);
$destination = $uploadDir . $filename;
if (move_uploaded_file($_FILES['document']['tmp_name'], $destination)) {
echo "File uploaded successfully to $filename";
} else {
echo "Failed to move uploaded file.";
}
move_uploaded_file(), not a generic rename() or copy(), is specifically required here — it performs an extra security check confirming the file genuinely was uploaded via HTTP POST (rather than, say, an attacker's crafted path pointing at an arbitrary server file), which a generic file-move function doesn't verify.
Generating a unique filename to avoid overwrites and directory traversal
Using uniqid() (or a similar random component) rather than the original uploaded filename directly avoids both accidentally overwriting an existing file with the same name and any directory-traversal risk from a maliciously crafted filename like ../../etc/passwd in the original name field.
Checking php.ini's upload limits
Following the guidance covered elsewhere on this site, upload_max_filesize and post_max_size both cap what PHP will accept before your own code even runs — a file larger than these limits produces an error in $_FILES before any application-level validation gets a chance to run at all.