【第四弾】Laravel入門資料

ここまでの知識の実践 - 商品管理アプリを作ろう

これまで学んだ知識を総動員して、実際に動く商品管理アプリケーションを作ります。

🎯 この実践で作るもの

  • ✅ 商品の一覧表示
  • ✅ 商品の詳細表示
  • ✅ 商品の新規登録
  • ✅ 商品の編集
  • ✅ 商品の削除
  • ✅ カテゴリーとのリレーション
  • ✅ バリデーション
  • ✅ フラッシュメッセージ

使用する技術スタック

  • バックエンド: Laravel (ルーティング、コントローラ、モデル)
  • データベース: MySQL
  • フロントエンド: Blade テンプレート
  • CSS: Tailwind CSS

Tailwind CSSとは

Tailwind CSSは、ユーティリティファーストのCSSフレームワークです。 通常のCSSのようにクラス名を考える必要がなく、bg-blue-500text-centerのような 小さなユーティリティクラスを組み合わせてデザインを構築します。

🎨 Tailwind CSSの特徴

  • CDN経由で簡単に導入可能: HTMLファイルに1行追加するだけで使える
  • 直感的なクラス名: text-red-500p-4rounded-lgなど、見ればわかるクラス名
  • レスポンシブデザインが簡単: md:text-lgのようにプレフィックスで対応
  • カスタマイズが容易: 設定ファイルで独自の色やサイズを定義可能

このアプリでのTailwind CSSの使い方

今回のアプリでは、各Bladeテンプレートの<head>タグ内で CDN版のTailwind CSSを読み込んでいます:

<script src="https://cdn.tailwindcss.com"></script>

この1行を追加するだけで、Tailwind CSSの全てのユーティリティクラスがHTMLで使えるようになります。

💡 Tailwind CSSクラスの例

  • bg-gray-100 - 灰色の背景
  • text-3xl - 大きな文字サイズ
  • font-bold - 太字
  • px-4 py-8 - 左右に16px、上下に32pxのパディング
  • rounded-lg - 大きめの角丸
  • hover:bg-blue-600 - ホバー時に背景色を変更
  • flex justify-between items-center - フレックスボックスで要素を両端配置、垂直中央揃え

Tailwind CSSを使った実際のHTML例

以下は、Tailwind CSSのクラスを使って、ボタンやカードをスタイリングする例です:

例1: ボタンのスタイリング

<!-- 青いボタン -->
<button class="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600">
    登録する
</button>

<!-- 赤いボタン -->
<button class="bg-red-500 text-white px-6 py-2 rounded-lg hover:bg-red-600">
    削除する
</button>

<!-- グレーのボタン -->
<button class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600">
    キャンセル
</button>

🎯 クラスの説明

  • bg-blue-500 - 背景色を青に
  • text-white - 文字色を白に
  • px-6 - 左右のパディングを24px (6 × 4px)
  • py-2 - 上下のパディングを8px (2 × 4px)
  • rounded-lg - 角を丸く
  • hover:bg-blue-600 - ホバー時に背景色を濃い青に変更

例2: カードのレイアウト

<div class="bg-white p-6 rounded-lg shadow-lg">
    <h2 class="text-2xl font-bold text-gray-800 mb-4">商品名</h2>
    <p class="text-gray-600 mb-4">商品の説明文がここに入ります。</p>
    <div class="flex justify-between items-center">
        <span class="text-3xl font-bold text-blue-600">¥1,200</span>
        <button class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
            購入する
        </button>
    </div>
</div>

🎯 クラスの説明

  • bg-white - 背景色を白に
  • p-6 - 全方向のパディングを24px
  • shadow-lg - 大きな影を追加
  • text-2xl - 文字サイズを24pxに
  • mb-4 - 下マージンを16px
  • flex - フレックスボックスを有効化
  • justify-between - 要素を両端に配置
  • items-center - 要素を垂直方向に中央揃え

例3: フォームのスタイリング

<form class="space-y-4">
    <div>
        <label class="block text-gray-700 font-bold mb-2">商品名</label>
        <input
            type="text"
            class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="商品名を入力"
        >
    </div>

    <div>
        <label class="block text-gray-700 font-bold mb-2">説明</label>
        <textarea
            class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
            rows="4"
            placeholder="商品の説明を入力"
        ></textarea>
    </div>

    <button class="w-full bg-blue-500 text-white px-6 py-3 rounded-lg hover:bg-blue-600">
        登録する
    </button>
