LP: 初心者向け紅茶特集サイト ②バックエンド(決済機能・メール送信機能)

※アイキャッチ画像はフロントエンドのデザインが完成次第、後ほど本実装デザインと差し替え予定。(この暫定の画像も含め、当サイトのアイキャッチ画像は全て自作。)

紅茶特集のLP記事リンクは以下:

LP: 初心者向け紅茶特集サイト①フロントエンド・デザイン(カート機能・カルーセル・画像&サイトデザイン)

LP: 初心者向け紅茶特集サイト ②バックエンド(決済機能・メール送信機能)

概要

ECサイトとして実際に機能するLPを目指し、決済処理と問い合わせメール機能を実装。

Stripe APIを用いた基本的な決済フローおよび、Laravelによるメール送信・ログ管理を通じて商用レベルの基盤を構築。

開発中に発生したバージョン依存や環境構築の問題も、サブドメインとPHPの環境分離で解消した。

以下がLPのリンクとなっている。

紅茶特集

Stripeが提供するテスト用カード番号(4242 4242 4242 4242など)以外の番号を入れても決済処理そのものが通らない仕様となっている。

3Dセキュアでの動作も確認済み。

技術的な観点

開発環境

決済機能の動作確認、メールの送受信はいずれもConoHa VPSサーバーに当サイト(kanakofolio.net)移行後にLP用サブドメイン(tea-lp.kanakofolio.net)を設けたのちに行った。(理由は後述)

使用ツール・言語

  • Laravel: バージョンは8.3。
  • Stripe: 決済機能の実装に使用。
  • ConoHa VPS: LPサイトのサーバー。OSはUbuntu。(当サイトと同様。詳しくは以下の記事で解説。)

サーバー構築: Kanakofolio(①概要・ドメイン移行)

技術的試行錯誤

Stripeの導入

①ライブラリのインストール

Stripeのライブラリのインストール自体は以下のコマンドで行える。(PHP・Laravelの場合)

composer require stripe/stripe-php

インストール後、クラスを呼び出せるようにするために該当のコントローラーのファイル冒頭に以下を表記。(必要なもののみで可)

//コントローラーファイルからの抜粋
use Stripe\Stripe;
use Stripe\Checkout\Session;
use Stripe\Webhook;
  • PHPのバージョン変更: Stripeライブラリと使用していたPHPのバージョンとの互換性が無かったので、LP用サブドメインを設けた上でPHP8.3を導入し、ライブラリをインストール可能に。
    • 当初は当サイト(kanakofolio)と同じVPS上でサブディレクトリを用いて運用していたため、PHPのバージョンの依存・競合関係を確実に回避する必要があった。
    • これらにより、同じサーバ内でWordPress製の当サイトと共存させながら、Stripeでの決済機能の実装を実現。
//利用可能なPHPバージョン確認
apt-cache showpkg php
//PHP8.3をインストール
sudo apt install php8.3 php8.3-cli php8.3-fpm php8.3-mbstring php8.3-xml php8.3-curl
  • 使用バージョン設定: VPSサーバーの/etc/apache2/sites-available/内に、LP専用のconfファイル(tea-lp.conf)を作成。
    • tea-lp.confファイルのDirectoryタグ内に、PHP8.3適用のためのソケットパスを記述。
//confファイル作成
sudo nano /etc/apache2/sites-available/tea-lp.conf

//ファイル権限設定
sudo chmod 664 /etc/apache2/sites-available/tea-lp.conf

//tea-lp.confからの抜粋
<FilesMatch \.php$>
      SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost/"
</FilesMatch>
②テスト用公開鍵・秘密鍵の設定
  • Laravel側からStripeを使用可能にするために、.envにStripe管理画面からコピーした公開可能キー(STRIPE_KEY)及びシークレットキー(STRIPE_SECRET)を貼り付ける。
  • その後、設定を反映させるためにキャッシュクリア用コマンドを実行。
//.env内から抜粋
STRIPE_KEY=pk_test_xxxxxxxxxxxxx(伏字)
STRIPE_SECRET=sk_test_yyyyyyyyyyyyyyy(伏字)

