静的解析とアーキテクチャテストでLaravelの設計をCIで保証する品質戦略の話

はじめに

こんにちは!エキサイト株式会社の伊藤(梨)です。

ジョインして半年、駆け抜けてきました。 密度が濃すぎて倍はいる感覚です。

エキサイトホールディングス Advent Calendar 2025の17日目の記事です。

qiita.com

BB.excite事業部では2025年12月現在、新しい開発環境の新設・リビルドによる刷新を進めています。

先日、同じチームの岡崎さんが「リビルドを始めた話」という記事でアーキテクチャ設計について紹介してくれました。 モノレポ採用やリポジトリパターンのレイヤー構成など、設計思想がまとまっています。

本記事では、その設計をコードレベルで保証する仕組みについて紹介します。

設計は「決めて終わり」ではない

アーキテクチャを決めても、開発が進むにつれてこんなことが起きがちです

  • 「急いでたからControllerから直接Repository呼んじゃった」
  • 「このModelにちょっとだけService呼ぶロジック入れちゃおう」
  • 「レビューで見落として、気づいたら依存関係がぐちゃぐちゃ」

コードレビューだけでこれを防ぐのは限界があります。人間は見落とすし、忙しいときは妥協しがちです。

そこで静的解析とアーキテクチャテストで自動的にルールを守らせる仕組みを導入しました。 このアーキテクチャテストは、私と同じくLaravel愛が強い社内のエンジニアが提案してくれました。

ポイントは、壊れてから気づくんじゃなくて 壊そうとした瞬間に落ちる 状態にすることです。

導入したツール

ツール 役割
PHPStan + Larastan 静的解析(型チェック)
phpat アーキテクチャテスト
Laravel Pint コードスタイル統一
GitHub Actions CI/CDで自動チェック

PHPStan + Larastan Level 8 - 厳格な型チェック

PHPStanとLarastanのレベルは0〜9まであり、数字が大きいほど厳格になります。

今回はLevel 8を採用しました。

Level 8では以下のようなチェックが行われます。

  • 戻り値・引数の型の厳密なチェック
  • nullableな値の適切なハンドリング
  • 存在しないプロパティ・メソッドへのアクセス検出

Larastanを併用することで、Eloquentモデルの$user->nameファサードDB::table()など、PHPStanだけでは解析できないLaravel特有の書き方も正しく型解析できます。

phpatでアーキテクチャをテストする

ここが本記事のメインです。

phpatは、クラス間の依存関係をテストできるツールです。「このクラスはあのクラスに依存してはいけない」というルールを、テストコードとして書けます。

アーキテクチャの図

私たちのアーキテクチャは以下のような構成です。

Controller ─┐
            ├→ UseCase(任意)→ Service → Repository
Command ────┘                      ↓
                              Model/DTO

上位層は下位層に依存してOK、逆はNGという原則です。

パッケージ構造

package/
├── Repository/   # データアクセス層
├── Model/        # DTO・Enum
├── Service/      # ビジネスロジック
├── UseCase/      # ユースケース(任意)
└── Application/  # DB接続、外部クライアント

LayerDependencyTest - レイヤー間の依存関係

<?php

final class LayerDependencyTest
{
    /**
     * CommandとControllerはRepositoryに直接依存してはいけない
     */
    public function testNoDirectRepositoryAccess(): Rule
    {
        return PHPat::rule()
            ->classes(
                Selector::inNamespace('App\Console\Commands'),
                Selector::inNamespace('App\Http\Controllers')
            )
            ->shouldNotDependOn()
            ->classes(Selector::inNamespace('Package\Repository'))
            ->because('Controller/CommandはRepository直参照禁止。Service経由にする');
    }

    /**
     * ServiceはCommandやControllerに依存してはいけない
     */
    public function testServiceIndependence(): Rule
    {
        return PHPat::rule()
            ->classes(Selector::inNamespace('Package\Service'))
            ->shouldNotDependOn()
            ->classes(
                Selector::inNamespace('App\Console\Commands'),
                Selector::inNamespace('App\Http\Controllers')
            )
            ->because('Serviceは上位層(Controller/Command)に依存しない');
    }

    /**
     * RepositoryはServiceやUseCaseに依存してはいけない
     */
    public function testRepositoryIndependence(): Rule
    {
        return PHPat::rule()
            ->classes(Selector::inNamespace('Package\Repository'))
            ->shouldNotDependOn()
            ->classes(
                Selector::inNamespace('Package\Service'),
                Selector::inNamespace('Package\UseCase')
            )
            ->because('RepositoryはService/UseCaseに依存しない(責務を固定する)');
    }
}

これにより、誰かがControllerからRepositoryを直接呼ぼうとすると、CIで検出されてマージできません。