</form>

🎯 クラスの説明

  • space-y-4 - 子要素間に16pxの縦方向の間隔を追加
  • block - ブロック要素として表示
  • w-full - 幅を100%に
  • border - ボーダーを追加
  • border-gray-300 - ボーダーの色をグレーに
  • focus:outline-none - フォーカス時のアウトラインを非表示
  • focus:ring-2 - フォーカス時に2pxのリングを表示
  • focus:ring-blue-500 - リングの色を青に

公式ドキュメント: https://tailwindcss.com/

【準備】モデルとマイグレーションの作成

まずは、モデルとマイグレーションを作成します。

💡 前提条件

  • ✅ MySQLがインストール済み
  • .env でデータベース接続設定済み
  • ✅ データベースGUIツール導入済み
    • • Mac: Sequel Ace
    • • Windows: A5M2

ステップ1: マイグレーションとモデルの作成

# カテゴリーモデル + マイグレーション
php artisan make:model Category -m

# 商品モデル + マイグレーション
php artisan make:model Product -m

ステップ2: マイグレーションファイルの編集

カテゴリーテーブル

database/migrations/xxxx_create_categories_table.php

public function up(): void
{
    Schema::create('categories', function (Blueprint $table) {
        $table->id();
        $table->string('name')->comment('カテゴリー名');
        $table->timestamps();
    });
}

商品テーブル

database/migrations/xxxx_create_products_table.php

public function up(): void
{
    Schema::create('products', function (Blueprint $table) {
        $table->id();
        $table->string('name')->comment('商品名');
        $table->text('description')->nullable()->comment('説明');
        $table->integer('price')->unsigned()->comment('価格');
        $table->integer('stock')->default(0)->comment('在庫数');
        $table->boolean('is_published')->default(true)->comment('公開状態');
        $table->foreignId('category_id')->constrained()->onDelete('cascade');
        $table->timestamps();
    });
}

ステップ3: モデルの設定

Categoryモデル

app/Models/Category.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    use HasFactory;

    protected $fillable = ['name'];

    // このカテゴリーに属する商品
    public function products()
    {
        return $this->hasMany(Product::class);
    }
}

Productモデル

app/Models/Product.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'description',
        'price',
        'stock',
        'is_published',
        'category_id',
    ];

    // この商品が属するカテゴリー
    public function category()
    {
        return $this->belongsTo(Category::class);
    }
}

ステップ4: シーダーの作成

php artisan make:seeder CategorySeeder
php artisan make:seeder ProductSeeder

CategorySeeder

database/seeders/CategorySeeder.php

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class CategorySeeder extends Seeder
{
    public function run(): void
    {
        DB::table('categories')->insert([
            ['name' => '家電', 'created_at' => now(), 'updated_at' => now()],
            ['name' => '食品', 'created_at' => now(), 'updated_at' => now()],
            ['name' => '衣類', 'created_at' => now(), 'updated_at' => now()],
            ['name' => '書籍', 'created_at' => now(), 'updated_at' => now()],
        ]);
    }
}

ProductSeeder

database/seeders/ProductSeeder.php

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class ProductSeeder extends Seeder
{
    public function run(): void
    {
        DB::table('products')->insert([
            [
                'name' => 'ノートPC',
                'description' => '高性能なノートパソコン',
                'price' => 120000,
                'stock' => 10,
                'is_published' => true,
                'category_id' => 1,
                'created_at' => now(),
                'updated_at' => now(),
            ],
            [
                'name' => 'ワイヤレスマウス',
                'description' => '快適な操作感',
                'price' => 3000,
                'stock' => 50,
                'is_published' => true,
                'category_id' => 1,
                'created_at' => now(),
                'updated_at' => now(),
            ],
            [
                'name' => 'オーガニックコーヒー',
                'description' => '香り高いコーヒー豆',
                'price' => 1500,
                'stock' => 30,
                'is_published' => true,
                'category_id' => 2,
                'created_at' => now(),
                'updated_at' => now(),
            ],
        ]);
    }
}

