Laravelファイルストレージとアップロード
ファイルシステムはLaravelの「倉庫管理」です。ファイルがローカルディスクに保存されてもS3クラウドに保存されても, コードは全く同じです。
1. 学ぶこと
- FileSystem抽象層:local/public/s3ドライバの設定
- ファイルアップロードの全体プロセス:バリデーション → 保存 → URL生成 → レスポンス
- ストレージシンボリックリンク:php artisan storage:link
- S3クラウドストレージの統合とプリサインURL
- ファイル操作:コピー/移動/削除/可視性とストリーム処理
2. 運用の世界の本当の話
(1) 痛点:サーバーのディスクが満杯で, 画像がすべて消失
ShopMetricsのすべての商品画像はサーバーのpublic/uploads/ディレクトリにローカル保存されていました。500GBの画像がディスクを埋め尽くし, サイトがクラッシュしました。さらに悪いことに, サーバーのハードウェア障害後にバックアップがなく, Aliceは2,000枚の商品画像をすべて失いました。BobはS3への移行を希望しましたが, ファイルパスがコード全体にハードコードされており, 3日間作業しても変更が終わりませんでした。
(2) ストレージ抽象層の解決策
Laravel Storageファサードは統一APIでファイルを管理します。ローカル, S3, その他のドライバでもコードは同じで, 移行は.env設定を変更するだけです。
// 同じコードがローカル, S3, どのドライバでも動作
Storage::disk('public')->put('shops/logo.jpg', $file);
$url = Storage::disk('public')->url('shops/logo.jpg');
// .envを変更するだけでS3に切り替え
// FILESYSTEM_DISK=s3
// それ以外はすべて同じ!
(3) 成果
Bobは.envの2行を変更するだけでS3に切り替え, コードの修正は不要でした。AliceのS3上の画像はイレブンナインの耐久性を持ち, ディスク容量不足とハードウェア障害はもう問題ではありません。
3. ストレージ抽象層
(1) ドライバ設定
// 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 | イレブンナイン | 従量課金 |
s3+CDN |
S3 + CloudFront | ✅ CDN | イレブンナイン | 従量課金 |
(1) ▶ サンプル:ShopMetricsストレージ設定
# .env — 開発:publicディスクを使用
FILESYSTEM_DISK=public
# .env — 本番: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
# publicディスクのシンボリックリンクを作成
php artisan storage:link
# [OK] Link created: public/storage -> storage/app/public
出力:
# コマンド実行成功
4. ファイルアップロードの完全なプロセス
(1) バリデーション → 保存 → URL生成
// ステップ1:バリデーション
$validated = $request->validate([
'logo' => 'required|image|mimes:jpeg,png,webp|max:2048',
]);
// ステップ2:保存
$path = $request->file('logo')->store('shops/logos', 'public');
// => "shops/logos/abc123def456.jpg"
// ステップ3:URL生成
$url = Storage::disk('public')->url($path);
// => "http://shopmetrics.test/storage/shops/logos/abc123def456.jpg"
// ステップ4:パスをデータベースに保存
$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') |
ランダム名 + Public | storeと同じ |
storePubliclyAs(...) |
カスタム名 + Public | storeAsと同じ |
(1) ▶ サンプル:ShopMetrics商品画像アップロード
// 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();
}
}
出力:
// 実行成功
5. シンボリックリンクとパブリックディスク
(1) シンボリックリンクの作成
php artisan storage:link
# 作成されるもの: public/storage → storage/app/public
(2) シンボリックリンクの仕組み
public/
├── index.php
├── storage/ → ../../storage/app/public/ (シンボリックリンク)
│ └── shops/logos/abc123.jpg (Web経由でアクセス可能)
storage/
└── app/
└── public/ (実際のファイルの場所)
└── shops/logos/abc123.jpg
| パス | 目的 | Webアクセス |
|---|---|---|
storage/app/public/ |
公開ファイルストレージ | ✅ シンボリックリンク経由 |
storage/app/ |
プライベートファイルストレージ | ❌ Web経由不可 |
public/ |
Webルートディレクトリ | ✅ 直接アクセス |
(1) ▶ サンプル:プライベートファイルのダウンロード
// プライベートファイル — Web経由でアクセス不可, コントローラ経由が必須
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",
);
}
}
出力:
// 実行成功
6. S3クラウドストレージとプリサインURL
(1) S3統合
composer require league/flysystem-aws-s3-v3:"^3.0"
(2) プリサインURL
プリサインURLは, クライアントがサーバーを経由せずにS3ファイルを直接アップロード・ダウンロードできるようにします。帯域幅を節約し, サーバーの負荷を軽減します。
// 一時アップロードURLの生成 (クライアントがS3に直接アップロード)
$uploadUrl = Storage::disk('s3')->temporaryUploadUrl(
"products/{$product->id}/images/" . $request->filename,
now()->addMinutes(30),
['ContentType' => $request->mime_type],
);
// 一時ダウンロードURLの生成
$downloadUrl = Storage::disk('s3')->temporaryUrl(
$image->path,
now()->addMinutes(15),
);
(3) S3とオンプレミスの比較
| 項目 | パブリックディスク | S3 |
|---|---|---|
| ファイル保存 | サーバーディスク | AWS S3 |
| Webアクセス | シンボリックリンク | URL/CDN |
| 拡張性 | ディスク制限 | 無制限 |
| 可用性 | サーバー依存 | 99.999999999% |
| CDN統合 | 設定が必要 | CloudFront |
| プリサインURL | ❌ | ✅ |
| コスト | 無料 | 従量課金 |
(1) ▶ サンプル:ShopMetrics S3プリサインアップロード
// 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'],
]);
}
}
出力:
// 実行成功
7. ファイル操作
(1) 一般的な操作
// 読み取り
$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');
// 書き込み
Storage::disk('s3')->put('reports/summary.csv', $csvContent);
Storage::disk('s3')->putFileAs('avatars', $uploadedFile, 'profile.jpg');
// コピー / 移動
Storage::disk('s3')->copy('old/path.jpg', 'new/path.jpg');
Storage::disk('s3')->move('temp/file.jpg', 'permanent/file.jpg');
// 削除
Storage::disk('s3')->delete('shops/logos/abc.jpg');
Storage::disk('s3')->delete(['file1.jpg', 'file2.jpg']);
// 可視性
Storage::disk('s3')->setVisibility('file.jpg', 'public');
Storage::disk('s3')->setVisibility('file.jpg', 'private');
$visibility = Storage::disk('s3')->getVisibility('file.jpg');
// ディレクトリ
$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');
// ファイルメタデータ
$size = Storage::disk('s3')->size('file.jpg');
$modified = Storage::disk('s3')->lastModified('file.jpg');
$path = Storage::disk('s3')->path('file.jpg');
(1) ▶ サンプル:ShopMetricsレポート生成と保存
// 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;
}
}
出力:
// 実行成功
8. 総合例:ShopMetricsファイルアップロードシステム
// ============================================
// 総合例: ShopMetricsファイルアップロードシステム
// 対象: アップロード, S3, プリサインURL, クリーンアップ, ストリーミング
// ============================================
// 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();
}
}
❓ よくある質問
storage:linkを作成するにはどうしますか?php artisan storage:linkはWindowsでも自動的にシンボリックリンクを作成しますが, 管理者権限が必要です。失敗する場合は手動で作成:mklink /D public\storage storage\app\public。upload_max_filesizeとpost_max_sizeの変更が必要です。AWS_URLをCloudFrontドメイン名に置き換えます。Storage::url()が返すすべてのURLが自動的にCDNを経由し, 世界中からのアクセスが高速化されます。boot()でdeletingイベントをリッスンして関連ファイルを削除。Artisanコマンドを定期実行して孤立ファイルをクリーンアップ。S3ライフサイクルポリシーで古いファイルを自動期限切れに。.envのFILESYSTEM_DISKでデフォルトディスクを切り替えられ, コードの修正は不要です。📖 まとめ
- Storage抽象層は統一APIを提供。ドライバの切り替えは.envの修正のみ
- パブリックディスクは
storage:linkシンボリックリンクでWebアクセス可能 - ファイルアップロード:バリデーション → store() → パスをDBに保存 → URL生成
- S3は本番環境に適している:高耐久性, 無制限の拡張性, CDN統合
- プリサインURLでクライアントがS3に直接アップロードし, サーバーの帯域幅を節約
- プライベートファイルはコントローラ経由でダウンロード, パブリックファイルはURLで直接アクセス可能
📝 練習問題
-
基本問題 (⭐):ShopMetricsでパブリックディスクストレージを設定し, 商品画像のアップロード (バリデーション, 保存, 表示)を実装し, Bladeページにアップロードされた画像を表示してください。
-
応用問題 (⭐⭐):S3ストレージに切り替え, プリサインURLによるアップロードを実装してください。フロントエンドがプリサインURLを取得してS3に直接アップロードし, バックエンドが確認後にMediaレコードを作成します。
-
チャレンジ (⭐⭐⭐):S3ファイルライフサイクル管理を実装してください。アップロード時にタグ (tenant_id)を設定し, テナント別に90日以上前のレポートファイルをクリーンアップするArtisanコマンドを書き, S3バッチ削除APIを使ってパフォーマンスを最適化してください。



