🏗️

PHP 5.2からLaravel 11への大規模リニューアル #2 アーキテクチャ設計と技術選定

に公開

はじめに

前回の記事では、20年以上稼働していたレガシーシステムの課題と、リニューアルプロジェクトの概要をご紹介しました。

※前回の記事リンク:https://zenn.dev/abg/articles/7d1004accd2e3b

今回は、新システムのアーキテクチャ設計と技術選定について、Claude Codeとの対話を通じてどのような判断基準で技術スタックを選び、どのような設計方針を採用したのかを詳しく解説します。

なぜLaravel 11を選んだのか

Claude Codeを活用した技術選定プロセス

技術選定にあたり、レガシーシステムの構造をClaude Codeに分析してもらい、最適なフレームワークの提案を受けました。以下の基準で評価を進めました:

  1. エコシステムの充実 - 必要な機能がパッケージで提供されている
  2. 長期サポート - 今後5年以上の運用に耐えうる
  3. 日本語情報の充実 - トラブルシューティングが容易
  4. ホスティング環境との相性 - さくらインターネットでの動作実績

候補となったフレームワーク

フレームワーク メリット デメリット 評価
Laravel 11 豊富な機能、活発なコミュニティ 学習コストがやや高い
Symfony 7 堅牢、エンタープライズ向け 設定が複雑
Slim 4 軽量、シンプル 機能が少ない
CodeIgniter 4 軽量、学習が容易 モダンな機能が少ない

Laravel採用の決定的な理由

Claude Codeはレガシーコードを分析した結果、Laravelの採用を強く推奨しました。特に重要だったのは、既存のホスティング環境をそのまま活用できることでした。

さくらインターネットでの継続運用

レガシーシステムは20年以上さくらインターネットのスタンダードプランで稼働していました。以下の理由から、同じ環境での移行を優先しました:

  • データ移行の簡易化 - 同一サーバー内でのデータベース移行により、ダウンタイムを最小化
  • ドメインの継続利用 - DNS変更やSSL証明書の再設定が不要
  • コスト最適化 - 新規サーバー契約や移行作業の費用を削減
  • 運用ノウハウの活用 - 20年間のサーバー運用経験を引き継げる

さくらインターネットのスタンダードプランでLaravelを動作させるための条件も満たしていました:

  • PHP 8.3対応 - 最新PHPバージョンの利用が可能
  • Composer利用可能 - SSH接続でComposerコマンドが実行可能
  • .htaccess対応 - Laravelのルーティングに必要な設定が可能
  • MySQL 8.0提供 - 最新のデータベース機能を活用可能

その他の理由:

// Laravelの強力なORM(Eloquent)の例
class Program extends Model
{
    // リレーションシップの定義が直感的
    public function schedules()
    {
        return $this->hasMany(ProgramSchedule::class);
    }
    
    // スコープによる再利用可能なクエリ
    public function scopeActive($query)
    {
        return $query->whereNull('end_date')
            ->orWhere('end_date', '>=', now());
    }
}

// 使用例
$activePrograms = Program::active()
    ->with('schedules')
    ->paginate(20);

特に以下の機能が決め手となりました。Claude Code自身もこれらの機能を熟知しており、効率的なコード生成が可能でした:

  1. Eloquent ORM - 直感的なデータベース操作
  2. マイグレーション - データベースのバージョン管理
  3. Artisanコマンド - カスタムコマンドの作成が容易(さくらインターネットのSSHで実行可能)
  4. Queue/Job - 非同期処理の実装が簡単
  5. 認証機能 - セキュアな認証システムがビルトイン
  6. 軽量な初期構成 - さくらインターネットのリソース制限内で快適に動作

レガシーシステムの構造分析

旧システムのフォルダ構成

新システムの設計前に、Claude Codeと共にレガシーシステムの構造を詳細に分析しました:

aniradi/ (ドキュメントルート)
├── _notes/              # Dreamweaverの設定ファイル
├── admin/               # 管理画面(独自実装)
│   ├── news_edit.php    # ニュース編集
│   ├── program_edit.php # 番組編集
│   └── user_manage.php  # ユーザー管理
├── include/             # 共通処理
│   ├── config.php       # DB接続情報
│   ├── functions.php    # グローバル関数の集合
│   └── db_connect.php   # mysql_connect()の処理
├── css/                 # スタイルシート
│   └── style.css        # 単一の巨大CSSファイル
├── images/              # 画像ファイル
├── program/             # 番組関連ページ
│   ├── index.php
│   ├── detail.php
│   └── schedule.php
├── news/                # ニュース関連ページ
├── guest/               # ゲスト情報ページ
└── index.php            # トップページ

フレームワークを導入せず、また当時にしてはちゃんと整理して実装理由はできていたとは思いますが、データベースへのアクセス方法とかはちょっとイマイチですね。
ここに、cgiで稼働していたチャットや掲示板(KENT-WEBのもの)、アクセス解析など他からのフリーソフトを活用していました。

レガシーコードの問題点

