404 Not Found

404 Not Found


nginx

第2フェーズ総合演習—ShopMetricsバックエンドCRUD

第2フェーズ総合演習は「コア機能の提供」に焦点を当て, レッスン8〜13の全知識を集結して, 完全に機能するバックエンドシステムを構築します。

1. 学ぶ内容


2. Bobの第2フェーズ検収ストーリー

(1) ペインポイント:バックエンド機能が各レッスンに散在し, 完全なプロダクトにまとめられない

Aliceは第2フェーズの全レッスンを完了しましたが, マイグレーションはレッスン8, モデルはレッスン9, バリデーションはレッスン12にあり, 各レッスン単体では問題なく動作するものの, 組み合わせると問題が発生します。外部キー制約が合わず, モデル関連が欠落し, バリデーションルールがデータベースカラムの型と一致しません。

(2) 総合演習のソリューション

このレッスンでは, データベース設計からCRUDページまでステップバイステップで進め, すべての知識を完全なシステムに織り込みます。各ステップは前のステップの出力の上に構築し, シームレスな進行を確保します。

BASH
# 第2フェーズ成果物:完全な管理CRUDシステム
php artisan migrate:fresh --seed
# → 10以上のテーブル, 100以上のレコード, 全CRUD動作

(3) 成果

総合演習を完了した後, Aliceは完全に機能するShopMetricsバックエンドCRUDシステムを持っています。テーブル作成からデータの挿入, 更新, 削除, 照会まで, すべてワンストップです。


3. データベース設計

(1) ShopMetricsマルチテナントER図

