レイヤードアーキテクチャとは — MVCとどう違うのか

レイヤードアーキテクチャは、アプリケーションを責務ごとに水平な層(Layer)へ分割する設計手法です。一般的には次の4層で構成されます。

責務依存方向
PresentationHTTPリクエスト・レスポンスの変換、入力バリデーション下の層のみ参照
Application (UseCase)ユースケース単位のオーケストレーションDomain 層を利用
Domainビジネスルール、エンティティ、値オブジェクト外部依存なし
InfrastructureDB接続、外部API通信、メール送信Domain 層のインターフェースを実装

MVCは「リクエスト → Controller → Model → View」というデータフローを定義しますが、「Model」が担う範囲が曖昧です。ORMのクエリビルダ、ビジネスルール、外部API呼び出しが同じModel内に混在し、数千行のFat Modelが生まれる原因になります。

レイヤードアーキテクチャはModelの内部をApplication・Domain・Infrastructureの3層に分解し、それぞれの責務境界を明確にします。MVCを否定するのではなく、MVCの「M」を精緻化する位置づけです。

PHPの言語特性がレイヤード構造に与える影響

PHPでレイヤードアーキテクチャを採用する場合、言語そのものの特性が設計にどう作用するかを把握しておく必要があります。

動的型付けとインターフェース設計

PHPは動的型付け言語ですが、interface・abstract class・type hintを備えています。レイヤードアーキテクチャではDomain層にインターフェース(Repository契約など)を定義し、Infrastructure層で実装する「依存性の逆転」が核になります。

// Domain層: app/Domain/Repository/UserRepositoryInterface.php
namespace App\Domain\Repository;

use App\Domain\Entity\User;

interface UserRepositoryInterface
{
    public function findById(int $id): ?User;
    public function save(User $user): void;
}
// Infrastructure層: app/Infrastructure/Persistence/EloquentUserRepository.php
namespace App\Infrastructure\Persistence;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Infrastructure\Persistence\Eloquent\UserModel;

class EloquentUserRepository implements UserRepositoryInterface
{
    public function findById(int $id): ?User
    {
        $record = UserModel::find($id);
        if ($record === null) {
            return null;
        }
        return new User($record->id, $record->name, $record->email);
    }

    public function save(User $user): void
    {
        UserModel::updateOrCreate(
            ['id' => $user->getId()],
            ['name' => $user->getName(), 'email' => $user->getEmail()]
        );
    }
}

PHPのインターフェースはJavaやC#と同等の抽象化機構を提供するため、「依存性の逆転」を言語レベルで実現できます。ただし、型の強制力はコンパイル言語より弱いため、静的解析ツール(PHPStan・Psalm)を併用してレイヤー間の依存違反を検出する運用が有効です。

PHP 8.x の型システム強化による恩恵

PHP 8.0以降の型システム強化は、レイヤードアーキテクチャとの相性を大幅に高めました。

PHP バージョン追加された型機能レイヤード設計への効果
8.0Union Types、Named Arguments、Constructor Promotion値オブジェクトの定義が簡潔に
8.1Enum、readonly プロパティ、Fibers、交差型ドメインモデルの不変性を言語で保証
8.2readonly クラス、DNF Typesエンティティ全体を不変にでき、層境界が明確化
8.3型付きクラス定数、json_validate()定数レベルの型安全性向上
8.4プロパティフック、非対称可視性getter/setterの自動化、不変オブジェクト設計がさらに容易に

特にPHP 8.1のreadonlyプロパティとEnumは、Domain層の値オブジェクトやエンティティを不変に保つうえで大きな武器です。

// 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(Money $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クラスだけで完結するため、Domain層のコードが大幅に簡潔になります。

名前空間とオートロードの仕組み(PSR-4)

PHPの名前空間はディレクトリ構造と1対1でマッピングできます。PSR-4オートロード規約によって、レイヤード構造をそのままディレクトリに反映させる設計が自然に実現します。

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

名前空間=レイヤーとなるため、IDE上で依存方向の逸脱を視覚的に把握でき、PHPStanのカスタムルールやDeptracのような依存分析ツールで自動検出もできます。