DatabaseSeederに登録

database/seeders/DatabaseSeeder.php

public function run(): void
{
    $this->call([
        CategorySeeder::class,
        ProductSeeder::class,
    ]);
}

ステップ5: マイグレーション実行

# テーブル作成 + データ投入
php artisan migrate:fresh --seed

ステップ6: データベースで確認

GUIツールでテーブルとデータが作成されたか確認しましょう。

確認するテーブル:

  • categories テーブル - 4件のデータ
  • products テーブル - 3件のデータ

✅ データベースの準備が完了しました!

【実践1】商品一覧ページの作成 (Read)

商品の一覧を表示するページを作成します。

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

php artisan make:controller ProductController --resource

💡 --resource オプションとは?

--resource オプションを付けると、リソースコントローラが作成されます。

自動生成されるメソッド:

  • index() - 一覧表示
  • create() - 登録フォーム表示
  • store() - 登録処理
  • show() - 詳細表示
  • edit() - 編集フォーム表示
  • update() - 更新処理
  • destroy() - 削除処理

これらのメソッドはCRUD操作の標準的なパターンに対応しており、
わざわざ手動で作成する手間が省けます。

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

routes/web.php

use App\Http\Controllers\ProductController;

Route::resource('products', ProductController::class);

💡 Route::resource() とは?

Route::resource() は、1行でCRUD操作に必要な7つのルートを自動生成します。

通常であれば Route::get()Route::post() を7回書く必要がありますが、
Route::resource() を使うと、わずか1行で全てのルートが設定されます。

自動生成されるルート一覧:

メソッド URI アクション
GET/productsindex
GET/products/createcreate
POST/productsstore
GET/products/{id}show
GET/products/{id}/editedit
PUT/PATCH/products/{id}update
DELETE/products/{id}destroy

ステップ3: indexメソッドの実装

app/Http/Controllers/ProductController.php

<?php

namespace App\Http\Controllers;

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

class ProductController extends Controller
{
    // 商品一覧
    public function index()
    {
        // 公開されている商品を新しい順に取得(カテゴリー情報も一緒に)
        $products = Product::with('category')
                           ->where('is_published', true)
                           ->latest()
                           ->get();

        return view('products.index', compact('products'));
    }
}

ステップ4: ビューの作成

resources/views/products/index.blade.php を作成

# ディレクトリ作成
mkdir resources/views/products
<!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="container mx-auto px-4 py-8">
        <div class="flex justify-between items-center mb-6">
            <h1 class="text-3xl font-bold text-gray-800">商品一覧</h1>
            <a href="{{ route('products.create') }}"
               class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
                新規登録
            </a>
        </div>

        @if (session('success'))
            <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
                {{ session('success') }}
            </div>
        @endif

        <div class="bg-white shadow-md rounded overflow-hidden">
            <table class="min-w-full">
                <thead class="bg-gray-50">
                    <tr>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">商品名</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">カテゴリー</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">価格</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">在庫</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
                    </tr>
                </thead>
                <tbody class="bg-white divide-y divide-gray-200">
                    @foreach ($products as $product)
                        <tr>
                            <td class="px-6 py-4">{{ $product->id }}</td>
                            <td class="px-6 py-4">{{ $product->name }}</td>
                            <td class="px-6 py-4">{{ $product->category->name }}</td>
                            <td class="px-6 py-4">¥{{ number_format($product->price) }}</td>
                            <td class="px-6 py-4">{{ $product->stock }}</td>
                            <td class="px-6 py-4 space-x-2">
                                <a href="{{ route('products.show', $product->id) }}"
                                   class="text-blue-600 hover:underline">詳細</a>
                                <a href="{{ route('products.edit', $product->id) }}"
                                   class="text-green-600 hover:underline">編集</a>
                                <form action="{{ route('products.destroy', $product->id) }}"
                                      method="POST" class="inline">
                                    @csrf
                                    @method('DELETE')
                                    <button type="submit"
                                            class="text-red-600 hover:underline"
                                            onclick="return confirm('本当に削除しますか?')">
                                        削除
                                    </button>
                                </form>
                            </td>
                        </tr>
                    @endforeach
                </tbody>
            </table>
        </div>
    </div>
