Laravelは標準でMVCパターンを採用しており、小〜中規模のアプリケーションでは十分に機能します。しかしプロジェクトが成長するにつれ、Controllerにビジネスロジックが集中する「Fat Controller」問題や、Modelが肥大化する「Fat Model」問題に直面するケースは少なくありません。

レイヤードアーキテクチャは、アプリケーションを責務ごとの層に分割し、各層の依存方向を一方向に制限する設計手法です。LaravelのサービスコンテナやDI(依存性注入)の仕組みと相性がよく、既存プロジェクトへ段階的に導入できる点が大きな強みとなっています。

レイヤードアーキテクチャの基本構造

レイヤードアーキテクチャでは、アプリケーションを以下の4つの層に分割します。

主な責務Laravelでの対応要素
UI(Presentation)層HTTPリクエストの受付・レスポンスの返却Controller, FormRequest, Resource
Application層ユースケースの実行・トランザクション管理UseCase, DTO
Domain層ビジネスルール・エンティティ・値オブジェクトEntity, ValueObject, DomainService
Infrastructure層データベース操作・外部API連携Repository実装, Eloquent Model, Mail

依存の方向は「UI → Application → Domain ← Infrastructure」です。Domain層はどの層にも依存せず、Infrastructure層はDomain層で定義されたインターフェースを実装する形をとります。この「依存性逆転の原則(DIP)」により、ビジネスロジックがフレームワークから独立した状態を保てます。

MVCとの根本的な違い

LaravelのMVCでは、Model(Eloquent)がデータアクセスとビジネスロジックの両方を担いがちです。レイヤードアーキテクチャでは、この2つの責務を明確に切り離します。

// MVCパターンでありがちな構造
Controller → Model(ビジネスロジック + DB操作が混在)→ View

// レイヤードアーキテクチャ
Controller → UseCase → Entity/DomainService → Repository(Interface)
                                          EloquentRepository(実装)

Eloquent ModelはInfrastructure層に閉じ込め、Domain層のEntityとは別のクラスとして扱います。Domain層のEntityはフレームワークに依存しないプレーンなPHPクラスとなるため、単体テストが容易になります。

Laravelプロジェクトでのディレクトリ構成

レイヤードアーキテクチャを採用したときの推奨ディレクトリ構成を示します。Laravelのapp/ディレクトリ以下を整理する形です。

app/
├── UI/
│   ├── Http/
│   │   ├── Controllers/
│   │   │   └── Order/
│   │   │       ├── CreateOrderController.php
│   │   │       └── ListOrderController.php
│   │   ├── Requests/
│   │   │   └── Order/
│   │   │       └── CreateOrderRequest.php
│   │   └── Resources/
│   │       └── Order/
│   │           └── OrderResource.php
│   └── Console/
│       └── Commands/
├── Application/
│   ├── UseCases/
│   │   └── Order/
│   │       ├── CreateOrderUseCase.php
│   │       └── ListOrderUseCase.php
│   └── DTOs/
│       └── Order/
│           ├── CreateOrderInput.php
│           └── OrderOutput.php
├── Domain/
│   ├── Entities/
│   │   └── Order.php
│   ├── ValueObjects/
│   │   ├── OrderId.php
│   │   ├── OrderStatus.php
│   │   └── Money.php
│   ├── Repositories/
│   │   └── OrderRepositoryInterface.php
│   └── Services/
│       └── OrderPricingService.php
└── Infrastructure/
    ├── Repositories/
    │   └── EloquentOrderRepository.php
    ├── Eloquent/
    │   └── OrderModel.php
    └── QueryServices/
        └── OrderQueryService.php

ポイントは3つです。

  1. Controllerを機能単位で1クラス1アクションにする(Single Action Controller)。__invoke()メソッドを使うと、ルーティングが簡潔になります
  2. Eloquent ModelをInfrastructure層に配置する。Domain層のEntityと混同しないよう、OrderModelのように接尾辞で区別します
  3. Domain層にRepositoryのインターフェースを定義する。実装はInfrastructure層に置き、LaravelのServiceProviderでバインドします

名前空間の設定

