PHPプロジェクトが成長するにつれて、Controllerに数百行のビジネスロジックが詰め込まれた「Fat Controller」や、ORMのクエリビルダとバリデーションとメール送信が混在する「Fat Model」に悩まされるケースは珍しくありません。レイヤードアーキテクチャは、この問題に対する設計上の解決策のひとつです。

レイヤードアーキテクチャ(Layered Architecture)とは、アプリケーションの責務を水平な層(Layer)へ分割する構造設計パターンです。Mark Richards著『Software Architecture Patterns』で定義された代表的な構成では、以下の4層に分かれます。

レイヤードアーキテクチャの4層構成と依存方向の図

MVCフレームワークで頻発する問題は「Model」の範囲が曖昧な点にあります。MVCの「M」にはDB操作もビジネスルールも外部連携も含まれうるため、責務の境界が開発者個人の判断に委ねられます。レイヤードアーキテクチャはこの「M」をApplication・Domain・Infrastructureの3層に分解し、責務境界を構造として定義する手法です。

PHP 8.x世代の型システムがレイヤード設計を支える

PHPは動的型付け言語ですが、interface・abstract class・型宣言を備えており、レイヤードアーキテクチャの核となる「依存性の逆転(Dependency Inversion)」を言語レベルで表現できます。さらにPHP 8.0以降の型システム強化は、Domain層の設計精度を大きく向上させました。

バージョン型に関する主な追加機能レイヤード設計への作用
8.0Union Types、Named Arguments、Constructor Promotion値オブジェクト定義の簡潔化
8.1Enum、readonly プロパティ、Intersection Typesドメインモデルの不変性を言語で表現
8.2readonly クラス、DNF Typesエンティティ全体を不変オブジェクト化
8.3型付きクラス定数、json_validate()定数の型安全性向上
8.4Property Hooks、Asymmetric Visibilitygetter/setter不要の不変設計、公開読み取り・非公開書き込みの分離

PHP 8.1で導入されたEnumとreadonlyプロパティは、Domain層の値オブジェクトやステータス管理に直接的な恩恵をもたらします。PHP 8.4のAsymmetric Visibility(非対称可視性)はpublic private(set)のように読み取りと書き込みで異なるアクセスレベルを設定でき、Domain Entityの不変性を保ちながら外部からの読み取りを許可する設計が自然に書けるようになりました。

// PHP 8.2: readonly クラスによる値オブジェクト
readonly class Money
{
    public function __construct(
        public int $amount,
        public Currency $currency,
    ) {
        if ($amount < 0) {
            throw new \InvalidArgumentException('金額は0以上にしてください');
        }
    }

    public function add(self $other): self
    {
        if (!$this->currency->equals($other->currency)) {
            throw new \DomainException('通貨が異なるため加算できません');
        }
        return new self($this->amount + $other->amount, $this->currency);
    }
}

PHP 7.x時代は、不変性をprivateプロパティとgetterの組み合わせで人為的に実現する必要がありましたが、PHP 8.2以降はreadonlyクラスだけで完結します。

PSR-4オートロードで層とディレクトリを一致させる

PHPのPSR-4オートロード規約は、名前空間とディレクトリ構造を1対1でマッピングする仕組みです。レイヤードアーキテクチャの各層をそのまま名前空間に反映できるため、コード上の依存方向がディレクトリ構造から視覚的に読み取れます。

{
    "autoload": {
        "psr-4": {
            "App\\Presentation\\": "src/Presentation/",
            "App\\Application\\": "src/Application/",
            "App\\Domain\\": "src/Domain/",
            "App\\Infrastructure\\": "src/Infrastructure/"
        }
    }
}

この名前空間=レイヤーの対応関係を前提にすると、後述するDeptracやPHPStanのカスタムルールで依存方向の違反を機械的に検出できます。

フレームワークごとの適合度 — ORM方式が分水嶺

PHPフレームワークとレイヤードアーキテクチャの相性を左右する最大の要因は、ORMの設計パターンです。ActiveRecordパターンを採用するフレームワークではDomain EntityとORMモデルの分離に工夫が必要になり、Data Mapperパターンを採用するフレームワークでは自然な分離が可能です。

