PHP | 問い合わせフォームのリファクタリング – MVCパターンで保守性と拡張性を向上

はじめに

本プロジェクトは、PHPとVue.jsを活用した問い合わせフォームのシステムです。近年のWebアプリケーション開発では、保守性や拡張性を考慮したアーキテクチャ設計が求められます。本記事では、これまで作成してきた問い合わせフォームをMVCアーキテクチャに沿った構造へリファクタリングした事例について紹介します。

これまでのフォームに関する詳細は以下の記事を参照してください:
これまで作ってきたフォーム

背景

プロジェクトの初期段階では、迅速な実装を優先した結果、以下のような課題が発生しました。

  • コードの役割が曖昧で、修正箇所が広範囲に及ぶためデバッグに時間がかかる
  • ビジネスロジックと表示ロジックの分離が不十分
  • テスト実施が困難で、バグの早期発見が難しい

これらの課題を解決するために、MVCアーキテクチャへの移行を決定しました。

MVCアーキテクチャとは

MVCは以下の3つの要素から構成されるアーキテクチャパターンです。

  1. Model (モデル) – データ管理やビジネスロジックを担当
  2. Controller (コントローラー) – リクエストの処理とModelを仲介
  3. View (ビュー) – 今回のプロジェクトではフロントエンドにVue.jsを導入し、index.htmlが表示側を担当

この構造により、役割が明確化され、コードの分離と保守性が向上します。

リファクタリング前後のファイル構成

リファクタリング前

api/
├── .htaccess
├── config.php
├── contact.php
├── csrf_token.php
├── recaptcha_key.php
.htaccess
index.html

リファクタリング後

api/
├── config/
│   └── config.php
├── controllers/
│   ├── AppController.php
│   ├── ContactController.php
│   ├── CsrfController.php
│   ├── ErrorController.php
│   └── RecaptchaController.php
├── logs/
├── models/
│   └── ContactModel.php
├── services/
│   ├── CSRFService.php
│   ├── CsvService.php
│   ├── DatabaseService.php
│   ├── EmailService.php
│   ├── RecaptchaService.php
│   └── ValidationService.php
├── storage/
├── .htaccess
├── routes.php
.htaccess
index.html

リファクタリング後のコード

controllers/AppController.php

<?php
/**
 * AppController.php
 *
 * 共通コントローラ
 * - 設定の読み込み、レスポンス管理、セキュリティ設定を担当。
 *
 * PHP Version: 8.2.22
 *
 * @category   Controller
 * @package    ContactForm
 * @author     AnalyzeGear Inc.
 * @license    MIT License
 * @link       https://analyzegear.co.jp/
 */

/**
 * 共通コントローラ
 * - 各コントローラで共有する汎用機能を提供
 * - 設定の読み込み、レスポンス送信、ヘッダー設定などを管理
 */
class AppController {
    /**
     * @var array 設定データ
     */
    protected $config;

    /**
     * コンストラクタ
     * 設定の読み込みと初期ヘッダー設定を実施
     */
    public function __construct() {
        // 設定ファイルの読み込み
        if (empty($this->config)) {
            $this->config = require __DIR__ . '/../config/config.php';
        }

        $this->setCorsHeaders(); // CORS設定適用
        $this->setSecurityHeaders(); // セキュリティヘッダー適用
        $this->handlePreflight(); // OPTIONSリクエスト対応
    }

    /**
     * 設定値取得メソッド
     * - ドット記法でネストした設定値を取得可能
     *
     * @param string $key 設定キー(例: 'recaptcha.site_key')
     * @param mixed $default デフォルト値(存在しない場合の返却値)
     * @return mixed 設定値またはデフォルト値
     */
    protected function getConfig($key, $default = null) {
        $keys = explode('.', $key); // ドット記法に対応
        $value = $this->config;

        foreach ($keys as $k) {
            if (isset($value[$k])) {
                $value = $value[$k];
            } else {
                return $default;
            }
        }
        return $value;
    }

    /**
     * JSONレスポンス送信メソッド
     * - APIからJSON形式でレスポンスを返す
     *
     * @param array $data レスポンスデータ
     * @param int $statusCode HTTPステータスコード(デフォルト: 200)
     */
    protected function sendJsonResponse($data, $statusCode = 200) {
        http_response_code($statusCode);
        header('Content-Type: application/json');
        echo json_encode($data);
        exit;
    }

