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つです。
- Controllerを機能単位で1クラス1アクションにする(Single Action Controller)。
__invoke()メソッドを使うと、ルーティングが簡潔になります - Eloquent ModelをInfrastructure層に配置する。Domain層のEntityと混同しないよう、
OrderModelのように接尾辞で区別します - 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 Test | HTTPリクエスト・レスポンス | UseCase |
| Application層 | Unit Test | UseCase処理フロー | Repository, DomainService |
| Domain層 | Unit Test | Entity, ValueObject, DomainService | なし(純粋なPHP) |
| Infrastructure層 | Integration Test | DB操作・外部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層は外部依存なしの高速テストを実現する
プロジェクトの規模や複雑さに応じて柔軟に適用範囲を調整し、チーム全体でアーキテクチャの方針を共有することが、長期的な保守性向上への鍵となります。