//キャッシュクリアコマンドで更新
php artisan cache:clear
php artisan config:clear
php artisan config:cache

決済機能の実装

①購入内容確認(/purchase.blade.php, confirm関数)

  1. localStorageで入れられたブラウザ内のカートの商品をJSON形式でLaravelに送信。
  2. config内の商品リストとカート内容を照合し、検証を行う。
    • コードの重複や修正コストを避けるために、商品情報をconfig/products.phpにてまとめて管理。
//confirm関数からの抜粋
$cartData = json_decode($request->input('cart_data'), true);//カート内容をJSON形式に変換
$products = config('products');//config内商品リスト呼び出し
$validatedItems = [];
$totalPrice = 0;
foreach($cartData as $item){ //カート内容を精算
      $id = $item['id'];
     $quantity = $item['quantity'];
     if(isset($products[$id])){
          $product = $products[$id];
          $product['quantity'] = $quantity;
          $product['subtotal'] = $product['price'] * $quantity;
          $totalPrice += $product['subtotal'];
          $validatedItems[] = $product;
    }
}
session(['cart' => $validatedItems]);//セッションに保存

②決済処理(store関数・success関数・handleCheckoutEvent関数)

  1. store関数: 購入確認画面で入力された個人情報とともに、決済データを一旦StripeのCheckoutセッションに保存
    • この時点では決済は未完了で、ユーザーがStripeの決済画面にて支払いを行うことでWebhook(handleCheckoutEvent関数)によって支払い成功・失敗の通知を受け取る仕様。
  2. Webhook処理: handleCheckoutEvents関数にて、決済の成功・失敗でそれぞれの処理を行い、DBに反映。
  3. success関数: セッションIDをもとに、注文情報を照会・表示。(失敗時はfailedとして上書き防止)
    • DBに注文情報を追加する際にfirstOrCreateを使用することで、Webhook処理とのタイムラグで情報が反映されない状態を防止。
//store関数からの抜粋
$validated = $request->validate([ //確認画面で入力された個人情報のバリデーション
            'name' => 'required',
            'email' => 'required|email',
            'address' => 'required',
        ]);
\Stripe\Stripe::setApiKey(env('STRIPE_SECRET'));//テスト用秘密鍵の読み込み
  //略
$metadataItems = json_encode($cart);
$session = \Stripe\Checkout\Session::create([ //Checkoutセッション作成
            'payment_method_types' => ['card'],
            'line_items' => $line_items,
            'mode' => 'payment',
            'success_url' => route('buy.success', [], true) . '?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url' => route('buy.cancel', [], true),
            'metadata' => [
                'name' => $request->name,
                'email' => $request->email,
                'address' => $request->address,
                'amount' => array_sum(array_map(fn($i) => $i['price'] * $i['quantity'], $cart)),
                'items' => $metadataItems,
            ],
]);
session(['checkout_session_id' => $session->id]);//セッションに保存
補足: Webhookでの検証処理
  • イベント情報取得: $payload変数でStripeサーバーから送信されたJSON形式のリクエストを取得。
  • 署名ヘッダー取得: $sigheader変数でStripe側から送信される署名ヘッダーを取得。
    • これはWebhook処理の際にStripe側の送信であるという「証拠(電子署名)」で、Laravel側サーバーで同じ計算をして一致するかを検証することでデータの第三者による偽装の対策を行う。
  • 検証: 取得した$payload、$sigHeaderをconstructEventにて突き合わせて、StripeのWebhook Signing Secret($endpointSecret)を用いて検証。検証が通ったら$eventを返す。
    • try{…}catch{…}: これで署名の検証が失敗した場合(つまり偽装や改竄など)に備えて、400エラーを返して不正リクエストの処理を防止する。
//handleCheckoutEvent関数からの抜粋
$payload = $request->getContent(); //Stripeでの決済データ取得
$sigHeader = $request->header('Stripe-Signature'); //Stripe側からの署名ヘッダー取得
$endpointSecret = env('STRIPE_WEBHOOK_SECRET');
        try{ 
            $event = \Stripe\Webhook::constructEvent(
                $payload, $sigHeader, $endpointSecret
            );
        }catch(\Exception $e){ //署名の偽装や改竄を弾く
            return response('Webhook error: '.$e->getMessage(), 400);
        }