以下の構造的問題が明らかでした:

  1. ビジネスロジックとプレゼンテーションの混在

    // 典型的なレガシーコード(program/detail.php)
    <?php
    include "../include/config.php";
    $id = $_GET['id'];
    $sql = "SELECT * FROM program WHERE Program_ID = '$id'";
    $result = mysql_query($sql);
    ?>
    <html>
    <body>
    <?php while($row = mysql_fetch_array($result)) { ?>
        <h1><?php echo $row['Program_name']; ?></h1>
    <?php } ?>
    </body>
    </html>
    
  2. グローバル変数の多用

  3. ファイル間の依存関係が不明確

  4. テスト不可能な構造

データベース設計の刷新

正規化とパフォーマンスのバランス

レガシーシステムでは非正規化されたテーブルが多く存在していました:

-- 旧システムのテーブル(非正規化)
CREATE TABLE old_programs (
    id INT PRIMARY KEY,
    title VARCHAR(255),
    personality_names TEXT,  -- カンマ区切りで複数名を格納
    station_name VARCHAR(100),
    station_frequency VARCHAR(20),
    -- ...
);

これを正規化した設計に変更:

-- 新システムのテーブル(正規化)
-- 番組テーブル
CREATE TABLE programs (
    id BIGINT PRIMARY KEY,
    title VARCHAR(255),
    description TEXT,
    -- ...
);

-- パーソナリティテーブル
CREATE TABLE personalities (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    type ENUM('individual', 'unit', 'special'),
    -- ...
);

-- 中間テーブル
CREATE TABLE program_personalities (
    program_id BIGINT,
    personality_id BIGINT,
    role VARCHAR(50),
    started_at DATE,
    ended_at DATE,
    PRIMARY KEY (program_id, personality_id),
    FOREIGN KEY (program_id) REFERENCES programs(id),
    FOREIGN KEY (personality_id) REFERENCES personalities(id)
);

週単位時間システムの導入

深夜番組の扱いに苦労していた問題を、週単位時間システムで解決:

// 週単位時間の計算例
class WeeklyTimeHelper
{
    /**
     * 通常の曜日・時刻から週単位時間に変換
     * 月曜6:00を0として、日曜29:59まで(167:59:59)
     */
    public static function toWeeklyTime($dayOfWeek, $time)
    {
        $baseHour = ($dayOfWeek - 1) * 24;
        [$hour, $minute, $second] = explode(':', $time);
        
        // 深夜帯の処理(24:00以降)
        if ($hour >= 24) {
            $baseHour += 24;
            $hour -= 24;
        }
        
        $weeklyHour = $baseHour + $hour;
        return sprintf('%03d:%02d:%02d', $weeklyHour, $minute, $second);
    }
}

これを行うまで、曜日と開始時間、開始分と放送時間の長さをデータベースでそれぞれのカラムで管理していました。
番組表を作成するのに、このデータ構造のほうが作業しやすかったためです。
今回のリューアルで最後まで悩んだ設計が、時間に関してのものでした。編集のしやすさとデータの扱いやすさ、これを天秤にかけながら、さらに深夜という要件をクリアさせる必要がありました。
おかげで、過去のスケジュールデータは全部破棄することになりました。

インデックス設計

パフォーマンスを考慮した複合インデックスの設計:

// マイグレーションファイル
Schema::create('program_schedules', function (Blueprint $table) {
    $table->id();
    $table->foreignId('program_id')->constrained();
    $table->foreignId('station_id')->constrained();
    $table->string('weekly_start_time', 10);
    $table->string('weekly_end_time', 10);
    
    // 複合インデックス
    $table->index(['station_id', 'weekly_start_time']);
    $table->index(['weekly_start_time', 'weekly_end_time']);
    
    // ユニーク制約
    $table->unique([
        'program_id', 
        'station_id', 
        'weekly_start_time',
        'frequency_pattern',
        'format'
    ], 'prog_sched_variation_unique');
});

認証・認可システムの設計

4段階の権限レベル

ユーザーの役割を明確に分類し、シンプルかつ柔軟な4段階の権限システムを設計しました:

// app/Enums/UserRole.php
enum UserRole: string
{
    case GENERAL = 'general';      // 一般ユーザー
    case VERIFIED = 'verified';    // メール認証済み
    case EDITOR = 'editor';        // 協力者(編集者)
    case ADMIN = 'admin';          // 管理者
    
    public function canEditPrograms(): bool
    {
        return in_array($this, [self::EDITOR, self::ADMIN]);
    }
    
    public function canDeleteContent(): bool
    {
        return $this === self::ADMIN;
    }
}

ポリシークラスによる認可

// app/Policies/GuestPolicy.php
class GuestPolicy
{
    public function create(User $user): bool
    {
        // ログインユーザーなら誰でも投稿可能
        return true;
    }
    
    public function update(User $user, Guest $guest): bool
    {
        // 編集者以上、または自分の投稿
        return $user->isEditor() || $user->id === $guest->user_id;
    }
    
    public function delete(User $user, Guest $guest): bool
    {
        // 管理者のみ
        return $user->isAdmin();
    }
}

