404 Not Found

404 Not Found


nginx

プロジェクト開発—ShopMetrics SaaSプラットフォームのコーディング実装

設計図がついにコードになりました—でも焦らないで。1モジュールずつ実装し, 各モジュールを完成させるたびにテストしましょう。

1. 学ぶこと


2. 協同開発の実話

(1) 痛み:3人がそれぞれコードを書いており, マージ時に競合が発生する

Bobはタスクを割り当てました:Aliceはテナント管理コードを書き, Charlieはデータ分析コードを書くことにしました。3日後, コードをマージすると, AliceのTenantモデルとCharlieのOrderモデルの関係が整合せず, ミドルウェアロジックも互いに競合し, マージ後はサイト全体で500エラーが発生しました。Bobは言いました。「全員のコードが独立して実行できることを保証する開発プロセスが必要だ。」

(2) モジュール開発による解決策

次の優先順位で開発します:認証 → テナント → 店舗 → 注文 → 分析 → 課金。各モジュールの完了後にテストを実行し, コードをマージして, メインブランチが常に実行可能な状態を保ちます。

TEXT
週1:Auth + Tenant + Middleware (基本フレームワーク)
週2:Shop + Order + Product (コアビジネス)
週3:Dashboard + Analytics (データ分析)
週4:Subscription + Stripe (課金システム)

(3) 成果

モジュール開発を採用した後, 3人の開発者は毎日1回コードをマージし, 競合率は40%から5%に低下し, メインブランチは常に実行可能な状態を維持しました。


3. マルチテナントミドルウェアとスコープ

(1) リクエスト処理ワークフロー

100%
sequenceDiagram
    participant Client
    participant Nginx
    participant Middleware
    participant TenantScope
    participant Controller
    participant DB

    Client->>Nginx: GET alice.shopmetrics.io/api/v1/shops
    Nginx->>Middleware: X-Tenant: alice
    Middleware->>DB: SELECT * FROM tenants WHERE slug=alice
    DB-->>Middleware: Tenant{id:1, slug:alice}
    Middleware->>TenantScope: 現在のテナントを設定
    Middleware->>Controller: テナントコンテキスト付きリクエスト
    Controller->>DB: SELECT * FROM shops WHERE tenant_id=1
    DB-->>Controller: Aliceの店舗のみ
    Controller-->>Client: 200 OK {data: [...]}

(2) ミドルウェアの実装

PHP
// app/Http/Middleware/SetTenantContext.php
class SetTenantContext
{
    public function handle(Request $request, Closure $next): Response
    {
        if (!$user = $request->user()) {
            return $next($request);
        }

        $tenant = $user->tenant;

        if (!$tenant || !$tenant->isActive()) {
            abort(403, 'Tenant account is inactive or suspended.');
        }

        // プラン制限の確認
        if ($tenant->isOverLimit()) {
            abort(402, 'Plan limit exceeded. Please upgrade your subscription.');
        }

        Tenant::setCurrent($tenant);
        $request->attributes->set('tenant', $tenant);

        return $next($request);
    }
}

// app/Http/Kernel.php - ミドルウェアの登録
protected $middlewareAliases = [
    'tenant' => SetTenantContext::class,
];

(1) ▶ サンプル:BelongsToTenantトレイトの完全実装

PHP
// app/Models/Traits/BelongsToTenant.php
namespace App\Models\Traits;

use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            $tenant = Tenant::current();
            if ($tenant) {
                $builder->where($builder->getModel()->getTable() . '.tenant_id', $tenant->id);
            }
        });

        static::creating(function (Model $model) {
            $tenant = Tenant::current();
            if ($tenant && !$model->isDirty('tenant_id')) {
                $model->tenant_id = $tenant->id;
            }
        });

        static::saving(function (Model $model) {
            $tenant = Tenant::current();
            if ($tenant) {
                $currentTenantId = $model->getOriginal('tenant_id') ?? $model->tenant_id;
                if ((int) $currentTenantId !== $tenant->id) {
                    throw new \Illuminate\Auth\Access\AuthorizationException(
                        'Cannot modify data belonging to another tenant.'
                    );
                }
            }
        });
    }

    public function tenant(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    public function scopeForTenant(Builder $query, Tenant $tenant): Builder
    {
        return $query->withoutGlobalScope('tenant')
            ->where('tenant_id', $tenant->id);
    }

    public function scopeAllTenants(Builder $query): Builder
    {
        return $query->withoutGlobalScope('tenant');
    }
}

