Somewhere past a few hundred megabytes, a normal PHP upload stops being a configuration problem and becomes a design problem. One enormous POST is fragile no matter how high you set the limits. The fix is to stop sending one request.
Why big uploads fail
A single-request upload has to survive every one of these at once:
- Size ceilings —
upload_max_filesizeandpost_max_sizein PHP, plusclient_max_body_size(Nginx),LimitRequestBody(Apache) ormaxAllowedContentLength(IIS). Covered in the size limits article. - Time ceilings —
max_input_timegoverns how long PHP will spend receiving the body. A 2 GB file on a 5 Mbit connection needs about 55 minutes; almost every default gives up long before. - Proxy and load-balancer timeouts — often 60 seconds and outside your control.
- The all-or-nothing problem — a dropped connection at 98% discards everything. The user starts again, with no indication of how far they got.
You can raise the first two. You cannot make one long request reliable.
Chunked uploads
Split the file in the browser, send each piece as its own small request, and reassemble server-side. Every individual request is well under any limit, finishes quickly, and can be retried in isolation.
Client side
File inherits slice() from Blob, so slicing costs nothing — no copy is made until the slice is read.
async function uploadInChunks(file, url, chunkSize = 5 * 1024 * 1024) {
const total = Math.ceil(file.size / chunkSize);
const id = crypto.randomUUID(); // identifies this upload
for (let i = 0; i < total; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const body = new FormData();
body.append('chunk', chunk);
body.append('uploadId', id);
body.append('index', i);
body.append('total', total);
body.append('name', file.name);
let attempt = 0;
while (true) {
try {
const res = await fetch(url, { method: 'POST', body });
if (!res.ok) throw new Error('HTTP ' + res.status);
break; // chunk accepted
} catch (err) {
if (++attempt >= 3) throw err; // give up after 3 tries
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
onProgress((end / file.size) * 100);
}
}
Because each chunk is a separate request, a failure retries 5 MB rather than 2 GB — and progress is a natural by-product of the loop rather than something you have to instrument.
Server side
<?php
declare(strict_types=1);
$tmpDir = '/var/www/uploads/parts'; // outside the web root
$id = preg_replace('/[^a-f0-9-]/i', '', $_POST['uploadId'] ?? '');
$index = (int) ($_POST['index'] ?? -1);
$total = (int) ($_POST['total'] ?? 0);
if ($id === '' || $index < 0 || $total <= 0 || $index >= $total) {
http_response_code(400);
exit('Bad chunk metadata.');
}
if (($_FILES['chunk']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
http_response_code(400);
exit('Chunk did not arrive.');
}
$dir = $tmpDir . '/' . $id; // $id is sanitised above
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
http_response_code(500);
exit('Cannot create staging directory.');
}
move_uploaded_file($_FILES['chunk']['tmp_name'], sprintf('%s/%06d.part', $dir, $index));
// all parts present? then assemble
if (count(glob($dir . '/*.part')) === $total) {
$final = fopen('/var/www/uploads/' . bin2hex(random_bytes(16)) . '.bin', 'wb');
for ($i = 0; $i < $total; $i++) {
$part = fopen(sprintf('%s/%06d.part', $dir, $i), 'rb');
stream_copy_to_stream($part, $final); // constant memory
fclose($part);
}
fclose($final);
array_map('unlink', glob($dir . '/*.part'));
rmdir($dir);
echo json_encode(['status' => 'complete']);
exit;
}
echo json_encode(['status' => 'chunk-received', 'index' => $index]);
Points worth keeping:
- Sanitise the upload id before it touches a path. It arrives from the client, so an unfiltered value is a directory-traversal hole.
- Zero-pad the part names (
%06d) so lexical ordering matches numeric ordering when you glob them. stream_copy_to_streamassembles in constant memory.file_get_contentsin a loop would need the whole file in RAM.- Validate after assembly, not per chunk — magic bytes only exist in chunk zero.
Concurrency and cleanup
Two details bite in production. If you send chunks in parallel for speed, the "is it complete?" check can fire twice at once — guard assembly with a lock file or an atomic mkdir. And abandoned uploads leave partial directories forever, so a cron job should remove staging directories older than a day:
find /var/www/uploads/parts -mindepth 1 -maxdepth 1 -type d -mtime +1 -exec rm -rf {} +
Making uploads resumable
Once chunks are stored individually, resuming is a small addition: before uploading, ask the server which parts it already holds and skip those.
// GET /upload-status.php?uploadId=...
$have = array_map(
fn($p) => (int) basename($p, '.part'),
glob($tmpDir . '/' . $id . '/*.part')
);
echo json_encode(['received' => $have]);
const { received } = await (await fetch(`/upload-status.php?uploadId=${id}`)).json();
const have = new Set(received);
for (let i = 0; i < total; i++) {
if (have.has(i)) { onProgress(((i + 1) / total) * 100); continue; }
// ... upload chunk i
}
Keep the upload id in localStorage keyed by file name and size, and a user who closes the tab mid-transfer can pick up where they left off rather than starting over.
Settings that still matter
Chunking removes the pressure on the big limits, but a few settings still apply per chunk:
upload_max_filesize = 8M ; just larger than one chunk
post_max_size = 10M ; chunk plus metadata
max_execution_time = 60 ; assembly of a very large file can take a while
memory_limit = 128M ; unchanged - streaming does not need more
Note that assembling a 20 GB file from 4,000 parts is itself slow. If that is your workload, write chunks directly into their final offsets with fseek and fwrite instead of concatenating at the end, or hand assembly to a background worker.
The built-in option
PHP File Uploader does this transparently: files are transferred in parts, streamed to disk, and reassembled server-side, with a progress bar and cancellation for the user. Nothing special is required in your page — the same few properties as a small upload:
<?php
$uploader = new PhpUploader();
$uploader->MultipleFilesUpload = true;
$uploader->InsertText = "Upload";
$uploader->AllowedFileExtensions = "zip,mp4,iso";
$uploader->SaveDirectory = "uploads";
$uploader->Render();
?>
The large-file demo runs it against real files, and the deployment guide covers the temporary-directory setup that large transfers need.
