404 Not Found

404 Not Found


nginx

フェーズ3総合演習—ShopMetrics APIとリアルタイム通知

フェーズ3総合演習は「高度な機能の提供」です—認証, API, ミドルウェア, イベント, ストレージを統合し, 本番グレードのAPIを構築します。

1. 学ぶこと


2. Aliceのフェーズ3验收ストーリー

(1) 痛み:6回のレッスンの内容が断片的で, 完全なAPIとして組み立てられない

フェーズ3—レッスン15のSanctum, レッスン16のResources, レッスン17のMiddleware—を終えた後, Aliceはそれらをすべて1つの完全なAPIにまとめる方法がわかりませんでした。BobからRESTful APIを書くよう頼まれ, 認証, リソース変換, ミドルウェア, イベントブロードキャスト, ファイルアップロードなどの機能が連携する必要があることに気づきました。それぞれを個別には理解していましたが, 組み合わせるとすべてが混乱してしまいました。

(2) 総合演習の解決策

このレッスンでは, ゼロから完全なRESTful APIを構築します—各エンドポイントに認証, テナント分離, レート制限, リソースマッピング, イベントブロードキャストを備え, 最終的に本番グレードのAPIシステムを提供します。

BASH
# フェーズ3の成果物:リアルタイム機能を備えた完全なRESTful API
php artisan migrate:fresh --seed
php artisan queue:work &
php artisan storage:link
# → 10以上のエンドポイント, 認証, レート制限, ブロードキャストがすべて動作

(3) 成果

Aliceが完了すると, 認証からブロードキャストまで完全に統合されたShopMetrics APIが完成し, フロントエンドチームに直接提供できるようになりました。


3. API認証レイヤー

(1) Sanctumトークン認証プロセス

100%
flowchart TD
    A[クライアント] --> B["POST /api/auth/login<br/>(email+password)"]
    B --> C["Sanctumがトークンを作成"]
    C --> D["plainTextTokenを返却"]
    D --> E["クライアントがトークンを保存"]
    E --> F["APIリクエストに<br/>Authorization: Bearer {token}を付与"]
    F --> G["Sanctumミドルウェア<br/>がUserを解決"]
    G --> H[TenantResolveミドルウェア]
    H --> I[コントローラー]

(2) 認証エンドポイント

PHP
// routes/api.php
Route::prefix('auth')->group(function () {
    Route::post('/register', [Auth\RegisterController::class, 'register']);
    Route::post('/login', [Auth\ApiTokenController::class, 'login']);
    Route::post('/forgot-password', [Auth\PasswordResetController::class, 'sendResetLink']);
    Route::post('/reset-password', [Auth\PasswordResetController::class, 'reset']);

    Route::middleware('auth:sanctum')->group(function () {
        Route::get('/user', fn (Request $r) => new UserResource($r->user()));
        Route::post('/logout', [Auth\ApiTokenController::class, 'logout']);
        Route::apiResource('tokens', Auth\TokenController::class)->only(['store', 'index', 'destroy']);
    });
});
エンドポイント メソッド 認証 説明
/auth/register POST 登録
/auth/login POST トークン取得
/auth/user GET 現在のユーザー
/auth/logout POST トークン失効
/auth/tokens POST 新しいトークンを作成
/auth/tokens GET トークン一覧
/auth/tokens/{id} DELETE トークン削除

(1) ▶ サンプル:ShopMetricsトークン認証の完全な実装

PHP
// app/Http/Controllers/Auth/ApiTokenController.php
class ApiTokenController extends Controller
{
    public function login(Request $request): JsonResponse
    {
        $request->validate([
            'email' => 'required|email',
            'password' => 'required|string',
            'device_name' => 'sometimes|string|max:255',
        ]);

        $user = User::where('email', $request->email)->first();

        if (!$user || !Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['Invalid credentials.'],
            ]);
        }

        if (!$user->tenant_id || $user->tenant->status !== 'active') {
            throw ValidationException::withMessages([
                'email' => ['Account is not active.'],
            ]);
        }

        $abilities = match ($user->role) {
            'super_admin' => ['*'],
            'tenant_owner' => ['read', 'write', 'manage-users'],
            'analyst' => ['read'],
            default => [],
        };

        $token = $user->createToken(
            $request->device_name ?? 'api-token',
            $abilities,
        );

        return response()->json([
            'user' => new UserResource($user),
            'token' => $token->plainTextToken,
            'abilities' => $abilities,
        ]);
    }

    public function logout(Request $request): JsonResponse
    {
        $request->user()->currentAccessToken()->delete();
        return response()->json(['message' => 'Token revoked.']);
    }
}

