Laravel 13 文件上传处理完全指南

文件上传是 Web 应用中常见的功能需求。本文将深入探讨 Laravel 13 中文件上传的各种处理方式和最佳实践。

文件存储配置

配置磁盘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// config/filesystems.php
return [
'default' => env('FILESYSTEM_DISK', 'local'),

'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'throw' => false,
],

'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
],

's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
],
],
];

基础文件上传

单文件上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class FileController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'file' => 'required|file|max:10240',
]);

$path = $request->file('file')->store('uploads');

return response()->json([
'path' => $path,
'url' => Storage::url($path),
]);
}

public function uploadToDisk(Request $request)
{
$path = $request->file('file')->store('uploads', 's3');

return response()->json([
'path' => $path,
'url' => Storage::disk('s3')->url($path),
]);
}

public function uploadWithCustomName(Request $request)
{
$file = $request->file('file');
$fileName = time() . '_' . $file->getClientOriginalName();

$path = $file->storeAs('uploads', $fileName);

return response()->json(['path' => $path]);
}
}

多文件上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public function uploadMultiple(Request $request)
{
$request->validate([
'files' => 'required|array',
'files.*' => 'file|max:10240',
]);

$paths = [];

foreach ($request->file('files') as $file) {
$paths[] = $file->store('uploads');
}

return response()->json(['paths' => $paths]);
}

public function uploadMultipleWithValidation(Request $request)
{
$request->validate([
'files' => 'required|array|min:1|max:5',
'files.*' => 'file|mimes:jpg,jpeg,png,pdf|max:5120',
]);

$uploadedFiles = collect($request->file('files'))
->map(fn($file) => [
'original_name' => $file->getClientOriginalName(),
'path' => $file->store('uploads'),
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
]);

return response()->json(['files' => $uploadedFiles]);
}

文件验证

验证规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public function rules(): array
{
return [
'avatar' => [
'required',
'file',
'max:2048',
'mimes:jpg,jpeg,png,gif',
'dimensions:min_width=100,min_height=100,max_width=2000,max_height=2000',
],

'document' => [
'required',
'file',
'max:10240',
'mimes:pdf,doc,docx,xls,xlsx',
],

'video' => [
'required',
'file',
'max:51200',
'mimetypes:video/mp4,video/quicktime',
],
];
}

自定义验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class ValidFileUpload implements ValidationRule
{
protected array $allowedMimes;
protected int $maxSize;

public function __construct(array $allowedMimes = [], int $maxSize = 10240)
{
$this->allowedMimes = $allowedMimes;
$this->maxSize = $maxSize;
}

public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!$value->isValid()) {
$fail('文件上传失败');
return;
}

if ($value->getSize() > $this->maxSize * 1024) {
$fail("文件大小不能超过 {$this->maxSize}KB");
}

if (!empty($this->allowedMimes)) {
$extension = $value->getClientOriginalExtension();
if (!in_array(strtolower($extension), $this->allowedMimes)) {
$fail('文件类型不支持');
}
}
}
}

安全文件上传

安全上传服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
<?php

namespace App\Services;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class SecureFileUploadService
{
protected array $allowedMimes = [
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
];

protected int $maxSize = 10485760;

protected array $dangerousExtensions = [
'php', 'php3', 'php4', 'php5', 'phtml',
'exe', 'bat', 'cmd', 'sh',
'js', 'html', 'htm',
];

public function upload(UploadedFile $file, string $directory = 'uploads'): array
{
$this->validate($file);

$safeName = $this->generateSafeName($file);
$path = $file->storeAs($directory, $safeName, 'local');

$this->scanFile($path);

return [
'original_name' => $file->getClientOriginalName(),
'path' => $path,
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'hash' => md5_file($file->path()),
];
}

protected function validate(UploadedFile $file): void
{
if (!$file->isValid()) {
throw new \Exception('文件上传失败');
}

if ($file->getSize() > $this->maxSize) {
throw new \Exception('文件大小超出限制');
}

if (!in_array($file->getMimeType(), $this->allowedMimes)) {
throw new \Exception('文件类型不允许');
}

$extension = strtolower($file->getClientOriginalExtension());
if (in_array($extension, $this->dangerousExtensions)) {
throw new \Exception('危险的文件类型');
}

$this->validateFileContent($file);
}

protected function validateFileContent(UploadedFile $file): void
{
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$actualMime = finfo_file($finfo, $file->path());
finfo_close($finfo);

if ($actualMime !== $file->getMimeType()) {
throw new \Exception('文件类型验证失败');
}
}

protected function generateSafeName(UploadedFile $file): string
{
$extension = $this->getSafeExtension($file);
return Str::random(40) . '.' . $extension;
}

protected function getSafeExtension(UploadedFile $file): string
{
$mimeToExt = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'application/pdf' => 'pdf',
];

$mime = $file->getMimeType();
return $mimeToExt[$mime] ?? 'bin';
}

protected function scanFile(string $path): void
{
// 可集成杀毒软件扫描
}
}

文件存储操作

基础操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use Illuminate\Support\Facades\Storage;

// 存储文件
Storage::put('file.txt', $content);
Storage::put('file.txt', $resource);

// 获取文件
$content = Storage::get('file.txt');

// 检查存在
$exists = Storage::exists('file.txt');
$missing = Storage::missing('file.txt');

// 删除文件
Storage::delete('file.txt');
Storage::delete(['file1.txt', 'file2.txt']);