出力:

TEXT
// 実行成功

4. Stripeサブスクリプション課金統合

(1) サブスクリプションライフサイクル

100%
stateDiagram-v2
    [*] --> Trial: 新規登録
    Trial --> Active: トライアル→有料
    Trial --> Cancelled: トライアル中にキャンセル
    Active --> PastDue: 支払い失敗
    PastDue --> Active: 支払いリトライ成功
    PastDue --> Cancelled: リトライ回数超過
    Active --> Cancelled: ユーザーキャンセル
    Cancelled --> Active: 再サブスクライブ

(2) PlanとSubscriptionモデル

PHP
// app/Models/Plan.php
class Plan extends Model
{
    protected $fillable = [
        'name', 'slug', 'shop_limit', 'user_limit',
        'price_cents', 'stripe_price_id', 'is_active',
    ];

    protected $casts = [
        'price_cents' => 'integer',
        'shop_limit' => 'integer',
        'user_limit' => 'integer',
        'is_active' => 'boolean',
    ];

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

    public function isFree(): bool
    {
        return $this->price_cents === 0;
    }

    public function formattedPrice(): string
    {
        return $this->isFree() ? 'Free' : '$' . number_format($this->price_cents / 100, 2) . '/mo';
    }
}

// app/Models/Subscription.php
class Subscription extends Model
{
    protected $fillable = [
        'tenant_id', 'plan_id', 'stripe_id',
        'stripe_status', 'trial_ends_at', 'ends_at',
    ];

    protected $casts = [
        'trial_ends_at' => 'datetime',
        'ends_at' => 'datetime',
    ];

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

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

    public function isActive(): bool
    {
        return in_array($this->stripe_status, ['active', 'trialing']);
    }

    public function onTrial(): bool
    {
        return $this->stripe_status === 'trialing' ||
               ($this->trial_ends_at && $this->trial_ends_at->isFuture());
    }

    public function onGracePeriod(): bool
    {
        return $this->ends_at && $this->ends_at->isFuture();
    }
}

(1) ▶ サンプル:Stripe CheckoutとWebhook処理

PHP
// app/Http/Controllers/SubscriptionController.php
class SubscriptionController extends Controller
{
    public function store(Request $request)
    {
        $request->validate(['plan_slug' => 'required|exists:plans,slug']);
        $plan = Plan::whereSlug($request->plan_slug)->firstOrFail();
        $tenant = $request->attributes->get('tenant');

        if ($plan->isFree()) {
            $tenant->updateToFreePlan($plan);
            return response()->json(['message' => 'Subscribed to free plan']);
        }

        // Stripe Checkoutセッションの作成
        $checkout = Stripe::checkout()->sessions()->create([
            'customer' => $tenant->stripe_customer_id ?? $this->createStripeCustomer($tenant),
            'mode' => 'subscription',
            'line_items' => [[
                'price' => $plan->stripe_price_id,
                'quantity' => 1,
            ]],
            'subscription_data' => [
                'trial_period_days' => config('shopmetrics.tenant.trial_days'),
                'metadata' => ['tenant_id' => $tenant->id],
            ],
            'success_url' => config('app.url') . '/billing?success=1',
            'cancel_url' => config('app.url') . '/billing?cancel=1',
        ]);

        return response()->json(['checkout_url' => $checkout->url]);
    }

    public function cancel(Request $request)
    {
        $tenant = $request->attributes->get('tenant');
        $subscription = $tenant->subscription;

        Stripe::subscriptions()->update($subscription->stripe_id, [
            'cancel_at_period_end' => true,
        ]);

        $subscription->update(['ends_at' => $subscription->current_period_end]);

        return response()->json(['message' => 'Subscription will cancel at period end']);
    }
}

// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
    public function handleStripe(Request $request)
    {
        $payload = $request->all();
        $event = Stripe::webhooks()->constructEvent(
            $payload,
            $request->header('Stripe-Signature'),
            config('services.stripe.webhook_secret')
        );

        match ($event->type) {
            'customer.subscription.created' => $this->handleSubscriptionCreated($event),
            'customer.subscription.updated' => $this->handleSubscriptionUpdated($event),
            'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event),
            'invoice.payment_failed' => $this->handlePaymentFailed($event),
            default => null,
        };

        return response()->json(['received' => true]);
    }

    private function handleSubscriptionUpdated($event): void
    {
        $stripeSubscription = $event->data->object;
        $subscription = Subscription::whereStripeId($stripeSubscription->id)->firstOrFail();
        $subscription->update([
            'stripe_status' => $stripeSubscription->status,
            'trial_ends_at' => $stripeSubscription->trial_end
                ? \Carbon\Carbon::createFromTimestamp($stripeSubscription->trial_end)
                : null,
            'ends_at' => $stripeSubscription->cancel_at_period_end
                ? \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end)
                : null,
        ]);
    }

    private function handlePaymentFailed($event): void
    {
        $stripeSubscription = $event->data->object->subscription;
        $subscription = Subscription::whereStripeId($stripeSubscription)->first();
        if ($subscription) {
            $subscription->tenant->users->each(
                fn ($user) => $user->notify(new PaymentFailedNotification())
            );
        }
    }
}

出力:

TEXT
// 実行成功

5. データ分析ダッシュボード

(1) ダッシュボードAPI設計

エンドポイント 返却データ キャッシュTTL
GET /dashboard/overview 総売上/注文数/顧客数/前年比 15分
GET /dashboard/revenue 売上推移グラフデータ 30分
GET /dashboard/top-products トップ10商品 1時間
GET /dashboard/shop-comparison 店舗比較 1時間

(1) ▶ サンプル:ShopMetricsダッシュボードサービス

PHP
// app/Services/DashboardService.php
class DashboardService
{
    public function getOverview(Tenant $tenant): array
    {
        return Cache::remember(
            "dashboard:{$tenant->id}:overview",
            now()->addMinutes(15),
            fn () => $this->calculateOverview($tenant)
        );
    }

    private function calculateOverview(Tenant $tenant): array
    {
        $currentMonth = now()->startOfMonth();
        $lastMonth = now()->subMonth()->startOfMonth();

        $current = DB::table('orders')
            ->where('tenant_id', $tenant->id)
            ->where('ordered_at', '>=', $currentMonth)
            ->select([
                DB::raw('SUM(total_cents) as revenue_cents'),
                DB::raw('COUNT(*) as order_count'),
                DB::raw('COUNT(DISTINCT customer_email) as customer_count'),
            ])
            ->first();

        $previous = DB::table('orders')
            ->where('tenant_id', $tenant->id)
            ->whereBetween('ordered_at', [$lastMonth, $currentMonth])
            ->select([
                DB::raw('SUM(total_cents) as revenue_cents'),
                DB::raw('COUNT(*) as order_count'),
            ])
            ->first();

        return [
            'revenue' => [
                'current' => (float) ($current->revenue_cents / 100),
                'previous' => (float) ($previous->revenue_cents / 100),
                'change_percent' => $this->percentChange(
                    $previous->revenue_cents, $current->revenue_cents
                ),
            ],
            'orders' => [
                'current' => (int) $current->order_count,
                'previous' => (int) $previous->order_count,
                'change_percent' => $this->percentChange(
                    $previous->order_count, $current->order_count
                ),
            ],
            'customers' => (int) $current->customer_count,
        ];
    }

    public function getRevenueChart(Tenant $tenant, string $period = '30d'): array
    {
        $days = match ($period) {
            '7d' => 7, '30d' => 30, '90d' => 90, '1y' => 365,
            default => 30,
        };

        return Cache::remember(
            "dashboard:{$tenant->id}:revenue:{$period}",
            now()->addMinutes(30),
            fn () => $this->calculateRevenueChart($tenant, $days)
        );
    }

    private function calculateRevenueChart(Tenant $tenant, int $days): array
    {
        $data = DB::table('orders')
            ->where('tenant_id', $tenant->id)
            ->where('ordered_at', '>=', now()->subDays($days))
            ->groupBy('date')
            ->orderBy('date')
            ->select([
                DB::raw('DATE(ordered_at) as date'),
                DB::raw('SUM(total_cents) as revenue_cents'),
                DB::raw('COUNT(*) as order_count'),
            ])
            ->get();

        return [
            'labels' => $data->pluck('date')->map(fn ($d) => \Carbon\Carbon::parse($d)->format('M d')),
            'revenue' => $data->pluck('revenue_cents')->map(fn ($v) => $v / 100),
            'orders' => $data->pluck('order_count'),
        ];
    }

    private function percentChange(float|int $old, float|int $new): float
    {
        if ($old == 0) return $new > 0 ? 100.0 : 0.0;
        return round((($new - $old) / $old) * 100, 1);
    }
}

出力:

TEXT
// 実行成功