出力:

TEXT
// 実行成功

4. APIリソースエンドポイント

(1) エンドポイントの完全一覧

エンドポイント メソッド リソース ミドルウェア
/v1/shops GET ShopResource::collection auth,tenant,throttle
/v1/shops POST ShopResource auth,tenant,throttle,role:owner
/v1/shops/{id} GET ShopResource auth,tenant
/v1/shops/{id} PUT ShopResource auth,tenant,role:owner
/v1/shops/{id} DELETE - auth,tenant,role:owner
/v1/shops/{id}/products GET ProductResource::collection auth,tenant
/v1/products POST ProductResource auth,tenant,role:owner
/v1/products/{id} GET ProductResource auth,tenant
/v1/products/{id} PUT ProductResource auth,tenant,role:owner
/v1/orders GET OrderResource::collection auth,tenant
/v1/orders/{id} GET OrderResource auth,tenant
/v1/orders/{id}/status PATCH OrderResource auth,tenant,role:owner
/v1/analytics/overview GET AnalyticsResource auth,tenant,ability:read
/v1/reports/generate POST ReportResource auth,tenant,ability:write

(2) ルート定義

PHP
// routes/api.php
Route::middleware(['auth:sanctum', 'tenant.resolve', 'throttle:tenant-api'])
    ->prefix('v1')->name('api.v1.')->group(function () {
        // ショップ
        Route::apiResource('shops', Api\V1\ShopController::class);
        Route::post('shops/{shop}/logo', Api\V1\ShopLogoController::class)->name('shops.logo');

        // 商品
        Route::apiResource('products', Api\V1\ProductController::class);

        // 注文
        Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show']);
        Route::patch('orders/{order}/status', [Api\V1\OrderController::class, 'updateStatus']);

        // アナリティクスとレポート
        Route::get('analytics/overview', [Api\V1\AnalyticsController::class, 'overview']);
        Route::post('reports/generate', [Api\V1\ReportController::class, 'generate']);

        // メディア
        Route::post('media/upload', [Api\V1\MediaController::class, 'upload']);
        Route::post('media/presign', [Api\V1\MediaController::class, 'presign']);
        Route::get('media/{media}/download', [Api\V1\MediaController::class, 'download']);
    });

(1) ▶ サンプル:ShopMetrics注文APIエンドポイント

PHP
// app/Http/Controllers/Api/V1/OrderController.php
class OrderController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $query = Order::where('tenant_id', tenant()->id)
            ->with(['shop', 'user'])
            ->withCount('items');

        if ($request->filled('status')) {
            $query->where('status', $request->status);
        }
        if ($request->filled('shop_id')) {
            $query->where('shop_id', $request->shop_id);
        }
        if ($request->filled('date_from')) {
            $query->where('created_at', '>=', $request->date('date_from'));
        }

        $orders = $query->latest()->paginate($request->integer('per_page', 15));

        return OrderResource::collection($orders);
    }

    public function show(Order $order): JsonResponse
    {
        $this->authorize('view', $order);
        $order->load(['items.product', 'shop', 'user']);
        return new OrderResource($order);
    }

    public function updateStatus(Request $request, Order $order): JsonResponse
    {
        $this->authorize('update', $order);

        $validated = $request->validate([
            'status' => 'required|in:processing,completed,cancelled,refunded',
        ]);

        $oldStatus = $order->status;
        $order->update(['status' => $validated['status']]);

        event(new OrderStatusChanged($order, $oldStatus, $validated['status']));

        return new OrderResource($order->fresh());
    }
}

出力:

TEXT
// 実行成功

5. カスタムミドルウェアレイヤー

(1) ▶ サンプル:ShopMetricsミドルウェアアーキテクチャ

PHP
// app/Http/Middleware/TenantResolve.php
class TenantResolve
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();
        if (!$user?->tenant_id) abort(403, 'No tenant.');
        $tenant = $user->tenant;
        if ($tenant->status !== 'active') abort(403, 'Tenant inactive.');
        app()->instance(Tenant::class, $tenant);
        return $next($request);
    }
}

