Skip to content

上传多个文件

概述

PHP 支持通过 HTML 表单同时上传多个文件。在 HTML 中使用 name="files[]" 数组语法或 name="files[...]" 命名语法,PHP 会在 $_FILES 超全局数组中以特定结构组织这些文件。处理多文件上传需要特别注意数组的重组和批量处理逻辑。

适用场景

  • 图片批量上传(相册)
  • 文档批量导入
  • 附件批量处理
  • 画廊管理系统

基础概念

HTML 表单语法

html
<!-- 方式一:数组语法 name="files[]" -->
<input type="file" name="files[]" multiple>
<input type="file" name="files[]" multiple accept="image/*">

<!-- 方式二:命名语法 name="files[avatar]" -->
<input type="file" name="files[avatar]">
<input type="file" name="files[cover]">

<!-- 方式三:多次 input(不使用 multiple) -->
<input type="file" name="file1">
<input type="file" name="file2">
<input type="file" name="file3">

$_FILES 数组结构对比

php
<?php

// 方式一:name="files[]" —— 多维数组结构
$_FILES['files'] = [
    'name'     => ['photo1.jpg', 'photo2.png', 'photo3.gif'],
    'type'     => ['image/jpeg', 'image/png', 'image/gif'],
    'tmp_name' => ['/tmp/phpA', '/tmp/phpB', '/tmp/phpC'],
    'error'    => [0, 0, 0],
    'size'     => [12345, 67890, 11111],
];

// 方式二:name="files[avatar]" —— 嵌套结构
$_FILES['files'] = [
    'name'     => ['avatar' => 'face.jpg', 'cover' => 'banner.png'],
    'type'     => ['avatar' => 'image/jpeg', 'cover' => 'image/png'],
    'tmp_name' => ['avatar' => '/tmp/phpA', 'cover' => '/tmp/phpB'],
    'error'    => ['avatar' => 0, 'cover' => 0],
    'size'     => ['avatar' => 12345, 'cover' => 67890],
];

数组结构

name="files[]" 生成的 $_FILES 结构是按字段分组的(每个字段是一个包含所有文件对应值的数组),而不是按文件分组。需要手动重组为数组。

语法与代码示例

重组 $_FILES 数组

php
<?php

/**
 * 将 $_FILES 多维数组重组为按文件索引排列的数组
 */
function reorganizeFiles(array $files): array
{
    $result = [];
    $count = count($files['name']);

    for ($i = 0; $i < $count; $i++) {
        $result[$i] = [
            'name'     => $files['name'][$i] ?? '',
            'type'     => $files['type'][$i] ?? '',
            'tmp_name' => $files['tmp_name'][$i] ?? '',
            'error'    => $files['error'][$i] ?? UPLOAD_ERR_NO_FILE,
            'size'     => $files['size'][$i] ?? 0,
        ];
    }

    return $result;
}

// 使用
$files = reorganizeFiles($_FILES['files']);
foreach ($files as $index => $file) {
    echo "文件 {$index}: {$file['name']} ({$file['size']} bytes)\n";
}

基本多文件上传处理

php
<?php

declare(strict_types=1);

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    exit('Method Not Allowed');
}

$uploadDir = '/var/www/html/uploads/' . date('Y/m/d');
if (!is_dir($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}

$files = reorganizeFiles($_FILES['files']);
$successCount = 0;
$errors = [];

foreach ($files as $index => $file) {
    // 跳过空上传
    if ($file['error'] === UPLOAD_ERR_NO_FILE) {
        continue;
    }

    // 检查错误
    if ($file['error'] !== UPLOAD_ERR_OK) {
        $errors[] = "文件 {$index} ({$file['name']}): 上传错误代码 {$file['error']}";
        continue;
    }

    // 验证大小
    if ($file['size'] > 5 * 1024 * 1024) {
        $errors[] = "文件 {$index} ({$file['name']}): 超过 5MB 限制";
        continue;
    }

    // 验证 MIME
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime = $finfo->file($file['tmp_name']);
    $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];

    if (!in_array($mime, $allowed, true)) {
        $errors[] = "文件 {$index} ({$file['name']}): 不支持的类型 {$mime}";
        continue;
    }

    // 生成文件名并移动
    $ext = pathinfo($file['name'], PATHINFO_EXTENSION);
    $newName = date('His') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
    $destPath = $uploadDir . DIRECTORY_SEPARATOR . $newName;

    if (move_uploaded_file($file['tmp_name'], $destPath)) {
        $successCount++;
        echo "上传成功: {$newName}\n";
    } else {
        $errors[] = "文件 {$index} ({$file['name']}): 保存失败";
    }
}

echo "\n总计成功: {$successCount} 个\n";
if (!empty($errors)) {
    echo "错误:\n" . implode("\n", $errors) . "\n";
}

命名方式的多文件上传

php
<?php

// HTML: <input name="files[avatar]"> <input name="files[cover]">
// $_FILES['files']['name'] = ['avatar' => 'face.jpg', 'cover' => 'banner.png']