6. 非同期レポート生成

(1) レポート生成プロセス

100%
sequenceDiagram
    participant User as Alice
    participant API as APIサーバー
    participant Queue as Redisキュー
    participant Worker as キューワーカー
    participant S3 as S3ストレージ
    participant Mail as メールサービス

    User->>API: POST /reports {type: monthly, format: xlsx}
    API->>Queue: GenerateReportJobをディスパッチ
    API-->>User: 202 {report_id, status: pending}

    Queue->>Worker: ジョブを取得
    Worker->>Worker: データのクエリと集計
    Worker->>S3: レポートファイルをアップロード
    Worker->>Worker: レポートステータス=completedに更新
    Worker->>Mail: 通知メールを送信
    Mail-->>User: レポート完成メール

    User->>API: GET /reports/{id}
    API-->>User: {status: completed, download_url}

(1) ▶ サンプル:ShopMetricsレポート生成ジョブ

PHP
// app/Jobs/GenerateReportJob.php
class GenerateReportJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 60;
    public int $timeout = 600;

    public function __construct(
        public Tenant $tenant,
        public string $type,
        public string $format,
        public array $filters = [],
    ) {}

    public function handle(
        ReportGenerator $generator,
        ReportStorage $storage,
    ): void {
        $report = Report::create([
            'tenant_id' => $this->tenant->id,
            'type' => $this->type,
            'format' => $this->format,
            'status' => 'processing',
            'filters' => $this->filters,
        ]);

        try {
            $data = $this->fetchData();
            $filePath = $generator->generate($data, $this->format, $report);
            $storagePath = $storage->store($filePath, $this->tenant, $report);

            $report->update([
                'status' => 'completed',
                'storage_path' => $storagePath,
                'generated_at' => now(),
            ]);

            $this->notifyUser($report);

        } catch (\Throwable $e) {
            $report->update(['status' => 'failed']);
            throw $e;
        } finally {
            Cache::forget("dashboard:{$this->tenant->id}:*");
        }
    }

    private function fetchData(): Collection
    {
        return match ($this->type) {
            'monthly' => $this->fetchMonthlyData(),
            'product_comparison' => $this->fetchProductComparison(),
            'shop_performance' => $this->fetchShopPerformance(),
            default => throw new \InvalidArgumentException("Unknown report type: {$this->type}"),
        };
    }

    private function fetchMonthlyData(): Collection
    {
        return Order::with('items.product')
            ->where('tenant_id', $this->tenant->id)
            ->whereBetween('ordered_at', [
                $this->filters['date_from'] ?? now()->subMonth(),
                $this->filters['date_to'] ?? now(),
            ])
            ->orderBy('ordered_at')
            ->get();
    }

    private function notifyUser(Report $report): void
    {
        $user = User::find($this->filters['user_id'] ?? $this->tenant->users()->first()->id);
        $user?->notify(new ReportReadyNotification($report));
    }
}

// app/Services/ReportGenerator.php
class ReportGenerator
{
    public function generate(Collection $data, string $format, Report $report): string
    {
        return match ($format) {
            'csv' => $this->generateCsv($data, $report),
            'xlsx' => $this->generateExcel($data, $report),
            'json' => $this->generateJson($data, $report),
            default => throw new \InvalidArgumentException("Unsupported format: {$format}"),
        };
    }

    private function generateCsv(Collection $data, Report $report): string
    {
        $path = tempnam(sys_get_temp_dir(), 'report_');
        $file = fopen($path, 'w');

        fputcsv($file, ['Order ID', 'Date', 'Customer', 'Total', 'Status']);

        foreach ($data as $order) {
            fputcsv($file, [
                $order->external_id,
                $order->ordered_at->format('Y-m-d'),
                $order->customer_email,
                number_format($order->total_cents / 100, 2),
                $order->status,
            ]);
        }

        fclose($file);
        return $path;
    }
}

出力:

TEXT
// 実行成功

7. APIレイヤーの完全実装

(1) APIリソース出力

PHP
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'platform' => $this->platform,
            'status' => $this->status,
            'metadata' => $this->metadata,
            'order_count' => $this->whenCounted('orders'),
            'revenue_cents' => $this->whenAggregated('orders', 'total_cents', 'sum'),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

