【第四弾】Laravel入門資料
- Windows環境構築
- Mac環境構築
- Laravelとは
- ルーティング
- ビュー
- コントローラ
- マイグレーション
- モデル
- ここまでの知識の実践
- 検索・フィルタ
- ソフトデリート
- ページネーション
- 画像アップロード
- 認証ライブラリ
- 認証実装
- ミドルウェア・ロール
- 総合実践
📚 Laravel公式ドキュメント - File Storage
https://laravel.com/docs/12.x/filesystem
💡 この講座では、上記の公式ドキュメントを基に解説していきます。
公式ドキュメントは初学者には内容が難しいため、エッセンスを優しく噛み砕いて解説していきます。
画像アップロード - 商品画像を登録する
商品に画像を追加することで、よりユーザーにわかりやすい情報を提供できます。
画像のアップロード、保存、表示の一連の流れを学びます。
🎯 この章で学ぶこと
- ✅ ファイルアップロードの基本 -
enctype="multipart/form-data" - ✅ 画像の保存 -
$request->file()->store() - ✅ 画像の表示 -
Storage::url()と シンボリックリンク - ✅ バリデーション - ファイルサイズ・種類の制限
- ✅ 画像の削除 - 古い画像ファイルの削除処理
💡 前提条件
- ✅
productsテーブルが作成済み - ✅ Product モデルが作成済み
※ 「ここまでの知識の実践」で作成したテーブルとモデルをそのまま使用します
画像アップロードの流れ
実装する機能の流れ
- マイグレーションで画像カラムを追加
- フォームにファイルアップロード欄を追加
- コントローラーで画像を保存
- シンボリックリンクで画像を公開
- ビューで画像を表示
マイグレーションで画像カラムを追加
まず、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. 一覧表示:
http://localhost:8000/image-upload/listにアクセス - 2. 編集: 「編集」ボタンをクリック → 画像を変更 → 保存
- 3. 確認:
storage/app/public/productsを確認 → 古い画像が削除されている - 4. 削除: 「削除」ボタンをクリック → 確認ダイアログで OK
- 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: 設定を変えるだけで簡単に切り替え可能
次の章では、ユーザー認証(ログイン/ログアウト)機能について学びます。