Laravel: Laravel文件存储与上传

最后更新:2026-08-26

文件系统是 Laravel 的"仓库管理"——不管文件存在本地磁盘还是 S3 云端,代码写法完全一样。

1. 你将学到


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_filesizepost_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(可靠可扩展)。通过 .envFILESYSTEM_DISK 切换默认磁盘,代码无需修改。

📖 小节


📝 作业

  1. 基础题(⭐):配置 ShopMetrics 使用 public 磁盘,实现商品图片上传(验证+存储+展示),在 Blade 页面中显示上传的图片。

  2. 进阶题(⭐⭐):切换到 S3 存储,实现预签名 URL 上传——前端获取预签名 URL 后直传 S3,后端确认后创建 Media 记录。

  3. 挑战题(⭐⭐⭐):实现 S3 文件生命周期管理——上传时设置标签(tenant_id),编写 Artisan 命令按租户清理 90 天前的报表文件,使用 S3 批量删除 API 优化性能。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