// app/Http/Resources/OrderResource.php
class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'external_id' => $this->external_id,
            'customer_email' => $this->customer_email,
            'total' => [
                'cents' => $this->total_cents,
                'formatted' => '$' . number_format($this->total_cents / 100, 2),
            ],
            'status' => $this->status,
            'items' => OrderItemResource::collection($this->whenLoaded('items')),
            'ordered_at' => $this->ordered_at->toIso8601String(),
        ];
    }
}

(2) フィルタリングとページネーション

PHP
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
    public function index(Request $request)
    {
        $query = Order::with(['items.product'])
            ->where('shop_id', $request->route('shop')->id);

        // フィルター
        if ($status = $request->query('status')) {
            $query->where('status', $status);
        }

        if ($dateFrom = $request->query('date_from')) {
            $query->where('ordered_at', '>=', $dateFrom);
        }

        if ($dateTo = $request->query('date_to')) {
            $query->where('ordered_at', '<=', $dateTo);
        }

        if ($minTotal = $request->query('min_total')) {
            $query->where('total_cents', '>=', $minTotal * 100);
        }

        if ($search = $request->query('search')) {
            $query->where('external_id', 'like', "%{$search}%")
                ->orWhere('customer_email', 'like', "%{$search}%");
        }

        // ソート
        $sortField = $request->query('sort_by', 'ordered_at');
        $sortDir = $request->query('sort_dir', 'desc');
        $query->orderBy($sortField, $sortDir);

        // ページネーション
        $orders = $query->paginate($request->query('per_page', 25));

        return OrderResource::collection($orders);
    }
}

(1) ▶ サンプル:ShopMetrics完全APIルートとコントローラー

PHP
// routes/api.php
Route::prefix('v1')->group(function () {
    Route::post('auth/login', [AuthController::class, 'login']);
    Route::post('auth/register', [AuthController::class, 'register']);

    Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
        Route::post('auth/logout', [AuthController::class, 'logout']);
        Route::get('auth/me', [AuthController::class, 'me']);

        Route::apiResource('shops', ShopController::class);
        Route::prefix('shops/{shop}')->group(function () {
            Route::apiResource('orders', OrderController::class)->only(['index', 'show']);
            Route::apiResource('products', ProductController::class);
        });

        Route::prefix('dashboard')->group(function () {
            Route::get('overview', [DashboardController::class, 'overview']);
            Route::get('revenue', [DashboardController::class, 'revenue']);
            Route::get('top-products', [DashboardController::class, 'topProducts']);
        });

        Route::apiResource('reports', ReportController::class)->only(['index', 'store', 'show']);
        Route::get('reports/{report}/download', [ReportController::class, 'download']);

        Route::apiResource('alerts', AlertController::class);

        Route::get('plans', [PlanController::class, 'index']);
        Route::post('subscriptions', [SubscriptionController::class, 'store']);
        Route::get('subscription', [SubscriptionController::class, 'show']);
        Route::delete('subscription', [SubscriptionController::class, 'cancel']);

        Route::middleware('role:tenant_owner')->prefix('team')->group(function () {
            Route::apiResource('members', TeamController::class)->only(['index', 'destroy']);
            Route::post('invite', [TeamController::class, 'invite']);
        });
    });

    Route::post('webhooks/stripe', [WebhookController::class, 'handleStripe']);
});

出力:

TEXT
// 実行成功

8. 総合サンプル:ShopMetrics全機能統合

PHP
// ============================================
// 総合:エンドツーエンドのリクエストフロー
// ログイン → ダッシュボード → レポート → 通知
// ============================================

// ステップ1:Aliceがログイン
// POST /api/v1/auth/login {email, password}
class AuthController extends Controller
{
    public function login(Request $request)
    {
        $credentials = $request->validate([
            'email' => 'required|email',
            'password' => 'required',
        ]);

        if (!Auth::attempt($credentials)) {
            return response()->json(['message' => 'Invalid credentials'], 401);
        }

        $user = Auth::user();
        $token = $user->createToken('auth-token')->plainTextToken;

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

// ステップ2:Aliceがダッシュボードを閲覧 (テナントミドルウェアが自動的にコンテキストを設定)
// GET /api/v1/dashboard/overview
class DashboardController extends Controller
{
    public function overview(Request $request)
    {
        $tenant = $request->attributes->get('tenant');
        return response()->json(
            app(DashboardService::class)->getOverview($tenant)
        );
    }
}

// ステップ3:Aliceが月次レポートを生成
// POST /api/v1/reports {type: monthly, format: xlsx, date_from, date_to}
class ReportController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([
            'type' => 'required|in:monthly,product_comparison,shop_performance',
            'format' => 'required|in:csv,xlsx,json',
            'date_from' => 'nullable|date',
            'date_to' => 'nullable|date|after:date_from',
        ]);

        $tenant = $request->attributes->get('tenant');

        // プラン制限の確認
        $monthlyReports = Report::where('tenant_id', $tenant->id)
            ->whereMonth('created_at', now()->month)
            ->count();
        $limit = $tenant->subscription->plan->limits['reports_per_month'] ?? 10;

        if ($monthlyReports >= $limit && $limit !== -1) {
            return response()->json([
                'message' => 'Monthly report limit reached. Upgrade your plan.',
            ], 402);
        }

        GenerateReportJob::dispatch(
            $tenant,
            $validated['type'],
            $validated['format'],
            [...$validated, 'user_id' => $request->user()->id]
        );

        return response()->json([
            'message' => 'Report generation started',
            'estimated_time' => '2-5 minutes',
        ], 202);
    }
}

