【第四弾】Laravel入門資料

📚 Laravel公式ドキュメント - File Storage

https://laravel.com/docs/12.x/filesystem

💡 この講座では、上記の公式ドキュメントを基に解説していきます。
公式ドキュメントは初学者には内容が難しいため、エッセンスを優しく噛み砕いて解説していきます。

画像アップロード - 商品画像を登録する

商品に画像を追加することで、よりユーザーにわかりやすい情報を提供できます。
画像のアップロード、保存、表示の一連の流れを学びます。

🎯 この章で学ぶこと

  • ✅ ファイルアップロードの基本 - enctype="multipart/form-data"
  • ✅ 画像の保存 - $request->file()->store()
  • ✅ 画像の表示 - Storage::url() と シンボリックリンク
  • ✅ バリデーション - ファイルサイズ・種類の制限
  • ✅ 画像の削除 - 古い画像ファイルの削除処理

💡 前提条件

  • products テーブルが作成済み
  • ✅ Product モデルが作成済み

※ 「ここまでの知識の実践」で作成したテーブルとモデルをそのまま使用します

画像アップロードの流れ

実装する機能の流れ

  1. マイグレーションで画像カラムを追加
  2. フォームにファイルアップロード欄を追加
  3. コントローラーで画像を保存
  4. シンボリックリンクで画像を公開
  5. ビューで画像を表示

マイグレーションで画像カラムを追加

まず、products テーブルに画像ファイルのパスを保存するカラムを追加します。

マイグレーションファイルの作成

php artisan make:migration add_image_to_products_table

database/migrations/xxxx_add_image_to_products_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->string('image')->nullable()->after('description');
        });
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->dropColumn('image');
        });
    }
};

💡 nullable()とは?

nullable() を付けると、NULL(空)を許可します。
画像は必須ではないため、nullable()を付けています。

マイグレーション実行

php artisan migrate

✅ productsテーブルにimageカラムが追加されました!

シンボリックリンクの作成

Laravelでは、アップロードした画像は storage/app/public に保存されます。
ブラウザから画像にアクセスできるようにするため、シンボリックリンクを作成します。

シンボリックリンクとは?

💡 シンボリックリンク(Symbolic Link)= ショートカット

別の場所にあるフォルダへの「近道リンク」を作る機能です。
Windowsの「ショートカット」、Macの「エイリアス」と同じ仕組みです。

具体例:

public/storage → storage/app/public

リンク元(ショートカット)   実体のフォルダ

なぜ必要?

❌ リンクがない場合

ブラウザ: /storage/image.jpg にアクセス
↓
public/storage/image.jpg を探す
↓
❌ 見つからない!
(実体は storage/app/public にある)

✅ リンクがある場合

ブラウザ: /storage/image.jpg にアクセス
↓
public/storage → storage/app/public(リンク)
↓
✅ 画像が表示される!

🔍 メカニズムの詳細

1. Laravelのフォルダ構造の問題

プロジェクトルート/
├─ public/           ← ブラウザからアクセスできるフォルダ
│  ├─ index.php
│  └─ css/
└─ storage/          ← ブラウザから直接アクセスできない
   └─ app/
      └─ public/    ← 画像はここに保存される

2. セキュリティ上の理由
storage/フォルダには、ログ・キャッシュ・アップロードファイルなど機密情報が含まれます。
全体を公開すると危険なので、ブラウザからアクセスできないようになっています。

3. シンボリックリンクで解決

php artisan storage:link を実行
↓
public/storage/ というショートカットが作られる
↓
このショートカットは storage/app/public/ を指す
↓
ブラウザは public/storage/ にアクセスできる
↓
実際には storage/app/public/ の中身が見える

シンボリックリンクの作成コマンド

php artisan storage:link

実行すると「The [public/storage] link has been connected to [storage/app/public].」と表示されます。

⚠️ 注意

このコマンドは一度だけ実行すればOKです。
すでにリンクが存在する場合はエラーになりますが、問題ありません。

✅ シンボリックリンクが作成されました!

画像アップロードフォームの作成

商品登録・編集フォームに画像アップロード欄を追加します。

重要: enctype属性

⚠️ 超重要! enctype="multipart/form-data"

ファイルをアップロードする場合、<form>タグに
enctype="multipart/form-data"必ず追加してください。
これがないと、ファイルが送信されません。

enctype(エンコードタイプ)とは?

フォームデータをどのような形式でサーバーに送るかを指定する属性です。

📝 通常のフォーム(デフォルト)

<form method="POST">
  <input name="name">
  <input name="email">
</form>

送信形式:

name=太郎&email=test@example.com

↑ テキストデータのみ
   シンプルで軽量

📁 ファイルアップロード

<form method="POST"
      enctype="multipart/form-data">
  <input name="name">
  <input type="file" name="image">
</form>

送信形式:

------boundary123
Content-Disposition: form-data; name="name"

太郎
------boundary123
Content-Disposition: form-data; name="image"; filename="photo.jpg"
Content-Type: image/jpeg

[画像のバイナリデータ]
------boundary123--

↑ バイナリデータも送信可能

⚠️ よくある間違い

❌ enctype を書き忘れた場合:
<form method="POST">  ← enctype がない!
  <input type="file" name="image">
</form>

結果:
$request->file('image')  // null
$request->hasFile('image')  // false
→ ファイル名だけ送信され、実体は送られない
✅ 正しい書き方:
<form method="POST" enctype="multipart/form-data">
  <input type="file" name="image">
</form>

結果:
$request->file('image')  // UploadedFile オブジェクト
→ ファイルが正しく送信される

🔍 multipart/form-data の仕組み

1. データを「パート(部品)」に分けて送信

通常のフォーム送信:
name=太郎&price=1000   ← 1つの文字列

multipart/form-data:
------WebKitFormBoundary...   ← 境界線
Content-Disposition: form-data; name="name"

太郎
------WebKitFormBoundary...   ← 境界線
Content-Disposition: form-data; name="price"

