Laravel: Laravel文件存储与上传
最后更新:2026-08-26
文件系统是 Laravel 的"仓库管理"——不管文件存在本地磁盘还是 S3 云端,代码写法完全一样。
1. 你将学到
- FileSystem 抽象层:local/public/s3 驱动配置
- 文件上传全流程:验证→存储→URL 生成→响应
- 存储符号链接:php artisan storage:link
- S3 云存储集成与预签名 URL
- 文件操作:copy/move/delete/visibility 与流式处理
2. 一个运维的真实故事
(1) 痛点:服务器磁盘满了,图片全丢
ShopMetrics 的商品图片全存在服务器本地 public/uploads/ 目录——500 GB 图片撑满了磁盘,站点直接宕机。更糟的是,服务器硬件故障后没有备份,Alice 的 2000 张商品图片全部丢失。Bob 想迁移到 S3,但代码里到处硬编码了文件路径,改了 3 天还没改完。
(2) Storage 抽象层的解法
Laravel Storage 门面用统一 API 操作文件——本地、S3、任何驱动代码都一样,迁移只需改 .env 配置。
PHP
// Same code works for local, S3, or any driver
Storage::disk('public')->put('shops/logo.jpg', $file);
$url = Storage::disk('public')->url('shops/logo.jpg');
// Switch to S3 by changing .env
// FILESYSTEM_DISK=s3
// Everything else stays the same!
(3) 收益
Bob 切换到 S3 只改了 2 行 .env,代码零修改。Alice 的图片在 S3 有 11 个 9 的持久性,磁盘满和硬件故障再也不是问题。
3. Storage 抽象层
(1) 驱动配置
PHP
// config/filesystems.php
'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',
],
'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'),
],
],
'default' => env('FILESYSTEM_DISK', 'local'),
(2) 驱动对比
| 驱动 | 存储位置 | 公网访问 | 持久性 | 成本 |
|---|---|---|---|---|
local |
storage/app/ | ❌ 需路由 | 服务器 | 免费 |
public |
storage/app/public/ | ✅ 符号链接 | 服务器 | 免费 |
s3 |
AWS S3 | ✅ URL | 11 个 9 | 按量 |
s3+CDN |
S3 + CloudFront | ✅ CDN | 11 个 9 | 按量 |
▶ 示例:ShopMetrics 存储配置
BASH
# .env — Development: use public disk
FILESYSTEM_DISK=public
# .env — Production: use S3
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=shopmetrics-uploads
# Create symbolic link for public disk
php artisan storage:link
# [OK] Link created: public/storage -> storage/app/public
输出:
TEXT
📖 仅展示
# 命令执行成功
4. 文件上传全流程
(1) 验证 → 存储 → URL 生成
PHP
// Step 1: Validate
$validated = $request->validate([
'logo' => 'required|image|mimes:jpeg,png,webp|max:2048',
]);
// Step 2: Store
$path = $request->file('logo')->store('shops/logos', 'public');
// => "shops/logos/abc123def456.jpg"
// Step 3: Generate URL
$url = Storage::disk('public')->url($path);
// => "http://shopmetrics.test/storage/shops/logos/abc123def456.jpg"
// Step 4: Save path to database
$shop->update(['logo_path' => $path]);
(2) 上传方法对比
| 方法 | 文件名 | 路径示例 |
|---|---|---|
store('dir', 'disk') |
随机哈希名 | shops/logos/abc123.jpg |
storeAs('dir', name, 'disk') |
自定义名 | shops/logos/alice-store.jpg |
storePublicly('dir', 'disk') |
随机名+公开 | 同 store |
storePubliclyAs(...) |
自定义名+公开 | 同 storeAs |
▶ 示例:ShopMetrics 商品图片上传
PHP
// app/Http/Controllers/ProductImageController.php
class ProductImageController extends Controller
{
public function store(Request $request, Product $product): JsonResponse
{
$validated = $request->validate([
'image' => 'required|image|mimes:jpeg,png,webp|max:5120',
'is_primary' => 'sometimes|boolean',
]);
$path = $request->file('image')->store(
"products/{$product->id}/images",
's3',
);
$image = $product->images()->create([
'path' => $path,
'is_primary' => $validated['is_primary'] ?? false,
'mime_type' => $request->file('image')->getMimeType(),
'size' => $request->file('image')->getSize(),
]);
return response()->json([
'message' => 'Image uploaded.',
'data' => [
'id' => $image->id,
'url' => Storage::disk('s3')->url($path),
],
], 201);
}
public function destroy(Product $product, Image $image): Response
{
Storage::disk('s3')->delete($image->path);
$image->delete();
return response()->noContent();
}
}
输出:
TEXT
📖 仅展示
// 执行成功
5. 符号链接与 public 磁盘
(1) 创建符号链接
BASH
php artisan storage:link
# Creates: public/storage → storage/app/public
(2) 符号链接原理
TEXT
📖 仅展示
public/
├── index.php
├── storage/ → ../../storage/app/public/ (symbolic link)
│ └── shops/logos/abc123.jpg (accessible via web)
storage/
└── app/
└── public/ (actual file location)
└── shops/logos/abc123.jpg
| 路径 | 用途 | Web 访问 |
|---|---|---|
storage/app/public/ |
公开文件存储 | ✅ 通过符号链接 |
storage/app/ |
私有文件存储 | ❌ 不通过 Web |
public/ |
Web 根目录 | ✅ 直接访问 |
▶ 示例:私有文件下载
PHP
// Private file — not accessible via web, must go through controller
Route::get('/reports/{report}/download', [ReportController::class, 'download'])
->middleware('auth');
class ReportController extends Controller
{
public function download(Report $report): StreamedResponse
{
$this->authorize('view', $report);
if (!Storage::disk('local')->exists($report->file_path)) {
abort(404, 'Report file not found.');
}
return Storage::disk('local')->download(
$report->file_path,
"report-{$report->id}.pdf",
);
}
}
输出:
TEXT
📖 仅展示
// 执行成功
6. S3 云存储与预签名 URL
(1) S3 集成
BASH
composer require league/flysystem-aws-s3-v3:"^3.0"
(2) 预签名 URL
预签名 URL 让客户端直接上传/下载 S3 文件,不经服务器中转——节省带宽和服务器负载。
PHP
// Generate a temporary upload URL (client uploads directly to S3)
$uploadUrl = Storage::disk('s3')->temporaryUploadUrl(
"products/{$product->id}/images/" . $request->filename,
now()->addMinutes(30),
['ContentType' => $request->mime_type],
);
// Generate a temporary download URL
$downloadUrl = Storage::disk('s3')->temporaryUrl(
$image->path,
now()->addMinutes(15),
);
(3) S3 与本地对比
| 维度 | public 磁盘 | S3 |
|---|---|---|
| 文件存储 | 服务器磁盘 | AWS S3 |
| Web 访问 | 符号链接 | URL/CDN |
| 扩容 | 受限于磁盘 | 无限 |
| 持久性 | 服务器依赖 | 99.999999999% |
| CDN 集成 | 需配置 | CloudFront |
| 预签名 URL | ❌ | ✅ |
| 成本 | 免费 | 按使用量 |
▶ 示例:ShopMetrics S3 预签名上传
PHP
// app/Http/Controllers/Api/FileUploadController.php
class FileUploadController extends Controller
{
public function presign(Request $request): JsonResponse
{
$validated = $request->validate([
'filename' => 'required|string',
'mime_type' => 'required|string|in:image/jpeg,image/png,image/webp',
'size' => 'required|integer|max:5120',
]);
$path = 'uploads/' . auth()->id() . '/' . Str::uuid() . '/' . $validated['filename'];
$uploadUrl = Storage::disk('s3')->temporaryUploadUrl(
$path,
now()->addMinutes(30),
['ContentType' => $validated['mime_type']],
);
return response()->json([
'upload_url' => $uploadUrl,
'path' => $path,
'expires_in' => 1800,
]);
}
public function confirm(Request $request): JsonResponse
{
$validated = $request->validate([
'path' => 'required|string',
'attachable_type' => 'required|string',
'attachable_id' => 'required|integer',
]);
$url = Storage::disk('s3')->url($validated['path']);
return response()->json([
'url' => $url,
'path' => $validated['path'],
]);
}
}
输出:
TEXT
📖 仅展示
// 执行成功
7. 文件操作
(1) 常用操作
PHP
// Read
$content = Storage::disk('s3')->get('shops/logos/abc.jpg');
$exists = Storage::disk('s3')->exists('shops/logos/abc.jpg');
$missing = Storage::disk('s3')->missing('shops/logos/abc.jpg');
// Write
Storage::disk('s3')->put('reports/summary.csv', $csvContent);
Storage::disk('s3')->putFileAs('avatars', $uploadedFile, 'profile.jpg');
// Copy / Move
Storage::disk('s3')->copy('old/path.jpg', 'new/path.jpg');
Storage::disk('s3')->move('temp/file.jpg', 'permanent/file.jpg');
// Delete
Storage::disk('s3')->delete('shops/logos/abc.jpg');
Storage::disk('s3')->delete(['file1.jpg', 'file2.jpg']);
// Visibility
Storage::disk('s3')->setVisibility('file.jpg', 'public');
Storage::disk('s3')->setVisibility('file.jpg', 'private');
$visibility = Storage::disk('s3')->getVisibility('file.jpg');
// Directory
$files = Storage::disk('s3')->files('shops/logos');
$allFiles = Storage::disk('s3')->allFiles('shops');
$directories = Storage::disk('s3')->directories('shops');
Storage::disk('s3')->makeDirectory('shops/new-dir');
Storage::disk('s3')->deleteDirectory('shops/old-dir');
// File metadata
$size = Storage::disk('s3')->size('file.jpg');
$modified = Storage::disk('s3')->lastModified('file.jpg');
$path = Storage::disk('s3')->path('file.jpg');
▶ 示例:ShopMetrics 报表生成与存储
PHP
// app/Services/ReportService.php
class ReportService
{
public function generateOrderReport(Tenant $tenant, string $format = 'csv'): string
{
$orders = Order::where('tenant_id', $tenant->id)
->with(['shop', 'items.product'])
->completed()
->latest()
->get();
$csv = "Order Number,Shop,Customer,Total,Status,Date\n";
foreach ($orders as $order) {
$csv .= implode(',', [
$order->order_number,
$order->shop->name,
$order->user->name,
$order->total,
$order->status,
$order->created_at->format('Y-m-d'),
]) . "\n";
}
$path = "reports/{$tenant->slug}/orders-" . now()->format('Y-m-d-His') . ".csv";
Storage::disk('s3')->put($path, $csv);
return $path;
}
public function getDownloadUrl(string $path, int $expiresMinutes = 15): string
{
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresMinutes));
}
public function cleanupOldReports(Tenant $tenant): int
{
$cutoff = now()->subDays(90)->format('Y-m-d');
$files = Storage::disk('s3')->allFiles("reports/{$tenant->slug}");
$deleted = 0;
foreach ($files as $file) {
if (str_contains($file, $cutoff) || Storage::disk('s3')->lastModified($file) < now()->subDays(90)->timestamp) {
Storage::disk('s3')->delete($file);
$deleted++;
}
}
return $deleted;
}
}
输出:
TEXT
📖 仅展示
// 执行成功
8. 综合示例:ShopMetrics 文件上传系统
PHP
// ============================================
// Comprehensive: ShopMetrics File Upload System
// Covers: upload, S3, presigned URLs, cleanup, streaming
// ============================================
// app/Http/Controllers/Api/MediaController.php
class MediaController extends Controller
{
public function upload(Request $request): JsonResponse
{
$validated = $request->validate([
'file' => 'required|file|max:10240',
'collection' => 'required|in:logos,products,reports',
'attachable_type' => 'sometimes|string',
'attachable_id' => 'sometimes|integer',
]);
$file = $request->file('file');
$tenantId = tenant()->id;
$path = $validated['collection'] . "/{$tenantId}/" . Str::uuid() . '.' . $file->extension();
$stored = Storage::disk('s3')->put($path, $file->getContent(), 'public');
if (!$stored) {
return response()->json(['message' => 'Upload failed.'], 500);
}
$media = Media::create([
'tenant_id' => $tenantId,
'path' => $path,
'filename' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'collection' => $validated['collection'],
]);
return response()->json([
'message' => 'File uploaded.',
'data' => [
'id' => $media->id,
'url' => Storage::disk('s3')->url($path),
'filename' => $media->filename,
'size' => $media->size,
],
], 201);
}
public function download(Media $media): StreamedResponse
{
$this->authorize('view', $media);
if ($media->isPublic()) {
return redirect(Storage::disk('s3')->url($media->path));
}
return Storage::disk('s3')->download($media->path, $media->filename);
}
public function temporaryUrl(Media $media): JsonResponse
{
$this->authorize('view', $media);
$url = Storage::disk('s3')->temporaryUrl(
$media->path,
now()->addMinutes(15),
);
return response()->json(['url' => $url, 'expires_in' => 900]);
}
public function destroy(Media $media): Response
{
$this->authorize('delete', $media);
Storage::disk('s3')->delete($media->path);
$media->delete();
return response()->noContent();
}
}
❓ 常见问题
Q storage:link 在 Windows 上怎么创建?
A Laravel 的
php artisan storage:link 在 Windows 上会自动创建符号链接,但需要管理员权限。如果失败,手动创建:mklink /D public\storage storage\app\public。Q 什么时候用预签名 URL?
A 当客户端(浏览器/移动端)需要直接上传文件到 S3 时用预签名 URL,避免文件经过服务器中转。大文件(>10MB)必须用预签名上传。
Q 如何处理上传大文件?
A 使用预签名 URL 让客户端直传 S3;或使用分片上传(Multipart Upload)支持 GB 级文件;PHP 端需修改
upload_max_filesize 和 post_max_size。Q S3 和 CloudFront CDN 怎么配合?
A 在 AWS 配置 CloudFront 分发指向 S3 bucket,修改
AWS_URL 为 CloudFront 域名。所有 Storage::url() 返回的 URL 自动走 CDN,加速全球访问。Q 如何清理不用的文件?
A 删除模型时在模型的
boot() 中监听 deleting 事件删除关联文件;定期运行 Artisan 命令清理孤立文件;使用 S3 生命周期策略自动过期旧文件。Q public 磁盘和 S3 可以同时用吗?
A 可以。开发用 public(免费快速),生产用 S3(可靠可扩展)。通过
.env 的 FILESYSTEM_DISK 切换默认磁盘,代码无需修改。📖 小节
- Storage 抽象层统一 API,切换驱动只需改 .env
- public 磁盘需要 storage:link 符号链接才能 Web 访问
- 文件上传:验证→store()→存路径到 DB→生成 URL
- S3 适合生产环境:持久性高、无限扩容、CDN 集成
- 预签名 URL 让客户端直传 S3,不占服务器带宽
- 私有文件通过控制器下载,公开文件直接 URL 访问
📝 作业
-
基础题(⭐):配置 ShopMetrics 使用 public 磁盘,实现商品图片上传(验证+存储+展示),在 Blade 页面中显示上传的图片。
-
进阶题(⭐⭐):切换到 S3 存储,实现预签名 URL 上传——前端获取预签名 URL 后直传 S3,后端确认后创建 Media 记录。
-
挑战题(⭐⭐⭐):实现 S3 文件生命周期管理——上传时设置标签(tenant_id),编写 Artisan 命令按租户清理 90 天前的报表文件,使用 S3 批量删除 API 优化性能。