My Blog

【Laravel × Stripe】サブスクリプション課金システムを爆速実装する方法

📝 お知らせ: この記事は生成AIでの文章構成の修正を行っています。

はじめに

SaaS系のサービスを開発していると、避けて通れないのがサブスクリプション課金システムの実装です。自前で決済システムを構築するのは非常に複雑で時間がかかりますが、Laravel CashierStripeを組み合わせることで、驚くほど簡単にサブスクリプション機能を実装できます。

今回は、実際に動くサブスクリプション課金システムを最短で構築する方法を、コード付きで詳しく解説します。初心者でも30分程度で基本的な機能を実装できるように、ステップバイステップで進めていきます。

なぜLaravel CashierとStripeなのか?

まず、なぜこの組み合わせが最強なのかを簡単に説明します:

  • Laravel Cashier:Laravelの公式パッケージで、Stripeとの連携が簡単
  • Stripe:世界的に信頼されている決済プラットフォーム、日本でも利用可能
  • 開発工数の大幅削減:複雑な課金ロジックがパッケージ化されている
  • セキュリティ:PCI DSS準拠のStripeを利用するため安全

他の選択肢との比較

PayPalやSquareとの比較では、Stripeは以下の点で優れています:

  • API設計が直感的で開発者フレンドリー
  • サブスクリプション機能が標準で充実
  • Webhookによるリアルタイム同期が簡単
  • テストモードが充実している

環境構築とパッケージインストール

まずは必要なパッケージをインストールします。Laravelプロジェクトが既にあることを前提に進めます。

# Laravel Cashierのインストール
composer require laravel/cashier

# マイグレーションファイルの作成
php artisan vendor:publish --tag="cashier-migrations"

# マイグレーション実行
php artisan migrate

次に、.envファイルにStripeの認証情報を設定します:

# Stripe設定(テストモード)
STRIPE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_SECRET=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx

# 本番環境では pk_live_ と sk_live_ を使用
CASHIER_CURRENCY=jpy
CASHIER_CURRENCY_LOCALE=ja_JP

Userモデルの設定

UserモデルにBillableトレイトを追加します:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable; // この行を追加
    
    protected $fillable = [
        'name',
        'email',
        'password',
    ];
    
    protected $hidden = [
        'password',
        'remember_token',
    ];

    // サブスクリプション状態を確認するヘルパーメソッド
    public function hasActiveSubscription(): bool
    {
        return $this->subscribed('default');
    }
    
    // プラン名を取得するメソッド
    public function getCurrentPlan(): ?string
    {
        $subscription = $this->subscription('default');
        return $subscription ? $subscription->stripe_price : null;
    }
}

サブスクリプションコントローラーの実装

課金処理を担当するコントローラーを作成します:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Cashier\Exceptions\IncompletePayment;

class SubscriptionController extends Controller
{
    /**
     * サブスクリプション開始
     */
    public function subscribe(Request $request)
    {
        $request->validate([
            'payment_method' => 'required|string',
            'plan' => 'required|string', // price_xxxxx形式のStripe Price ID
        ]);

        $user = Auth::user();
        
        // 既にサブスクリプションがある場合はエラー
        if ($user->subscribed('default')) {
            return response()->json([
                'error' => '既にサブスクリプションが存在します'
            ], 400);
        }

        try {
            // サブスクリプション作成
            $subscription = $user->newSubscription('default', $request->plan)
                ->create($request->payment_method);
                
            return response()->json([
                'success' => true,
                'subscription_id' => $subscription->id,
                'status' => $subscription->stripe_status
            ]);
            
        } catch (IncompletePayment $exception) {
            // 3Dセキュアなどで追加認証が必要な場合
            return response()->json([
                'requires_action' => true,
                'payment_intent' => [
                    'id' => $exception->payment->id,
                    'client_secret' => $exception->payment->client_secret
                ]
            ]);
        } catch (\Exception $e) {
            return response()->json([
                'error' => 'サブスクリプションの作成に失敗しました: ' . $e->getMessage()
            ], 500);
        }
    }
    
    /**
     * サブスクリプション解約
     */
    public function cancel()
    {
        $user = Auth::user();
        
        if (!$user->subscribed('default')) {
            return response()->json([
                'error' => '有効なサブスクリプションが見つかりません'
            ], 404);
        }
        
        // 期間終了時に解約(即座に解約する場合は cancelNow() を使用)
        $user->subscription('default')->cancel();
        
        return response()->json([
            'success' => true,
            'message' => 'サブスクリプションを解約しました(期間終了まで利用可能)'
        ]);
    }
    
    /**
     * プラン変更
     */
    public function changePlan(Request $request)
    {
        $request->validate([
            'plan' => 'required|string'
        ]);
        
        $user = Auth::user();
        
        if (!$user->subscribed('default')) {
            return response()->json([
                'error' => '有効なサブスクリプションが見つかりません'
            ], 404);
        }
        
        // プラン変更(即座に適用)
        $user->subscription('default')->swap($request->plan);
        
        return response()->json([
            'success' => true,
            'new_plan' => $request->plan
        ]);
    }
}

フロントエンド実装(Blade + JavaScript)

サブスクリプション画面のBladeテンプレートとJavaScriptを実装します:

<!-- resources/views/subscription.blade.php -->
@extends('layouts.app')