// ステップ4:ワーカーがジョブを処理し, S3にアップロードし, メールを送信
//  (セクション6のGenerateReportJobを参照)

// ステップ5:Aliceがレポートをダウンロード
// GET /api/v1/reports/{report}/download
class ReportController extends Controller
{
    public function download(Report $report, Request $request)
    {
        $tenant = $request->attributes->get('tenant');
        if ($report->tenant_id !== $tenant->id) {
            abort(403);
        }

        if ($report->status !== 'completed') {
            return response()->json(['message' => 'Report not ready', 'status' => $report->status], 202);
        }

        // 一時的なS3 URLを生成 (5分間有効)
        $url = Storage::disk('s3')->temporaryUrl(
            $report->storage_path,
            now()->addMinutes(5)
        );

        return redirect($url);
    }
}

❓ よくある質問

Q Global Scopeはバイパス可能ですか?
A はい, raw SQLやwithoutGlobalScope()でバイパスできます。対策:API層でtenant_idの所有権を検証, Row-Level Security (RLS)ポリシーを実装, バイパス試行を監査ログに記録します。
Q Stripe Webhookのセキュリティはどう確保しますか?
A Stripe-Signatureヘッダー (HMAC-SHA256)を検証します。生のペイロードは一切信頼しないでください。Webhookエンドポイントには認証ミドルウェアは不要ですが, 署名の検証は必須です。
Q レポート生成ジョブがタイムアウトした場合はどうすればよいですか?
A ジョブのtimeoutを600 (10分), triesを3, backoffを60に設定します。タイムアウト後, ジョブは失敗してリトライされます。3回試行後は「failed」とマークされ, ユーザーにリトライが通知されます。大規模データセットは先にシャード処理してください。
Q マルチテナント環境でキャッシュキーはどう設計すべきですか?
A すべてのキャッシュキーにtenant_idをプレフィックスとして付けます:dashboard:{tenant_id}:overview。期限切れ確認時はプレフィックスでキャッシュをスキャン (Redis SCAN)。KEYSは使用しないでください (ブロックが発生します)。
Q APIリソースでwhenLoadedはいつ使うべきですか?
A 関連付けがeager loadされている場合のみ出力します。そうしないとN+1クエリが発生します。クライアントは?include=items.productで関連付けをリクエストし, コントローラーはincludeパラメータに基づいてwith()を決定します。
Q 3人の開発者は開発中にどのようにコードを調整しますか?
A 毎日メインブランチにマージし, 未完成の機能はフィーチャーフラグで制御します。各モジュールは独立してテスト (Feature Test)し, マージ後にフルテストスイートを実行します。CIは失敗したマージリクエストをブロックします。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopResourceとOrderResourceを実装し, 関連データ (whenLoaded)を含め, 対応する機能テストを記述してJSON構造を検証してください。

  2. 応用問題 (⭐⭐):Stripeサブスクリプションワークフローを完全に実装してください—Checkoutセッションの作成 + Webhook処理 (3イベント:created/updated/deleted) + サブスクリプションステータスの同期 + 支払い失敗時のメール通知。

  3. チャレンジ (⭐⭐⭐):完全なレポート生成システムを実装してください—GenerateReportJob—CSVとJSONの両形式をサポート, 進捗追跡 (Redisに進捗率を保存), 一時S3ダウンロードURL (5分間有効), スケジュールジョブの制限チェック, 完了時のメール通知。全ワークフローをカバーする機能テストを記述してください:作成 → 処理中 → 完了 → ダウンロード。

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%