// app/Http/Middleware/CheckAbility.php
class CheckAbility
{
    public function handle(Request $request, Closure $next, string $ability): Response
    {
        if ($request->user()->tokenCan('*') || $request->user()->tokenCan($ability)) {
            return $next($request);
        }
        abort(403, "Missing ability: {$ability}");
    }
}

// app/Http/Middleware/EnsureSubscriptionActive.php
class EnsureSubscriptionActive
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenant = app()->make(Tenant::class);
        if (!$tenant->subscription?->isActive()) {
            abort(402, 'Active subscription required.');
        }
        return $next($request);
    }
}

出力:

TEXT
// 実行成功

(2) ▶ サンプル:ShopMetricsレート制限設定

PHP
// app/Providers/AppServiceProvider.php
public function boot(): void
{
    RateLimiter::for('tenant-api', function (Request $request) {
        $tenant = app()->make(Tenant::class);
        $plan = $tenant->plan ?? null;

        return Limit::perMinute(match ($plan->slug ?? 'starter') {
            'enterprise' => 600,
            'pro' => 120,
            default => 60,
        })->by($tenant->id);
    });
}

出力:

TEXT
// 実行成功

6. イベントブロードキャスト統合

(1) ブロードキャストイベント

PHP
// app/Events/OrderPlaced.php
class OrderPlaced implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public Order $order) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('tenant.' . $this->order->tenant_id)];
    }

    public function broadcastWith(): array
    {
        return [
            'order_id' => $this->order->id,
            'order_number' => $this->order->order_number,
            'total' => (float) $this->order->total,
            'shop_name' => $this->order->shop->name,
        ];
    }

    public function broadcastAs(): string { return 'order.placed'; }
}

(2) フロントエンドEchoリスニング

JAVASCRIPT
// resources/js/app.js
window.Echo.private(`tenant.${tenantId}`)
    .listen('.order.placed', (e) => {
        showNotification(`New order: ${e.order_number} ($${e.total})`);
    })
    .listen('.order.status_changed', (e) => {
        updateOrderRow(e.order_id, e.new_status);
    });

7. S3ファイルアップロード統合

(1) ▶ サンプル:ShopMetrics画像アップロードエンドポイント

PHP
// app/Http/Controllers/Api/V1/ShopLogoController.php
class ShopLogoController extends Controller
{
    public function __invoke(Request $request, Shop $shop): JsonResponse
    {
        $this->authorize('update', $shop);

        $validated = $request->validate([
            'logo' => 'required|image|mimes:jpeg,png,webp|max:2048',
        ]);

        if ($shop->logo_path) {
            Storage::disk('s3')->delete($shop->logo_path);
        }

        $path = $request->file('logo')->store(
            "shops/{$shop->id}/logos",
            's3',
        );

        $shop->update(['logo_path' => $path]);

        return response()->json([
            'message' => 'Logo uploaded.',
            'logo_url' => Storage::disk('s3')->url($path),
        ]);
    }
}

// app/Http/Controllers/Api/V1/MediaController.php
class MediaController extends Controller
{
    public function presign(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'filename' => 'required|string',
            'mime_type' => 'required|in:image/jpeg,image/png,image/webp',
        ]);

        $path = 'uploads/' . tenant()->id . '/' . Str::uuid() . '/' . $validated['filename'];
        $url = Storage::disk('s3')->temporaryUploadUrl($path, now()->addMinutes(30));

        return response()->json(['upload_url' => $url, 'path' => $path]);
    }
}

出力:

TEXT
// 実行成功

8. 総合サンプル:ShopMetricsフェーズ3完全API

PHP
// ============================================
// 総合:ShopMetricsフェーズ3完全API
// 対象:auth, resources, middleware, events, storage
// ============================================

// routes/api.php — 完全なAPIルート
Route::prefix('auth')->group(function () {
    Route::post('/register', [Auth\RegisterController::class, 'register']);
    Route::post('/login', [Auth\ApiTokenController::class, 'login']);
    Route::middleware('auth:sanctum')->group(function () {
        Route::get('/user', fn (Request $r) => new UserResource($r->user()));
        Route::post('/logout', [Auth\ApiTokenController::class, 'logout']);
    });
});