</body>
</html>

💡 number_format() とは?

number_format() は、数値を3桁区切りでフォーマットするPHP関数です。
例: number_format(120000)"120,000"
価格表示など、大きな数値を見やすくする時に使います。

ステップ5: 確認

ブラウザで /products にアクセスして確認します。

✅ 商品一覧ページが完成しました!

【実践2】商品詳細ページの作成 (Read)

商品の詳細情報を表示するページを作成します。

showメソッドの実装

app/Http/Controllers/ProductController.php

// 商品詳細
public function show($id)
{
    // カテゴリー情報も一緒に取得
    $product = Product::with('category')->findOrFail($id);

    return view('products.show', compact('product'));
}

💡 findOrFail() とは?

find($id) は、IDが存在しない場合に null を返します。
findOrFail($id) は、IDが存在しない場合に 自動的に404エラーを返します。

メリット: わざわざ「nullチェック → 404返す」というコードを書く必要がありません。
詳細ページや編集ページなど、特定のIDが必須の場合に非常に便利です。

ビューの作成

resources/views/products/show.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="container mx-auto px-4 py-8">
        <div class="mb-6">
            <a href="{{ route('products.index') }}"
               class="text-blue-600 hover:underline">← 一覧に戻る</a>
        </div>

        <div class="bg-white shadow-md rounded-lg p-6">
            <h1 class="text-3xl font-bold text-gray-800 mb-4">{{ $product->name }}</h1>

            <div class="space-y-3">
                <div class="flex border-b pb-2">
                    <span class="font-semibold w-32">ID:</span>
                    <span>{{ $product->id }}</span>
                </div>

                <div class="flex border-b pb-2">
                    <span class="font-semibold w-32">カテゴリー:</span>
                    <span>{{ $product->category->name }}</span>
                </div>

                <div class="flex border-b pb-2">
                    <span class="font-semibold w-32">価格:</span>
                    <span class="text-xl text-red-600">¥{{ number_format($product->price) }}</span>
                </div>

                <div class="flex border-b pb-2">
                    <span class="font-semibold w-32">在庫:</span>
                    <span>{{ $product->stock }} 個</span>
                </div>

                <div class="flex border-b pb-2">
                    <span class="font-semibold w-32">公開状態:</span>
                    <span>
                        @if ($product->is_published)
                            <span class="bg-green-100 text-green-800 px-2 py-1 rounded text-sm">公開中</span>
                        @else
                            <span class="bg-gray-100 text-gray-800 px-2 py-1 rounded text-sm">非公開</span>
                        @endif
                    </span>
                </div>

                <div class="border-b pb-2">
                    <span class="font-semibold">説明:</span>
                    <p class="mt-2 text-gray-700">{{ $product->description ?? '説明はありません' }}</p>
                </div>

                <div class="flex border-b pb-2">
                    <span class="font-semibold w-32">登録日時:</span>
                    <span>{{ $product->created_at->format('Y年m月d日 H:i') }}</span>
                </div>

                <div class="flex border-b pb-2">
                    <span class="font-semibold w-32">更新日時:</span>
                    <span>{{ $product->updated_at->format('Y年m月d日 H:i') }}</span>
                </div>
            </div>

            <div class="mt-6 space-x-3">
                <a href="{{ route('products.edit', $product->id) }}"
                   class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded">
                    編集
                </a>
                <form action="{{ route('products.destroy', $product->id) }}"
                      method="POST" class="inline">
                    @csrf
                    @method('DELETE')
                    <button type="submit"
                            class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded"
                            onclick="return confirm('本当に削除しますか?')">
                        削除
                    </button>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

✅ 商品詳細ページが完成しました!

【実践3】商品登録ページの作成 (Create)

新しい商品を登録するページを作成します。

ステップ1: createメソッドの実装

app/Http/Controllers/ProductController.php

// 商品登録フォーム表示
public function create()
{
    // カテゴリー一覧を取得
    $categories = Category::all();

    return view('products.create', compact('categories'));
}

ステップ2: storeメソッドの実装

