Upload multiple files with a progress bar in PHP and JavaScript
A tested multi-file uploader with one progress bar per file, built with XMLHttpRequest and a PHP handler that checks errors, size and real file type and stores files safely.
Updated 7 min read

In this article
To upload multiple files with a progress bar for each, send every file with XMLHttpRequest and listen to its upload progress event. That JS file upload progress code works with a PHP upload progress bar handler that checks error codes, size and the real file type before storing anything.
To show a progress bar while files upload, send each file with XMLHttpRequest and listen to xhr.upload's progress event, which reports how many bytes have been sent. fetch() still has no upload progress events, so XHR remains the tool for this. On the server, PHP receives each file in $_FILES; the handler must check the upload error code, the size and the real file type before moving the file somewhere the web server will not execute it. This guide builds both halves, with one request and one progress bar per file.
Tested with PHP 8.5.7 using the built-in server, and in a current Chromium browser. We uploaded a PNG, a PHP script renamed to .png, a 6 MB file and a 12 MB request; the results are shown below. The code needs PHP 8.1 or later for never and match.
How the upload works
- The user picks several files in one
<input type="file" multiple>. - JavaScript sends each file in its own request, so each gets an accurate progress bar and one rejected file does not cancel the others.
- PHP validates the file and stores it under a random name outside the public folder, then answers with JSON.
Sending all files in one request also works, but then you get one combined progress value, and a single file over the size limit makes PHP discard the whole request.
This is an AJAX upload: the browser sends each file in the background with XMLHttpRequest and a FormData body, so the page never reloads and each progress bar can show a percentage. With jQuery the request is the same idea: pass $.ajax an xhr option that returns an XMLHttpRequest and add the progress listener to its upload object. Nothing on the PHP side changes.
The HTML form
<form id="upload-form">
<label for="files">Choose files (PDF, PNG or JPEG, up to 5 MB each)</label>
<input id="files" name="files[]" type="file" multiple accept=".pdf,.png,.jpg,.jpeg">
<button type="submit">Upload</button>
</form>
<ul id="progress-list"></ul>
<script src="upload.js"></script>The accept attribute only filters the file picker. It is a convenience for users, not a security check: anyone can send any file to your endpoint, which is why the server checks again.
The JavaScript: one request and one progress bar per file
// One request per file, so each file gets its own progress bar and one failure does not lose the others.
function uploadFile(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'upload.php');
xhr.responseType = 'json';
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
xhr.addEventListener('load', () => {
if (xhr.status === 201) resolve(xhr.response);
else reject(new Error(xhr.response?.error ?? `Upload failed (${xhr.status})`));
});
xhr.addEventListener('error', () => reject(new Error('Network error')));
const body = new FormData();
body.append('file', file);
xhr.send(body);
});
}
document.getElementById('upload-form').addEventListener('submit', async (event) => {
event.preventDefault();
const list = document.getElementById('progress-list');
const files = [...document.getElementById('files').files];
const uploads = files.map((file) => {
const item = document.createElement('li');
const bar = document.createElement('progress');
bar.max = 100;
bar.value = 0;
bar.setAttribute('aria-label', `Uploading ${file.name}`);
const status = document.createElement('span');
item.append(`${file.name} `, bar, status);
list.append(item);
return uploadFile(file, (percent) => { bar.value = percent; })
.then((result) => { status.textContent = ` Uploaded (${result.size} bytes)`; })
.catch((error) => { status.textContent = ` ${error.message}`; });
});
await Promise.allSettled(uploads);
});A few details make this robust:
- Listen on
xhr.upload, notxhr.xhr.onprogressreports the download of the response;xhr.upload.onprogressreports the upload. - Check
lengthComputablebefore dividing; without a known total, show an indeterminate bar. - Do not set
Content-Typeyourself. The browser adds the multipart boundary when you send aFormDatabody. - Use
<progress>with a label. It is announced by screen readers, unlike a styleddiv. Promise.allSettledwaits for every file, whether it succeeded or failed.
Uploading many large files at once can saturate the user's connection. To limit concurrency, start the next upload only when one finishes, for example by processing the files array in batches of three.
The PHP handler: validate, then store
<?php
declare(strict_types=1);
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED = ['application/pdf' => 'pdf', 'image/png' => 'png', 'image/jpeg' => 'jpg'];
const STORAGE = __DIR__ . '/../storage'; // outside the web root, so uploads can never be executed or browsed
header('Content-Type: application/json');
function fail(int $status, string $message): never
{
http_response_code($status);
echo json_encode(['error' => $message]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') fail(405, 'Use POST');
// A body larger than post_max_size arrives with empty $_POST and $_FILES.
if (empty($_FILES) && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) fail(413, 'The upload is larger than the server allows');
$file = $_FILES['file'] ?? null;
if (!is_array($file) || is_array($file['error'])) fail(400, 'Send one file in the "file" field');
match ($file['error']) {
UPLOAD_ERR_OK => null,
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => fail(413, 'The file is too large'),
UPLOAD_ERR_PARTIAL => fail(400, 'The upload was interrupted'),
UPLOAD_ERR_NO_FILE => fail(400, 'No file was sent'),
default => fail(500, 'The server could not store the file'),
};
if ($file['size'] > MAX_BYTES) fail(413, 'Files must be 5 MB or smaller');
// Trust the file's contents, never the browser's type or the file name.
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$extension = ALLOWED[$mime] ?? fail(415, 'Only PDF, PNG and JPEG files are accepted');
$name = bin2hex(random_bytes(16)) . '.' . $extension;
if (!move_uploaded_file($file['tmp_name'], STORAGE . '/' . $name)) fail(500, 'The server could not store the file');
http_response_code(201);
echo json_encode(['id' => $name, 'size' => $file['size'], 'originalName' => basename($file['name'])]);What each check protects against:
- Empty
$_FILESwith a body: when a request is larger thanpost_max_size, PHP drops the whole body without an upload error code. CheckingCONTENT_LENGTHis the only way to tell the user why. is_array($file['error']): a request that sendsfile[]instead offileproduces arrays in every field; rejecting it keeps the rest of the code simple.- The error code:
UPLOAD_ERR_INI_SIZEmeans the file is larger thanupload_max_filesize;UPLOAD_ERR_PARTIALmeans the connection dropped. finfoon the temporary file:$_FILES['file']['type']and the file name come from the browser and can say anything.finforeads the file's first bytes.- A random name and our own extension: the user's file name is never used as a path, so
../../index.phporshell.php.pngcannot overwrite or plant anything. - Storage outside the web root: even a file that slips past validation cannot be requested and run as PHP. Serve stored files through a script that checks permissions and sends
Content-Disposition. move_uploaded_fileonly moves files PHP itself received in this request, which stops path tricks withtmp_name.
What our test uploads returned
| Upload | Status | Response |
|---|---|---|
| A real PNG | 201 | {"id":"d75a…807c.png","size":90,"originalName":"t.png"} |
PHP code named evil.png | 415 | Only PDF, PNG and JPEG files are accepted |
| A 6 MB file | 413 | Files must be 5 MB or smaller |
A 12 MB request (over post_max_size) | 413 | The upload is larger than the server allows |
| A GET request | 405 | Use POST |
In the browser, three files picked together each got their own bar: the PNG and a 3 MB PDF reached 100% and showed "Uploaded", and the renamed script showed the 415 message next to its bar. With display_errors on, PHP also prints a warning before the JSON for the oversized request; keep display_errors=Off in production.
Server limits to set
| Setting | Default | Meaning |
|---|---|---|
upload_max_filesize | 2M | Largest single file |
post_max_size | 8M | Largest whole request; must be larger than upload_max_filesize |
max_file_uploads | 20 | Files per request |
memory_limit | 128M | Not used for the file itself, which goes to a temporary file |
Nginx client_max_body_size | 1m | Nginx rejects larger requests with 413 before PHP sees them |
Keep the application limit (MAX_BYTES) at or below upload_max_filesize, and the web server limit at or above post_max_size, so the user always gets your JSON message rather than a bare server error page. If uploads then fail with a 502 under load, see fixing Nginx 502 errors with PHP-FPM, and if PHP cannot write the storage folder on a Laravel server, see Laravel storage permissions with Nginx and PHP-FPM.
Next steps
- CSRF: if the endpoint relies on a session cookie, add a CSRF token to the
FormDataand check it. - Images: re-encode uploaded images with GD or Imagick to strip metadata and anything hidden after the image data.
- Very large files: split them into chunks with
file.slice()and resume failed chunks, or upload straight to S3-compatible storage with pre-signed URLs.
For a wider checklist covering passwords, SQL, XSS and sessions, see our PHP security checklist.
Frequently asked questions
How do I show upload progress for a file in JavaScript?
Send the file with
XMLHttpRequestand listen to theprogressevent onxhr.upload. Divideevent.loadedbyevent.totalwhenevent.lengthComputableis true.fetch()has no upload progress event.Why is $_FILES empty in PHP?
Usually because the request is larger than
post_max_size, in which case PHP discards the body. Other causes are a form withoutenctype="multipart/form-data", a missingnameon the input, orfile_uploadsturned off.How do I check the real type of an uploaded file in PHP?
Use
(new finfo(FILEINFO_MIME_TYPE))->file($_FILES['file']['tmp_name'])and compare the result with an allowlist. Do not trust$_FILES['file']['type']or the extension, which the client controls.How do I increase the upload size limit in PHP?
Raise
upload_max_filesizeandpost_max_sizein php.ini (or the PHP-FPM pool), keeppost_max_sizelarger, and raiseclient_max_body_sizein Nginx to match. Restart PHP-FPM and reload Nginx afterwards.
Sources
- Handling file uploads — PHP Manual
- Error messages explained (UPLOAD_ERR_*) — PHP Manual
- move_uploaded_file — PHP Manual
- finfo::file — PHP Manual
- XMLHttpRequest: upload property — MDN Web Docs
- Using FormData objects — MDN Web Docs
- File Upload Cheat Sheet — OWASP
- client_max_body_size — nginx
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Read next

Industry updatePHP4 min read
PHP RCE vulnerabilities: patched versions and upgrade guide
Two critical PHP RCE vulnerabilities are fixed in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Exposure by server API, interim measures and a safe upgrade plan.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

TutorialDevOps5 min read
nginx rate limiting for APIs and login endpoints: protect capacity without locking out users
Configure nginx rate limiting with trusted client addresses, bounded bursts, a dry-run rollout and application-level account controls for API and login traffic.
By Nilesh Kushvaha

Troubleshooting guideDevOps5 min read
nginx 502 vs 504 errors: distinguish failed upstream responses from timeout boundaries
Compare nginx 502 vs 504 errors using the response origin, upstream timing, connection failures and application evidence before changing limits or restarting services.
By Nilesh Kushvaha

TutorialDevOps5 min read
Multiple Node.js applications behind nginx: host routing, process ownership and shared-server capacity
Run multiple Node.js applications behind nginx with explicit host-to-port routing, separate process ownership, scoped state and acceptance checks for every hosted service.
By Nilesh Kushvaha