Route::middleware(['auth:sanctum', 'tenant.resolve', 'throttle:tenant-api', 'subscription.active'])
    ->prefix('v1')->group(function () {
        Route::apiResource('shops', Api\V1\ShopController::class);
        Route::post('shops/{shop}/logo', Api\V1\ShopLogoController::class);

        Route::apiResource('products', Api\V1\ProductController::class);
        Route::post('products/{product}/images', Api\V1\ProductImageController::class);

        Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show']);
        Route::patch('orders/{order}/status', [Api\V1\OrderController::class, 'updateStatus']);

        Route::get('analytics/overview', [Api\V1\AnalyticsController::class, 'overview'])
            ->middleware('ability:read');
        Route::post('reports/generate', [Api\V1\ReportController::class, 'generate'])
            ->middleware('ability:write');

        Route::post('media/upload', [Api\V1\MediaController::class, 'upload']);
        Route::post('media/presign', [Api\V1\MediaController::class, 'presign']);
        Route::get('media/{media}/download', [Api\V1\MediaController::class, 'download']);
    });

// routes/channels.php
Broadcast::channel('tenant.{tenantId}', fn ($user, $tenantId) => $user->tenant_id === (int) $tenantId);

// ブロードキャスト設定
// BROADCAST_CONNECTION=redis
// QUEUE_CONNECTION=redis
// 実行:php artisan queue:work
// 実行:php artisan reverb:start (Laravel 11内蔵WebSocketサーバー)

❓ よくある質問

Q フェーズ3の演習にはどのくらい時間がかかりますか?
A 約6〜8時間です。認証:1.5時間, APIリソース+エンドポイント:2時間, ミドルウェア:1時間, イベントブロードキャスト:1.5時間, ファイルアップロード:1時間, 統合テスト:1時間。各モジュールの完了後にPostmanでテストすることをお勧めします。
Q フロントエンドとバックエンドのAPIをどのように統合しますか?
A まずPostmanまたはNewmanですべてのエンドポイントをテストし, Collectionとして保存します。フロントエンド開発時はPostmanのMock Serverを使用するか, バックエンドに直接接続します。CORSが正しく設定されていることを確認してください。
Q WebSocketサービスにはReverbとPusherのどちらを使うべきですか?
A Laravel 11にはデフォルトでReverb (自己ホスト型WebSocketサーバー)が含まれています。Reverbは無料で開発に迅速に使用できます。本番環境では, 規模に応じてReverb (自己ホスト)またはPusher (ホスト型)を選択してください。
Q APIドキュメントはどのように生成しますか?
A Scribe (knuckleswtf/scribe)を使用してコードからOpenAPIドキュメントを自動生成することをお勧めします。Postmanで手動で書くよりも古くなりにくいです。各コントローラーメソッドにPHPDocコメントを追加するだけで済みます。
Q レート制限はどのようにテストしますか?
A Postman Runnerまたはab (Apache Bench)を使用してレート制限を超えるリクエストを送信し, 429ステータスコードとRetry-Afterヘッダーが返されることを確認します。PHPUnitテストを書いてレート制限をシミュレートすることもできます。
Q ローカル開発時にS3アップロードをテストするにはどうすればよいですか?
A MinIO (S3互換のローカルサービス)を実際のS3の代わりに使用します:docker run -p 9000:9000 minio/minio server /data。AWS_URLをlocalhost:9000に変更するだけです。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopMetrics API認証レイヤーを構築し, 3つのエンドポイント (登録, ログイン, ログアウト)を実装し, Postmanを使用してトークン取得と保護されたエンドポイントへのアクセスをテストしてください。

  2. 応用問題 (⭐⭐):完全なShop + Product CRUD APIを実装し, リソース変換, TenantResolve分離, サブスクリプションベースのレート制限を含めてください。Postman Collectionを使用してすべてのエンドポイントをテストしてください。

  3. チャレンジ (⭐⭐⭐):注文ステータス変更のリアルタイムブロードキャストを実装してください—バックエンドのPATCH /orders/{id}/statusリクエストがOrderStatusChangedイベントをトリガーし, フロントエンドのEchoが更新をリッスンしてUIを更新し, 同時にAPIリクエストをデータベースにログ記録します (terminateミドルウェアを使用)。

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%