    /**
     * エラーレスポンス送信メソッド
     * - エラーメッセージとステータスコードを返す
     * - デバッグモード時には追加情報を含める
     *
     * @param string $message エラーメッセージ
     * @param int $statusCode HTTPステータスコード(デフォルト: 400)
     * @param array $debugData デバッグ用追加情報
     */
    protected function sendErrorResponse($message, $statusCode = 400, $debugData = []) {
        $this->logError($message); // ログにエラーメッセージを記録

        $response = ['success' => false, 'error' => $message];
        if ($this->getConfig('debug', false) && !empty($debugData)) {
            $response['debug'] = $debugData; // デバッグ情報追加
        }

        $this->sendJsonResponse(['success' => false, 'error' => $message], $statusCode);
    }

    /**
     * 成功レスポンス送信メソッド
     * - 成功フラグとデータを返す
     *
     * @param array $data レスポンスデータ
     */
    protected function sendSuccessResponse($data = []) {
        $this->sendJsonResponse(array_merge(['success' => true], $data));
    }

    /**
     * CORSヘッダー設定
     * - クロスオリジンリクエストを許可するヘッダーを設定
     */
    protected function setCorsHeaders() {
        header('Access-Control-Allow-Origin: ' . $this->getConfig('cors.allowed_origins', '*'));
        header('Access-Control-Allow-Methods: ' . implode(',', $this->getConfig('cors.allowed_methods', ['GET', 'POST', 'OPTIONS'])));
        header('Access-Control-Allow-Headers: ' . implode(',', $this->getConfig('cors.allowed_headers', ['Content-Type', 'X-CSRF-TOKEN'])));
    }

    /**
     * セキュリティヘッダー設定
     * - 基本的なセキュリティ対策ヘッダーを追加
     */
    protected function setSecurityHeaders() {
        header('X-Content-Type-Options: nosniff');
        header('X-Frame-Options: DENY');
        header('X-XSS-Protection: 1; mode=block');
    }

    /**
     * HTTPメソッド制限
     * - 許可されたHTTPメソッドのみを受け入れる
     * - 不正なリクエストはエラーレスポンスで終了
     *
     * @param array $allowedMethods 許可するHTTPメソッド
     */
    public function validateHttpMethod($allowedMethods) {
        error_log('----------');
        error_log('Method: ' . $_SERVER['REQUEST_METHOD']);
        error_log('Allowed: ' . implode(', ', $allowedMethods));
        error_log('Request URI: ' . $_SERVER['REQUEST_URI']);
        error_log('Headers: ' . print_r(getallheaders(), true));
        $input = file_get_contents('php://input');
        error_log('Input: ' . $input);

        if (!in_array(strtoupper($_SERVER['REQUEST_METHOD']), $allowedMethods)) {
            error_log('拒否されたリクエスト: ' . $_SERVER['REQUEST_METHOD']);
            $this->sendErrorResponse('許可されていないHTTPメソッドです', 405);
            exit;
        }
    }

    /**
     * プリフライトリクエスト対応
     * - OPTIONSメソッドに204ステータスで応答
     */
    public function handlePreflight() {
        if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
            http_response_code(204); // No Content
            exit;
        }
    }

    /**
     * エラーログ記録
     * - エラーメッセージをログファイルに記録
     *
     * @param string $message エラーメッセージ
     */
    protected function logError($message) {
        $logPath = $this->getConfig('log.error_log', __DIR__ . '/../logs/error.log');
        error_log('[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL, 3, $logPath);
    }

    /**
     * デバッグログ記録
     * - デバッグモード時のみログ出力
     *
     * @param string $message デバッグメッセージ
     */
    protected function debugLog($message) {
        if ($this->getConfig('debug', false)) {
            error_log('[DEBUG][' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL);
        }
    }
}

controllers/ContactController.php

<?php
/**
 * ContactController.php
 * 
 * 問い合わせフォーム コントローラクラス
 * - 問い合わせフォームの処理を管理
 * - 入力データの検証、reCAPTCHAチェック、データ保存、メール送信を担当
 *
 * PHP Version: 8.2.22
 *
 * @category   Controller
 * @package    ContactForm
 * @author     AnalyzeGear Inc.
 * @license    MIT License
 * @link       https://analyzegear.co.jp/
 */

require_once 'AppController.php';
require_once __DIR__ . '/../models/ContactModel.php';
require_once __DIR__ . '/../services/EmailService.php';
require_once __DIR__ . '/../services/CSRFService.php';
require_once __DIR__ . '/../services/RecaptchaService.php';
require_once __DIR__ . '/../services/ValidationService.php';
require_once __DIR__ . '/../services/CsvService.php';
require_once __DIR__ . '/../services/DatabaseService.php';

/**
 * 問い合わせフォーム コントローラ
 * - 入力検証、CSRF対策、reCAPTCHA検証、データ保存、メール通知を管理
 */