1000
------WebKitFormBoundary...   ← 境界線
Content-Disposition: form-data; name="image"; filename="product.jpg"
Content-Type: image/jpeg

[バイナリデータ...]
------WebKitFormBoundary...--   ← 終了マーカー

2. 各パートの構造

  • Content-Disposition - フィールド名とファイル名
  • Content-Type - データの種類(image/jpeg, text/plainなど)
  • 空行の後に実際のデータ
  • 境界線(boundary)で各パートを区切る

3. なぜこの形式が必要?
テキストとバイナリ(画像・動画・PDF)を同時に送るため、
データを区切る「境界線」が必要になります。

multipart/form-data の歴史とメカニズム

📜 歴史的背景: なぜ multipart/form-data が生まれたのか?

1990年代初頭 - インターネット黎明期

  • • Web が登場した当初、フォームで送れるのはテキストのみ
  • • メールでは既に添付ファイルが使われていた(MIME規格)
  • • 「Web でもファイルを送りたい」というニーズが発生

MIME(Multipurpose Internet Mail Extensions)の登場

  • 1992年: RFC 1341 で MIME が標準化(メール用)
  • • メールにテキスト・画像・添付ファイルを混在させる技術
  • multipart/mixed というフォーマットで複数パートを境界線で区切る
  • • この技術が Web にも流用できることに気づく

1995年: RFC 1867 - Form-based File Upload in HTML

  • • HTML の <input type="file"> が提案される
  • multipart/form-data がフォーム用に定義される
  • • MIME の multipart 技術を Web フォームに応用
  • • Netscape Navigator 2.0 で初めて実装される

現在(2025年)

  • • すべてのブラウザで標準サポート
  • • HTML5 仕様で正式に標準化
  • • 30年前の技術が今も現役で使われている

🔬 HTTP と MIME の関係

HTTP プロトコル自体は、ボディの中身のフォーマットを規定していません。
HTTP は「ヘッダーとボディを区切る」枠組みだけを提供します。

HTTP の役割(RFC 7230):

POST /upload HTTP/1.1          ← リクエストライン
Host: example.com              ← ヘッダー
Content-Type: multipart/form-data; boundary=...
Content-Length: 1234
                               ← 空行でヘッダーとボディを区切る
[ボディ]                        ← この中身のフォーマットは HTTP の範囲外

MIME の役割(RFC 2046):

ボディの中を boundary で区切る具体的なルールを定義:

------boundary123               ← パートの開始
Content-Disposition: ...        ← パートのヘッダー
                               ← 空行
データ                          ← パートの内容
------boundary123               ← 次のパート
Content-Disposition: ...
...
------boundary123--             ← 終了マーカー(-- が2つ)

HTTP

  • • リクエスト/レスポンスの構造
  • • ステータスコード
  • • ヘッダーの定義
  • • ボディの枠組みのみ

MIME

  • • ボディの中身のフォーマット
  • • boundary の使い方
  • • Content-Type の詳細
  • • パートの構造

つまり: HTTP は配送の枠組み、MIME は荷物の梱包方法

🔧 ブラウザが自動的にやってくれること

enctype="multipart/form-data" を指定すると、ブラウザが自動的に以下の処理を行います。
開発者が手動で boundary を作る必要はありません。

ステップ1: ランダムな boundary を生成

----WebKitFormBoundaryXa7bK9qR2vZ5nP8L  ← ランダム生成

他のデータと絶対に衝突しないように、長くてランダムな文字列を使用

ステップ2: Content-Type ヘッダーに boundary を含める

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXa7bK9qR2vZ5nP8L

サーバーがこの boundary を使ってパートを区切ることができる

ステップ3: 各フィールドを boundary で区切る

------WebKitFormBoundaryXa7bK9qR2vZ5nP8L
Content-Disposition: form-data; name="name"

太郎
------WebKitFormBoundaryXa7bK9qR2vZ5nP8L
Content-Disposition: form-data; name="image"; filename="photo.jpg"
Content-Type: image/jpeg

[画像のバイナリデータ...]
------WebKitFormBoundaryXa7bK9qR2vZ5nP8L--

ステップ4: 終了マーカーを追加

------WebKitFormBoundaryXa7bK9qR2vZ5nP8L--
                                                               ↑↑
                                        最後のハイフン2つで「終了」を示す

🔍 Chrome DevTools で実際に確認する方法

実際にブラウザがどのようなデータを送信しているか確認できます。

手順1: DevTools を開く

  • • Chrome で F12 キーを押す
  • • または、右クリック → 「検証」
  • Network タブをクリック

手順2: フォームを送信する

  • • Network タブを開いた状態で、画像付きフォームを送信
  • • リクエスト一覧に新しいリクエストが追加される

手順3: Headers タブで Content-Type を確認

  • • リクエストをクリック
  • Headers タブを選択
  • Request Headers セクションを見る
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXa7bK9qR2vZ5nP8L
                                                           ↑
                                        ブラウザが自動生成した boundary

手順4: Payload タブで実際のデータを確認

  • Payload タブを選択
  • • デフォルトでは整形表示されている
  • 「view source」 をクリックすると生データが見える
------WebKitFormBoundaryXa7bK9qR2vZ5nP8L
Content-Disposition: form-data; name="name"

太郎
------WebKitFormBoundaryXa7bK9qR2vZ5nP8L
Content-Disposition: form-data; name="price"

1000
------WebKitFormBoundaryXa7bK9qR2vZ5nP8L
Content-Disposition: form-data; name="image"; filename="product.jpg"
Content-Type: image/jpeg

���� JFIF ��  ... [binary data]
------WebKitFormBoundaryXa7bK9qR2vZ5nP8L--

💡 確認できること

  • boundary が自動生成されている
  • ✅ 各フィールドが boundary で区切られている
  • ✅ テキストとバイナリが混在して送信されている
  • ✅ ファイル名と Content-Type が自動的に付与されている
  • enctype なしで送信すると、画像は [object File] という文字列になる(ファイルが送られない)