// 商品登録処理
public function store(Request $request)
{
    // バリデーション
    $validated = $request->validate([
        'name' => 'required|max:255',
        'description' => 'nullable',
        'price' => 'required|integer|min:0',
        'stock' => 'required|integer|min:0',
        'is_published' => 'boolean',
        'category_id' => 'required|exists:categories,id',
    ], [
        'name.required' => '商品名は必須です',
        'name.max' => '商品名は255文字以内で入力してください',
        'price.required' => '価格は必須です',
        'price.integer' => '価格は整数で入力してください',
        'price.min' => '価格は0円以上で入力してください',
        'stock.required' => '在庫数は必須です',
        'stock.integer' => '在庫数は整数で入力してください',
        'stock.min' => '在庫数は0以上で入力してください',
        'category_id.required' => 'カテゴリーは必須です',
        'category_id.exists' => '選択されたカテゴリーは存在しません',
    ]);

    // 公開状態のデフォルト値
    $validated['is_published'] = $request->has('is_published');

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

    // リダイレクト
    return redirect()
        ->route('products.index')
        ->with('success', '商品を登録しました');
}

💡 with('success', 'メッセージ') とは?

with() は、フラッシュメッセージ(1回だけ表示されるメッセージ)をセッションに保存します。

リダイレクト先のビューで session('success') または @if (session('success')) を使って、
「商品を登録しました」などの成功メッセージを表示できます。
ページをリロードすると自動的に消えるので、ユーザー体験が向上します。

ステップ3: ビューの作成

resources/views/products/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="container mx-auto px-4 py-8">
        <div class="mb-6">
            <a href="{{ route('products.index') }}"
               class="text-blue-600 hover:underline">← 一覧に戻る</a>
        </div>

        <div class="bg-white shadow-md rounded-lg p-6 max-w-2xl">
            <h1 class="text-3xl font-bold text-gray-800 mb-6">商品登録</h1>

            <form action="{{ route('products.store') }}" method="POST">
                @csrf

                <div class="bg-blue-50 border border-blue-200 p-3 rounded mb-4">
                    <p class="text-xs text-gray-700 mb-1"><strong>💡 @csrf とは?</strong></p>
                    <p class="text-xs text-gray-600">
                        <code>@csrf</code> は <strong>CSRF(クロスサイトリクエストフォージェリ)攻撃</strong>から守るためのトークンです。<br>
                        Laravelでは、POST/PUT/DELETE リクエストを送信する全てのフォームに<strong>必須</strong>です。<br>
                        このトークンがないと、フォーム送信時に<strong>419エラー</strong>が発生します。
                    </p>
                </div>

                <!-- 商品名 -->
                <div class="mb-4">
                    <label for="name" class="block text-sm font-medium text-gray-700 mb-2">
                        商品名 <span class="text-red-500">*</span>
                    </label>
                    <input type="text" name="name" id="name"
                           value="{{ old('name') }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required>

                    <div class="bg-green-50 border border-green-200 p-2 rounded mt-2 text-xs">
                        <strong>💡 old('name') とは?</strong><br>
                        バリデーションエラーが発生した時、<strong>入力した値を保持</strong>してくれます。<br>
                        これがないと、エラー時に全ての入力内容が消えてしまいます。
                    </div>

                    @if($errors->has('name'))
                        <p class="text-red-500 text-sm mt-1">{{ $errors->first('name') }}</p>
                    @endif

                    <div class="bg-yellow-50 border border-yellow-200 p-2 rounded mt-2 text-xs">
                        <strong>💡 $errors->has('name') とは?</strong><br>
                        バリデーションエラーが発生した時のみ、エラーメッセージを表示します。<br>
                        <code>$errors->first('name')</code> には、コントローラで設定したエラーメッセージが入ります。
                    </div>
                </div>

                <!-- カテゴリー -->
                <div class="mb-4">
                    <label for="category_id" class="block text-sm font-medium text-gray-700 mb-2">
                        カテゴリー <span class="text-red-500">*</span>
                    </label>
                    <select name="category_id" id="category_id"
                            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                            required>
                        <option value="">選択してください</option>
                        @foreach ($categories as $category)
                            <option value="{{ $category->id }}"
                                    {{ old('category_id') == $category->id ? 'selected' : '' }}>
                                {{ $category->name }}
                            </option>
                        @endforeach
                    </select>
                    @if($errors->has('category_id'))
                        <p class="text-red-500 text-sm mt-1">{{ $errors->first('category_id') }}</p>
                    @endif
                </div>

                <!-- 価格 -->
                <div class="mb-4">
                    <label for="price" class="block text-sm font-medium text-gray-700 mb-2">
                        価格(円) <span class="text-red-500">*</span>
                    </label>
                    <input type="number" name="price" id="price"
                           value="{{ old('price') }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required min="0">
                    @if($errors->has('price'))
                        <p class="text-red-500 text-sm mt-1">{{ $errors->first('price') }}</p>
                    @endif
                </div>

                <!-- 在庫数 -->
                <div class="mb-4">
                    <label for="stock" class="block text-sm font-medium text-gray-700 mb-2">
                        在庫数 <span class="text-red-500">*</span>
                    </label>
                    <input type="number" name="stock" id="stock"
                           value="{{ old('stock', 0) }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required min="0">
                    @if($errors->has('stock'))
                        <p class="text-red-500 text-sm mt-1">{{ $errors->first('stock') }}</p>
                    @endif
                </div>

                <!-- 説明 -->
                <div class="mb-4">
                    <label for="description" class="block text-sm font-medium text-gray-700 mb-2">
                        説明
                    </label>
                    <textarea name="description" id="description" rows="4"
                              class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('description') }}</textarea>
                    @if($errors->has('description'))
                        <p class="text-red-500 text-sm mt-1">{{ $errors->first('description') }}</p>
                    @endif
                </div>

                <!-- 公開状態 -->
                <div class="mb-6">
                    <label class="flex items-center">
                        <input type="checkbox" name="is_published" value="1"
                               {{ old('is_published', true) ? 'checked' : '' }}
                               class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
                        <span class="ml-2 text-sm text-gray-700">公開する</span>
                    </label>
                </div>

                <div class="flex space-x-3">
                    <button type="submit"
                            class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded">
                        登録
                    </button>
                    <a href="{{ route('products.index') }}"
                       class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-6 py-2 rounded inline-block">
                        キャンセル
                    </a>
                </div>
            </form>
        </div>
    </div>