composer.jsonのautoload設定を確認し、各層の名前空間が正しくマッピングされていることを確かめます。LaravelのデフォルトではApp\app/に対応しているため、追加設定なしで上記構成を利用できます。

{
    "autoload": {
        "psr-4": {
            "App\\": "app/"
        }
    }
}

各層の実装パターン

UI層:Controllerの実装

ControllerはHTTPリクエストの受け取りとレスポンスの返却だけを担当します。ビジネスロジックはUseCaseに委譲します。

<?php

namespace App\UI\Http\Controllers\Order;

use App\Application\UseCases\Order\CreateOrderUseCase;
use App\Application\DTOs\Order\CreateOrderInput;
use App\UI\Http\Requests\Order\CreateOrderRequest;
use App\UI\Http\Resources\Order\OrderResource;
use Illuminate\Http\JsonResponse;

class CreateOrderController
{
    public function __construct(
        private readonly CreateOrderUseCase $useCase,
    ) {}

    public function __invoke(CreateOrderRequest $request): JsonResponse
    {
        $input = new CreateOrderInput(
            customerId: $request->validated('customer_id'),
            items: $request->validated('items'),
        );

        $output = $this->useCase->execute($input);

        return new JsonResponse(
            new OrderResource($output),
            201,
        );
    }
}

FormRequestでバリデーションを実行し、Controllerではバリデーション済みの値だけを扱います。この分離によって、Controller自体のテストが不要になり、FormRequestとUseCaseをそれぞれ独立してテストできます。

Application層:UseCaseの実装

UseCaseはアプリケーション固有の処理フローを記述する場所です。トランザクション管理もこの層で行います。

<?php

namespace App\Application\UseCases\Order;