Laravel(Taylor Otwell氏が開発)

Laravelは、Service Container(IoCコンテナ)によるDIが標準で備わっており、レイヤードアーキテクチャとの親和性が高いフレームワークです。

適合する要素:

  • ServiceProviderでインターフェースと実装クラスの紐付けが完結する
  • app/配下のディレクトリ構成を自由に再編成できる
  • Eloquent Modelの利用をInfrastructure層に閉じ込める設計が可能
// ServiceProviderでのインターフェースバインディング
use App\Domain\Repository\OrderRepositoryInterface;
use App\Infrastructure\Persistence\EloquentOrderRepository;

class RepositoryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            OrderRepositoryInterface::class,
            EloquentOrderRepository::class
        );
    }
}

考慮が必要な要素:

Eloquent ORMはActiveRecordパターンを採用しているため、Eloquent ModelにはDB操作のメソッドが組み込まれています。Domain EntityとEloquent Modelを同一クラスにすると、Domain層にインフラ層の知識が侵入します。Domain EntityはプレーンPHPクラスとして定義し、EloquentモデルはInfrastructure層のRepository実装内で利用する設計が推奨されます。

また、FacadeはグローバルなアクセスポイントとしてDIコンテナを暗黙的に呼び出すため、レイヤー間の依存が見えにくくなります。レイヤード構造を厳密に保つ場合は、コンストラクタインジェクションを優先する方が依存関係が明示的になります。

Symfony(Symfony SASが開発・維持)

SymfonyはPHPフレームワークの中でレイヤードアーキテクチャおよびDDD(Domain-Driven Design)との整合性が最も高い選択肢です。

適合する要素:

  • Doctrine ORM(Doctrine Projectが開発)はData Mapperパターンを採用しており、Domain EntityをPOPO(Plain Old PHP Object)として定義できる
  • EntityのORMマッピングをXMLやYAMLで外部定義でき、Domain EntityにORMのアトリビュートを一切書かずに済む
  • DI ContainerがYAML/PHP設定ファイルベースで厳密に管理でき、autowire機能も備える
  • Bundleシステムで境界づけられたコンテキスト(Bounded Context)を表現できる
// Doctrine ORM: Domain EntityはPOPO
namespace App\Domain\Entity;

class Order
{
    private int $id;
    private OrderStatus $status;

    public function __construct(OrderStatus $status = OrderStatus::Draft)
    {
        $this->status = $status;
    }

    public function confirm(): void
    {
        if ($this->status !== OrderStatus::Draft) {
            throw new \DomainException('下書き状態でなければ確定できません');
        }
        $this->status = OrderStatus::Confirmed;
    }
}

Doctrine ORMのData Mapperパターンは、Domain EntityがORMの存在を知らなくて済むため、Domain層の独立性を最も自然に保てます。

CakePHP(Cake Software Foundation, Inc.が管理)

CakePHPは「規約優先(Convention over Configuration)」を設計思想とするフレームワークです。ActiveRecordパターンを中核に据えており、レイヤードアーキテクチャの導入には構造上の工夫が必要です。

課題となる構造:

  • Table/Entityクラスがフレームワーク規約と密結合しており、Domain Entityとして直接流用するとインフラ層の知識がDomain層に漏れる
  • src/Model/Table/src/Controller/などの固定的なディレクトリ規約が、自由なレイヤー構成と衝突しやすい

実用的な対処法:

CakePHPの規約を完全に無視するのではなく、「規約に従う層」と「プレーンPHPで記述する層」を分離するアプローチが現実的です。CakePHPのTableクラスはInfrastructure層のRepository実装内でのみ利用し、それより上位の層には渡さない設計にすることで、レイヤードアーキテクチャの原則を維持できます。

適合度の比較