⚠️ enctype なしで送信した場合の DevTools 確認

enctype を忘れると何が起こるか、DevTools で確認できます。

Headers タブ:

Content-Type: application/x-www-form-urlencoded
              ↑
              デフォルトのフォーム送信形式(テキストのみ)

Payload タブ:

name=%E5%A4%AA%E9%83%8E&price=1000&image=[object+File]
                                                        ↑
                                        ファイルが文字列になっている(送信失敗)

サーバー側:
$request->file('image')  // null
$request->input('image') // "[object File]" という文字列

ステップ1: ルーティングの設定

routes/web.php

use App\Http\Controllers\ImageUploadController;

Route::get('/image-upload', [ImageUploadController::class, 'index']);          // 一覧表示
Route::get('/image-upload/create', [ImageUploadController::class, 'create']);  // 登録フォーム
Route::post('/image-upload', [ImageUploadController::class, 'store']);         // 登録処理

💡 なぜ ProductController ではなく ImageUploadController?

実務では、画像アップロード機能は ProductController に統合するのが一般的です。
商品の作成・更新・削除と画像は同じリソース(商品)の責務なので、1つのコントローラーにまとめます。

📚 実務での構成例:

ProductController
├─ index()   // 一覧
├─ create()  // 作成フォーム
├─ store()   // 保存(画像も含む)
├─ edit()    // 編集フォーム
├─ update()  // 更新(画像も含む)
└─ destroy() // 削除(画像も削除)

→ 画像は store() と update() の中で処理

✅ この教材で分ける理由:

  • 学習のしやすさ - 画像機能だけに集中できる
  • 独立性 - 前の章のコードと干渉しない
  • テストしやすさ - 途中から始めても動く
  • 段階的学習 - 各章で1つの機能を完結させる

※ 実際のプロジェクトでは、ProductController に統合することを推奨します。

ステップ1.5: モデルの $fillable に image を追加

⚠️ 重要: これがないと画像パスが保存されません!

LaravelはMass Assignment保護により、$fillableに指定していないカラムはcreate()update()で保存できません。

app/Models/Product.php

protected $fillable = [
    'name',
    'description',
    'price',
    'stock',
    'is_published',
    'category_id',
    'image',  // ⭐ これを追加!
];

💡 なぜ必要?

$fillableにない場合

Product::create(['image' => 'xxx.jpg']);
// → imageは無視される(保存されない)

$fillableに追加した場合

Product::create(['image' => 'xxx.jpg']);
// → 正常に保存される!

ステップ2: コントローラーの作成

画像アップロード専用のコントローラーを作成します。

php artisan make:controller ImageUploadController

app/Http/Controllers/ImageUploadController.php

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;

class ImageUploadController extends Controller
{
    // 一覧表示
    public function index()
    {
        $products = Product::latest()->get();
        return view('image-upload.index', compact('products'));
    }

    // 登録フォーム表示
    public function create()
    {
        return view('image-upload.create');
    }

    // 登録処理
    public function store(Request $request)
    {
        // バリデーション
        $validated = $request->validate([
            'name' => 'required|max:255',
            'description' => 'nullable|max:1000',
            'price' => 'required|integer|min:0',
            'stock' => 'required|integer|min:0',
            'category_id' => 'required|exists:categories,id',
            'image' => 'nullable|image|max:2048', // 2MB以下の画像のみ
        ]);

        // 画像がアップロードされた場合のみ保存
        if ($request->hasFile('image')) {
            // storage/app/public/products に保存
            $imagePath = $request->file('image')->store('products', 'public');
            $validated['image'] = $imagePath;
        }

        // 商品を作成
        Product::create($validated);

        return redirect('/image-upload')->with('success', '商品を登録しました');
    }
}

ファイルアップロード関連メソッドの詳細解説

📘 hasFile() メソッド

役割: ファイルがアップロードされたかを確認する

if ($request->hasFile('image')) {
    // ファイルがアップロードされた場合の処理
}

内部で何をチェックしているか:

  • ✅ フォームから 'image' という名前のファイルが送信されたか
  • ✅ ファイルが正常にアップロードされたか(エラーなし)
  • ✅ ファイルサイズが 0 より大きいか(空ファイルではない)

⚠️ hasFile() を使わないとどうなる?

// ❌ ファイルがない場合でも store() を実行してエラー
$imagePath = $request->file('image')->store('products', 'public');

// ✅ hasFile() で確認してから実行
if ($request->hasFile('image')) {
    $imagePath = $request->file('image')->store('products', 'public');
}

📗 store() メソッド

基本構文:

$path = $request->file('image')->store('フォルダ名', 'ディスク名');

例:

$imagePath = $request->file('image')->store('products', 'public');
//                                                    ↓           ↓
//                                                フォルダ名    ディスク名

// 結果: 'products/ランダムな文字列.jpg' (例: products/a1b2c3d4e5.jpg)
// 実際の保存場所: storage/app/public/products/a1b2c3d4e5.jpg
// ※ ファイル名は自動生成されます(重複を避けるため)

store() が自動でやってくれること:

  • ✅ ランダムなファイル名を生成(重複防止)
  • ✅ 指定したフォルダに保存(フォルダがなければ自動作成)
  • ✅ ファイルパスを返す(DB保存用)
  • ✅ 元の拡張子を保持(.jpg, .png など)

パラメータの詳細:

パラメータ 説明
第1引数 保存先フォルダ 'products'
第2引数 ディスク名(storage設定) 'public'

🤔 store() メソッドはベストな方法?

結論: 多くの場合は store() で十分ですが、場合によって使い分けます。

方法1: store()(最もシンプル)
$path = $request->file('image')->store('products', 'public');
// 結果: products/a1b2c3d4e5.jpg

✅ メリット:
  - コードが短い(1行)
  - ファイル名の重複を自動で防ぐ
  - 拡張子を自動で保持