@section('content')
<div class="container">
    <h2>サブスクリプション管理</h2>
    
    @if(auth()->user()->hasActiveSubscription())
        <div class="alert alert-success">
            現在のプラン: {{ auth()->user()->getCurrentPlan() }}
        </div>
        
        <button id="cancel-subscription" class="btn btn-danger">解約する</button>
        
        <h3>プラン変更</h3>
        <select id="plan-select" class="form-control">
            <option value="price_basic">ベーシックプラン (¥1,000/月)</option>
            <option value="price_premium">プレミアムプラン (¥2,000/月)</option>
        </select>
        <button id="change-plan" class="btn btn-primary mt-2">プラン変更</button>
    @else
        <h3>プランを選択してください</h3>
        <div id="subscription-form">
            <select id="plan-select" class="form-control mb-3">
                <option value="price_basic">ベーシックプラン (¥1,000/月)</option>
                <option value="price_premium">プレミアムプラン (¥2,000/月)</option>
            </select>
            
            <div id="card-element" class="form-control mb-3">
                <!-- Stripe Elements will create form elements here -->
            </div>
            
            <button id="subscribe-button" class="btn btn-success">サブスクリプション開始</button>
        </div>
    @endif
</div>

<script src="https://js.stripe.com/v3/"></script>
<script>
// Stripe初期化
const stripe = Stripe('{{ config("cashier.key") }}');
const elements = stripe.elements();

// カード要素作成
const cardElement = elements.create('card', {
    style: {
        base: {
            fontSize: '16px',
            color: '#424770',
            '::placeholder': {
                color: '#aab7c4',
            },
        },
    },
});

if (document.getElementById('card-element')) {
    cardElement.mount('#card-element');
}

// サブスクリプション開始
document.getElementById('subscribe-button')?.addEventListener('click', async () => {
    const {paymentMethod, error} = await stripe.createPaymentMethod({
        type: 'card',
        card: cardElement,
    });
    
    if (error) {
        alert('カード情報にエラーがあります: ' + error.message);
        return;
    }
    
    // サーバーにサブスクリプション作成リクエスト
    const response = await fetch('/api/subscribe', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
        },
        body: JSON.stringify({
            payment_method: paymentMethod.id,
            plan: document.getElementById('plan-select').value
        })
    });
    
    const result = await response.json();
    
    if (result.success) {
        alert('サブスクリプションを開始しました!');
        location.reload();
    } else if (result.requires_action) {
        // 3Dセキュア認証が必要な場合
        const {error: confirmError} = await stripe.confirmCardPayment(result.payment_intent.client_secret);
        if (!confirmError) {
            alert('サブスクリプションを開始しました!');
            location.reload();
        }
    } else {
        alert('エラー: ' + result.error);
    }
});
</script>
@endsection

Webhookの設定

Stripeからの通知を受け取るWebhookを設定します:

<?php
// routes/web.php

use Laravel\Cashier\Http\Controllers\WebhookController;

// Cashierが提供するWebhookエンドポイント
Route::post('/stripe/webhook', [WebhookController::class, 'handleWebhook']);

// カスタムWebhookハンドラーが必要な場合
Route::post('/stripe/webhook', function (Request $request) {
    $payload = $request->getContent();
    $sig_header = $request->header('stripe-signature');
    $endpoint_secret = config('cashier.webhook.secret');

    try {
        $event = \Stripe\Webhook::constructEvent(
            $payload, $sig_header, $endpoint_secret
        );
    } catch(\Exception $e) {
        return response('Webhook signature verification failed.', 400);
    }

    // イベントハンドリング
    switch ($event['type']) {
        case 'customer.subscription.deleted':
            // サブスクリプション削除時の処理
            $subscription = $event['data']['object'];
            Log::info('Subscription cancelled: ' . $subscription['id']);
            break;
        case 'invoice.payment_failed':
            // 決済失敗時の処理(メール通知など)
            $invoice = $event['data']['object'];
            Log::warning('Payment failed for invoice: ' . $invoice['id']);
            break;
    }

    return response('OK', 200);
});

実装時の注意点とベストプラクティス

セキュリティ対策

  • 本番環境では必ずHTTPS:Stripe APIは本番環境でHTTPSを必須とします
  • Webhook署名検証:不正なリクエストを防ぐため、必ず署名を検証してください
  • CSRFトークン:AjaxリクエストでもCSRF保護を忘れずに

エラーハンドリング

  • IncompletePayment例外:3Dセキュアなどで追加認証が必要な場合があります
  • 決済失敗:カード期限切れなどで決済が失敗する場合の処理を実装
  • ユーザーフレンドリーなエラーメッセージ:技術的なエラーをユーザーに分かりやすく伝える

テスト時のTips

  • テストカード番号:4242 4242 4242 4242(成功)、4000 0000 0000 0002(失敗)
  • Webhookテスト:Stripe CLIを使用してローカル環境でWebhookをテスト可能
  • 金額の単位:日本円の場合、Stripeは円単位(銭単位ではない)

まとめ

Laravel CashierとStripeを組み合わせることで、複雑なサブスクリプション課金システムを短時間で実装できました。

実装した機能:

  • サブスクリプション開始・解約
  • プラン変更
  • 3Dセキュア対応
  • Webhook連携
  • エラーハンドリング

次のステップとして以下を検討してください:

  • 請求書機能の実装
  • クーポン・割引機能
  • 使用量ベース課金
  • 複数サブスクリプションの管理
  • アナリティクス・レポート機能

この実装をベースに、ビジネス要件に合わせてカスタマイズしていけば、本格的なSaaSサービスの課金システムを構築できます。Laravel CashierのドキュメントやStripeの豊富なAPIドキュメントも併せて参考にしてください。