補足: Webhook処理と決済完了画面反映のタイムラグ
  • タイムラグ問題: ユーザーが決済を完了すると、Stripe側が「checkout.session.completed」イベントを発行。
    • これは非同期処理で数秒~数十秒遅れる場合があり、success関数が呼ばれた時点でLaravelにはWebhookが届かず、注文内容が画面に反映されない場合がある。
  • 対策: データベース処理時にfirstOrCreateを用いることで、Webhookが未到着の場合はsuccess関数側で暫定的にデータベースを登録
    • Webhookが到着した場合は同じセッションIDをもつレコードが既に存在するため、この処理により二重登録を防止。
    • これらの処理により、ユーザー側の画面にはStripeでの処理のタイムラグに拘わらず決済完了画面に内容が反映されるようになる。
  • status更新処理: statusが「failedでもpaidでもない場合」にデータベース作成後にpaidに書き換えて決済完了ビューを表示。
    • pendingのままなら「注文処理中」ビューを表示させる。
    • 実運用では殆どの場合にpaidになるものの、「例外的に空白画面にならない保険」としてpending画面も表示できるようにすることでユーザー導線を確保。
//success関数からの抜粋
$sessionId = $request->query('session_id');//StripeからIDを取得
$session = \Stripe\Checkout\Session::retrieve($sessionId);//Stripeの決済情報の呼び出し
$status = match($session->payment_status){ //payment_statusの表示変換
            'paid' => 'paid',
            'unpaid' => 'failed',
            default => 'pending',
        };
$order = Order::firstOrCreate( //Webhookとのタイムラグの対策
            ['stripe_id' => $session->id],
            [
                'name' => $session->metadata->name ?? 'ゲスト',
                'email' => $session->metadata->email ?? 'no-email@example.com',
                'address' => $session->metadata->address ?? '',
                'amount' => $session->metadata->amount ?? 0,
                'status' => $session->status ?? 'pending',
            ]
);
if($order->status !== 'failed' && $session->payment_status === 'paid'){
            $order->status = 'paid';
            $order->save();
        }
if(!$order || $order->status === 'pending'){
            return view('success_pending', ['title' => '注文処理中です']);
        }
        return view('success', [
            'order' => $order,
        ]);