❌ デメリット:
  - ファイル名が指定できない(ランダム)
  - 元のファイル名が失われる
方法2: storeAs()(ファイル名を指定)
// 実務でよく使われるパターン
$filename = date('YmdHis') . '_' . $request->file('image')->getClientOriginalName();
$path = $request->file('image')->storeAs('products', $filename, 'public');
// 結果: products/20250131143025_sample.jpg

✅ メリット:
  - ファイル名を自分で指定できる
  - 元のファイル名を保持できる
  - ファイル名から日時がわかる(管理しやすい)
  - 秒単位のタイムスタンプでほぼ一意

❌ デメリット:
  - 同じ秒に複数アップロードされると重複の可能性(低い)
  - ファイル名のサニタイズが必要(セキュリティ)

💡 実務でよく使われる storeAs() のパターン

パターン1: タイムスタンプ + 元のファイル名(最も一般的)

$filename = date('YmdHis') . '_' . $request->file('image')->getClientOriginalName();
// 例: 20250131143025_商品画像.jpg

パターン2: タイムスタンプ + ランダム文字列(より安全)

$filename = date('YmdHis') . '_' . uniqid() . '.' . $request->file('image')->extension();
// 例: 20250131143025_65b9f2a1b3d4e.jpg

パターン3: ユーザーID + タイムスタンプ(マルチテナント)

$filename = auth()->id() . '_' . time() . '.' . $request->file('image')->extension();
// 例: 42_1706693425.jpg
方法3: move()(低レベルAPI)
$file = $request->file('image');
$filename = uniqid() . '.' . $file->getClientOriginalExtension();
$file->move(storage_path('app/public/products'), $filename);

✅ メリット:
  - 完全なコントロールが可能
  - 細かい処理を自分で実装できる

❌ デメリット:
  - コードが長い
  - 自分でパスを管理する必要がある
  - エラーハンドリングを自分で実装
どれを使うべき?
ケース 推奨メソッド
基本的な画像アップロード store() ← 推奨
元のファイル名を保持したい storeAs()
複雑な処理(リサイズなど) move() or Intervention/Image
S3などクラウドストレージ store('path', 's3')

✅ この教材では store() を使用

理由: シンプルで安全、Laravelの標準的な使い方、ファイル名の重複を自動で防ぐ

💡 コード全体の流れ

// ステップ1: ファイルがアップロードされたか確認
if ($request->hasFile('image')) {

    // ステップ2: storage/app/public/products に保存
    $imagePath = $request->file('image')->store('products', 'public');
    // $imagePath = "products/a1b2c3d4e5.jpg"

    // ステップ3: DB に保存するデータに追加
    $validated['image'] = $imagePath;
}

// ステップ4: DB に保存
Product::create($validated);
// → products テーブルの image カラムに "products/a1b2c3d4e5.jpg" が保存される

ステップ3-1: 一覧ビューの作成

まず、resources/views/image-upload/index.blade.php(一覧表示)を作成します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>商品一覧</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
    <div class="max-w-6xl mx-auto p-8">
        <div class="flex justify-between items-center mb-8">
            <h1 class="text-3xl font-bold">商品一覧</h1>
            <a href="/image-upload/create" class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg">
                新規登録
            </a>
        </div>

        {{-- 成功メッセージ --}}
        @if(session('success'))
        <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-6">
            {{ session('success') }}
        </div>
        @endif

        {{-- 商品一覧(グリッド表示) --}}
        <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
            @foreach($products as $product)
            <div class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition">
                {{-- 画像表示 --}}
                @if($product->image)
                    <img src="{{ asset('storage/' . $product->image) }}"
                         alt="{{ $product->name }}"
                         class="w-full h-48 object-cover">
                @else
                    <div class="w-full h-48 bg-gray-200 flex items-center justify-center">
                        <span class="text-gray-500 text-sm">画像なし</span>
                    </div>
                @endif

                {{-- 商品情報 --}}
                <div class="p-4">
                    <h3 class="font-bold text-xl mb-2 text-gray-800">{{ $product->name }}</h3>
                    <p class="text-gray-600 text-sm mb-3">{{ $product->description }}</p>
                    <div class="flex justify-between items-center">
                        <p class="text-blue-600 font-bold text-lg">¥{{ number_format($product->price) }}</p>
                        <p class="text-gray-500 text-sm">在庫: {{ $product->stock }}</p>
                    </div>
                </div>
            </div>
            @endforeach
        </div>
    </div>
</body>
</html>

ステップ3-2: 登録フォームビューの作成