100%
erDiagram
    Tenant ||--o{ User : "複数持つ"
    Tenant ||--o| Subscription : "1つ持つ"
    Tenant ||--o{ Shop : "複数持つ"
    Plan ||--o{ Subscription : "複数持つ"
    Shop ||--o{ Product : "複数持つ"
    Shop ||--o{ Order : "複数持つ"
    User ||--o{ Order : "発注する"
    Product }o--o{ Category : "多対多"
    Order ||--o{ OrderItem : "含む"
    Product ||--o{ OrderItem : "含まれる"

(2) マイグレーションチェックリスト

# マイグレーションファイル テーブル名 キーフィールド
1 create_plans_table plans name, slug, price, shop_limit, features(json)
2 create_tenants_table tenants name, slug, domain, plan_id (FK), status
3 create_subscriptions_table subscriptions tenant_id (FK), plan_id (FK), stripe_id, status
4 create_users_table users tenant_id (FK), name, email, password, role
5 create_shops_table shops tenant_id (FK), name, slug, status, revenue
6 create_categories_table categories name, slug
7 create_products_table products shop_id (FK), name, sku, price, stock, is_active
8 create_category_product_table category_product category_id (FK), product_id (FK)
9 create_orders_table orders tenant_id (FK), shop_id (FK), user_id (FK), total, status
10 create_order_items_table order_items order_id (FK), product_id (FK), qty, price

(1) ▶ サンプル:コアマイグレーションファイル

PHP
// database/migrations/2024_01_01_000001_create_plans_table.php
Schema::create('plans', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->decimal('price', 8, 2);
    $table->integer('shop_limit')->default(5);
    $table->json('features')->nullable();
    $table->boolean('is_active')->default(true);
    $table->timestamps();
});

// database/migrations/2024_01_01_000005_create_shops_table.php
Schema::create('shops', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->string('slug');
    $table->text('description')->nullable();
    $table->enum('status', ['active', 'suspended', 'closed'])->default('active');
    $table->decimal('revenue', 12, 2)->default(0);
    $table->timestamps();
    $table->softDeletes();
    $table->unique(['tenant_id', 'slug']);
    $table->index(['tenant_id', 'status']);
});

出力:

TEXT
// 実行成功

4. Eloquentモデル層

(1) モデル一覧と関連

モデル 関連 スコープ アクセサ/ミューテータ
Tenant shops(), users(), subscription() active() domain_url
Shop products(), orders(), tenant() active() revenue_formatted
Product categories(), shop() active(), inStock() price_formatted
Order items(), shop(), user() completed(), thisMonth() status_badge
OrderItem product(), order() subtotal

(1) ▶ サンプル:ShopMetricsコアモデル

PHP
// app/Models/Tenant.php
class Tenant extends Model
{
    protected $fillable = ['name', 'slug', 'domain', 'plan_id', 'status'];
    protected $casts = ['status' => 'string'];

    public function shops(): HasMany
    {
        return $this->hasMany(Shop::class);
    }

    public function users(): HasMany
    {
        return $this->hasMany(User::class);
    }

    public function subscription(): HasOne
    {
        return $this->hasOne(Subscription::class)->where('status', 'active');
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('status', 'active');
    }

    public function getDomainUrlAttribute(): string
    {
        return 'https://' . $this->domain;
    }
}

// app/Models/Shop.php
class Shop extends Model
{
    use SoftDeletes;

    protected $fillable = ['tenant_id', 'name', 'slug', 'description', 'status', 'revenue'];
    protected $casts = ['revenue' => 'decimal:2', 'deleted_at' => 'datetime'];

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }

    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('status', 'active');
    }

    public function getRevenueFormattedAttribute(): string
    {
        return '$' . number_format($this->revenue, 2);
    }
}

// app/Models/Product.php
class Product extends Model
{
    protected $fillable = ['shop_id', 'name', 'sku', 'price', 'stock', 'is_active'];
    protected $casts = ['price' => 'decimal:2', 'is_active' => 'boolean'];

    public function shop(): BelongsTo
    {
        return $this->belongsTo(Shop::class);
    }

    public function categories(): BelongsToMany
    {
        return $this->belongsToMany(Category::class)->withTimestamps();
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }

    public function scopeInStock(Builder $query): Builder
    {
        return $query->where('stock', '>', 0);
    }

    public function getPriceFormattedAttribute(): string
    {
        return '$' . number_format($this->price, 2);
    }
}

出力:

TEXT
// 実行成功

5. リソースコントローラCRUD

(1) コントローラとバリデーションクラス一覧

コントローラ メソッド フォームリクエスト
ShopController index/create/store/show/edit/update/destroy StoreShopRequest/UpdateShopRequest
ProductController index/create/store/show/edit/update/destroy StoreProductRequest/UpdateProductRequest
OrderController index/show/update UpdateOrderRequest

(1) ▶ サンプル:ShopController完全CRUD

PHP
// app/Http/Controllers/ShopController.php
class ShopController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
        $this->middleware('tenant.resolve');
    }

    public function index(Request $request): View
    {
        $shops = Shop::where('tenant_id', tenant()->id)
            ->withCount(['products', 'orders as recent_orders' => fn ($q) => $q->where('created_at', '>=', now()->subDays(30))])
            ->when($request->search, fn ($q, $s) => $q->where('name', 'like', "%{$s}%"))
            ->when($request->status, fn ($q, $s) => $q->where('status', $s))
            ->orderBy($request->sort ?? 'created_at', $request->direction ?? 'desc')
            ->paginate(15);

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

    public function create(): View
    {
        return view('shops.create');
    }

    public function store(StoreShopRequest $request): RedirectResponse
    {
        $shop = Shop::create(array_merge(
            $request->validated(),
            ['tenant_id' => tenant()->id],
        ));

        return redirect()->route('shops.show', $shop)
            ->with('success', 'Shop created successfully.');
    }

    public function show(Shop $shop): View
    {
        $shop->load(['products' => fn ($q) => $q->active()->latest()->take(10),
                      'orders' => fn ($q) => $q->with('user')->latest()->take(10)]);

        $stats = [
            'total_revenue' => $shop->orders()->completed()->sum('total'),
            'total_orders' => $shop->orders()->count(),
            'total_products' => $shop->products()->active()->count(),
        ];

        return view('shops.show', compact('shop', 'stats'));
    }

    public function edit(Shop $shop): View
    {
        return view('shops.edit', compact('shop'));
    }

    public function update(UpdateShopRequest $request, Shop $shop): RedirectResponse
    {
        $shop->update($request->validated());
        return redirect()->route('shops.show', $shop)
            ->with('success', 'Shop updated.');
    }

    public function destroy(Shop $shop): RedirectResponse
    {
        $shop->delete();
        return redirect()->route('shops.index')
            ->with('success', 'Shop deleted.');
    }
}

出力:

TEXT
// 実行成功

6. バリデータクラスの実装

(1) ▶ サンプル:ShopMetricsフォームリクエストクラス

PHP
// app/Http/Requests/StoreShopRequest.php
class StoreShopRequest extends FormRequest
{
    public function authorize(): bool
    {
        return auth()->user()->role === 'tenant_owner';
    }

    public function rules(): array
    {
        return [
            'name' => 'required|string|max:255',
            'slug' => 'required|alpha_dash|unique:shops,slug,NULL,id,tenant_id,' . tenant()->id,
            'description' => 'nullable|string|max:5000',
            'status' => 'sometimes|in:active,suspended',
        ];
    }

    protected function prepareForValidation(): void
    {
        $this->merge(['slug' => Str::slug($this->slug ?? $this->name)]);
    }
}

// app/Http/Requests/StoreProductRequest.php
class StoreProductRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => 'required|string|max:255',
            'sku' => 'required|string|unique:products,sku',
            'price' => 'required|numeric|min:0.01|max:999999.99',
            'stock' => 'required|integer|min:0',
            'description' => 'nullable|string|max:5000',
            'is_active' => 'boolean',
            'categories' => 'sometimes|array',
            'categories.*' => 'exists:categories,id',
            'image' => 'sometimes|image|max:2048',
        ];
    }
}