Stripe管理画面でのWebhook連携
  • 関数の登録: Stripeでの決済情報をLaravel側で正しく処理出来るようにするため、WebhookをStripe管理画面側にてLP内での専用の関数を登録。
    • これにより決済完了時(checkout.session.complete)や失敗時(payment_intent.payment_failed)などのイベントをLaravel側が受信し、処理の自動化が可能に。
    • これがされない場合、Stripe側に注文情報が届かず、その後の処理も行えない。
  • Webhook作成: Stripeの管理画面内でLaravel側の関数のルーティングURIを登録。(https://lp-tea.kanakofolio.net/buy/stripe/webhook)
    • Stripe管理画面ログイン後にサンドボックスに切替→「開発者(画面左下)」→メニューの「Webhook」→右上の「送信先を追加(紫色の枠)」で作成画面に入る。
  • Laravel側での受信: 登録したルーティングに対応する関数(今回はhandleCheckoutEvent)をコントローラーに登録。これにより、Stripeが決済イベントを通知の際に関数が呼び出されるように。

(画像)

補足: web.php・api.phpの違い
  • web.php: ブラウザ経由の通常リクエスト向けで、セッションやCSRF保護が有効。
  • api.php: 外部サービスとの連携やAPIリクエスト向け。CSRF保護が無効で、通常はJSONレスポンスが前提。
    • 今回のWebhookルーティングはapi.phpに記述。外部サーバ(Stripe)からPOSTリクエストが送信され、ブラウザ経由向けのセッションやCSRF保護は不要。
//api.php
Route::post('/buy/stripe/webhook', [BuyController::class, 'handleCheckoutEvent'])->name('buy.checkout');
補足: 不可視文字によるエラーの解消
  • 開発中に突如決済用処理のLaravelコントローラーファイルが「Class does not exist」エラーとなり、Tinkerにて探ったところ、不可視文字(ASCII 22)が原因であることが判明。
    • 該当ファイル全体を見直しても全角スペースや文字化けなど目に見える瑕疵を見つけることはできなかった。
    • そこで、PCに標準のメモ帳にコードをコピー&ペーストした後、新規でLaravelコントローラーファイルを作成し、メモ帳の内容をペースト。これでエラーは無事解消された。
    • 注意点: 不可視文字はコードをそのままコピーするだけではそのまま引き継がれる場合があるため、一旦別のエディタを通して新規作成することをおすすめしたい。

メール機能の実装

ユーザーが問い合わせフォームから入力・送信した内容が、Laravel側で受け取られ処理されるよう実装した。

ただし、実際にメールが送信されるかわりに.envファイルでMAIL_MAILER=logを設定することで送信内容をlaravel.logに書き込む仕様となっている。

これにより、本番運用前でも実務的な動作確認を可能にしつつ、不要な外部送信を防ぐセキュリティ面の配慮も両立した。

  1. 環境設定: .envのMAIL_MAILERをlogに設定。実際の送信は行わずログに書き込む形でテスト。
    • 他のメール設定(MAIL_HOST、MAIL_PORTなど)はログ送信用なので不要。
    • 設定変更後は「php artisan cache:clear」「php artisan config:clear」「php artisan config:cache」でキャッシュを削除し、設定を反映させる。
  2. バリデーション: Requestで受け取った内容を$validatedでバリデーション。
    • name、email、title、messageの各項目を必須・文字数制限などを設定。
  3. メール送信処理: submit関数にて、Mail::send(‘view’, […], function($message){…});で送信処理。
    • ログに書き込まれるので、実際には送信されない。
    • viewの部分にメール本文に使うビューを設定。(今回はcontact.contact)
    • 送信先メールアドレスを一旦コメントアウトするか、テスト用アドレス(mymail@example.comなど)に設定。
  4. 完了ページ表示:
    return view(‘contact.complete’, [‘title’ => ‘送信完了しました’]) で送信完了ページを表示。
    • 完了ページ内に$titleで「送信完了しました」と表示。
  5. ログの確認: メール内容の反映を確認するために、laravel.logを「tail -f storage/logs/laravel.log」を実行。
    • このコマンドでは、リアルタイムで更新されるログを見ることが出来るため、フォーム送信→即ログ反映を確認したい場合に使いやすい。
//.envからの抜粋
APP_DEBUG=true
MAIL_MAILER=log
#MAIL_HOST=smtp.gmail.com ログ記録の場合は以下の項目群は不要。
#MAIL_PORT=587
#MAIL_USERNAME=mymail@example.com
#MAIL_PASSWORD=xxxxxxxxxxxxx
#MAIL_ENCRYPTION=tls
#MAIL_FROM_ADDRESS=mymail@example.com
#MAIL_FROM_NAME="${APP_NAME}"

//submit関数からの抜粋
$validated = $request->validate([ //メールの項目のバリデーション
            'name' => 'required|string|max:50',
            'email' => 'required|email',
            'title' => 'required|string|max:50',
            'message' => 'required|string'
        ]);
Mail::send('contact.contact', ['validated' => $validated], function($message) use ($validated){
            $message ->to('mymail@example.com') //ログでは使用されないのでダミーのアドレスを使用。
                     ->subject($validated['title']);
        });
補足:ログ用テキストの成形
  • MAIL_MAILER=logの仕様上、送信後に書き込まれるログにビューのHTMLなどもそのまま書き出されることが判明。
  • そこで、submit関数内でバリデーション処理の直後にLog::info(…);を用いることで送信内容のみが表示されるように設定。これにより、送信内容の確認を行いながら容量の圧迫も防止。
    • Mail::raw(…)を用いた場合は送信内容の確認は可能だが、ビューは使用不可であるため、全体の動作確認を行いたい場合は不向き。
//submit関数からの抜粋
\Log::info("Contact Mail: \n"
            ."Name: {$validated['name']}\n"
            ."Email: {$validated['email']}\n"
            ."Title: {$validated['title']}\n"
            ."Message: {$validated['message']}\n"
        );