フレームワーク別の適合度比較

PHPの主要3フレームワークについて、レイヤードアーキテクチャとの相性を整理します。

Laravel — Service ContainerとDIの充実

Laravelはレイヤードアーキテクチャとの親和性が高いフレームワークです。

相性が良い理由:

  • Service Container(IoC Container) がDI(依存性注入)を標準サポートしており、インターフェースと実装の紐付けがServiceProviderで完結します
  • ディレクトリ構成の自由度 が高く、app/配下を自由に再構成できます。デフォルトのMVC構成に縛られません
  • Eloquent ORMの分離 が明示的に可能です。Domain層にはプレーンPHPクラスを置き、Eloquentモデルは Infrastructure層に閉じ込める設計ができます
// app/Providers/RepositoryServiceProvider.php
namespace App\Providers;

use App\Domain\Repository\UserRepositoryInterface;
use App\Infrastructure\Persistence\EloquentUserRepository;
use Illuminate\Support\ServiceProvider;

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

注意すべき点:

  • Eloquent ModelのActiveRecordパターンとDomain Entityの役割が重複しやすく、「どこまでEloquentに頼るか」の線引きが必要です
  • Facadeの多用はレイヤー間の依存を暗黙的にするため、レイヤードアーキテクチャを厳密に適用する場合はコンストラクタインジェクションを優先する方が安全です

Symfony — DDD指向との高い整合性

SymfonyはPHPフレームワークの中でレイヤードアーキテクチャとの相性が最も良いと言えます。

相性が良い理由:

  • DI Container が設定ファイル(YAML/PHP)ベースで厳密に管理でき、autowire機能でインターフェースの自動解決も可能です
  • Bundleシステム によって境界づけられたコンテキスト(Bounded Context)をBundle単位で表現できます
  • Doctrine ORM はData Mapperパターンを採用しており、Domain EntityをPOPO(Plain Old PHP Object)として定義できます。ActiveRecordパターンと異なり、ORMの知識がDomain層に漏れ出しません
// Doctrine: Domain EntityはPOPO(ORMアノテーション不要、XML/YAMLマッピング可)
namespace App\Domain\Entity;

class Order
{
    private int $id;
    private array $items = [];
    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では、EntityのマッピングをXMLやYAMLで外部定義できるため、Domain EntityにORMの属性(アノテーションやアトリビュート)を一切書かずに済みます。これはDomain層の独立性を保つうえで大きな利点です。

CakePHP — ActiveRecordパターンとの折り合い

CakePHPはActiveRecordパターンを中核に据えたフレームワークであるため、レイヤードアーキテクチャとの相性には一定の制約があります。

課題となる点:

  • Table/Entityの二重構造: CakePHPのEntityはORMのActiveRecordと密結合しており、Domain Entityとして流用するとInfrastructure層の知識がDomain層に侵入します
  • 規約ベースの構成: src/Model/Table/src/Controller/ などフレームワークが期待するディレクトリ配置が固定的で、自由なレイヤー構成が取りにくい面があります

現実的な対処法:

CakePHPの規約を完全に無視するのではなく、「フレームワークの規約に従う層」と「プレーンPHPで書く層」を明確に分けるアプローチが有効です。

src/
├── Controller/        # CakePHP規約に従う(Presentation層)
├── Model/
│   └── Table/         # CakePHP規約に従う(Infrastructure層)
├── Domain/            # プレーンPHP(Domain層)
│   ├── Entity/
│   ├── ValueObject/
│   └── Repository/    # インターフェース定義のみ
├── Application/       # UseCase(Application層)
│   └── UseCase/
└── Infrastructure/    # Repository実装(CakePHP Table を利用)
    └── Persistence/

CakePHPのTable クラスは Infrastructure層のRepository実装内でのみ利用し、それより上位の層には渡さない設計にすることで、レイヤードアーキテクチャの原則をある程度守れます。

フレームワーク適合度の比較表

評価項目LaravelSymfonyCakePHP
DIコンテナの柔軟性Service Container で十分設定ベースで厳密管理可標準では限定的
ORMとDomain層の分離Eloquent分離は可能だが工夫が必要Doctrine Data Mapperで自然に分離ActiveRecordと密結合
ディレクトリ構成の自由度高い高い規約ベースでやや制約あり
静的解析ツールとの連携PHPStan/Larastanで対応PHPStanが標準的PHPStan対応あり
レイヤードアーキテクチャ総合適合度高い非常に高い中程度(妥協点の設計が必要)

PHPプロジェクトでの実装例 — ディレクトリ構成とコード

4層構成のディレクトリ設計

Laravelをベースとした実用的なディレクトリ構成例です。

app/
├── Presentation/
│   ├── Http/
│   │   ├── Controllers/
│   │   │   └── OrderController.php
│   │   ├── Requests/
│   │   │   └── CreateOrderRequest.php
│   │   └── Resources/
│   │       └── OrderResource.php
│   └── Console/
│       └── Commands/
├── Application/
│   ├── UseCase/
│   │   ├── CreateOrderUseCase.php
│   │   └── GetOrderUseCase.php
│   └── DTO/
│       ├── CreateOrderInput.php
│       └── OrderOutput.php
├── Domain/
│   ├── Entity/
│   │   └── Order.php
│   ├── ValueObject/
│   │   ├── OrderId.php
│   │   └── Money.php
│   ├── Repository/
│   │   └── OrderRepositoryInterface.php
│   └── Service/
│       └── OrderPricingService.php
└── Infrastructure/
    ├── Persistence/
    │   ├── Eloquent/
    │   │   └── OrderModel.php
    │   └── EloquentOrderRepository.php
    └── External/
        └── PaymentGatewayClient.php

ポイントは、Domain/ 配下のクラスがLaravelへの依存を一切持たないことです。フレームワークを将来変更する場合でも、ビジネスルールの移植コストが最小限に抑えられます。

UseCase層の実装コード

namespace App\Application\UseCase;

use App\Application\DTO\CreateOrderInput;
use App\Application\DTO\OrderOutput;
use App\Domain\Entity\Order;
use App\Domain\Repository\OrderRepositoryInterface;
use App\Domain\ValueObject\Money;

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

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

        return OrderOutput::fromEntity($order);
    }
}

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

Repositoryインターフェースと実装の分離

依存性の逆転を実現する鍵はRepository パターンです。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;
    public function delete(OrderId $id): void;
}
// Infrastructure層: Eloquent実装
namespace App\Infrastructure\Persistence;

use App\Domain\Entity\Order;
use App\Domain\Repository\OrderRepositoryInterface;
use App\Domain\ValueObject\OrderId;
use App\Infrastructure\Persistence\Eloquent\OrderModel;

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

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

    public function delete(OrderId $id): void
    {
        OrderModel::destroy($id->value());
    }

    private function toEntity(OrderModel $model): Order
    {
        // Eloquent Model → Domain Entity への変換
        return Order::reconstruct(
            id: new OrderId($model->id),
            customerId: $model->customer_id,
            totalPrice: new \App\Domain\ValueObject\Money(
                $model->total_amount,
                $model->currency,
            ),
            status: \App\Domain\Entity\OrderStatus::from($model->status),
        );
    }
}

この分離によって、将来ORMをDoctrineに変更したり、データストアをRDBMSからNoSQLに切り替えたりする場合も、Infrastructure層の実装を差し替えるだけでDomain層やApplication層は一切変更せずに済みます。

導入すべきケースと見送るべきケースの判断基準

レイヤードアーキテクチャは万能ではありません。プロジェクトの特性に応じて導入判断が変わります。

導入が効果的なケース

  • ビジネスルールが複雑なアプリケーション: ECサイトの在庫管理、保険料の計算、ワークフロー管理など、ドメイン固有の判定ロジックが多い場合
  • 長期運用が前提のシステム: 3年以上の運用でチームメンバーが入れ替わる可能性がある場合、責務が明確な構造がコード理解を助けます
  • テスタビリティを重視する場合: UseCase層・Domain層は外部依存がないため、ユニットテストが書きやすくなります
  • 複数の入出力チャネルを持つ場合: Web UI・API・CLI・バッチ処理など複数のPresentation層が同じビジネスロジックを共有するケース