function reorganizeNamedFiles(array $files): array
{
    $result = [];
    foreach (array_keys($files['name']) as $key) {
        $result[$key] = [
            'name'     => $files['name'][$key],
            'type'     => $files['type'][$key],
            'tmp_name' => $files['tmp_name'][$key],
            'error'    => $files['error'][$key],
            'size'     => $files['size'][$key],
        ];
    }
    return $result;
}

// 使用
$files = reorganizeNamedFiles($_FILES['files']);
$avatar = $files['avatar'] ?? null;
$cover = $files['cover'] ?? null;

实战示例

完整的多文件上传类

php
<?php

declare(strict_types=1);

class MultiFileUploader
{
    private string $uploadDir;
    private int $maxFileSize;
    private array $allowedMimes;
    private int $maxFiles;

    public function __construct(
        string $uploadDir,
        int $maxFileSize = 10485760,
        array $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
        int $maxFiles = 20
    ) {
        $this->uploadDir = $uploadDir;
        $this->maxFileSize = $maxFileSize;
        $this->allowedMimes = $allowedMimes;
        $this->maxFiles = $maxFiles;
    }

    /**
     * 批量处理上传
     */
    public function upload(array $filesField): UploadResult
    {
        $result = new UploadResult();
        $files = $this->reorganize($filesField);

        if (count($files) > $this->maxFiles) {
            $result->addError('超过最大文件数量限制: ' . $this->maxFiles);
            return $result;
        }

        foreach ($files as $index => $file) {
            $this->processOne($file, $index, $result);
        }

        return $result;
    }

    private function processOne(array $file, int $index, UploadResult $result): void
    {
        // 跳过空文件
        if ($file['error'] === UPLOAD_ERR_NO_FILE) {
            return;
        }

        // 检查错误
        if ($file['error'] !== UPLOAD_ERR_OK) {
            $result->addError("文件 {$index}: " . $this->errorMessage($file['error']));
            return;
        }

        // 验证大小
        if ($file['size'] > $this->maxFileSize) {
            $result->addError("文件 {$index} ({$file['name']}): 超过大小限制");
            return;
        }

        // 验证 MIME
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mime = $finfo->file($file['tmp_name']);

        if (!in_array($mime, $this->allowedMimes, true)) {
            $result->addError("文件 {$index} ({$file['name']}): 不允许的类型");
            return;
        }

        // 生成目标路径
        $ext = $this->extensionFromMime($mime);
        $dateDir = $this->uploadDir . DIRECTORY_SEPARATOR . date('Y/m/d');
        if (!is_dir($dateDir)) {
            mkdir($dateDir, 0755, true);
        }

        $filename = date('Ymd_His') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
        $destPath = $dateDir . DIRECTORY_SEPARATOR . $filename;

        if (move_uploaded_file($file['tmp_name'], $destPath)) {
            chmod($destPath, 0644);
            $result->addSuccess($filename, $destPath, $file['size'], $mime);
        } else {
            $result->addError("文件 {$index}: 保存失败");
        }
    }

    private function reorganize(array $files): array
    {
        $result = [];
        $count = count($files['name']);

        for ($i = 0; $i < $count; $i++) {
            $result[$i] = [
                'name'     => $files['name'][$i] ?? '',
                'tmp_name' => $files['tmp_name'][$i] ?? '',
                'error'    => $files['error'][$i] ?? UPLOAD_ERR_NO_FILE,
                'size'     => $files['size'][$i] ?? 0,
            ];
        }

        return $result;
    }

    private function extensionFromMime(string $mime): string
    {
        $map = [
            'image/jpeg' => 'jpg', 'image/png' => 'png',
            'image/gif' => 'gif', 'image/webp' => 'webp',
            'application/pdf' => 'pdf',
        ];
        return $map[$mime] ?? 'bin';
    }

    private function errorMessage(int $code): string
    {
        $map = [
            UPLOAD_ERR_INI_SIZE  => '超过服务器限制',
            UPLOAD_ERR_FORM_SIZE => '超过表单限制',
            UPLOAD_ERR_PARTIAL   => '上传不完整',
            UPLOAD_ERR_NO_TMP_DIR => '临时目录不存在',
            UPLOAD_ERR_CANT_WRITE => '写入失败',
            UPLOAD_ERR_EXTENSION => '被扩展阻止',
        ];
        return $map[$code] ?? '未知错误';
    }
}

class UploadResult
{
    public array $successFiles = [];
    public array $errors = [];

    public function addSuccess(string $name, string $path, int $size, string $mime): void
    {
        $this->successFiles[] = [
            'name' => $name, 'path' => $path,
            'size' => $size, 'mime' => $mime,
        ];
    }

    public function addError(string $message): void
    {
        $this->errors[] = $message;
    }

    public function isSuccess(): bool
    {
        return empty($this->errors) && !empty($this->successFiles);
    }

    public function toArray(): array
    {
        return [
            'success' => $this->successFiles,
            'errors'  => $this->errors,
            'count'   => count($this->successFiles),
        ];
    }
}