// 复制和移动
Storage::copy('old/file.txt', 'new/file.txt');
Storage::move('old/file.txt', 'new/file.txt');

目录操作

1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建目录
Storage::makeDirectory('directory');

// 删除目录
Storage::deleteDirectory('directory');

// 获取文件列表
$files = Storage::files('directory');
$allFiles = Storage::allFiles('directory');

// 获取目录列表
$directories = Storage::directories('directory');
$allDirectories = Storage::allDirectories('directory');

预签名 URL

1
2
3
4
5
6
7
8
// 临时 URL
$url = Storage::temporaryUrl('file.pdf', now()->addMinutes(5));

// S3 临时上传 URL
$uploadUrl = Storage::temporaryUploadUrl(
'file.pdf',
now()->addMinutes(5)
);

文件下载

基础下载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function download($path)
{
return Storage::download($path);
}

public function downloadWithName($path)
{
return Storage::download($path, 'custom-name.pdf');
}

public function downloadWithHeaders($path)
{
return Storage::download($path, 'document.pdf', [
'Content-Type' => 'application/pdf',
]);
}

流式下载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function streamDownload()
{
return response()->streamDownload(function () {
$content = $this->generateLargeContent();
echo $content;
}, 'large-file.txt');
}

public function downloadFromCloud($path)
{
$stream = Storage::disk('s3')->readStream($path);

return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => Storage::disk('s3')->mimeType($path),
'Content-Length' => Storage::disk('s3')->size($path),
]);
}

图片上传处理

图片处理服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?php

namespace App\Services;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Facades\Image;

class ImageUploadService
{
protected array $sizes = [
'thumbnail' => [150, 150],
'medium' => [400, 400],
'large' => [800, 800],
];

public function upload(UploadedFile $file, string $directory = 'images'): array
{
$hash = md5_file($file->path());
$extension = $file->getClientOriginalExtension();
$filename = "{$hash}.{$extension}";

$paths = [];

// 原图
$paths['original'] = $file->storeAs("{$directory}/original", $filename);

// 生成缩略图
foreach ($this->sizes as $name => [$width, $height]) {
$image = Image::make($file)
->fit($width, $height)
->encode($extension, 80);

$path = "{$directory}/{$name}/{$filename}";
Storage::put($path, $image);
$paths[$name] = $path;
}

return $paths;
}

public function uploadWithWatermark(UploadedFile $file, string $watermarkPath): string
{
$image = Image::make($file);

$watermark = Image::make(Storage::path($watermarkPath))
->opacity(50);

$image->insert($watermark, 'bottom-right', 10, 10);

$path = 'images/' . uniqid() . '.jpg';
Storage::put($path, $image->encode('jpg', 90));

return $path;
}
}

文件上传进度

进度追踪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;

class UploadProgressService
{
public function start(string $uploadId, int $totalSize): void
{
Cache::put("upload:{$uploadId}", [
'total' => $totalSize,
'uploaded' => 0,
'status' => 'uploading',
'started_at' => now(),
], 3600);
}

public function update(string $uploadId, int $uploaded): void
{
$data = Cache::get("upload:{$uploadId}");
$data['uploaded'] = $uploaded;
Cache::put("upload:{$uploadId}", $data, 3600);
}

public function complete(string $uploadId, string $path): void
{
$data = Cache::get("upload:{$uploadId}");
$data['status'] = 'completed';
$data['path'] = $path;
$data['completed_at'] = now();
Cache::put("upload:{$uploadId}", $data, 3600);
}

public function getProgress(string $uploadId): array
{
return Cache::get("upload:{$uploadId}", [
'status' => 'not_found',
]);
}
}

分块上传

大文件分块上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class ChunkUploadController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'file' => 'required|file',
'chunk_index' => 'required|integer',
'total_chunks' => 'required|integer',
'file_id' => 'required|string',
]);

$chunk = $request->file('file');
$fileId = $request->file_id;
$chunkIndex = $request->chunk_index;
$totalChunks = $request->total_chunks;

$chunkPath = "chunks/{$fileId}/{$chunkIndex}";
Storage::put($chunkPath, file_get_contents($chunk));

if ($chunkIndex === $totalChunks - 1) {
return $this->assembleChunks($fileId, $totalChunks);
}

return response()->json([
'message' => 'Chunk uploaded',
'chunk_index' => $chunkIndex,
]);
}

protected function assembleChunks(string $fileId, int $totalChunks): array
{
$finalPath = "uploads/{$fileId}";
$finalFile = fopen(Storage::path($finalPath), 'wb');

for ($i = 0; $i < $totalChunks; $i++) {
$chunkPath = Storage::path("chunks/{$fileId}/{$i}");
$chunk = fopen($chunkPath, 'rb');
stream_copy_to_stream($chunk, $finalFile);
fclose($chunk);
Storage::delete("chunks/{$fileId}/{$i}");
}

fclose($finalFile);
Storage::deleteDirectory("chunks/{$fileId}");

return [
'path' => $finalPath,
'url' => Storage::url($finalPath),
];
}
}

总结

Laravel 13 的文件上传处理提供了:

  • 灵活的文件存储配置
  • 完善的文件验证机制
  • 安全的文件上传处理
  • 多文件上传支持
  • 文件存储操作
  • 图片处理功能
  • 大文件分块上传

掌握文件上传处理技巧是构建功能完善 Web 应用的必备技能。