出力:

TEXT
// 実行成功

7. データ投入

(1) ▶ サンプル:ShopMetrics完全シーダー

PHP
// database/seeders/ShopMetricsSeeder.php
class ShopMetricsSeeder extends Seeder
{
    public function run(): void
    {
        $starter = Plan::create(['name' => 'Starter', 'slug' => 'starter', 'price' => 29, 'shop_limit' => 5, 'features' => ['basic_analytics']]);
        $pro = Plan::create(['name' => 'Pro', 'slug' => 'pro', 'price' => 79, 'shop_limit' => 25, 'features' => ['advanced_analytics', 'api']]);
        $enterprise = Plan::create(['name' => 'Enterprise', 'slug' => 'enterprise', 'price' => 199, 'shop_limit' => null, 'features' => ['all']]);

        User::factory()->create(['email' => 'admin@shopmetrics.io', 'role' => 'super_admin']);

        Category::factory()->count(10)->create();

        Tenant::factory()->count(20)->create()->each(function ($tenant) use ($starter, $pro, $enterprise) {
            $plan = fake()->randomElement([$starter, $pro, $enterprise]);
            Subscription::create(['tenant_id' => $tenant->id, 'plan_id' => $plan->id, 'status' => 'active']);
            $owner = User::factory()->for($tenant)->create(['role' => 'tenant_owner']);
            $analysts = User::factory()->count(3)->for($tenant)->create(['role' => 'analyst']);

            $shopCount = min(fake()->numberBetween(2, 5), $plan->shop_limit ?? 99);
            Shop::factory()->count($shopCount)->for($tenant)->create()->each(function ($shop) use ($tenant, $owner, $analysts) {
                $products = Product::factory()->count(fake()->numberBetween(10, 30))->for($shop)->create();
                $products->each(fn ($p) => $p->categories()->attach(Category::inRandomOrder()->take(rand(1, 3))->pluck('id')));

                Order::factory()->count(fake()->numberBetween(20, 60))->for($tenant)->for($shop)->for(fake()->randomElement(array_merge([$owner], $analysts->all())))->create()->each(function ($order) use ($products) {
                    $items = $products->random(rand(1, 5));
                    foreach ($items as $product) {
                        $qty = rand(1, 3);
                        $order->items()->create(['product_id' => $product->id, 'quantity' => $qty, 'price' => $product->price]);
                    }
                    $order->update(['total' => $order->items->sum(fn ($i) => $i->price * $i->quantity)]);
                });
            });
        });
    }
}

出力:

TEXT
// 実行成功

8. N+1検出と最適化

(1) Debugbarのインストール

BASH
composer require barryvdh/laravel-debugbar --dev

(2) よくあるN+1シナリオと解決策

シナリオ N+1問題 解決策
店舗一覧 $shop->products->count() withCount('products')
注文一覧 $order->user->name with('user')
商品詳細 $product->categories with('categories')
ダッシュボード 複数関連への同時アクセス with(['user', 'shop.items'])

(1) ▶ サンプル:Debugbarクエリ分析

PHP
// 開発環境ではDebugbarが各ページのクエリ数を表示
// N+1を段階的に最適化:

// ステップ1:特定 — Debugbarがshops.indexで"41 queries"と表示
// ステップ2:Eagerローディングを追加
$shops = Shop::with(['products', 'tenant', 'orders'])->get(); // 4クエリ
// ステップ3:完全な関連の代わりにwithCountを使用
$shops = Shop::withCount('products', 'orders')->with('tenant')->get(); // 3クエリ
// ステップ4:確認 — Debugbarが"3 queries"と表示

出力:

TEXT
// 実行成功

9. 総合例:ShopMetricsバックエンドCRUDシステム

PHP
// ============================================
// 総合:ShopMetrics管理CRUDセットアップ
// カバー:マイグレーション, モデル, コントローラ, リクエスト, シーダー
// ============================================