// 使用示例
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['photos'])) {
    $uploader = new MultiFileUploader(
        '/var/www/html/uploads',
        maxFileSize: 5 * 1024 * 1024,
        allowedMimes: ['image/jpeg', 'image/png', 'image/webp']
    );

    $result = $uploader->upload($_FILES['photos']);
    header('Content-Type: application/json');
    echo json_encode($result->toArray(), JSON_UNESCAPED_UNICODE);
}

拖拽上传 API 端点

php
<?php

declare(strict_types=1);

// 处理通过 JavaScript FormData 上传的多个文件
header('Content-Type: application/json; charset=utf-8');

$uploadDir = '/var/www/html/uploads';
if (!is_dir($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['error' => 'Method Not Allowed']);
    exit;
}

if (empty($_FILES)) {
    http_response_code(400);
    echo json_encode(['error' => '没有文件上传']);
    exit;
}

// 找到上传文件字段名
$fieldNames = array_keys($_FILES);
$uploadedFiles = [];

foreach ($fieldNames as $fieldName) {
    $filesField = $_FILES[$fieldName];

    // 单文件
    if (!is_array($filesField['name'])) {
        $filesField = [
            'name'     => [$filesField['name']],
            'tmp_name' => [$filesField['tmp_name']],
            'error'    => [$filesField['error']],
            'size'     => [$filesField['size']],
        ];
    }

    $count = count($filesField['name']);
    for ($i = 0; $i < $count; $i++) {
        if ($filesField['error'][$i] !== UPLOAD_ERR_OK) {
            continue;
        }

        $ext = pathinfo($filesField['name'][$i], PATHINFO_EXTENSION);
        $newName = bin2hex(random_bytes(16)) . '.' . $ext;
        $destPath = $uploadDir . DIRECTORY_SEPARATOR . $newName;

        if (move_uploaded_file($filesField['tmp_name'][$i], $destPath)) {
            $uploadedFiles[] = [
                'name'     => $newName,
                'original' => $filesField['name'][$i],
                'size'     => $filesField['size'][$i],
            ];
        }
    }
}

echo json_encode([
    'success' => true,
    'files'   => $uploadedFiles,
    'count'   => count($uploadedFiles),
], JSON_UNESCAPED_UNICODE);

注意事项

max_file_uploads 限制

php
<?php

// php.ini 中的 max_file_uploads 控制单次请求最多上传的文件数
// 默认值 20,超出部分会被静默忽略

echo '最大上传文件数: ' . ini_get('max_file_uploads') . PHP_EOL;

// 检查实际收到的文件数
$field = $_FILES['photos'] ?? [];
$actualCount = is_array($field['name']) ? count($field['name']) : 1;
$maxAllowed = (int)ini_get('max_file_uploads');

if ($actualCount > $maxAllowed) {
    trigger_error("上传文件数({$actualCount})超过限制({$maxAllowed})", E_USER_WARNING);
}

内存限制

php
<?php

// 大量文件上传会占用大量临时内存
// 每个上传文件都会占用一份临时空间
// 注意 post_max_size 和 memory_limit 的配合

function estimateUploadMemory(array $files): int
{
    $totalSize = 0;
    $field = $_FILES[array_key_first($files)] ?? [];

    if (is_array($field['size'])) {
        $totalSize = array_sum($field['size']);
    } else {
        $totalSize = $field['size'] ?? 0;
    }

    return $totalSize;
}

最佳实践

1. 限制总上传大小

php
<?php

// 不仅限制单个文件大小,还要限制总大小
function checkTotalUploadSize(array $files, int $maxTotal): bool
{
    $totalSize = 0;

    foreach ($files as $field) {
        if (is_array($field['size'])) {
            $totalSize += array_sum($field['size']);
        } else {
            $totalSize += $field['size'] ?? 0;
        }
    }

    return $totalSize <= $maxTotal;
}

2. 异步批量处理

php
<?php

// 对于大量文件,先保存再异步处理
// 1. 上传阶段:快速保存所有文件
// 2. 处理阶段:后台处理(缩略图、压缩等)
class AsyncUploadProcessor
{
    public function saveAll(array $files, string $stagingDir): array
    {
        $saved = [];
        $files = $this->reorganize($files);

        foreach ($files as $file) {
            if ($file['error'] !== UPLOAD_ERR_OK) continue;
            $dest = $stagingDir . DIRECTORY_SEPARATOR . bin2hex(random_bytes(16));
            if (move_uploaded_file($file['tmp_name'], $dest)) {
                $saved[] = ['path' => $dest, 'original' => $file['name']];
            }
        }

        return $saved;
    }

    private function reorganize(array $files): array
    {
        $result = [];
        $count = count($files['name']);
        for ($i = 0; $i < $count; $i++) {
            $result[] = [
                'name' => $files['name'][$i],
                'tmp_name' => $files['tmp_name'][$i],
                'error' => $files['error'][$i],
                'size' => $files['size'][$i],
            ];
        }
        return $result;
    }
}

参考链接