Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
PHPJavaScriptTutorial

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.

Nilesh Kushvaha

Updated 7 min read

Cloudsoft Zone cover: Upload multiple files with a progress bar in PHP and JavaScript
Image: Cloudsoft Zone
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

  1. The user picks several files in one <input type="file" multiple>.
  2. JavaScript sends each file in its own request, so each gets an accurate progress bar and one rejected file does not cancel the others.
  3. 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

HTML
<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

JavaScript
// 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, not xhr. xhr.onprogress reports the download of the response; xhr.upload.onprogress reports the upload.
  • Check lengthComputable before dividing; without a known total, show an indeterminate bar.
  • Do not set Content-Type yourself. The browser adds the multipart boundary when you send a FormData body.
  • Use <progress> with a label. It is announced by screen readers, unlike a styled div.
  • Promise.allSettled waits 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
<?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 $_FILES with a body: when a request is larger than post_max_size, PHP drops the whole body without an upload error code. Checking CONTENT_LENGTH is the only way to tell the user why.
  • is_array($file['error']): a request that sends file[] instead of file produces arrays in every field; rejecting it keeps the rest of the code simple.
  • The error code: UPLOAD_ERR_INI_SIZE means the file is larger than upload_max_filesize; UPLOAD_ERR_PARTIAL means the connection dropped.
  • finfo on the temporary file: $_FILES['file']['type'] and the file name come from the browser and can say anything. finfo reads 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.php or shell.php.png cannot 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_file only moves files PHP itself received in this request, which stops path tricks with tmp_name.

What our test uploads returned

UploadStatusResponse
A real PNG201{"id":"d75a…807c.png","size":90,"originalName":"t.png"}
PHP code named evil.png415Only PDF, PNG and JPEG files are accepted
A 6 MB file413Files must be 5 MB or smaller
A 12 MB request (over post_max_size)413The upload is larger than the server allows
A GET request405Use 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

SettingDefaultMeaning
upload_max_filesize2MLargest single file
post_max_size8MLargest whole request; must be larger than upload_max_filesize
max_file_uploads20Files per request
memory_limit128MNot used for the file itself, which goes to a temporary file
Nginx client_max_body_size1mNginx 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 FormData and 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 XMLHttpRequest and listen to the progress event on xhr.upload. Divide event.loaded by event.total when event.lengthComputable is 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 without enctype="multipart/form-data", a missing name on the input, or file_uploads turned 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_filesize and post_max_size in php.ini (or the PHP-FPM pool), keep post_max_size larger, and raise client_max_body_size in Nginx to match. Restart PHP-FPM and reload Nginx afterwards.

Sources

  1. Handling file uploads — PHP Manual
  2. Error messages explained (UPLOAD_ERR_*) — PHP Manual
  3. move_uploaded_file — PHP Manual
  4. finfo::file — PHP Manual
  5. XMLHttpRequest: upload property — MDN Web Docs
  6. Using FormData objects — MDN Web Docs
  7. File Upload Cheat Sheet — OWASP
  8. client_max_body_size — nginx

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.