Reader Stacks

How to Upload a File in Plain PHP, Without a Framework

The $_FILES superglobal and move_uploaded_file() are what Laravel's own file upload handling is built on top of — worth understanding directly, even in a framework-based project.

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

<form action="upload.php" method="POST" enctype="multipart/form-data">
    <input type="file" name="document">
    <button type="submit">Upload</button>
</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

<?php
if ($_FILES['document']['error'] !== UPLOAD_ERR_OK) {
    die('Upload failed with error code: ' . $_FILES['document']['error']);
}

$allowedTypes = ['application/pdf', 'image/jpeg', 'image/png'];
if (!in_array($_FILES['document']['type'], $allowedTypes, true)) {
    die('Invalid file type.');
}

$maxSize = 5 * 1024 * 1024; // 5MB
if ($_FILES['document']['size'] > $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 = bin2hex(random_bytes(16)) . '.pdf';
$destination = $uploadDir . $filename;

if (move_uploaded_file($_FILES['document']['tmp_name'], $destination)) {
    echo "File uploaded successfully.";
} else {
    echo "Failed to move uploaded file.";
}

move_uploaded_file(), rather than a generic rename() or copy(), verifies that the source really is a file PHP accepted through its HTTP upload mechanism before moving it. Generate the server-side filename yourself instead of trusting the client-supplied name; a random filename avoids collisions and prevents path-like user input from becoming part of your storage path.

Checking php.ini's upload limits

Following the upload-limit configuration guidance, 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.

Validate the file from its contents, not its extension or browser MIME value

A stronger version of the earlier check asks PHP's Fileinfo extension to inspect the temporary file:

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['document']['tmp_name']);

$allowed = [
    'application/pdf' => 'pdf',
    'image/jpeg' => 'jpg',
    'image/png' => 'png',
];

if (!isset($allowed[$mime])) {
    die('Unsupported file type.');
}

$filename = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];

This is still not a complete malware scanner, but it removes the mistake of deciding trust from a filename such as invoice.pdf.exe or from a client-provided content type. If the application accepts complex formats such as office documents, archives, or SVG, treat them according to what the application will later do with them, not just whether their MIME type is on a list.

Store uploads outside the web root unless direct public access is intentional

An uploads directory that the web server executes or serves without restrictions can turn a file-upload bug into code execution or persistent script hosting. Private documents should live outside the public document root and be served through application code after authorization. Public images can be exposed deliberately, but the server should still be configured so uploaded content is never interpreted as PHP.

Size limits exist at more than one layer

upload_max_filesize limits an individual uploaded file; post_max_size limits the entire request body, including form fields and multiple files. A reverse proxy or web server may have its own body-size limit before PHP sees the request. When a large upload produces an empty or unexpected $_FILES, inspect all three layers rather than only increasing the application-level maximum.