見送るべき(あるいは簡略化すべき)ケース

  • CRUDが中心の管理画面: ビジネスルールがほぼなく、データの登録・表示・更新・削除だけの場合、レイヤー分割がコード量の増大に見合いません
  • プロトタイプや短期プロジェクト: 3か月以内にリリースして終了するようなプロジェクトでは、MVCで十分です
  • チームの経験値が不足している場合: レイヤードアーキテクチャの概念理解が浅いチームに一度に導入すると、「層を増やしただけの手続き的コード」になるリスクがあります。段階的に取り入れるのが現実的です
  • 表示ロジックが大部分を占めるアプリケーション: CMSやランディングページのように、プレゼンテーション処理の比重が大きく、ドメインロジックが薄い場合

段階的導入という選択肢

フルスペックのレイヤードアーキテクチャをいきなり導入する必要はありません。以下のように段階的に取り入れる方法もあります。

  1. ステップ1: Controllerからビジネスロジックを分離し、Service(UseCase)クラスに移動する
  2. ステップ2: 直接的なEloquent呼び出しをRepository経由に変更する
  3. ステップ3: Domain EntityとEloquent Modelを分離する

ステップ1だけでも「Fat Controller」を解消でき、テスタビリティが向上します。

よくある失敗パターンと回避策

PHPプロジェクトでレイヤードアーキテクチャを導入する際に陥りやすいパターンを整理します。

失敗1: 値オブジェクトの形骸化

すべてのプリミティブ値を値オブジェクトで包むと、ビジネス上の制約や振る舞いを持たない「ただのラッパー」が大量発生します。

回避策: 値オブジェクトは「ビジネス上の制約(例: 金額は0以上)」や「振る舞い(例: 消費税込み金額の算出)」を持つ値にのみ適用し、制約のない単純な文字列や数値はプリミティブのまま扱います。

失敗2: Domain層にPresentation関心事が混入する

JSON整形やHTMLフォーマットの処理をDomain EntityやValue Objectに書いてしまうケースです。表示形式はPresentation層やApplication層の責務であり、Domain層が「どう見せるか」を知る必要はありません。

回避策: Domain EntityにはtoArray()やtoJson()のような整形メソッドを持たせず、Presentation層のResource/Transformer/Presenterクラスに変換ロジックを配置します。

失敗3: レイヤーの飛び越し

ControllerからRepositoryを直接呼び出したり、Domain EntityからInfrastructure層のクラスをnewしたりするケースです。

回避策: PHPStanのカスタムルールやDeptracを導入し、CIパイプラインで依存方向の違反を自動検出します。

# 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

失敗4: プロジェクト規模に見合わない過剰設計

小規模なCRUDアプリケーションに4層すべてを厳密に適用すると、同じデータを層ごとに変換するボイラープレートコードが爆発します。

回避策: 前述の「段階的導入」を採用し、ビジネスルールが増えてきた時点で層を追加します。最初からすべてを分離するのではなく、必要性を感じた箇所から段階的にリファクタリングする方が健全です。

まとめ

PHPとレイヤードアーキテクチャの相性は、PHP 8.x系の型システム強化によって年々向上しています。readonlyプロパティ・Enum・Union Typesといった機能が、Domain層の不変オブジェクト設計を言語レベルで支えるようになりました。

フレームワーク選択の面では、Doctrine ORMのData MapperパターンによってDomain層をPOPOのまま保てるSymfonyが最も整合性が高く、LaravelもService ContainerとDIの充実度によって十分に対応できます。CakePHPはActiveRecordパターンとの兼ね合いで制約がありますが、「フレームワーク規約に従う層」と「プレーンPHPの層」を切り分ければ現実的な運用は可能です。

導入判断で最も重要なのは「プロジェクトの複雑さに見合っているか」です。ビジネスルールが複雑で長期運用が見込まれるなら効果が大きく、CRUDが中心の短期プロジェクトならMVCで十分です。まずはControllerからのビジネスロジック分離(UseCase/Service層の導入)から始めて、必要に応じて段階的に層を増やしていくのが、PHPプロジェクトでの現実的な進め方です。