フェーズ3総合演習—ShopMetrics APIとリアルタイム通知
フェーズ3総合演習は「高度な機能の提供」です—認証, API, ミドルウェア, イベント, ストレージを統合し, 本番グレードのAPIを構築します。
1. 学ぶこと
- SanctumトークンAPI認証の完全なプロセス
- 10以上のAPIリソースエンドポイント:Tenant/Product/Order/Subscription CRUD
- カスタムミドルウェア:TenantResolver/RateLimiter/CorsHandler
- 注文イベントブロードキャスト:Alice, Bob, CharlieへのリアルタイムWebSocketプッシュ通知
- S3への商品画像アップロードとプレサインURLによるダウンロード
2. Aliceのフェーズ3验收ストーリー
(1) 痛み:6回のレッスンの内容が断片的で, 完全なAPIとして組み立てられない
フェーズ3—レッスン15のSanctum, レッスン16のResources, レッスン17のMiddleware—を終えた後, Aliceはそれらをすべて1つの完全なAPIにまとめる方法がわかりませんでした。BobからRESTful APIを書くよう頼まれ, 認証, リソース変換, ミドルウェア, イベントブロードキャスト, ファイルアップロードなどの機能が連携する必要があることに気づきました。それぞれを個別には理解していましたが, 組み合わせるとすべてが混乱してしまいました。
(2) 総合演習の解決策
このレッスンでは, ゼロから完全なRESTful APIを構築します—各エンドポイントに認証, テナント分離, レート制限, リソースマッピング, イベントブロードキャストを備え, 最終的に本番グレードのAPIシステムを提供します。
# フェーズ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トークン認証プロセス
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) 認証エンドポイント
// 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トークン認証の完全な実装
// 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.']);
}
}
出力:
// 実行成功
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) ルート定義
// 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エンドポイント
// 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());
}
}
出力:
// 実行成功
5. カスタムミドルウェアレイヤー
(1) ▶ サンプル:ShopMetricsミドルウェアアーキテクチャ
// 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);
}
}
出力:
// 実行成功
(2) ▶ サンプル:ShopMetricsレート制限設定
// 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);
});
}
出力:
// 実行成功
6. イベントブロードキャスト統合
(1) ブロードキャストイベント
// 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リスニング
// 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画像アップロードエンドポイント
// 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]);
}
}
出力:
// 実行成功
8. 総合サンプル:ShopMetricsフェーズ3完全API
// ============================================
// 総合: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サーバー)
❓ よくある質問
knuckleswtf/scribe)を使用してコードからOpenAPIドキュメントを自動生成することをお勧めします。Postmanで手動で書くよりも古くなりにくいです。各コントローラーメソッドにPHPDocコメントを追加するだけで済みます。docker run -p 9000:9000 minio/minio server /data。AWS_URLをlocalhost:9000に変更するだけです。📖 まとめ
- SanctumトークンはAPIにステートレス認証を提供します
- APIリソースの標準化されたJSON出力形式。ネストされた関連付けは
whenLoaded条件を使用します - ミドルウェアスタック:CORS → TenantResolve → Auth → Throttle → Ability
- イベントブロードキャストにより, 注文変更のリアルタイムプッシュ通知がフロントエンドに届きます
- S3ファイルアップロードは直接アップロード (プレサインURL)とサーバー経由アップロードの両方をサポートします
- フェーズ3完了後, 本番対応のRESTful APIが完成します
📝 練習問題
-
基本問題 (⭐):ShopMetrics API認証レイヤーを構築し, 3つのエンドポイント (登録, ログイン, ログアウト)を実装し, Postmanを使用してトークン取得と保護されたエンドポイントへのアクセスをテストしてください。
-
応用問題 (⭐⭐):完全なShop + Product CRUD APIを実装し, リソース変換, TenantResolve分離, サブスクリプションベースのレート制限を含めてください。Postman Collectionを使用してすべてのエンドポイントをテストしてください。
-
チャレンジ (⭐⭐⭐):注文ステータス変更のリアルタイムブロードキャストを実装してください—バックエンドの
PATCH /orders/{id}/statusリクエストがOrderStatusChangedイベントをトリガーし, フロントエンドのEchoが更新をリッスンしてUIを更新し, 同時にAPIリクエストをデータベースにログ記録します (terminateミドルウェアを使用)。