class ContactController extends AppController {
    /** @var ContactModel モデルインスタンス */
    protected $contactModel;
    /** @var EmailService メール送信サービス */
    protected $emailService;
    /** @var CSRFService CSRF対策サービス */
    protected $csrfService;
    /** @var RecaptchaService reCAPTCHA検証サービス */
    protected $recaptchaService;
    /** @var ValidationService 入力検証サービス */
    protected $validationService;
    /** @var CsvService CSV保存サービス */
    protected $csvService;
    /** @var DatabaseService データベース操作サービス */
    protected $databaseService;

    /**
     * コンストラクタ
     * - サービスやモデルの初期化
     * - CORSとHTTPメソッド制限の設定
     */
    public function __construct() {
        parent::__construct(); // 親クラスの設定を適用

        // OPTIONSリクエストを許可 (CORS対応)
        if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
            header('Access-Control-Allow-Origin: *');
            header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
            header('Access-Control-Allow-Headers: Content-Type, X-CSRF-TOKEN, Authorization');
            http_response_code(204); // No Content
            exit;
        }

        // HTTPメソッド検証 (POSTのみ許可)
        if ($_SERVER['REQUEST_URI'] === '/api/contact') {
            $this->validateHttpMethod(['POST']);
        }

        // サービスとモデルの初期化
        $this->contactModel = new ContactModel($this->config);
        $this->emailService = new EmailService($this->config);
        $this->csrfService = new CSRFService();
        $this->recaptchaService = new RecaptchaService($this->config);
        $this->validationService = new ValidationService();
        $this->csvService = new CsvService();
        $this->databaseService = new DatabaseService($this->config);
    }

    /**
     * 問い合わせフォームのリクエスト処理
     * - 入力データの検証、保存、メール送信
     */
    public function handleRequest() {
        try {
            // 入力データの取得
            $data = json_decode(file_get_contents('php://input'), true);

            // CSRFトークン検証
            if (!$this->csrfService->validateCsrfToken($_SERVER['HTTP_X_CSRF_TOKEN'])) {
                $this->sendErrorResponse('CSRFトークンが無効です。');
            }

            // reCAPTCHA検証
            if (!$this->recaptchaService->validate($data['recaptcha_token'])) {
                $this->sendErrorResponse('reCAPTCHA検証に失敗しました。');
            }

            // 入力データの検証とサニタイズ
            $name = $this->validationService->sanitizeInput($data['name']);
            $email = $this->validationService->validateEmail($data['email']);
            $message = $this->validationService->sanitizeInput($data['message']);

            if (!$email) {
                $this->sendErrorResponse('メールアドレスが無効です。');
            }

            // ユーザー情報取得
            $ipAddress = $_SERVER['REMOTE_ADDR'] ?? '不明';
            $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '不明';

            // CSV保存
            $csvPath = __DIR__ . '/../storage/submissions.csv';
            $timestamp = date('Y-m-d H:i:s');
            $csvData = [$timestamp, $name, $email, $message, $ipAddress, $userAgent];
            if (!$this->csvService->saveToCsv($csvPath, $csvData)) {
                $this->sendErrorResponse('CSVファイルへの保存に失敗しました。');
            }

            // データベース保存
            $sql = "INSERT INTO submissions (name, email, message, ip_address, user_agent) VALUES (?, ?, ?, ?, ?)";
            $params = [$name, $email, $message, $ipAddress, $userAgent];
            if (!$this->databaseService->insert($sql, $params)) {
                $this->sendErrorResponse('データベース保存に失敗しました。');
            }

            // メール送信
            $adminMailSent = $this->emailService->sendAdminNotification($name, $email, $message);
            $userMailSent = $this->emailService->sendUserConfirmation($name, $email, $message);

            if ($adminMailSent && $userMailSent) {
                $this->sendSuccessResponse(['message' => 'お問い合わせを受け付けました。']);
            } else {
                $this->sendErrorResponse('メール送信に失敗しました。');
            }
        } catch (Exception $e) {
            $this->sendErrorResponse('サーバーエラーが発生しました: ' . $e->getMessage(), 500);
        }
    }
}

その他のコードも改めて全て公開したいと思います。

改修後の成果

  • コードの可読性と保守性が向上
  • 機能追加時の修正範囲が局所化
  • ユニットテストと結合テストの導入が容易に

まとめ

本記事では、MVCアーキテクチャへのリファクタリングの流れを紹介しました。このプロセスにより、保守性や拡張性が大幅に向上し、開発効率も高まりました。これからも継続的にコードの改善を図り、より良いシステム構築を目指します。

コメント

タイトルとURLをコピーしました