Track PHP file uploads with session upload progress
PHP can report how much of an upload it has received while the browser is still sending the file. The upload and progress requests need to run concurrently, and a progress reading must never replace the upload’s final result. This local example accepts a JPEG, PNG, or PDF up to 10 MiB, shows progress during a slow transfer, and confirms when PHP has stored the file.
Server configuration requirements
Use Docker with Linux containers and a current browser. The configuration below uses PHP 8.4.26
with PHP-FPM and Nginx 1.28.3; it was tested on Linux with Chromium 152. Create a new directory named
php-upload-progress, with a public subdirectory. Save the following four configuration files in
php-upload-progress; the three application files in later sections go in public.
Save this as Dockerfile:
FROM php:8.4.26-fpm-alpine3.23
RUN apk add --no-cache nginx=1.28.3-r7 \
&& mkdir -p /var/lib/php-upload-demo /var/lib/php-upload-sessions \
&& chown www-data:www-data /var/lib/php-upload-demo /var/lib/php-upload-sessions \
&& chmod 0700 /var/lib/php-upload-demo /var/lib/php-upload-sessions
COPY php.ini /usr/local/etc/php/conf.d/zz-upload.ini
COPY fpm.conf /usr/local/etc/php-fpm.d/zz-upload.conf
COPY nginx.conf /etc/nginx/nginx.conf
COPY public/ /app/public/
CMD ["sh", "-c", "php-fpm -D && exec nginx -g 'daemon off;'"]
Save this as php.ini. PHP parses the upload before running upload.php, so these settings belong
in configuration, not in an ini_set() call inside the handler. The progress key combines
upload_progress_ with the value of the form’s PHP_SESSION_UPLOAD_PROGRESS field.
PHP’s session upload progress manual
describes this lifecycle.
session.save_handler = files
session.save_path = /var/lib/php-upload-sessions
session.name = PHPUPLOADDEMO
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_httponly = 1
session.cookie_samesite = Strict
session.upload_progress.enabled = On
session.upload_progress.cleanup = On
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = 0.1
upload_max_filesize = 10M
post_max_size = 12M
max_input_time = 120
log_errors = On
display_errors = Off
Save this as fpm.conf. Four workers allow a progress request to run while another worker receives
the upload. A single busy worker cannot serve both requests at once.
[www]
pm = static
pm.max_children = 4
request_terminate_timeout = 120s
Save this as nginx.conf:
user www-data;
worker_processes 1;
error_log /dev/stderr warn;
events { worker_connections 128; }
http {
include /etc/nginx/mime.types;
access_log /dev/stdout;
server {
listen 8080;
root /app/public;
index index.html;
client_max_body_size 12m;
location / { try_files $uri $uri/ =404; }
location ~ \.php$ {
try_files $uri =404;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass 127.0.0.1:9000;
fastcgi_request_buffering off;
fastcgi_read_timeout 120s;
}
}
}
The crucial setting is
fastcgi_request_buffering off:
Nginx forwards incoming bytes to PHP immediately. fastcgi_buffering controls responses, and
proxy_request_buffering applies to an HTTP upstream; neither replaces this setting for PHP-FPM.
The 12 MiB request limit leaves room for multipart overhead above the 10 MiB file limit. This demo
connects directly to Nginx; another proxy that buffers uploads would hide intermediate progress.
Implementing the upload handler
Save this as public/upload.php. It checks PHP’s upload error, inspects the temporary file’s MIME
type, and stores accepted bytes under a random name outside the webroot. It never uses the original
filename as a path. PHP’s upload documentation
explains why the browser-supplied MIME type is not a validation check.
<?php
// public/upload.php
declare(strict_types=1);
function reply(int $status, array $body): never {
http_response_code($status);
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode($body, JSON_THROW_ON_ERROR);
exit;
}
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
reply(405, ['error' => 'Use POST to upload a file.']);
}
$file = $_FILES['file'] ?? null;
if (!is_array($file) || !isset($file['error'], $file['size'], $file['tmp_name']) ||
!is_int($file['error']) || !is_int($file['size']) || !is_string($file['tmp_name'])) {
reply(400, ['error' => 'Choose one file.']);
}
if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['size'] > 10 * 1024 * 1024) {
reply(413, ['error' => 'The file exceeds 10 MiB.']);
}
if ($file['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'])) {
reply(400, ['error' => 'PHP did not receive a complete file.']);
}
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset($extensions[$mime])) {
reply(415, ['error' => 'Choose a JPEG, PNG, or PDF.']);
}
$directory = '/var/lib/php-upload-demo';
if (!is_dir($directory) || !is_writable($directory)) {
throw new RuntimeException('Private storage is unavailable');
}
$filename = bin2hex(random_bytes(16)) . '.' . $extensions[$mime];
$destination = $directory . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
throw new RuntimeException('Could not store upload');
}
reply(201, ['success' => true, 'filename' => $filename, 'size' => $file['size']]);
} catch (Throwable $error) {
error_log('Upload failed: ' . get_class($error));
reply(500, ['error' => 'The upload service is unavailable.']);
}
A successful response means the file has been moved into private storage. It does not mean the content is safe to publish. Repeated accepted uploads get separate random filenames; this example does not deduplicate files or resume an interrupted transfer.
Progress tracking implementation
Save this as public/progress.php. Calling it without an ID first establishes the session cookie.
Subsequent calls use the same cookie to read the upload’s entry. Close the session immediately after
reading it: PHP’s file sessions lock data while open, which can delay other requests using that
session. See session_write_close().
<?php
// public/progress.php
declare(strict_types=1);
header('Content-Type: application/json');
$id = $_GET['id'] ?? '';
if (!is_string($id) || ($id !== '' && !preg_match('/\A[0-9a-f-]{36}\z/', $id))) {
http_response_code(400);
echo json_encode(['error' => 'Invalid progress ID.']);
exit;
}
if (!session_start()) {
http_response_code(503);
echo json_encode(['error' => 'Tracking is unavailable.']);
exit;
}
$current = $_SESSION['upload_progress_' . $id] ?? null;
session_write_close();
header('Cache-Control: no-store');
echo json_encode(['progress' => $current === null ? null : [
'loaded' => $current['bytes_processed'],
'total' => $current['content_length'],
]], JSON_THROW_ON_ERROR);
Progress measures the multipart request, including its overhead. With
session.upload_progress.cleanup
enabled, PHP removes the entry after reading the request body. A missing entry can therefore mean
“not started” or “already received”; it cannot establish success or failure. Only upload.php
reports whether validation and storage succeeded.
Client-side integration
Save this as public/index.html. The hidden progress field comes before the file so PHP sees its
ID before receiving file bytes. Construct FormData before disabling the controls, because disabled
controls are excluded from form data.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PHP upload progress</title>
</head>
<body>
<h1>Upload a JPEG, PNG, or PDF</h1>
<form id="upload-form">
<fieldset id="controls">
<legend>One file, up to 10 MiB</legend>
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" id="progress-id" />
<label for="file">File</label>
<input id="file" type="file" name="file" accept="image/jpeg,image/png,application/pdf" required />
<button type="submit">Upload</button>
</fieldset>
</form>
<label for="progress">Request received by PHP</label>
<progress id="progress" value="0" max="100"></progress>
<p id="status" role="status">Choose a file.</p>
<script>
const form = document.getElementById('upload-form')
const controls = document.getElementById('controls')
const progressId = document.getElementById('progress-id')
const bar = document.getElementById('progress')
const status = document.getElementById('status')
let activeJob = null
async function poll(job) {
try {
const response = await fetch(`progress.php?id=${job.id}`, {
cache: 'no-store',
signal: job.controller.signal,
})
if (!response.ok) throw new Error('Progress request failed')
const { progress } = await response.json()
// An old response can arrive after completion or during the next upload.
if (activeJob !== job) return
if (progress && progress.total > 0) {
const percentage = Math.min(100, Math.floor(100 * progress.loaded / progress.total))
bar.value = percentage
status.textContent = percentage === 100
? 'Request received; waiting for server confirmation.'
: `Uploading: ${percentage}%`
}
} catch {
if (activeJob === job) {
status.textContent = 'Progress unavailable; waiting for the upload response.'
}
}
// Schedule after this request settles, so polls never overlap.
if (activeJob === job) job.timer = setTimeout(() => poll(job), 500)
}
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (activeJob !== null) return
const job = { id: crypto.randomUUID(), controller: new AbortController(), timer: null }
progressId.value = job.id
const body = new FormData(form)
activeJob = job
controls.disabled = true
bar.value = 0
status.textContent = 'Starting upload…'
let message = 'Upload could not be confirmed. Check the server before retrying.'
let stored = false
try {
const session = await fetch('progress.php', { cache: 'no-store' })
if (!session.ok) throw new Error('Session initialization failed')
job.timer = setTimeout(() => poll(job), 500)
const response = await fetch('upload.php', { method: 'POST', body })
if (!response.ok) {
message = response.status === 413 ? 'The file exceeds the upload size limit.'
: response.status === 415 ? 'Choose a JPEG, PNG, or PDF.'
: response.status >= 500 ? 'The upload service is unavailable.'
: 'The upload was rejected. Choose a file and try again.'
throw new Error('Upload rejected')
}
const result = await response.json()
if (result.success !== true) throw new Error('Missing storage confirmation')
stored = true
message = `Upload complete! Stored as ${result.filename} (${result.size} bytes).`
} catch {
// A network failure can occur after storage; do not claim a safe automatic retry.
} finally {
activeJob = null
clearTimeout(job.timer)
job.controller.abort()
controls.disabled = false
status.textContent = message
if (stored) bar.value = 100
}
})
</script>
</body>
</html>
The page accepts one upload at a time. It disables file selection and submission while that upload
is pending, and the handler also ignores repeated submit events. Each job has its own progress ID
and abort controller. Completion invalidates that job before enabling the form again. Even if a
poll has already reached response.json(), the identity check prevents it from changing another
job’s feedback. Aborting fetch
also stops unnecessary pending poll work.
Run a slow upload
After saving all seven files, paste this from the directory containing php-upload-progress.
The subshell leaves your current directory unchanged, and the && chain stops if navigation or
building fails. Docker needs network access for the first build. Choose another host port if 8080
is already occupied.
(
cd php-upload-progress &&
docker build -t php-upload-progress . &&
docker run --rm --name php-upload-progress \
--publish 127.0.0.1:8080:8080 php-upload-progress
)
Leave that terminal running and open http://127.0.0.1:8080/. In your browser’s developer tools,
set an upload bandwidth limit around 256 KiB/s, then choose a roughly 4 MiB JPEG, PNG, or PDF and
press Upload. Intermediate percentages should appear before
Upload complete!, followed by the generated filename and byte
count. Fast local uploads can finish between polls and jump straight to completion.
If progress stays at zero during a slow transfer, check that cookies are enabled, the bootstrap
progress.php request succeeded, and its cookie accompanies both requests. Check the worker count
and request buffering next. A 413 response can come from either PHP or Nginx; a request above
Nginx’s 12 MiB limit never reaches the PHP handler. Choosing a renamed text file should produce a
type rejection, since the HTML accept attribute is only a picker hint.
Accepted files live at /var/lib/php-upload-demo inside the running container. They have no public
URL. Stop the foreground container with Ctrl+C when finished; --rm then deletes its uploads and
session data. Rebuilding the image is necessary after changing a saved source or configuration file.
Security caveats
This is a loopback-only demonstration, with no login, CSRF token, quotas, or malware scanning. MIME detection restricts formats but does not prove a file harmless. Keep content private until your application’s validation and processing finish. A deployed version also needs HTTPS, secure session cookies, access control, and an explicit retention policy; do not expose this container as an upload service unchanged.
Browser compatibility
The page uses fetch, FormData, AbortController, and crypto.randomUUID() without polyfills.
randomUUID() requires a secure context:
loopback HTTP works for this demo; remote use requires HTTPS. Cookies must be enabled. Reloading or
closing the page abandons its feedback, and this implementation offers no pause or resume button.