</body>
</html>

💡 重要なポイント

  • old() - バリデーションエラー時に入力値を保持
  • $errors->has() - バリデーションエラーメッセージの表示
  • @csrf - CSRF対策トークン(必須)
  • exists:categories,id - 存在するカテゴリーIDかチェック

✅ 商品登録ページが完成しました!

【実践4】商品編集ページの作成 (Update)

既存の商品を編集するページを作成します。

ステップ1: editメソッドの実装

app/Http/Controllers/ProductController.php

// 商品編集フォーム表示
public function edit($id)
{
    $product = Product::findOrFail($id);
    $categories = Category::all();

    return view('products.edit', compact('product', 'categories'));
}

ステップ2: updateメソッドの実装

// 商品更新処理
public function update(Request $request, $id)
{
    $product = Product::findOrFail($id);

    // バリデーション
    $validated = $request->validate([
        'name' => 'required|max:255',
        'description' => 'nullable',
        'price' => 'required|integer|min:0',
        'stock' => 'required|integer|min:0',
        'is_published' => 'boolean',
        'category_id' => 'required|exists:categories,id',
    ], [
        'name.required' => '商品名は必須です',
        'price.required' => '価格は必須です',
        'stock.required' => '在庫数は必須です',
        'category_id.required' => 'カテゴリーは必須です',
    ]);

    // 公開状態の設定
    $validated['is_published'] = $request->has('is_published');

    // 更新
    $product->update($validated);

    return redirect()
        ->route('products.show', $product->id)
        ->with('success', '商品を更新しました');
}

ステップ3: ビューの作成

