Skip to content

How to Upload Multiple Files in PHP

Accepting one file in PHP is a five-line job. Accepting several at once trips people up, because $_FILES does not nest the way you would expect. This walks through the markup, the array shape, a processing loop that fails safely, and the configuration limits that quietly cap how many files arrive.

The form

Two things make a form capable of sending multiple files: the multiple attribute on the input, and a name ending in [] so PHP groups the values into an array.

<form action="upload.php" method="post" enctype="multipart/form-data">
  <input type="file" name="documents[]" multiple accept=".jpg,.png,.pdf">
  <button type="submit">Upload</button>
</form>

Miss enctype="multipart/form-data" and the browser sends only the file names as ordinary form fields — $_FILES arrives empty and nothing in the PHP error log explains why. It is the single most common cause of "my upload script does nothing".

The accept attribute filters the file picker. It is a convenience, not a control: anyone can bypass it, so the server still has to validate. See the security checklist for what that validation should look like.

The shape of $_FILES is the awkward part

You might reasonably expect one entry per file. PHP does the opposite: it groups by property first, then by index.

// What you might expect:
$_FILES['documents'][0]['name']  // 'invoice.pdf'

// What PHP actually gives you:
$_FILES['documents']['name'][0]  // 'invoice.pdf'
$_FILES['documents']['type'][0]  // 'application/pdf'
$_FILES['documents']['tmp_name'][0]
$_FILES['documents']['error'][0]
$_FILES['documents']['size'][0]

So $_FILES['documents']['name'] is an array of names, ['size'] an array of sizes, and the index ties them together. A single-file input (name="document", no brackets) uses the expected flat shape, which is exactly why code written for one file breaks when someone adds multiple.

Normalising it once

Rather than index gymnastics scattered through your code, reshape the array once at the top and work with a clean list:

function normalise_files(array $field): array
{
    // already flat (single-file input)
    if (!is_array($field['name'])) {
        return [$field];
    }

    $out = [];
    foreach (array_keys($field['name']) as $i) {
        $out[] = [
            'name'     => $field['name'][$i],
            'type'     => $field['type'][$i],
            'tmp_name' => $field['tmp_name'][$i],
            'error'    => $field['error'][$i],
            'size'     => $field['size'][$i],
        ];
    }
    return $out;
}

Now a loop reads naturally, and the same handler copes with both single and multiple inputs.

Processing the upload

<?php
$targetDir = __DIR__ . '/../uploads';   // outside the web root
$allowed   = ['jpg' => 'image/jpeg', 'png' => 'image/png', 'pdf' => 'application/pdf'];
$maxBytes  = 5 * 1024 * 1024;
$saved     = [];
$failed    = [];

foreach (normalise_files($_FILES['documents'] ?? []) as $file) {

    // 1. did this one arrive intact?
    if ($file['error'] !== UPLOAD_ERR_OK) {
        $failed[] = [$file['name'], upload_error_message($file['error'])];
        continue;
    }

    // 2. is it really an uploaded file, not a local path someone injected?
    if (!is_uploaded_file($file['tmp_name'])) {
        $failed[] = [$file['name'], 'not an uploaded file'];
        continue;
    }

    // 3. size
    if ($file['size'] > $maxBytes) {
        $failed[] = [$file['name'], 'larger than 5 MB'];
        continue;
    }

    // 4. extension allow-list, checked against real content type
    $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
    if (!isset($allowed[$ext])) {
        $failed[] = [$file['name'], 'file type not allowed'];
        continue;
    }

    $finfo = new finfo(FILEINFO_MIME_TYPE);
    if ($finfo->file($file['tmp_name']) !== $allowed[$ext]) {
        $failed[] = [$file['name'], 'content does not match extension'];
        continue;
    }

    // 5. never reuse the client-supplied name
    $safeName = bin2hex(random_bytes(8)) . '.' . $ext;

    if (move_uploaded_file($file['tmp_name'], $targetDir . '/' . $safeName)) {
        $saved[] = $safeName;
    } else {
        $failed[] = [$file['name'], 'could not be saved'];
    }
}

Four details in there earn their place:

Collect failures, do not abort

A loop that die()s on the first bad file leaves the user with eight successful uploads they cannot see and no idea which one broke. Gather per-file results and report them together:

foreach ($failed as [$name, $why]) {
    echo htmlspecialchars("$name: $why") . "<br>";
}
echo count($saved) . ' file(s) uploaded.';

The limits that cap how many files arrive

Multiple-file forms hit configuration ceilings that single-file forms never reach, because the whole batch travels as one request.

SettingDefaultEffect on a batch
max_file_uploads20Files beyond the 20th are silently dropped — no error, they simply are not in $_FILES.
post_max_size8MApplies to the combined size. Exceed it and $_POST and $_FILES both arrive empty.
upload_max_filesize2MPer file. Larger files get UPLOAD_ERR_INI_SIZE.

max_file_uploads is the nasty one: dropping files without an error looks like a bug in your loop. If you expect users to send more than twenty files at a time, raise it, and always check the batch count against it. The interaction between these values is covered in PHP upload size limits explained.

Watch for: a batch that exceeds post_max_size produces an empty $_FILES with no error entry to inspect. Detect it by comparing $_SERVER['CONTENT_LENGTH'] against the configured maximum when $_POST is unexpectedly empty on a POST request.

What plain forms still cannot do

The code above is a correct multi-file upload, but the experience has hard limits. The page blocks while the batch transfers. There is no progress indication, because a normal form post exposes none. The user cannot cancel, cannot add a file after pressing submit, and if the connection drops at 90% the whole batch starts again. Validation failures only surface after every byte has been sent.

Fixing those means moving the transfer into JavaScript: an AJAX upload with a progress bar covers the mechanics, and uploading large files without timeouts covers chunking for anything big.

Doing it with PHP File Uploader

The same job with our component is a few properties — multiple selection, a per-file progress bar, client-side validation and cancellation come as standard:

<?php
require_once "phpuploader/include_phpuploader.php";

$uploader = new PhpUploader();
$uploader->MultipleFilesUpload = true;
$uploader->MaxSizeKB = 1024000;
$uploader->AllowedFileExtensions = "jpg,png,pdf";
$uploader->InsertText = "Select files";
$uploader->SaveDirectory = "uploads";
$uploader->Render();
?>

Files are streamed to disk rather than buffered in memory, so the 2 MB and 8 MB ceilings above stop being the constraint. There is a live multiple-file demo you can try, and the full demo set covers custom UI, manual start and AJAX handling.