Search
PHP File Upload Tutorial: Secure Image Upload with Validation, MIME Type Check and Multiple Files
Profile pictures, product photos, payment receipts, scanned IDs: sooner or later almost every PHP project needs a file upload form. It is also one of the easiest places to open a serious security hole. A careless upload script lets someone send a .php file instead of a photo, open it in the browser, and run any command they like on your server.
In this PHP file upload tutorial you will build a small image uploader step by step: an HTML form that accepts one or many images, a handler that checks every upload properly (error codes, size, real MIME type, real image data), random safe file names, and an upload folder that can never run scripts. Every file was run on PHP 8.3, and the test results at the end are copied straight from the terminal.
Requirements
- PHP 8.0 or newer (the code uses
matchand arrow functions). XAMPP, Laragon, WAMP or the PHP built-in server all work. - The
fileinfoextension, which is enabled by default in most installs. On Windows/XAMPP, make sureextension=fileinfois not commented out inphp.ini. - Basic PHP: forms, arrays and sessions.
The complete project, sample images and test script are in the zip file at the bottom of this page.
Project Structure
image-upload/
├── config.php settings: upload folder, size limit, allowed types
├── functions.php validation and helper functions
├── index.php upload form and gallery
├── upload.php handles the POST request
├── style.css
└── uploads/
└── .htaccess blocks scripts inside the upload folder
Step 1: Understand the $_FILES Array
When a form is submitted with enctype="multipart/form-data", PHP saves each file to a temporary folder and describes it in $_FILES. To see exactly what PHP gives you, save this tiny script as show_files.php:
I started it with php -S localhost:8001 and sent files with curl, first one file named avatar, then two files using photos[]:
$ curl -F "avatar=@samples/beach.jpg" localhost:8001/show_files.php
Array
(
[avatar] => Array
(
[name] => beach.jpg
[full_path] => beach.jpg
[type] => image/jpeg
[tmp_name] => /tmp/phpFjbChM
[error] => 0
[size] => 3641
)
)
$ curl -F "photos[]=@samples/logo.png" -F "photos[]=@samples/sunset.webp" localhost:8001/show_files.php
Array
(
[photos] => Array
(
[name] => Array
(
[0] => logo.png
[1] => sunset.webp
)
[full_path] => Array
(
[0] => logo.png
[1] => sunset.webp
)
[type] => Array
(
[0] => image/png
[1] => application/octet-stream
)
[tmp_name] => Array
(
[0] => /tmp/phpdlfJpM
[1] => /tmp/phpEFPLpM
)
[error] => Array
(
[0] => 0
[1] => 0
)
[size] => Array
(
[0] => 1129
[1] => 870
)
)
)
Three things to notice:
nameandtypecome from the browser. The user controls them completely. Look at the second request: curl sent the WEBP image asapplication/octet-stream. A browser could just as easily label a PHP script asimage/jpeg. Never use these two values for security decisions.tmp_nameis where PHP stored the file. It is deleted automatically when the request ends, so you must move it somewhere permanent.- With
photos[](multiple files), PHP groups the data by property, not by file: all names together, all sizes together. We will write a helper to flip that into a normal list.
Step 2: The Configuration File
Keep the settings in one place. Save this as config.php:
- <?php
- // Upload settings. Change these to fit your project.
- const UPLOAD_DIR = __DIR__ . '/uploads/';
- const UPLOAD_URL = 'uploads/';
- const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
- // Allowed MIME types and the extension we will save each one with.
- const ALLOWED_TYPES = [
- 'image/jpeg' => 'jpg',
- 'image/png' => 'png',
- 'image/gif' => 'gif',
- 'image/webp' => 'webp',
- ];
- function e(string $value): string
- {
- }
The ALLOWED_TYPES map is an allowlist. Each real MIME type is paired with the extension we will save it with, so the extension of the stored file never comes from the user. The e() helper escapes output to prevent XSS when we print file names.
Step 3: The Upload Form
Save this as index.php. It shows the form, any messages from the last upload, and a gallery of saved images:
- <?php
- require_once __DIR__ . '/functions.php';
- $messages = $_SESSION['messages'] ?? [];
- $images = listImages();
- ?>
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>PHP Image Upload</title>
- <link rel="stylesheet" href="style.css">
- </head>
- <body>
- <main>
- <h1>PHP Image Upload</h1>
- <?php foreach ($messages as [$type, $text]): ?>
- <p class="msg <?= e($type) ?>"><?= e($text) ?></p>
- <?php endforeach; ?>
- <form action="upload.php" method="post" enctype="multipart/form-data">
- <input type="hidden" name="MAX_FILE_SIZE" value="<?= MAX_FILE_SIZE ?>">
- <label for="photos">Choose images (JPG, PNG, GIF or WEBP, max 2 MB each)</label>
- <input type="file" id="photos" name="photos[]" accept="image/*" multiple required>
- <button type="submit">Upload</button>
- </form>
- <div class="gallery">
- <?php foreach ($images as $img): ?>
- <a href="<?= e(UPLOAD_URL . $img) ?>" target="_blank">
- <img src="<?= e(UPLOAD_URL . $img) ?>" alt="Uploaded image">
- </a>
- <?php endforeach; ?>
- </div>
- </main>
- </body>
- </html>
The two details that beginners usually miss:
enctype="multipart/form-data"on the form. Without it,$_FILESstays empty.name="photos[]"with themultipleattribute, so users can pick several images at once.
The hidden MAX_FILE_SIZE field and the accept="image/*" attribute are only conveniences for the user. Anyone can remove them, so the real checks must happen on the server.
Step 4: Validate and Save Each File
This is the heart of the tutorial. Save this as functions.php:
- <?php
- require_once __DIR__ . '/config.php';
- /**
- * Translate PHP's upload error codes into friendly messages.
- */
- function uploadErrorMessage(int $code): string
- {
- return match ($code) {
- UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file is too large.',
- UPLOAD_ERR_PARTIAL => 'The file was only partially uploaded. Please try again.',
- UPLOAD_ERR_NO_FILE => 'Please choose a file to upload.',
- UPLOAD_ERR_NO_TMP_DIR => 'Server error: missing temporary folder.',
- UPLOAD_ERR_CANT_WRITE => 'Server error: failed to write the file to disk.',
- UPLOAD_ERR_EXTENSION => 'Server error: a PHP extension stopped the upload.',
- default => 'Unknown upload error.',
- };
- }
- /**
- * Validate one uploaded image and move it into UPLOAD_DIR.
- * Returns the new file name on success, or throws RuntimeException.
- */
- {
- // 1. Did PHP receive the file without errors?
- throw new RuntimeException('Invalid upload request.');
- }
- if ($file['error'] !== UPLOAD_ERR_OK) {
- throw new RuntimeException(uploadErrorMessage($file['error']));
- }
- // 2. Is it really an uploaded file and within our size limit?
- throw new RuntimeException('Possible file upload attack.');
- }
- if ($file['size'] > MAX_FILE_SIZE) {
- throw new RuntimeException('The file is too large. Maximum size is 2 MB.');
- }
- // 3. Check the real content type. Never trust $file['type'] or the file name.
- $finfo = new finfo(FILEINFO_MIME_TYPE);
- throw new RuntimeException('Only JPG, PNG, GIF and WEBP images are allowed.');
- }
- // 4. Make sure the image can actually be read as an image.
- if ($info === false || $info[0] < 1 || $info[1] < 1) {
- throw new RuntimeException('The file is not a valid image.');
- }
- // 5. Build a new, random file name with an extension WE choose.
- throw new RuntimeException('Server error: cannot create the upload folder.');
- }
- // 6. Move it out of the temporary folder.
- throw new RuntimeException('Server error: failed to save the file.');
- }
- return $newName;
- }
- /**
- * Turn the awkward $_FILES['photos'] structure for multiple uploads
- * into a simple list of single-file arrays.
- */
- {
- $list = [];
- foreach ($files['name'] as $i => $name) {
- $list[] = [
- 'name' => $name,
- 'type' => $files['type'][$i],
- 'tmp_name' => $files['tmp_name'][$i],
- 'error' => $files['error'][$i],
- 'size' => $files['size'][$i],
- ];
- }
- return $list;
- }
- /**
- * List uploaded images, newest first.
- */
- {
- );
- }
saveUploadedImage() runs six checks in order, and stops at the first failure:
- Upload error code. PHP reports problems like a file that is too large for
php.inior an upload that was cut off.uploadErrorMessage()turns the numeric codes into readable messages. is_uploaded_file()and size. Confirms the path really came from an HTTP upload, then enforces our own 2 MB limit.- Real MIME type with
finfo.finforeads the first bytes of the file itself, so a PHP script renamed tophoto.jpgis detected astext/x-php, not an image. getimagesize(). A second check that the file can actually be read as an image with a real width and height.- A random new name.
bin2hex(random_bytes(16))gives 32 random hex characters, and the extension comes from our allowlist. This stops path tricks like../../index.php, avoids overwriting other users' files, and makes uploaded files hard to guess. move_uploaded_file(). The only safe way to move a file out of the temporary folder.
The listImages() function filters the folder by our allowed extensions. I first wrote it with glob(..., GLOB_BRACE), but GLOB_BRACE does not exist on every system (my test PHP build reported "Undefined constant GLOB_BRACE"), so the version above uses array_filter() instead and works everywhere.
Step 5: Handle the POST Request
Save this as upload.php:
- <?php
- require_once __DIR__ . '/functions.php';
- // Only accept POST requests.
- if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
- exit;
- }
- $messages = [];
- // If the whole request is bigger than post_max_size, PHP empties $_POST and $_FILES.
- $messages[] = ['error', 'The upload is larger than the server allows.'];
- $messages[] = ['error', 'Please choose a file to upload.'];
- } else {
- foreach (normalizeFiles($_FILES['photos']) as $file) {
- if ($file['error'] === UPLOAD_ERR_NO_FILE) {
- continue; // empty file input, skip it
- }
- try {
- $saved = saveUploadedImage($file);
- $messages[] = ['success', "Uploaded {$file['name']} as $saved"];
- } catch (RuntimeException $ex) {
- $messages[] = ['error', "{$file['name']}: " . $ex->getMessage()];
- }
- }
- }
- if (!$messages) {
- $messages[] = ['error', 'Please choose a file to upload.'];
- }
- // Post/Redirect/Get: show the messages on index.php.
- $_SESSION['messages'] = $messages;
- exit;
A few things worth pointing out:
- Each file is checked separately, so one bad file does not stop the good ones from being saved.
- If the whole request is bigger than
post_max_size, PHP throws away$_POSTand$_FILEScompletely. The only clue is aCONTENT_LENGTHheader with an empty$_FILES, which is what the firstifchecks. - After processing, the script stores the messages in the session and redirects back to
index.php. This Post/Redirect/Get pattern prevents the browser from re-uploading everything when the user presses refresh.
Step 6: Stop Scripts From Running in the Upload Folder
Even with all the checks above, it is good practice to add a second layer of protection. On Apache (including XAMPP), put this .htaccess file inside the uploads folder:
# Never run scripts from the upload folder (Apache).
<FilesMatch "\.(php|phtml|phar|php\d|pl|py|cgi|sh)$">
Require all denied
</FilesMatch>
<IfModule mod_php.c>
php_flag engine off
</IfModule>
Options -Indexes -ExecCGI
If anything ever slips through, the web server will still refuse to execute it. On Nginx, add a location rule that denies .php files under /uploads/. Even better, store uploads outside the public web root and serve them through a PHP script that checks permissions, which is the right approach for private files like IDs and receipts.
Step 7: Check php.ini Limits
Two php.ini settings decide how big an upload can be before your code even runs:
upload_max_filesize: the maximum size of a single file. The default is2M.post_max_size: the maximum size of the whole request. The default is8M, and it must be larger thanupload_max_filesize.
Our app enforces its own 2 MB limit in code, so for testing I raised upload_max_filesize to 5 MB to make sure our own check, not PHP's, produced the error. Also keep display_errors off on a live server: when a request exceeds post_max_size, PHP prints a warning before your script runs, and if that warning is displayed it breaks the redirect.
Step 8: Test It
Start the app from inside the image-upload folder:
php -S localhost:8000 -d upload_max_filesize=5M -d post_max_size=8M -d display_errors=0 -d log_errors=1
Open http://localhost:8000 and upload a few images. To test the tricky cases quickly, the zip includes test_upload.sh, which uses curl to send good and bad files, including a one-line PHP web shell renamed to shell.jpg and sent with the image/jpeg type. Here is the real output:
1) valid JPG: [success] Uploaded beach.jpg as 8248c3553ff48cd817bfa63309c7c4ba.jpg 2) two images at once: [success] Uploaded logo.png as 039c231f0a2e02cdceed39f293324b00.png [success] Uploaded sunset.webp as d938a38aa7e6b7b6bcd7f88ffbdbda73.webp 3) PHP code renamed to .jpg: [error] shell.jpg: Only JPG, PNG, GIF and WEBP images are allowed. 4) text file: [error] notes.txt: Only JPG, PNG, GIF and WEBP images are allowed. 5) file over 2 MB: [error] big.jpg: The file is too large. Maximum size is 2 MB. 6) request over post_max_size: [error] The upload is larger than the server allows. 7) form sent with no file: [error] Please choose a file to upload. 8) files in uploads/: 039c231f0a2e02cdceed39f293324b00.png 8248c3553ff48cd817bfa63309c7c4ba.jpg d938a38aa7e6b7b6bcd7f88ffbdbda73.webp 9) gallery count: <h2>Gallery (3)</h2> 10) GET upload.php redirects: HTTP 302 -> http://localhost:8000/index.php
Every case behaves as it should: real images are saved with random names, two files in one request both succeed, the fake JPG and the text file are rejected because of their real content, the 3.9 MB file hits our 2 MB limit, the 9 MB request is caught by the post_max_size check, and an empty form gets a friendly message. Only the three genuine images ended up in the uploads folder.
Common Mistakes to Avoid
- Trusting
$_FILES['type']or the file extension. Both are chosen by the user. Usefinfoon the file contents. - Keeping the original file name. It can contain spaces, unicode tricks,
../, or a double extension likephoto.php.jpg. Generate your own. - Using a blocklist such as "anything except .php". There are too many dangerous extensions (
.phtml,.phar,.php5...). Allow only what you need. - Forgetting
enctype="multipart/form-data". The number one reason$_FILESis empty. - Using
copy()orrename()instead ofmove_uploaded_file(). The latter verifies the file really came from an upload.
Conclusion
A secure PHP file upload is not complicated, but every step matters: check the error code, limit the size, detect the real MIME type with finfo, confirm the image with getimagesize(), save it under a random name with an extension you choose, and make sure the upload folder can never run code. Download the zip below, run the test script, and reuse saveUploadedImage() in your next capstone, inventory or profile page project. If you want to store the file names in a database, save the new random name in a VARCHAR column next to the record it belongs to.