DomainIndependenceTest - ドメイン層の独立性

<?php

final class DomainIndependenceTest
{
    /**
     * ModelはLaravelフレームワークに依存してはいけない
     */
    public function testModelFrameworkIndependence(): Rule
    {
        return PHPat::rule()
            ->classes(Selector::inNamespace('Package\Model'))
            ->shouldNotDependOn()
            ->classes(Selector::inNamespace('Illuminate'))
            ->excluding(
                Selector::inNamespace('Illuminate\Support\Carbon')
            )
            ->because('Model(DTO/Enum)はフレームワーク非依存にして再利用性とテスト容易性を守る');
    }

    /**
     * Modelは上位層に依存してはいけない
     */
    public function testModelLayerIndependence(): Rule
    {
        return PHPat::rule()
            ->classes(Selector::inNamespace('Package\Model'))
            ->shouldNotDependOn()
            ->classes(
                Selector::inNamespace('Package\Service'),
                Selector::inNamespace('Package\Repository'),
                Selector::inNamespace('Package\UseCase')
            )
            ->because('Modelは純粋なデータ表現。上位層の都合に引っ張られない');
    }
}

Model(DTOEnum)はフレームワークに依存しない純粋なPHPクラスとして保つことで、テストが書きやすくなり、フレームワークのバージョンアップにも影響されにくく、ロジックも明確になります。

GitHub ActionsでCIに組み込む

GitHub Actionsを使って、以下のタイミングで自動チェックを行っています。

  • push時:静的解析 + コードスタイルチェック
  • PR時:上記 + PHPUnitテスト

違反があればCIが落ちるので、ルールを破ったコードはマージされません。

導入してよかったこと

  1. レビューの指摘が「好み」じゃなく「ルール」になる

    • 「この依存関係おかしくない?」が個人の意見ではなく、機械的な判定により指摘が不要になる
    • 機械がチェックしてくれるので、人間はビジネスロジックのレビューに集中できる
  2. 新メンバーでも安心

    • ルールを全部覚えなくても、違反したらCIが教えてくれる
    • 「新しいメンバーでも設計に素早く適応」を後押しできる
  3. 設計意図がコードに残る

    • なぜこのルールがあるのか、コメントで説明できる
    • テストコード自体がドキュメントになる

なぜここまでやるのか ─ 持続可能性への想い

正直に言うと、静的解析やアーキテクチャテストは「やらなくても動く」ものです。 導入には時間もかかるし、最初は面倒に感じることもあります。

でも、私はこう考えています。

サービスは続く。でも、人は永遠じゃない。

辞める人もいれば、定年を迎える人もいる。どんなに長く働いても、いつかは必ずいなくなります。 そのとき、技術や知識がその人の頭の中だけにあったら、一緒に消えてしまい保守不能になってしまいます。

少子化でエンジニアも減っていく今の時代は小さいチームで大きな仕事をこなす必要があります。 だからこそ、持続可能な開発を意識すべきだと思っています。

私はコードベースを「大きな木」のようにイメージしています。

地上に見える枝はコードと捉え、開発が進むほど伸びていきます。でも、見えないところで根っこが繋がっている。 設計思想も、コーディング規約やアーキテクチャテストも、すべてその根っこの一部です。

そして、その根っこは人と人も繋いでいます。 今いる人から次の人へと技術を継承していくための土台だと思います。

次の世代へ継承する

会社は利益を追求するもの。それはもちろんです。 でも、エンジニアの仕事はそれだけじゃないと思っています。

技術を継承して、繋がりを広げること

インターン生が記事で「『なぜ?』を問う思考の重要性」について書いてくれました。

メンターから「実装方針は真似しないでほしい」と言われた

これはレガシーコードを「こう書いてあるから」と深く考えずに真似しないでほしいという意味です。 同時に、「なぜ?」を自分で考えられるエンジニアになってほしいという願いでもあります。

ただ仕事をするだけでなく、経験を活かして成長してほしい。人生を輝かせてほしい。 そしていつか自分を超えていってほしい、と私は日々思って業務に向き合っています。 そのためにも、設計をドキュメントに書くだけでなく、コードで保証して、誰でも自然に良い設計に導かれる仕組みを作りたかったのです。

まとめ

PHPStanとLarastanで静的解析、phpatでアーキテクチャテストを行い設計意図をテストとして表現し、CIで自動的に保証できます。 「壊そうとした瞬間に落ちる」状態を作ることで、設計が守られ続けます。

アーキテクチャ設計と合わせて、チーム全体で品質を維持していく仕組みが整いつつあります。

これからリビルドを進める方、レガシーコードと戦っている方の参考になれば幸いです。

参考