これまでは、管理者とユーザーをゾーン別で10段階に分類していました(実際の運用は管理者とユーザーの2種類でした)。
ユーザーロールとして、未登録ユーザー、登録ユーザー、編集者、管理者の4段階で判断し、一般的な機能は未登録ユーザーでも利用できるようにしてあります。
データの編集などを行う箇所は、管理者だけで行うのは大変だったので、登録ユーザーや編集者といった権限でゾーニングしてあります。

API設計とフロントエンド連携

RESTful API設計

// routes/api.php
Route::prefix('v1')->group(function () {
    // 番組検索API
    Route::get('/programs/search', [ProgramApiController::class, 'search']);
    
    // ゲスト情報API(認証必須)
    Route::middleware('auth:sanctum')->group(function () {
        Route::apiResource('guests', GuestApiController::class);
        Route::post('/guests/{guest}/verify', [GuestApiController::class, 'verify']);
    });
});

レスポンス形式の統一

// app/Http/Resources/ProgramResource.php
class ProgramResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'personalities' => PersonalityResource::collection($this->personalities),
            'schedules' => ScheduleResource::collection($this->schedules),
            'links' => [
                'self' => route('api.programs.show', $this->id),
                'website' => $this->website_url,
            ],
            'meta' => [
                'is_active' => $this->is_active,
                'created_at' => $this->created_at->toIso8601String(),
            ],
        ];
    }
}

外部サービス連携

X (Twitter) API v2連携

// app/Services/TwitterApiService.php
class TwitterApiService
{
    private string $bearerToken;
    
    public function searchTweets(string $query, ?string $sinceId = null): array
    {
        $response = Http::withToken($this->bearerToken)
            ->get('https://api.twitter.com/2/tweets/search/recent', [
                'query' => $query,
                'max_results' => 10,
                'tweet.fields' => 'created_at,author_id,entities',
                'since_id' => $sinceId,
            ]);
            
        // レート制限の管理
        $this->logApiUsage($response->headers());
        
        return $response->json();
    }
}

X連携は正直、費用が高くて作ったものの稼働させていません。読み込みAPIだけでもレート制限解除してほしいですね。

OpenAI API連携(ゲスト情報解析)

// app/Services/AIAnalysisService.php
class AIAnalysisService
{
    public function analyzeGuestInfo(string $text): array
    {
        $response = OpenAI::chat()->create([
            'model' => 'gpt-4-turbo-preview',
            'messages' => [
                ['role' => 'system', 'content' => $this->getSystemPrompt()],
                ['role' => 'user', 'content' => $text],
            ],
            'temperature' => 0.3,
            'response_format' => ['type' => 'json_object'],
        ]);
        
        return json_decode($response->choices[0]->message->content, true);
    }
}

キャッシュ戦略

多層キャッシュアーキテクチャ

// config/cache.php
'stores' => [
    // セッション用(Redis)
    'redis' => [
        'driver' => 'redis',
        'connection' => 'cache',
    ],
    
    // 静的コンテンツ用(ファイル)
    'file' => [
        'driver' => 'file',
        'path' => storage_path('framework/cache/data'),
    ],
];

キャッシュの実装例

class ProgramService
{
    public function getActivePrograms()
    {
        return Cache::remember('active_programs', 3600, function () {
            return Program::active()
                ->with(['personalities', 'schedules'])
                ->get();
        });
    }
    
    public function clearProgramCache()
    {
        Cache::forget('active_programs');
        Cache::tags(['programs'])->flush();
    }
}

セキュリティ対策

CSRFトークン

{{-- resources/views/forms/guest.blade.php --}}
<form method="POST" action="{{ route('guests.store') }}">
    @csrf
    {{-- フォームフィールド --}}
</form>

XSS対策

{{-- 自動エスケープ --}}
<p>{{ $guest->description }}</p>

{{-- HTMLを許可する場合 --}}
<div>{!! $purified_content !!}</div>

SQLインジェクション対策

// Eloquentによる自動的なプリペアドステートメント
$programs = Program::where('title', 'LIKE', "%{$search}%")->get();

// 生SQLが必要な場合
$results = DB::select('SELECT * FROM programs WHERE id = ?', [$id]);

まとめ

今回は、Aniradi Networkの新システムにおけるアーキテクチャ設計と技術選定について解説しました。

主なポイント:

  • Laravel 11の採用により、開発効率と保守性が大幅に向上
  • データベース正規化と適切なインデックス設計でパフォーマンス改善
  • 4段階の権限システムで柔軟な認可を実現
  • 外部API連携により、機能を大幅に拡張
  • 多層的なセキュリティ対策で安全性を確保

次回は、最も困難だったデータ移行について、どのように複雑な文字コード問題を解決し、135,000件のデータを安全に移行したのか、その具体的な手法を詳しく解説します。

次回予告

次回の記事では、以下の内容をお届けします:

  • EUC-JP → UTF-8mb4変換の落とし穴
  • 135,000レコードの段階的移行戦略
  • Artisanコマンドによる移行自動化
  • データ整合性のチェック手法

お楽しみに!

Discussion