評価軸LaravelSymfonyCakePHP
ORMとDomain層の分離しやすさEloquent分離は設計次第Doctrine Data Mapperで自然分離ActiveRecord密結合で工夫必要
DIコンテナの設計自由度Service Containerで十分YAML/PHP設定で厳密管理可能標準では限定的
ディレクトリ構成の柔軟性自由度高い自由度高い規約ベースで制約あり
DDD/レイヤード設計の総合適合度高い非常に高い中程度

実プロダクト事例: Adobe CommerceのPresentation-Service-Domain-Persistence構成

Adobe(旧Magento Inc.)が開発するECプラットフォーム「Adobe Commerce(Magento)」は、PHPで構築された大規模プロダクトの中でレイヤードアーキテクチャを公式に採用している代表例です。Adobe Commerceの公式ドキュメントでは以下の4層が定義されています。

  1. Presentation Layer — Webページやwebapi-xmlベースのREST/SOAP APIのリクエスト処理
  2. Service Layer — サービスコントラクト(PHP interfaceの集合)として公開されるビジネスロジックの入口
  3. Domain Layer — ビジネスロジック本体(ヘルパー、モデル)
  4. Persistence Layer — リソースモデルによるデータベース操作

Service Layerをinterfaceの集合体として独立させている点が特徴的です。外部連携やAPIはService Layerのinterfaceを通じてのみビジネスロジックにアクセスでき、内部実装の変更がAPI互換性に影響しない構造を実現しています。この設計は、サードパーティモジュールとの互換性が重要なECプラットフォームならではの要件に応えたものです。

Deptrac・PHPStanでレイヤー間の依存違反をCIで自動検出する

レイヤードアーキテクチャの設計意図を長期間維持するには、コードレビューだけでなく自動検証の仕組みが有効です。海外のPHPコミュニティでは、ツールによる依存方向の強制がベストプラクティスとして定着しています。

Deptrac(Qossmic社が開発、現在はDeptracプロジェクトとして独立運営)

Deptracは、PHPコードベースのレイヤー間依存ルールを定義し、違反を検出する静的解析ツールです。

# deptrac.yaml
deptrac:
  paths:
    - ./src
  layers:
    - name: Presentation
      collectors:
        - type: directory
          value: src/Presentation/.*
    - name: Application
      collectors:
        - type: directory
          value: src/Application/.*
    - name: Domain
      collectors:
        - type: directory
          value: src/Domain/.*
    - name: Infrastructure
      collectors:
        - type: directory
          value: src/Infrastructure/.*
  ruleset:
    Presentation:
      - Application
    Application:
      - Domain
    Domain: []
    Infrastructure:
      - Domain

上記設定では、Domain層が他の層に依存することを禁止し、各層が許可された方向にのみ依存できるルールを定義しています。GitHub ActionsやGitLab CIにvendor/bin/deptrac analyseを組み込めば、プルリクエスト時に依存違反を自動検出できます。

PHPStan(Ondřej Mirtes氏が開発)のカスタムルール

PHPStanのカスタムルールを作成することでも、レイヤー間の依存方向を検証できます。たとえば「Domain名前空間のクラスがInfrastructure名前空間のクラスをuseしている場合にエラーを出す」といったルールを実装し、既存のPHPStan解析パイプラインに統合する方法です。

依存性逆転の実装 — RepositoryパターンとUseCase層

レイヤードアーキテクチャの核となる設計原則は「依存性の逆転」です。Domain層にインターフェースを定義し、Infrastructure層で具体実装を提供することで、ビジネスルールが技術的詳細に依存しない構造を作ります。

Domain層: インターフェース定義

namespace App\Domain\Repository;

use App\Domain\Entity\Order;
use App\Domain\ValueObject\OrderId;

interface OrderRepositoryInterface
{
    public function findById(OrderId $id): ?Order;
    public function save(Order $order): void;
}

Infrastructure層: Eloquentによる具体実装

namespace App\Infrastructure\Persistence;

use App\Domain\Entity\Order;
use App\Domain\Repository\OrderRepositoryInterface;
use App\Domain\ValueObject\OrderId;