use App\Application\DTOs\Order\CreateOrderInput;
use App\Application\DTOs\Order\OrderOutput;
use App\Domain\Entities\Order;
use App\Domain\Repositories\OrderRepositoryInterface;
use App\Domain\Services\OrderPricingService;
use Illuminate\Support\Facades\DB;

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

    public function execute(CreateOrderInput $input): OrderOutput
    {
        return DB::transaction(function () use ($input) {
            $order = Order::create(
                customerId: $input->customerId,
                items: $input->items,
            );

            $order = $this->pricingService->calculate($order);

            $this->orderRepository->save($order);

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

UseCaseクラスの設計で守るべきルールは以下のとおりです。

  • 1クラスにつき1つのpublicメソッド(executeまたは__invoke
  • 引数・戻り値にはDTOを使い、Domain層のEntityを直接外部に公開しない
  • 複数のRepositoryやDomainServiceを組み合わせてユースケースを実現する

DTOの設計

DTO(Data Transfer Object)は層間のデータ受け渡しに使うイミュータブルなオブジェクトです。PHP 8.1以降のreadonly propertyを活用すると、簡潔に記述できます。

<?php

namespace App\Application\DTOs\Order;

class CreateOrderInput
{
    public function __construct(
        public readonly string $customerId,
        public readonly array $items,
    ) {}
}
<?php

namespace App\Application\DTOs\Order;

use App\Domain\Entities\Order;

class OrderOutput
{
    public function __construct(
        public readonly string $id,
        public readonly string $customerId,
        public readonly int $totalAmount,
        public readonly string $status,
    ) {}

    public static function fromEntity(Order $order): self
    {
        return new self(
            id: $order->getId()->value(),
            customerId: $order->getCustomerId(),
            totalAmount: $order->getTotalAmount()->value(),
            status: $order->getStatus()->value(),
        );
    }
}

Domain層:EntityとValueObject

Domain層にはフレームワーク依存のコードを置きません。すべてプレーンなPHPクラスで構成します。

<?php

namespace App\Domain\Entities;

use App\Domain\ValueObjects\OrderId;
use App\Domain\ValueObjects\OrderStatus;
use App\Domain\ValueObjects\Money;

class Order
{
    private function __construct(
        private OrderId $id,
        private string $customerId,
        private array $items,
        private Money $totalAmount,
        private OrderStatus $status,
    ) {}

    public static function create(string $customerId, array $items): self
    {
        return new self(
            id: OrderId::generate(),
            customerId: $customerId,
            items: $items,
            totalAmount: Money::zero(),
            status: OrderStatus::Pending,
        );
    }

    public function getId(): OrderId
    {
        return $this->id;
    }

    public function getCustomerId(): string
    {
        return $this->customerId;
    }

    public function getTotalAmount(): Money
    {
        return $this->totalAmount;
    }

    public function getStatus(): OrderStatus
    {
        return $this->status;
    }

    public function applyPricing(Money $total): void
    {
        $this->totalAmount = $total;
    }
}

ValueObjectは不変性と自己検証を持たせます。

<?php

namespace App\Domain\ValueObjects;

use InvalidArgumentException;

class Money
{
    public function __construct(
        private readonly int $amount,
        private readonly string $currency = 'JPY',
    ) {
        if ($amount < 0) {
            throw new InvalidArgumentException('金額は0以上である必要があります');
        }
    }

    public static function zero(): self
    {
        return new self(0);
    }

    public function value(): int
    {
        return $this->amount;
    }

    public function add(Money $other): self
    {
        return new self($this->amount + $other->amount, $this->currency);
    }
}
<?php

namespace App\Domain\ValueObjects;

enum OrderStatus: string
{
    case Pending = 'pending';
    case Confirmed = 'confirmed';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
}

PHP 8.1のEnumを使うと、ステータス管理を型安全に行えます。

Infrastructure層:Repositoryの実装

Domain層で定義したインターフェースをInfrastructure層で実装します。

<?php

namespace App\Domain\Repositories;

use App\Domain\Entities\Order;
use App\Domain\ValueObjects\OrderId;

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

namespace App\Infrastructure\Repositories;

use App\Domain\Entities\Order;
use App\Domain\Repositories\OrderRepositoryInterface;
use App\Domain\ValueObjects\OrderId;
use App\Infrastructure\Eloquent\OrderModel;

class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function save(Order $order): void
    {
        OrderModel::updateOrCreate(
            ['id' => $order->getId()->value()],
            [
                'customer_id' => $order->getCustomerId(),
                'total_amount' => $order->getTotalAmount()->value(),
                'status' => $order->getStatus()->value,
            ],
        );
    }

    public function findById(OrderId $id): ?Order
    {
        $model = OrderModel::find($id->value());

        if ($model === null) {
            return null;
        }

        return $this->toEntity($model);
    }

    private function toEntity(OrderModel $model): Order
    {
        // Eloquent ModelからDomain Entityへの変換処理
        return Order::reconstruct(
            id: new OrderId($model->id),
            customerId: $model->customer_id,
            items: json_decode($model->items, true),
            totalAmount: new \App\Domain\ValueObjects\Money($model->total_amount),
            status: \App\Domain\ValueObjects\OrderStatus::from($model->status),
        );
    }
}

ServiceProviderでのバインド

RepositoryインターフェースとEloquent実装の紐づけは、ServiceProviderで行います。

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Domain\Repositories\OrderRepositoryInterface;
use App\Infrastructure\Repositories\EloquentOrderRepository;

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

bootstrap/providers.php(Laravel 11以降)またはconfig/app.phpにこのServiceProviderを登録すれば、DIコンテナがインターフェース経由で自動的に実装クラスを注入します。

クリーンアーキテクチャ・オニオンアーキテクチャとの違い

レイヤードアーキテクチャは、クリーンアーキテクチャやオニオンアーキテクチャとよく比較されます。共通点は「関心の分離」と「依存方向の制御」ですが、設計思想に違いがあります。

観点レイヤードアーキテクチャクリーンアーキテクチャオニオンアーキテクチャ
依存方向上位層→下位層(一方向)外側→内側(同心円)外側→内側(同心円)
層の数4層が一般的4層(Entities, Use Cases, Interface Adapters, Frameworks)4層(Domain Model, Domain Services, Application Services, Outer)
フレームワーク依存Infrastructure層に限定最外周に隔離最外周に隔離
導入の難易度低〜中中〜高中〜高
Laravel適用の現実解段階的に導入可能厳密適用はコスト大厳密適用はコスト大

Laravelプロジェクトでは、まずレイヤードアーキテクチャを導入し、プロジェクト規模の拡大に応じてクリーンアーキテクチャの要素を取り入れていく段階的アプローチが現実的です。実際に、宅配クリーニングサービス「リネット」を運営するホワイトプラスでは、ActiveRecordパターン → DDD導入によるレイヤー分離 → クリーンアーキテクチャの要素を取り入れた設計へと段階的にアーキテクチャを進化させた事例が公開されています。

CQRSパターンとの併用

CQRS(Command Query Responsibility Segregation)は、データの書き込み(Command)と読み取り(Query)でモデルを分離するパターンです。レイヤードアーキテクチャと組み合わせることで、参照系処理のパフォーマンスを大幅に改善できます。

書き込み(Command)側

Entityを経由した通常のレイヤード構成をそのまま使います。

Controller → UseCase → Entity → Repository → DB

読み取り(Query)側

EntityやRepositoryを経由せず、QueryServiceから直接DTOを返します。

Controller → QueryService → DB → ReadModel(DTO)
<?php

namespace App\Infrastructure\QueryServices;

use Illuminate\Support\Facades\DB;

class OrderQueryService
{
    public function listByCustomer(string $customerId, int $page = 1): array
    {
        return DB::table('orders')
            ->where('customer_id', $customerId)
            ->orderByDesc('created_at')
            ->paginate(20, ['*'], 'page', $page)
            ->toArray();
    }
}

参照系の処理ではビジネスルールの適用が不要なため、Eloquent ModelやEntityへの変換コストを省略できます。一覧画面や検索機能など、読み取り頻度が高い機能に効果的です。

テスト戦略

レイヤードアーキテクチャの大きなメリットのひとつがテスタビリティの向上です。各層ごとに適切なテスト種別を割り当てます。

テスト種別テスト対象モック範囲
UI層Feature TestHTTPリクエスト・レスポンスUseCase
Application層Unit TestUseCase処理フローRepository, DomainService
Domain層Unit TestEntity, ValueObject, DomainServiceなし(純粋なPHP)
Infrastructure層Integration TestDB操作・外部API実DB(テスト用)

Domain層のテスト例

Domain層のクラスはフレームワークに依存しないため、PHPUnit単体で高速にテストできます。

<?php

namespace Tests\Unit\Domain\ValueObjects;

use App\Domain\ValueObjects\Money;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

class MoneyTest extends TestCase
{
    public function test_金額の加算ができる(): void
    {
        $a = new Money(1000);
        $b = new Money(500);

        $result = $a->add($b);

        $this->assertSame(1500, $result->value());
    }

    public function test_負の金額は生成できない(): void
    {
        $this->expectException(InvalidArgumentException::class);

        new Money(-1);
    }
}

UseCase層のテスト例

UseCaseのテストでは、RepositoryをモックしてDB依存を排除します。

<?php

namespace Tests\Unit\Application\UseCases\Order;

use App\Application\DTOs\Order\CreateOrderInput;
use App\Application\UseCases\Order\CreateOrderUseCase;
use App\Domain\Repositories\OrderRepositoryInterface;
use App\Domain\Services\OrderPricingService;
use PHPUnit\Framework\TestCase;

class CreateOrderUseCaseTest extends TestCase
{
    public function test_注文が正常に作成される(): void
    {
        $repository = $this->createMock(OrderRepositoryInterface::class);
        $repository->expects($this->once())->method('save');

        $pricingService = $this->createMock(OrderPricingService::class);

        $useCase = new CreateOrderUseCase($repository, $pricingService);

        $input = new CreateOrderInput(
            customerId: 'cust-001',
            items: [['product_id' => 'prod-1', 'quantity' => 2]],
        );

        $output = $useCase->execute($input);

        $this->assertSame('cust-001', $output->customerId);
    }
}

既存MVCプロジェクトからの移行手順

稼働中のLaravelアプリケーションにレイヤードアーキテクチャを導入する場合、一括リファクタリングは現実的ではありません。以下の5ステップで段階的に移行します。

ステップ1:ディレクトリ構造の準備

app/配下にUI・Application・Domain・Infrastructureの各ディレクトリを作成します。既存のControllerやModelはまだ移動しません。

ステップ2:新機能からレイヤード構成で実装

新しい機能を追加する際に、最初からレイヤード構成で実装します。既存コードとの共存が可能です。

ステップ3:Fat ControllerからUseCaseを抽出

既存ControllerのビジネスロジックをUseCaseクラスに切り出します。Controllerの修正とUseCaseの新設だけで済むため、影響範囲が限定的です。

ステップ4:Eloquent ModelとDomain Entityの分離

Eloquent Modelのビジネスロジック(スコープ、アクセサに埋め込まれた計算処理など)をDomain EntityやValueObjectに移します。Eloquent ModelはInfrastructure層のデータアクセス専用に変更します。

ステップ5:Repositoryパターンの導入

直接Eloquent Modelを操作していた箇所をRepositoryインターフェース経由に切り替えます。ServiceProviderでバインドを設定し、DIで注入する形に変更します。

各ステップは独立してデプロイできるため、プルリクエスト単位でレビュー・マージが可能です。

導入時に起きやすい問題と対処法

問題1:クラス数の増加による見通しの悪化

レイヤードアーキテクチャではクラス数が増える傾向にあります。命名規則を統一し、ドメインコンテキストごとにサブディレクトリを切ることで管理しやすくなります。Artisan Makeコマンドをカスタムして、所定のディレクトリにスキャフォールドする仕組みを用意すると開発速度を維持できます。

問題2:ValueObjectの形骸化

値オブジェクトが単なるラッパーになり、バリデーションやドメインロジックを持たないケースがあります。ValueObjectを導入する基準として「そのクラスにビジネスルールが1つ以上含まれるか」をチーム内で合意しておくと、過度なクラス分割を防げます。

問題3:Eloquentの機能を活かしきれない

レイヤードアーキテクチャを厳密に適用すると、EloquentのリレーションやスコープをDomain層から使えなくなります。対処としては、Infrastructure層でEloquentの機能をフル活用し、Domain Entityへの変換をRepositoryの責務とする設計が有効です。参照系の処理ではCQRSパターンを採用し、QueryServiceから直接Eloquent QueryBuilderを使う方法も実用的です。

問題4:チームメンバーの学習コスト

アーキテクチャのルールが暗黙知になると、新メンバーが正しい層にコードを配置できません。ADR(Architecture Decision Record)やコーディングガイドラインをリポジトリ内に整備し、プルリクエストレビューで層の責務違反をチェックする運用が効果的です。

プロジェクト規模ごとの適用判断

レイヤードアーキテクチャはすべてのプロジェクトに適しているわけではありません。規模と複雑さに応じて適用範囲を調整します。

プロジェクト規模推奨アプローチ補足
小規模(1〜3人、短期)MVCのままアーキテクチャコストがメリットを上回る
中規模(3〜10人、中長期)レイヤードアーキテクチャUseCase + Repository分離で効果を実感しやすい
大規模(10人以上、長期運用)レイヤード + CQRS + モジュール分割境界づけられたコンテキストごとにモジュール化

中規模以上のプロジェクトで「ControllerやModelの見通しが悪くなってきた」と感じた時点が、レイヤードアーキテクチャの導入タイミングです。

まとめ

レイヤードアーキテクチャは、Laravelの強力な機能(サービスコンテナ、DI、ServiceProvider)を活かしながら、責務を明確に分離できる設計手法です。

導入のポイントを振り返ります。

  • 4層分離:UI・Application・Domain・Infrastructureの責務を明確にする
  • 依存性逆転:Domain層にインターフェースを定義し、Infrastructure層で実装する
  • 段階的移行:既存MVCプロジェクトから一括ではなく機能単位で段階的に導入する
  • CQRS併用:読み取り系はQueryServiceで効率化し、書き込み系はEntity経由の堅牢な処理にする
  • テスト戦略:層ごとに適切なテスト種別を選択し、Domain層は外部依存なしの高速テストを実現する

プロジェクトの規模や複雑さに応じて柔軟に適用範囲を調整し、チーム全体でアーキテクチャの方針を共有することが、長期的な保守性向上への鍵となります。