resources/views/products/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>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
    <div class="container mx-auto px-4 py-8">
        <div class="mb-6">
            <a href="{{ route('products.show', $product->id) }}"
               class="text-blue-600 hover:underline">← 詳細に戻る</a>
        </div>

        <div class="bg-white shadow-md rounded-lg p-6 max-w-2xl">
            <h1 class="text-3xl font-bold text-gray-800 mb-6">商品編集</h1>

            <form action="{{ route('products.update', $product->id) }}" method="POST">
                @csrf
                @method('PUT')

                <div class="bg-purple-50 border border-purple-200 p-3 rounded mb-4">
                    <p class="text-xs text-gray-700 mb-1"><strong>💡 @method('PUT') / @method('DELETE') とは?</strong></p>
                    <p class="text-xs text-gray-600 mb-2">
                        HTMLの <code><form></code> タグは <strong>GET と POST しかサポートしていません</strong>。
                    </p>
                    <p class="text-xs text-gray-600">
                        しかしLaravelのリソースルートは PUT や DELETE を使います。<br>
                        <code>@method('PUT')</code> を使うと、Laravelが内部的にPUTリクエストとして扱ってくれます。<br>
                        これを<strong>HTTPメソッドの偽装(Method Spoofing)</strong>と言います。
                    </p>
                </div>

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

                <!-- カテゴリー -->
                <div class="mb-4">
                    <label for="category_id" class="block text-sm font-medium text-gray-700 mb-2">
                        カテゴリー <span class="text-red-500">*</span>
                    </label>
                    <select name="category_id" id="category_id"
                            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                            required>
                        @foreach ($categories as $category)
                            <option value="{{ $category->id }}"
                                    {{ old('category_id', $product->category_id) == $category->id ? 'selected' : '' }}>
                                {{ $category->name }}
                            </option>
                        @endforeach
                    </select>
                    @if($errors->has('category_id'))
                        <p class="text-red-500 text-sm mt-1">{{ $errors->first('category_id') }}</p>
                    @endif
                </div>

                <!-- 価格 -->
                <div class="mb-4">
                    <label for="price" class="block text-sm font-medium text-gray-700 mb-2">
                        価格(円) <span class="text-red-500">*</span>
                    </label>
                    <input type="number" name="price" id="price"
                           value="{{ old('price', $product->price) }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required min="0">
                    @if($errors->has('price'))
                        <p class="text-red-500 text-sm mt-1">{{ $errors->first('price') }}</p>
                    @endif
                </div>

                <!-- 在庫数 -->
                <div class="mb-4">
                    <label for="stock" class="block text-sm font-medium text-gray-700 mb-2">
                        在庫数 <span class="text-red-500">*</span>
                    </label>
                    <input type="number" name="stock" id="stock"
                           value="{{ old('stock', $product->stock) }}"
                           class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
                           required min="0">
                    @if($errors->has('stock'))
                        <p class="text-red-500 text-sm mt-1">{{ $errors->first('stock') }}</p>
                    @endif
                </div>

                <!-- 説明 -->
                <div class="mb-4">
                    <label for="description" class="block text-sm font-medium text-gray-700 mb-2">
                        説明
                    </label>
                    <textarea name="description" id="description" rows="4"
                              class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('description', $product->description) }}</textarea>
                    @if($errors->has('description'))
                        <p class="text-red-500 text-sm mt-1">{{ $errors->first('description') }}</p>
                    @endif
                </div>

                <!-- 公開状態 -->
                <div class="mb-6">
                    <label class="flex items-center">
                        <input type="checkbox" name="is_published" value="1"
                               {{ old('is_published', $product->is_published) ? 'checked' : '' }}
                               class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
                        <span class="ml-2 text-sm text-gray-700">公開する</span>
                    </label>
                </div>

                <div class="flex space-x-3">
                    <button type="submit"
                            class="bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded">
                        更新
                    </button>
                    <a href="{{ route('products.show', $product->id) }}"
                       class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-6 py-2 rounded inline-block">
                        キャンセル
                    </a>
                </div>
            </form>
        </div>
    </div>
</body>
</html>

💡 編集フォームのポイント

  • @method('PUT') - HTTPメソッドをPUTに指定
  • old('name', $product->name) - エラー時は入力値、正常時はDB値
  • • 登録と編集でほぼ同じフォーム → 後でコンポーネント化できる

✅ 商品編集ページが完成しました!

【実践5】商品削除機能の実装 (Delete)

商品を削除する機能を実装します。

destroyメソッドの実装

app/Http/Controllers/ProductController.php

// 商品削除処理
public function destroy($id)
{
    $product = Product::findOrFail($id);
    $product->delete();

    return redirect()
        ->route('products.index')
        ->with('success', '商品を削除しました');
}