class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function findById(OrderId $id): ?Order
    {
        $model = \App\Infrastructure\Eloquent\OrderModel::find($id->value());
        return $model ? $this->toEntity($model) : null;
    }

    public function save(Order $order): void
    {
        \App\Infrastructure\Eloquent\OrderModel::updateOrCreate(
            ['id' => $order->id()->value()],
            $order->toArray()
        );
    }

    private function toEntity(\App\Infrastructure\Eloquent\OrderModel $model): Order
    {
        return Order::reconstruct(
            id: new OrderId($model->id),
            customerId: $model->customer_id,
            status: \App\Domain\Entity\OrderStatus::from($model->status),
        );
    }
}

Application層: UseCase

namespace App\Application\UseCase;

use App\Domain\Repository\OrderRepositoryInterface;
use App\Domain\Entity\Order;

final class CreateOrderUseCase
{
    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository,
    ) {}

    public function execute(CreateOrderInput $input): OrderOutput
    {
        $order = Order::create(
            customerId: $input->customerId,
            totalPrice: new \App\Domain\ValueObject\Money(
                $input->totalAmount, $input->currency
            ),
        );
        $this->orderRepository->save($order);
        return OrderOutput::fromEntity($order);
    }
}

UseCase層はDomain層のオブジェクトだけに依存し、Eloquent ModelやHTTP Requestなどフレームワーク固有のクラスを参照しません。テスト時はRepositoryのモック実装を注入するだけで、DB接続なしにビジネスロジックを検証できます。

各層のテスト方針

レイヤードアーキテクチャの利点のひとつは、層ごとにテスト戦略を明確にできることです。

テスト種別テスト対象外部依存の扱い
DomainユニットテストEntity・ValueObject・DomainService外部依存なし。純粋なPHPコードのみ
ApplicationユニットテストUseCaseRepositoryはモック/スタブに差し替え
Infrastructure統合テストRepository実装・外部API連携テスト用DBまたはインメモリDB使用
PresentationE2E / HTTPテストController・ミドルウェアアプリケーション全体を起動

Domain層とApplication層はフレームワーク非依存のため、PHPUnit単体で高速に実行できます。Infrastructure層のテストのみDBやネットワーク接続が必要です。この分離により、CIパイプラインではDomain/Application層のテストを先に実行し、失敗時はInfrastructure層のテストをスキップする段階的な実行戦略も取れます。

プロジェクト特性に応じた導入判断

レイヤードアーキテクチャは万能ではありません。プロジェクトの複雑さに見合わない適用は、コード量の増大やチーム生産性の低下を招きます。

レイヤードアーキテクチャ導入判断フローチャート

効果が高いケース

  • ビジネスルールが複雑なアプリケーション(EC・保険・ワークフロー管理など)
  • 3年以上の長期運用でチームメンバーが入れ替わる可能性がある
  • Web UI・REST API・CLI・バッチなど複数の入出力チャネルが同一ビジネスロジックを共有する
  • テスタビリティの向上が開発効率に直結する規模のプロジェクト

MVCで十分なケース

  • CRUDが中心の管理画面(ビジネスルールがほぼない)
  • 3か月以内にリリースして完了するプロトタイプ
  • CMSやランディングページのように表示ロジックが大部分を占める
  • チーム全員がレイヤードアーキテクチャ未経験で、学習コストが開発に上乗せされる

海外の開発コミュニティでは「レイヤードアーキテクチャは過剰設計ではないか」という批判的な議論も活発です。Martin Fowler氏が提唱した「Anemic Domain Model」アンチパターンへの懸念とともに、「層を増やしただけでデータを受け渡すだけの素通り処理(Sinkhole Anti-pattern)が80%を超えるなら設計を見直すべき」という指摘は、導入判断の重要な基準になります。

MVCから段階的にレイヤードへ移行する

フルスペックのレイヤードアーキテクチャを一度に導入する必要はありません。以下の3ステップで段階的に移行できます。

ステップ1: ControllerからUseCase層を分離する

Controllerに書かれたビジネスロジックをUseCaseクラス(またはServiceクラス)に移動します。これだけでFat Controllerが解消され、ビジネスロジックのユニットテストが書けるようになります。

