404 Not Found

404 Not Found


nginx

Laravelファイルストレージとアップロード

ファイルシステムはLaravelの「倉庫管理」です。ファイルがローカルディスクに保存されてもS3クラウドに保存されても, コードは全く同じです。

1. 学ぶこと


2. 運用の世界の本当の話

(1) 痛点:サーバーのディスクが満杯で, 画像がすべて消失

ShopMetricsのすべての商品画像はサーバーのpublic/uploads/ディレクトリにローカル保存されていました。500GBの画像がディスクを埋め尽くし, サイトがクラッシュしました。さらに悪いことに, サーバーのハードウェア障害後にバックアップがなく, Aliceは2,000枚の商品画像をすべて失いました。BobはS3への移行を希望しましたが, ファイルパスがコード全体にハードコードされており, 3日間作業しても変更が終わりませんでした。

(2) ストレージ抽象層の解決策

Laravel Storageファサードは統一APIでファイルを管理します。ローカル, S3, その他のドライバでもコードは同じで, 移行は.env設定を変更するだけです。

PHP
// 同じコードがローカル, 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) ドライバ設定

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 イレブンナイン 従量課金
s3+CDN S3 + CloudFront ✅ CDN イレブンナイン 従量課金

(1) ▶ サンプル:ShopMetricsストレージ設定

BASH
# .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

出力:

TEXT
# コマンド実行成功

4. ファイルアップロードの完全なプロセス

(1) バリデーション → 保存 → URL生成

PHP
// ステップ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商品画像アップロード

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. シンボリックリンクとパブリックディスク

(1) シンボリックリンクの作成

BASH
php artisan storage:link
# 作成されるもの: public/storage → storage/app/public

(2) シンボリックリンクの仕組み

TEXT
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) ▶ サンプル:プライベートファイルのダウンロード

PHP
// プライベートファイル — 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",
        );
    }
}

出力:

TEXT
// 実行成功

6. S3クラウドストレージとプリサインURL

(1) S3統合

BASH
composer require league/flysystem-aws-s3-v3:"^3.0"

(2) プリサインURL

プリサインURLは, クライアントがサーバーを経由せずにS3ファイルを直接アップロード・ダウンロードできるようにします。帯域幅を節約し, サーバーの負荷を軽減します。

PHP
// 一時アップロード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プリサインアップロード

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
// 読み取り
$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レポート生成と保存

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
// ============================================
// 総合例: 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();
    }
}

❓ よくある質問

Q Windowsでstorage:linkを作成するにはどうしますか?
A Laravelのphp artisan storage:linkはWindowsでも自動的にシンボリックリンクを作成しますが, 管理者権限が必要です。失敗する場合は手動で作成:mklink /D public\storage storage\app\public
Q プリサインURLはいつ使うべきですか?
A クライアント (ブラウザ/モバイル)がサーバーを経由せずにS3に直接ファイルをアップロードする必要がある場合に使用します。大きなファイル (10MB超)はプリサインURLでアップロードする必要があります。
Q 大きなファイルのアップロードはどう扱いますか?
A プリサインURLを使ってクライアントからS3に直接アップロードするか, マルチパートアップロードを使用します (ギガバイト級のファイルに対応)。PHP側ではupload_max_filesizepost_max_sizeの変更が必要です。
Q S3とCloudFront CDNはどう連携しますか?
A AWSでCloudFrontディストリビューションをS3バケットに向け, AWS_URLをCloudFrontドメイン名に置き換えます。Storage::url()が返すすべてのURLが自動的にCDNを経由し, 世界中からのアクセスが高速化されます。
Q 未使用ファイルのクリーンアップはどうしますか?
A モデル削除時にモデルのboot()deletingイベントをリッスンして関連ファイルを削除。Artisanコマンドを定期実行して孤立ファイルをクリーンアップ。S3ライフサイクルポリシーで古いファイルを自動期限切れに。
Q パブリックディスクとS3を同時に使えますか?
A はい。開発ではパブリックディスク (無料で高速), 本番ではS3 (信頼性と拡張性)を使用します。.envFILESYSTEM_DISKでデフォルトディスクを切り替えられ, コードの修正は不要です。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopMetricsでパブリックディスクストレージを設定し, 商品画像のアップロード (バリデーション, 保存, 表示)を実装し, Bladeページにアップロードされた画像を表示してください。

  2. 応用問題 (⭐⭐):S3ストレージに切り替え, プリサインURLによるアップロードを実装してください。フロントエンドがプリサインURLを取得してS3に直接アップロードし, バックエンドが確認後にMediaレコードを作成します。

  3. チャレンジ (⭐⭐⭐):S3ファイルライフサイクル管理を実装してください。アップロード時にタグ (tenant_id)を設定し, テナント別に90日以上前のレポートファイルをクリーンアップするArtisanコマンドを書き, S3バッチ削除APIを使ってパフォーマンスを最適化してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%