⚠️ 削除の確認

削除は取り消せない操作なので、必ず確認ダイアログを表示します。
一覧ページと詳細ページの削除ボタンには、すでに onclick="return confirm(...)" が実装されています。

完成したProductController全体

app/Http/Controllers/ProductController.php

<?php

namespace App\Http\Controllers;

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

class ProductController extends Controller
{
    // 商品一覧
    public function index()
    {
        $products = Product::with('category')
                           ->where('is_published', true)
                           ->latest()
                           ->get();

        return view('products.index', compact('products'));
    }

    // 商品登録フォーム表示
    public function create()
    {
        $categories = Category::all();
        return view('products.create', compact('categories'));
    }

    // 商品登録処理
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|max:255',
            'description' => 'nullable',
            'price' => 'required|integer|min:0',
            'stock' => 'required|integer|min:0',
            'is_published' => 'boolean',
            'category_id' => 'required|exists:categories,id',
        ]);

        $validated['is_published'] = $request->has('is_published');
        Product::create($validated);

        return redirect()
            ->route('products.index')
            ->with('success', '商品を登録しました');
    }

    // 商品詳細
    public function show($id)
    {
        $product = Product::with('category')->findOrFail($id);
        return view('products.show', compact('product'));
    }

    // 商品編集フォーム表示
    public function edit($id)
    {
        $product = Product::findOrFail($id);
        $categories = Category::all();
        return view('products.edit', compact('product', 'categories'));
    }

    // 商品更新処理
    public function update(Request $request, $id)
    {
        $product = Product::findOrFail($id);

        $validated = $request->validate([
            'name' => 'required|max:255',
            'description' => 'nullable',
            'price' => 'required|integer|min:0',
            'stock' => 'required|integer|min:0',
            'is_published' => 'boolean',
            'category_id' => 'required|exists:categories,id',
        ]);

        $validated['is_published'] = $request->has('is_published');
        $product->update($validated);

        return redirect()
            ->route('products.show', $product->id)
            ->with('success', '商品を更新しました');
    }

    // 商品削除処理
    public function destroy($id)
    {
        $product = Product::findOrFail($id);
        $product->delete();

        return redirect()
            ->route('products.index')
            ->with('success', '商品を削除しました');
    }
}

✅ CRUD機能が全て完成しました!

まとめ

商品管理アプリケーションが完成しました!

実装した機能

機能 ルート メソッド
一覧表示 GET /products index()
詳細表示 GET /products/{id} show()
登録フォーム GET /products/create create()
登録処理 POST /products store()
編集フォーム GET /products/{id}/edit edit()
更新処理 PUT /products/{id} update()
削除処理 DELETE /products/{id} destroy()

使用した技術

バックエンド

  • • リソースコントローラ
  • • Eloquent ORM
  • • リレーション(belongsTo)
  • • スコープ(published)
  • • Eager Loading(N+1問題対策)

フロントエンド

  • • Blade テンプレート
  • • バリデーション表示($errors)
  • • フラッシュメッセージ
  • • 入力値保持(old())
  • • CSRF対策(@csrf)

ベストプラクティス

  • findOrFail() を使用
    存在しないIDは自動で404エラーに
  • バリデーションルールを日本語化
    ユーザーフレンドリーなエラーメッセージ
  • Eager Loadingでクエリ最適化
    N+1問題を防ぐ
  • フラッシュメッセージで操作結果を通知
    UX向上
  • リソースルートで統一感のあるURL設計
    RESTfulなAPI設計

次のステップ

このアプリケーションをさらに改善するアイデア:

  • • ページネーション(大量データ対応)
  • • 検索機能(商品名、カテゴリーで絞り込み)
  • • 並び替え機能(価格順、新着順など)
  • • 画像アップロード機能
  • • 在庫アラート機能
  • • カテゴリー管理機能
  • • ユーザー認証機能

🎉 お疲れ様でした!

完全に動作する商品管理アプリケーションが完成しました!
これまで学んだルーティング、コントローラ、ビュー、マイグレーション、モデルの知識を全て使って、
実際のWebアプリケーションを作成できるようになりました。

このアプリケーションを土台に、さらに機能を追加していきましょう!