ステップ2: Eloquent直接呼び出しをRepository経由に変更する

UseCase層がEloquent Modelを直接呼び出している箇所にRepositoryインターフェースを導入し、依存性の逆転を適用します。テスト時のDB依存が排除されます。

ステップ3: Domain EntityとEloquent Modelを分離する

Domain Entityをフレームワーク非依存のプレーンPHPクラスとして定義し、EloquentモデルはInfrastructure層のRepository内でのみ使用する構造に切り替えます。

ステップ1だけでもFat Controllerの解消とテスタビリティの向上が得られます。ステップ3まで進めるかどうかは、ビジネスルールの複雑さとチームの習熟度に応じて判断してください。

導入時に陥りやすいアンチパターン

値オブジェクトのラッパー化

全てのプリミティブ値をValueObjectクラスで包んでしまい、ビジネス上の制約も振る舞いも持たない「ただのラッパー」が大量発生するケースです。ValueObjectは「金額は0以上」「メールアドレスの形式検証」のようにビジネス制約を内包する値にのみ適用し、制約のない単純な文字列や数値はプリミティブのまま扱うのが適切です。

Domain層へのPresentation関心の混入

JSON整形やHTMLフォーマットの処理をDomain EntityやValueObjectに実装してしまうパターンです。表示形式はPresentation層またはApplication層のResponder/Presenterの責務であり、Domain層が「どう見せるか」を知る必要はありません。

レイヤーの飛び越し呼び出し

ControllerからRepositoryを直接呼び出す、Domain EntityからInfrastructure層のクラスをnewするなど、層を跨いだ依存が発生するケースです。前述のDeptracやPHPStanカスタムルールをCIに組み込むことで、レビュー前に自動検出できます。

規模に見合わない過剰な層分割

小規模なCRUDアプリに4層全てを厳密に適用し、同じデータ構造を層ごとに変換するボイラープレートが爆発するケースです。PHP Conference 2018での発表でも「3か月で終わるプロジェクトにレイヤードアーキテクチャはオーバーキルだった」という失敗事例が共有されています。

モジュラーモノリスとの組み合わせ

近年の海外PHPコミュニティでは、レイヤードアーキテクチャを「モジュラーモノリス」と組み合わせるアプローチが注目されています。モジュラーモノリスとは、単一のデプロイユニット内で機能をモジュール単位に分離する設計です。

各モジュール内部にレイヤードアーキテクチャを適用し、モジュール間はService Layer(publicなinterface)を通じてのみ通信する構造です。マイクロサービスへの移行前段として、モノリスの内部構造を整理するために有効です。Symfony Bundleはこのモジュール境界と自然に対応します。

モジュラーモノリスとレイヤードアーキテクチャを組み合わせたディレクトリ構成

まとめ

PHPとレイヤードアーキテクチャの相性は、PHP 8.x系の型システム強化(readonly・Enum・Property Hooks・Asymmetric Visibility)によって年々向上しています。フレームワーク選択では、Doctrine ORMのData MapperパターンによりDomain層をPOPOのまま保てるSymfonyが最も自然に適合し、LaravelもService ContainerによるDIの充実度で十分に対応可能です。CakePHPはActiveRecordパターンとの兼ね合いで制約がありますが、規約に従う層とプレーンPHPの層を切り分ければ運用できます。

導入を検討する際に最も重要な判断基準は「プロジェクトのビジネスルールの複雑さ」です。CRUDが中心ならMVCで十分ですが、複雑なドメインロジックを長期間保守する必要があるなら、レイヤード構造による責務分離の効果は大きくなります。まずはControllerからUseCaseを分離するステップ1から始めて、必要に応じて段階的に層を追加するのが、PHPプロジェクトでの現実的な進め方です。

DeptracやPHPStanによる依存方向の自動検証をCIに組み込めば、設計意図がチームの成長やメンバー交代を超えて維持されます。レイヤードアーキテクチャは「導入するかしないか」の二択ではなく、プロジェクトの成熟度に合わせて段階的に取り入れていく設計戦略として捉えるのが適切です。