次に、resources/views/image-upload/create.blade.php(登録フォーム)を作成します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>商品登録</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
    <div class="max-w-2xl mx-auto p-8">
        <div class="bg-white rounded-lg shadow-md p-8">
            <h1 class="text-2xl font-bold mb-6">商品登録</h1>

            {{-- 商品登録フォーム --}}
            <form action="/image-upload" method="POST" enctype="multipart/form-data">
                @csrf

                <div class="mb-4">
                    <label class="block text-gray-700 font-bold mb-2">商品名 *</label>
                    <input type="text" name="name" value="{{ old('name') }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" required>
                    @error('name')
                        <span class="text-red-500 text-sm">{{ $message }}</span>
                    @enderror
                </div>

                <div class="mb-4">
                    <label class="block text-gray-700 font-bold mb-2">説明</label>
                    <textarea name="description" rows="4"
                              class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500">{{ old('description') }}</textarea>
                    @error('description')
                        <span class="text-red-500 text-sm">{{ $message }}</span>
                    @enderror
                </div>

                <div class="mb-4">
                    <label class="block text-gray-700 font-bold mb-2">価格 *</label>
                    <input type="number" name="price" value="{{ old('price') }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" required>
                    @error('price')
                        <span class="text-red-500 text-sm">{{ $message }}</span>
                    @enderror
                </div>

                <div class="mb-4">
                    <label class="block text-gray-700 font-bold mb-2">在庫数 *</label>
                    <input type="number" name="stock" value="{{ old('stock') }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" required>
                    @error('stock')
                        <span class="text-red-500 text-sm">{{ $message }}</span>
                    @enderror
                </div>

                <div class="mb-4">
                    <label class="block text-gray-700 font-bold mb-2">カテゴリー *</label>
                    <select name="category_id"
                            class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" required>
                        <option value="">選択してください</option>
                        <option value="1" {{ old('category_id') == 1 ? 'selected' : '' }}>家電</option>
                        <option value="2" {{ old('category_id') == 2 ? 'selected' : '' }}>食品</option>
                        <option value="3" {{ old('category_id') == 3 ? 'selected' : '' }}>衣類</option>
                        <option value="4" {{ old('category_id') == 4 ? 'selected' : '' }}>書籍</option>
                    </select>
                    @error('category_id')
                        <span class="text-red-500 text-sm">{{ $message }}</span>
                    @enderror
                </div>

                {{-- 画像アップロード --}}
                <div class="mb-6">
                    <label class="block text-gray-700 font-bold mb-2">商品画像</label>
                    <input type="file" name="image" accept="image/*"
                           class="w-full px-3 py-2 border border-gray-300 rounded-lg">
                    @error('image')
                        <span class="text-red-500 text-sm">{{ $message }}</span>
                    @enderror
                </div>

                <div class="flex gap-4">
                    <button type="submit" class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-bold">
                        登録する
                    </button>
                    <a href="/image-upload" class="bg-gray-300 hover:bg-gray-400 text-gray-700 px-6 py-3 rounded-lg font-bold">
                        キャンセル
                    </a>
                </div>
            </form>
        </div>
    </div>
</body>
</html>

💡 accept属性(HTML標準機能)

accept="image/*"HTMLの標準属性で、Laravel固有の機能ではありません。
画像ファイルのみ選択可能にすることで、ユーザーが間違えてPDFなどを選択するのを防げます。

acceptの値の例:

  • accept="image/*" → すべての画像形式(jpg, png, gif, webpなど)
  • accept="image/png, image/jpeg" → PNGとJPEGのみ
  • accept=".pdf" → PDFファイルのみ
  • accept="video/*" → すべての動画形式

⚠️ 注意: accept属性は「推奨」であり「強制」ではない

ブラウザのファイル選択ダイアログで画像を優先表示しますが、
ユーザーは「すべてのファイル」を選択すれば他のファイルも選べます。
→ サーバー側でも必ずvalidate(['image' => 'image'])でチェックが必要です。

✅ 実装完了!

ルート・コントローラー・ビューの3つが揃いました。
http://localhost/image-upload にアクセスして、画像付き商品を登録してみましょう!

✅ 画像アップロード機能の実装ができました!

バリデーションルールの詳細

画像アップロードで使用するバリデーションルールについて詳しく学びましょう。

✅ 画像バリデーションのルール

'image' => 'nullable|image|max:2048'
  • nullable - 画像は任意(なくてもOK)
    → 画像なしでも商品登録が可能になります
  • image - 画像ファイルのみ許可
    → jpg, jpeg, png, bmp, gif, svg, webp が許可されます
  • max:2048 - 最大2MB(2048KB)
    → ファイルサイズを制限して、サーバー負荷を軽減

💡 その他の便利なバリデーション

  • 'image' => 'required|image'
    → 画像を必須にする場合
  • 'image' => 'nullable|image|mimes:jpeg,png'
    → JPEGとPNGのみに制限
  • 'image' => 'nullable|image|dimensions:min_width=100,min_height=100'
    → 最小サイズを指定(100x100px以上)
  • 'image' => 'nullable|image|max:5120'
    → 最大5MB(5120KB)に変更

画像の表示方法

保存した画像をビューで表示する方法を学びます。

💡 初心者の疑問: asset('storage/xxx.jpg') に public がないのになぜアクセスできる?

1. Webサーバーの root 設定が public を指している

// Nginx の設定(default.conf)
root /data/public;  ← これが重要!

// Apache の設定(httpd.conf)
DocumentRoot "/var/www/html/public"

// php artisan serve の場合
php -S localhost:8000 -t public  ← -t オプションで指定

2. URLの / は public/ を指している

{{ asset('storage/image.jpg') }}
    ↓
http://localhost:8000/storage/image.jpg
                      ↑ / から始まる = root からの相対パス
    ↓
Webサーバーが root 設定を参照
    ↓
root /data/public  + /storage/image.jpg
    ↓
/data/public/storage/image.jpg にアクセス

3. シンボリックリンクで実ファイルにアクセス

public/storage  → storage/app/public へのリンク

/data/public/storage/image.jpg
    ↓ (シンボリックリンク)
/data/storage/app/public/image.jpg  ← 実ファイル

✅ これがセキュリティの仕組み

root 設定により、URLは必ず public/ 配下を参照
/../.env のような攻撃も防げる(../ は正規化される)
public/ 外のファイル(config/, storage/app/)にはアクセス不可

⚠️ 重要

シンボリックリンクでも .php ファイルは実行される!
バリデーション(imageルール)が必須

asset() ヘルパーで画像を表示(推奨)

{{-- 商品一覧での画像表示例 --}}
@foreach($products as $product)
    <div class="product-card">
        @if($product->image)
            <img src="{{ asset('storage/' . $product->image) }}"
                 alt="{{ $product->name }}"
                 style="width: 200px; height: 200px; object-fit: cover;">
        @else
            <div style="width: 200px; height: 200px; background: #e5e7eb; display: flex; align-items: center; justify-content: center; color: #9ca3af;">
                No Image
            </div>
        @endif
        <h3>{{ $product->name }}</h3>
        <p>¥{{ number_format($product->price) }}</p>
    </div>
@endforeach

💡 asset() の動作

{{ asset('storage/' . $product->image) }}
                ↓
{{ asset('storage/products/abc123.jpg') }}
                ↓