// セットアップコマンド (順番に実行)
// 1. php artisan migrate:fresh --seed
// 2. php artisan storage:link
// 3. php artisan serve

// routes/web.php — 完全な管理ルート
Route::middleware('auth')->prefix('dashboard')->name('dashboard.')->group(function () {
    Route::get('/', [DashboardController::class, 'index'])->name('index');

    // 店舗CRUD
    Route::resource('shops', ShopController::class);
    Route::post('/shops/{shop}/logo', [ShopLogoController::class, 'update'])->name('shops.logo');

    // 商品CRUD (店舗の下にネスト)
    Route::resource('shops.products', ProductController::class)->shallow();

    // 注文 (読み取り専用 + ステータス更新)
    Route::resource('orders', OrderController::class)->only(['index', 'show', 'update']);
    Route::post('/orders/{order}/export', ExportOrdersController::class)->name('orders.export');

    // カテゴリ (シンプルCRUD)
    Route::resource('categories', CategoryController::class)->except('show');

    // アナリティクス
    Route::get('/analytics', [AnalyticsController::class, 'index'])->name('analytics');
});

// 最適化クエリ付きDashboardController
class DashboardController extends Controller
{
    public function index(): View
    {
        $tenant = tenant();
        $stats = Cache::remember("dashboard.{$tenant->id}", 300, function () use ($tenant) {
            return [
                'total_revenue' => $tenant->shops()->sum('revenue'),
                'active_shops' => $tenant->shops()->active()->count(),
                'monthly_orders' => $tenant->orders()->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])->count(),
                'total_products' => Product::whereHas('shop', fn ($q) => $q->where('tenant_id', $tenant->id))->active()->count(),
            ];
        });

        $recentOrders = Order::where('tenant_id', $tenant->id)
            ->with(['shop', 'user'])
            ->latest()
            ->take(5)
            ->get();

        return view('dashboard.index', compact('stats', 'recentOrders'));
    }
}

❓ よくある質問

Q 第2フェーズの演習にはどのくらい時間がかかりますか?
A 約4〜6時間です。データベース設計:1時間, モデルとリレーション:1.5時間, CRUDコントローラ:1.5時間, データ投入とデバッグ:1時間。どの手順もスキップせず, 各手順を確認してから次に進んでください。
Q マイグレーションの順序が正しいことを確認するにはどうすればよいですか?
A 外部キーの依存関係があるテーブルを先に作成する必要があります。マイグレーションファイル名のタイムスタンプが実行順序を決定します。参照されるテーブルが先に作成され, それを参照するテーブルが後に続きます。またはphp artisan migrate:statusで順序を確認してください。
Q CRUDページに重複コードが多いのですが, どう減らせますか?
A Bladeコンポーネントでフォームフィールドをカプセル化し (<x-input>, <x-select>), artisan make:controller --resourceでボイラープレートコードを生成してください。重複は正常です。機能が正しく動作することを確認してから抽象化を検討してください。
Q 本番環境ではDebugbarを無効にすべきですか?
A 必須です。Debugbarは機密情報 (SQLクエリ, メモリ使用量, 設定値)を公開します。APP_DEBUG=trueの場合のみ有効になり, 本番環境ではAPP_DEBUG=falseで自動的に無効になります。
Q Seederのデータとテストデータの違いは何ですか?
A Seederデータは開発環境の基本データ (管理者アカウント, プラン)を生成し, データベースに永続的に保存されます。テストデータはPHPUnit内でFactoryを使用して生成され, 各テスト後に自動的にロールバックされます。
Q N+1が完全に最適化されたことを確認するにはどうすればよいですか?
A Debugbarに表示されるクエリ数が1 (メインクエリ)+ 事前ロードの結合数に等しくなるべきです。例えば, with(['user', 'shop'])なら3クエリです。期待値より多い場合は, 見落とした遅延ロードがないか確認してください。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopMetrics管理画面の10テーブルのマイグレーションを完了し, モデルを定義してください。migrate:fresh --seedを実行して全データが正しく生成され, 外部キーエラーがないことを確認してください。

  2. 応用問題 (⭐⭐):ShopとProductの完全CRUD機能を実装し (フォームリクエストバリデーション, 検索フィルタリング, ページネーションを含む), Debugbarでページあたり5クエリ以下であることを確認してください。

  3. チャレンジ (⭐⭐⭐):ダッシュボード向けにキャッシュ最適化されたデータ集計を実装してください。統計クエリを5分間キャッシュし, 新規注文受信時にモデルイベントでキャッシュを自動クリアし, データのリアルタイム性とシステム全体のパフォーマンスのバランスを確保してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%