http://localhost:8000/storage/products/abc123.jpg  ← 完全なURLを生成

asset() は自動的にドメインを付けた完全なURLを生成します。

Storage::url() を使う方法(応用)

{{-- Storage::url() を使う場合 --}}
@if($product->image)
    <img src="{{ Storage::url($product->image) }}"
         alt="{{ $product->name }}">
@endif

💡 Storage::url() の動作

{{ Storage::url($product->image) }}
                ↓
{{ Storage::url('products/abc123.jpg') }}
                ↓
/storage/products/abc123.jpg  ← / から始まる相対パス

🤔 なぜ http:// がないのにアクセスできる?

答え: ブラウザが自動補完するから

<img src="/storage/products/abc123.jpg">
         ↑ / で始まるパス
    ↓
ブラウザが解釈
    ↓
<img src="http://localhost:8000/storage/products/abc123.jpg">
         ↑ 現在のドメインを自動で付ける

HTMLの仕様:

  • /storage/xxx.jpg (/始まり) = 絶対パス → ドメインを自動補完
  • images/xxx.jpg (/なし) = 相対パス → 現在のURLに追加
  • http://xxx.jpg = 完全なURL → そのまま使用

ℹ️ いつ使う?

  • • S3などクラウドストレージを使う場合に便利
  • use Illuminate\Support\Facades\Storage; が必要
  • • 基本は asset() で十分

⚠️ シンボリックリンクを忘れずに!

画像が表示されない場合は、以下を確認してください:

  • php artisan storage:link を実行したか?
  • public/storage フォルダが存在するか?
  • ✅ ブラウザの開発者ツールで404エラーが出ていないか?

✅ 画像表示ができました!

画像の更新と削除

商品の編集・削除機能を実装し、画像の差し替えや削除も行えるようにします。

📋 この章で作るもの

  • ✅ 商品一覧ページ(編集・削除ボタン付き)
  • ✅ 商品編集ページ(画像の差し替え機能)
  • ✅ 画像の更新・削除処理
  • ✅ ルート定義(index, edit, update, destroy)

ステップ1: ルーティングの設定

routes/web.php

use App\Http\Controllers\ImageUploadController;

// 商品一覧
Route::get('/image-upload/list', [ImageUploadController::class, 'index']);

// 商品編集フォーム表示
Route::get('/image-upload/{product}/edit', [ImageUploadController::class, 'edit']);

// 商品更新
Route::put('/image-upload/{product}', [ImageUploadController::class, 'update']);

// 商品削除
Route::delete('/image-upload/{product}', [ImageUploadController::class, 'destroy']);

💡 補足

画像アップロード機能の作成ルート(Route::get('/image-upload'))は既に作成済みです。
ここでは一覧・編集・削除機能を ImageUploadController に追加します。

ステップ2: 商品一覧ページの作成

resources/views/image-upload/index.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>商品一覧</title>
    <style>
        body { font-family: sans-serif; padding: 20px; }
        .success { background: #d4edda; padding: 10px; border-radius: 4px; margin-bottom: 20px; }
        table { width: 100%; border-collapse: collapse; margin: 20px 0; }
        th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
        th { background: #4CAF50; color: white; }
        img { border-radius: 4px; }
        .btn { padding: 6px 12px; text-decoration: none; border-radius: 4px; margin-right: 5px; }
        .btn-edit { background: #2196F3; color: white; }
        .btn-delete { background: #f44336; color: white; border: none; cursor: pointer; }
        .btn-create { background: #4CAF50; color: white; padding: 10px 20px; }
    </style>
</head>
<body>
    <h1>商品一覧</h1>

    @if(session('success'))
        <div class="success">{{ session('success') }}</div>
    @endif

    <a href="/image-upload" class="btn btn-create">新規登録</a>

    <table>
        <thead>
            <tr>
                <th>画像</th>
                <th>ID</th>
                <th>商品名</th>
                <th>価格</th>
                <th>在庫</th>
                <th>操作</th>
            </tr>
        </thead>
        <tbody>
            @foreach($products as $product)
            <tr>
                <td>
                    @if($product->image)
                        <img src="{{ asset('storage/' . $product->image) }}"
                             alt="{{ $product->name }}"
                             style="width: 50px; height: 50px; object-fit: cover;">
                    @else
                        No Image
                    @endif
                </td>
                <td>{{ $product->id }}</td>
                <td>{{ $product->name }}</td>
                <td>¥{{ number_format($product->price) }}</td>
                <td>{{ $product->stock }}</td>
                <td>
                    <a href="/image-upload/{{ $product->id }}/edit" class="btn btn-edit">編集</a>
                    <form action="/image-upload/{{ $product->id }}" method="POST" style="display:inline;">
                        @csrf
                        @method('DELETE')
                        <button type="submit" class="btn btn-delete"
                                onclick="return confirm('本当に削除しますか?')">
                            削除
                        </button>
                    </form>
                </td>
            </tr>
            @endforeach
        </tbody>
    </table>
</body>
</html>

💡 ポイント

  • @if(session('success')) - 成功メッセージを表示
  • asset('storage/' . $product->image) - 画像のURLを生成
  • • 編集ボタン - /image-upload/{id}/edit に遷移
  • • 削除ボタン - フォームで @method('DELETE') を使用
  • onclick="return confirm()" - 削除前に確認ダイアログ

ステップ3: コントローラーに index メソッドを追加

app/Http/Controllers/ImageUploadController.php

public function index()
{
    $products = Product::with('category')->get();
    return view('image-upload.index', compact('products'));
}

ステップ4: 編集フォーム(全文)

resources/views/image-upload/edit.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>商品編集</title>
    <style>
        body { font-family: sans-serif; padding: 20px; max-width: 600px; margin: 0 auto; }
        .form-group { margin-bottom: 20px; }
        label { display: block; font-weight: bold; margin-bottom: 5px; }
        input[type="text"], input[type="number"], textarea, input[type="file"], select {
            width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;
        }
        button { padding: 10px 20px; background: #4CAF50; color: white; border: none; cursor: pointer; border-radius: 4px; }
        button:hover { background: #45a049; }
        .current-image { margin: 10px 0; }
        .current-image img { border: 2px solid #ddd; border-radius: 8px; }
        .error { color: red; font-size: 14px; margin-top: 5px; }
    </style>
</head>
<body>
    <h1>商品編集</h1>

    <form action="/image-upload/{{ $product->id }}" method="POST" enctype="multipart/form-data">
        @csrf
        @method('PUT')

        <div class="form-group">
            <label>商品名</label>
            <input type="text" name="name" value="{{ old('name', $product->name) }}" required>
            @error('name')
                <div class="error">{{ $message }}</div>
            @enderror
        </div>

        <div class="form-group">
            <label>説明</label>
            <textarea name="description" rows="4">{{ old('description', $product->description) }}</textarea>
            @error('description')
                <div class="error">{{ $message }}</div>
            @enderror
        </div>

        <div class="form-group">
            <label>価格</label>
            <input type="number" name="price" value="{{ old('price', $product->price) }}" required>
            @error('price')
                <div class="error">{{ $message }}</div>
            @enderror
        </div>

        <div class="form-group">
            <label>在庫数</label>
            <input type="number" name="stock" value="{{ old('stock', $product->stock) }}" required>
            @error('stock')
                <div class="error">{{ $message }}</div>
            @enderror
        </div>

        <div class="form-group">
            <label>カテゴリー</label>
            <select name="category_id" required>
                <option value="">選択してください</option>
                @foreach($categories as $category)
                    <option value="{{ $category->id }}"
                        {{ old('category_id', $product->category_id) == $category->id ? 'selected' : '' }}>
                        {{ $category->name }}
                    </option>
                @endforeach
            </select>
            @error('category_id')
                <div class="error">{{ $message }}</div>
            @enderror
        </div>

        <div class="form-group">
            <label>
                <input type="checkbox" name="is_published" value="1"
                    {{ old('is_published', $product->is_published) ? 'checked' : '' }}>
                公開する
            </label>
        </div>

        <div class="form-group">
            <label>商品画像</label>

            {{-- 現在の画像を表示 --}}
            @if($product->image)
                <div class="current-image">
                    <p>現在の画像:</p>
                    <img src="{{ asset('storage/' . $product->image) }}"
                         alt="{{ $product->name }}"
                         style="width: 200px; height: 200px; object-fit: cover;">
                </div>
            @endif

            <label>新しい画像をアップロード(変更する場合のみ)</label>
            <input type="file" name="image" accept="image/*">
            <small style="color: #666;">※ 画像を選択しない場合は、現在の画像がそのまま保持されます</small>

            @error('image')
                <div class="error">{{ $message }}</div>
            @enderror
        </div>

        <button type="submit">更新</button>
        <a href="/image-upload/list" style="margin-left: 10px;">キャンセル</a>
    </form>
</body>
</html>

💡 ポイント

  • old('name', $product->name) - バリデーションエラー時は入力値、初回表示時はDB値
  • @if($product->image) - 画像が登録済みの場合のみ表示
  • asset('storage/' . $product->image) - 公開URLを生成
  • accept="image/*" - 画像ファイルのみ選択可能
  • • 画像を選択しない場合は現在の画像が保持される

ステップ5: コントローラーに edit メソッドを追加

編集フォームを表示するためのメソッドです。

public function edit(Product $product)
{
    $categories = Category::all();
    return view('image-upload.edit', compact('product', 'categories'));
}

💡 ポイント

  • • ルートモデルバインディングで Product $product を自動取得
  • compact('product', 'categories') で両方のデータをビューに渡す

ステップ6: コントローラーに update メソッドを追加(古い画像を削除)

use Illuminate\Support\Facades\Storage;

public function update(Request $request, Product $product)
{
    $validated = $request->validate([
        'name' => 'required|max:255',
        'description' => 'nullable',
        'price' => 'required|numeric|min:0',
        'stock' => 'required|integer|min:0',
        'category_id' => 'required|exists:categories,id',
        'image' => 'nullable|image|max:2048',
    ]);

    // 新しい画像がアップロードされた場合
    if ($request->hasFile('image')) {
        // 🔥 古い画像を削除
        if ($product->image) {
            Storage::disk('public')->delete($product->image);
        }

        // 新しい画像を保存
        $imagePath = $request->file('image')->store('products', 'public');
        $validated['image'] = $imagePath;
    }

    $product->update($validated);

    return redirect('/image-upload/list')->with('success', '商品を更新しました');
}

🔥 Storage::delete() の重要性

古い画像を削除しないと、画像を更新するたびに
不要なファイルが storage/app/public に溜まり続けます。

  • Storage::delete() でファイル削除(シンプルな書き方)
  • • 削除前に if ($product->image) で存在確認
  • • ディスク容量の無駄を防ぐために必須の処理

💡 Storage::disk('public')->delete() と書くこともできますが、
Storage::delete('public/products/xxx.jpg') の方が一般的です。

ステップ7: コントローラーに destroy メソッドを追加

商品削除時は画像ファイルも一緒に削除します。

public function destroy(Product $product)
{
    // 商品に画像がある場合は削除
    if ($product->image) {
        Storage::disk('public')->delete($product->image);
    }

    $product->delete();

    return redirect('/image-upload/list')->with('success', '商品を削除しました');
}

⚠️ 重要

  • • 商品を削除する前に画像ファイルを削除
  • $product->delete() 後は $product->image にアクセスできなくなる
  • • 削除確認は JavaScript の confirm() で実装済み

💡 実務での削除はケースバイケース

上記は物理削除(完全削除)の実装ですが、実務では要件によって使い分けます。

📌 ケース1: 物理削除(Storage::delete を使う)

  • • ECサイトで在庫切れ商品を完全削除する
  • • 一時的なファイル(キャッシュ、プレビュー画像)を削除
  • • 管理者が「完全削除」を明示的に実行した時
  • • GDPR対応でユーザーのデータ削除権に応える時

📌 ケース2: 論理削除(ソフトデリート)

  • • ユーザーが誤って削除する可能性がある
  • • 削除履歴や監査ログが必要(金融系、医療系)
  • • 統計・分析データとして残す必要がある
  • • 関連データとの整合性を保つ必要がある
  • → この場合、Storage::delete() は呼ばず画像も残す

💡 どちらを使うかはプロジェクトの要件次第です。
迷ったらまず論理削除で実装し、必要に応じて物理削除機能を追加するのが安全です。

動作確認の手順

✅ テストしてみましょう

  1. 1. 一覧表示: http://localhost:8000/image-upload/list にアクセス
  2. 2. 編集: 「編集」ボタンをクリック → 画像を変更 → 保存
  3. 3. 確認: storage/app/public/products を確認 → 古い画像が削除されている
  4. 4. 削除: 「削除」ボタンをクリック → 確認ダイアログで OK
  5. 5. 確認: 商品とファイルの両方が削除されている

✅ 画像の更新・削除処理が実装できました!
これで完全なCRUD(作成・読取・更新・削除)機能が完成です。

まとめ

画像アップロード機能の実装で学んだことをまとめます。

✅ この章で学んだこと

  • 1. マイグレーション - string('image')->nullable() で画像カラムを追加
  • 2. シンボリックリンク - php artisan storage:link で公開フォルダとリンク
  • 3. フォーム - enctype="multipart/form-data" は必須!
  • 4. ファイル保存 - $request->file('image')->store('products', 'public')
  • 5. バリデーション - nullable|image|max:2048 でファイル検証
  • 6. 画像表示 - asset('storage/' . $product->image) で公開URLに変換
  • 7. ファイル削除 - Storage::disk('public')->delete($path) で古い画像を削除
  • 8. CRUD実装 - index, edit, update, destroy メソッドで完全な機能を実装

よくあるエラーと対処法

⚠️ トラブルシューティング

エラー 原因 解決方法
画像が表示されない シンボリックリンク未作成 php artisan storage:link を実行
ファイルがアップロードされない enctype が未指定 enctype="multipart/form-data" を追加
画像が巨大すぎる サーバーのアップロード上限 php.ini の upload_max_filesize を確認
古い画像が残り続ける 削除処理の未実装 更新・削除時に Storage::delete() を実行

応用編:画像リサイズとサムネイル生成

🚀 さらに学びたい人向け

実務では、アップロードされた画像をリサイズして保存することが多いです。
Laravelでは intervention/image パッケージを使うのが一般的です。

composer require intervention/image

use Intervention\Image\Facades\Image;

$image = Image::make($request->file('image'))
    ->resize(800, null, function ($constraint) {
        $constraint->aspectRatio(); // アスペクト比を維持
        $constraint->upsize(); // 拡大しない
    });

$filename = uniqid() . '.jpg';
$image->save(storage_path('app/public/products/' . $filename));
$product->image = 'products/' . $filename;

🎉 画像アップロード機能の実装完了!

これでLaravelでの画像アップロード機能が実装できるようになりました。

実務では外部ストレージ(AWS S3 / Azure Blob)を使用

この教材では storage/app/public にファイルを保存しましたが、
実務の本番環境では、AWS S3 や Azure Blob Storage などの外部ストレージを使うのが一般的です。

⚠️ ローカルストレージの問題点: 複数サーバー構成での同期

アプリケーションサーバーを2台以上で冗長化した場合、ローカルストレージでは問題が発生します。

構成図:

【ローカルストレージの場合】

ユーザー → ロードバランサー
              ├─→ サーバー1 (storage/app/public/products/abc.jpg) ✅
              └─→ サーバー2 (storage/app/public/products/???.jpg) ❌

問題:
1. ユーザーがサーバー1にアップロード → abc.jpg が保存される
2. 次のリクエストがロードバランサーでサーバー2に振り分けられる
3. サーバー2には abc.jpg が存在しない → 404エラー!

なぜ同期が取れない?

  • • 各サーバーは独立した storage/app/public を持つ
  • • サーバー1にアップロードしたファイルは、サーバー2には自動で同期されない
  • • rsync などで同期する方法もあるが、複雑でリアルタイム性がない

✅ 外部ストレージ(S3 / Azure Blob)による解決

構成図:

【外部ストレージの場合】

ユーザー → ロードバランサー
              ├─→ サーバー1 ─┐
              └─→ サーバー2 ─┴─→ AWS S3 / Azure Blob Storage(共通ストレージ)

メリット:
✅ すべてのサーバーが同じストレージにアクセス
✅ サーバーを何台増やしても問題なし
✅ サーバーが落ちてもファイルは安全
✅ ファイルの同期を考える必要がない

💡 環境別の使い分け

環境 ストレージ 理由
開発環境 ローカル (storage/app/public) 開発しやすい、コスト不要
本番環境(1台) ローカルでもOK 冗長化してなければ問題ない
本番環境(複数台) AWS S3 / Azure Blob 必須(同期問題を回避)

🚀 Laravelでの実装は簡単

コードはほぼ変更不要!設定だけで切り替え可能です。

ローカルストレージ:

$request->file('image')->store('products', 'public');

AWS S3(1文字だけ変更):

$request->file('image')->store('products', 's3');
                                          ↑
                            ここを変えるだけ

// config/filesystems.php で s3 の設定を追加するだけ
'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
    ],
],

📌 まとめ

  • 開発環境: ローカルストレージ (storage/app/public) で十分
  • 本番環境(複数サーバー): 外部ストレージ(S3 / Azure Blob)が必須
  • 理由: サーバー間でファイルの同期が取れないため
  • Laravel: 設定を変えるだけで簡単に切り替え可能

次の章では、ユーザー認証(ログイン/ログアウト)機能について学びます。