PHP アーキテクチャ設計入門:レイヤリング・依存逆転・ユースケース分離
PHP プロジェクトが成長するにつれて、コードの保守性や拡張性が問題になることはありませんか?特に、ビジネスロジックがコントローラーやモデルに散らばってしまい、変更が困難になる経験をされた方も多いでしょう。
本記事では、PHP アプリケーションにおけるレイヤリング、依存逆転の原則(DIP)、そしてユースケース分離という 3 つの重要なアーキテクチャ設計手法を解説します。これらの手法を理解することで、変更に強く、テストしやすい PHP アプリケーションを構築できるようになりますよ。
背景
なぜアーキテクチャ設計が必要なのか
PHP は柔軟な言語であり、小規模なプロジェクトでは手軽に開発を進められます。しかし、プロジェクトが成長すると、以下のような問題が顕在化してきます。
- ビジネスロジックがフレームワークやデータベースと密結合している
- テストが困難で、変更のたびに予期しない不具合が発生する
- 新機能の追加や既存機能の修正に多大な時間がかかる
これらの問題を解決するために、適切なアーキテクチャ設計が必要となるのです。
アーキテクチャ設計の基本概念
以下の図は、適切なアーキテクチャ設計が実現する構造を示しています。
mermaidflowchart TB
subgraph presentation["プレゼンテーション層"]
controller["Controller<br/>(Web リクエスト処理)"]
end
subgraph application["アプリケーション層"]
usecase["UseCase<br/>(ビジネスロジック)"]
end
subgraph domain["ドメイン層"]
entity["Entity<br/>(ビジネスルール)"]
repository_interface["Repository Interface<br/>(データ操作の抽象)"]
end
subgraph infrastructure["インフラストラクチャ層"]
repository_impl["Repository 実装<br/>(DB アクセス)"]
external["外部サービス連携"]
end
controller -->|依存| usecase
usecase -->|依存| entity
usecase -->|依存| repository_interface
repository_interface -.->|実装| repository_impl
repository_impl -->|アクセス| external
図で理解できる要点:
- 各層が明確な責務を持ち、上位層から下位層への依存関係が確立されている
- インターフェースを介することで、実装の詳細から独立している
- ビジネスロジックが中心に位置し、技術的詳細から隔離されている
この図が示すように、アーキテクチャ設計では関心の分離と依存関係の制御が重要なポイントとなります。
課題
典型的な PHP アプリケーションの問題点
多くの PHP アプリケーションでは、以下のような課題が見られます。
| # | 課題 | 具体例 | 影響 |
|---|---|---|---|
| 1 | ビジネスロジックの散在 | Controller や Model に直接ロジックを記述 | 重複コード、テスト困難 |
| 2 | フレームワークへの強依存 | Laravel の Eloquent に直接依存 | フレームワーク変更が困難 |
| 3 | データベースへの密結合 | SQL 文がビジネスロジックに混在 | データソース変更が困難 |
| 4 | テストの困難さ | モックが作成できない、DB が必須 | テスト実行時間の増加 |
| 5 | 変更の影響範囲が不明確 | 1 箇所の修正が予期しない箇所に影響 | バグの増加、開発速度の低下 |
密結合なコードの例
以下は、典型的な密結合コードの例です。
php<?php
// 悪い例:全てが密結合している Controller
class UserController
{
public function register(Request $request)
{
// バリデーション
if (empty($request->input('email'))) {
return response()->json(['error' => 'Email is required'], 400);
}
// ビジネスロジックが Controller に直接記述されている
$user = new User();
$user->email = $request->input('email');
$user->password = bcrypt($request->input('password'));
// データベースアクセスが直接記述されている
$user->save();
// メール送信ロジックも混在
Mail::to($user->email)->send(new WelcomeMail($user));
return response()->json(['message' => 'User registered'], 201);
}
}
このコードには以下の問題があります。
- バリデーション、ビジネスロジック、データ永続化、外部サービス連携が全て 1 つのメソッドに混在している
- フレームワーク(Laravel)に強く依存しており、テストが困難
- ビジネスルールの再利用ができない
次のセクションでは、これらの課題を解決する具体的な手法を見ていきましょう。
解決策
レイヤリング(層化)の導入
レイヤリングとは、アプリケーションを責務ごとに複数の層に分割する設計手法です。一般的には以下の 4 層構造が用いられます。
4 層アーキテクチャの構成
| # | 層名 | 責務 | 具体例 |
|---|---|---|---|
| 1 | プレゼンテーション層 | ユーザーインターフェース、リクエスト処理 | Controller, View, API エンドポイント |
| 2 | アプリケーション層 | ユースケースの実行、処理の調整 | UseCase, Service, ApplicationService |
| 3 | ドメイン層 | ビジネスルール、ビジネスロジック | Entity, ValueObject, DomainService |
| 4 | インフラストラクチャ層 | 技術的詳細、外部システム連携 | Repository 実装, DB アクセス, API クライアント |
各層は明確な責務を持ち、上位層は下位層に依存しますが、その逆はありません。
レイヤリングのルール
レイヤリングを適用する際の重要なルールを理解しましょう。
php<?php
namespace App\Presentation\Controller;
use App\Application\UseCase\RegisterUserUseCase;
use App\Application\UseCase\RegisterUserRequest;
// プレゼンテーション層:リクエストを受け取り、UseCase を呼び出す
class UserController
{
private RegisterUserUseCase $registerUserUseCase;
public function __construct(RegisterUserUseCase $registerUserUseCase)
{
// 依存性注入により、UseCase を受け取る
$this->registerUserUseCase = $registerUserUseCase;
}
}
上記のコードでは、Controller がアプリケーション層の UseCase に依存していることが明確です。これにより、プレゼンテーション層の責務が「リクエストの受け取りと応答」に限定されます。
php<?php
public function register(Request $request)
{
// リクエストデータを UseCase 用のデータ構造に変換
$useCaseRequest = new RegisterUserRequest(
email: $request->input('email'),
password: $request->input('password')
);
// ビジネスロジックは UseCase に委譲
$result = $this->registerUserUseCase->execute($useCaseRequest);
// 結果を HTTP レスポンスに変換
return response()->json([
'user_id' => $result->userId,
'message' => 'User registered successfully'
], 201);
}
Controller のメソッドは、リクエストの変換、UseCase の実行、レスポンスの生成という 3 つのステップに整理されました。
依存逆転の原則(DIP)の適用
依存逆転の原則(Dependency Inversion Principle)は、SOLID 原則の 1 つであり、以下を定めています。
上位レベルのモジュールは下位レベルのモジュールに依存してはならない。両方とも抽象に依存すべきである。
以下の図は、依存逆転の原則がどのように機能するかを示します。
mermaidflowchart LR
subgraph before["依存逆転前"]
uc1["UseCase"] -->|直接依存| db1["MySQL 実装"]
end
subgraph after["依存逆転後"]
uc2["UseCase"] -->|依存| interface["Repository<br/>Interface"]
mysql["MySQL 実装"] -.->|実装| interface
postgres["PostgreSQL 実装"] -.->|実装| interface
memory["InMemory 実装"] -.->|実装| interface
end
style interface fill:#e1f5ff
style uc2 fill:#fff4e1
図で理解できる要点:
- 依存逆転前は UseCase が具体的な実装(MySQL)に直接依存している
- 依存逆転後は UseCase が抽象(Interface)に依存し、実装を自由に切り替えられる
- テスト時には InMemory 実装、本番では MySQL 実装というように使い分けが可能
インターフェースの定義
まず、データアクセスの抽象化としてインターフェースを定義します。
php<?php
namespace App\Domain\Repository;
use App\Domain\Entity\User;
// ドメイン層:Repository のインターフェース(抽象)
interface UserRepositoryInterface
{
/**
* ユーザーを保存する
*/
public function save(User $user): void;
/**
* メールアドレスでユーザーを検索する
*/
public function findByEmail(string $email): ?User;
/**
* ID でユーザーを検索する
*/
public function findById(int $id): ?User;
}
このインターフェースはドメイン層に配置され、ビジネスロジックが必要とするデータ操作を定義しています。
インターフェースの実装
次に、インフラストラクチャ層で具体的な実装を提供します。
php<?php
namespace App\Infrastructure\Repository;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use Illuminate\Support\Facades\DB;
// インフラストラクチャ層:Repository の具体的実装
class MySQLUserRepository implements UserRepositoryInterface
{
public function save(User $user): void
{
// データベースへの保存処理
DB::table('users')->insert([
'email' => $user->getEmail(),
'password' => $user->getPasswordHash(),
'created_at' => now(),
]);
}
実装クラスはインターフェースを実装し、具体的なデータベースアクセスロジックを提供します。
php<?php
public function findByEmail(string $email): ?User
{
$row = DB::table('users')
->where('email', $email)
->first();
if ($row === null) {
return null;
}
// データベースの行を Entity に変換
return new User(
id: $row->id,
email: $row->email,
passwordHash: $row->password
);
}
public function findById(int $id): ?User
{
$row = DB::table('users')
->where('id', $id)
->first();
if ($row === null) {
return null;
}
return new User(
id: $row->id,
email: $row->email,
passwordHash: $row->password
);
}
}
この実装により、データベースの詳細がインフラストラクチャ層に隔離されました。
依存性注入の設定
Laravel のサービスコンテナを使用して、インターフェースと実装をバインドします。
php<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Domain\Repository\UserRepositoryInterface;
use App\Infrastructure\Repository\MySQLUserRepository;
// 依存性注入の設定
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// インターフェースに対して具体的な実装をバインド
$this->app->bind(
UserRepositoryInterface::class,
MySQLUserRepository::class
);
}
}
これにより、UserRepositoryInterface を要求するクラスには自動的に MySQLUserRepository が注入されます。必要に応じて、テスト環境では別の実装に切り替えることも容易です。
ユースケース分離の実装
ユースケース分離とは、各ビジネスユースケースを独立したクラスとして実装する手法です。これにより、ビジネスロジックの再利用性とテスタビリティが向上します。
UseCase クラスの構造
php<?php
namespace App\Application\UseCase;
// UseCase のリクエストデータを表すクラス
class RegisterUserRequest
{
public function __construct(
public readonly string $email,
public readonly string $password
) {}
}
リクエストデータは、イミュータブルな(読み取り専用の)データ構造として定義します。
php<?php
namespace App\Application\UseCase;
// UseCase のレスポンスデータを表すクラス
class RegisterUserResponse
{
public function __construct(
public readonly int $userId,
public readonly string $email
) {}
}
レスポンスデータも同様に、イミュータブルな構造として定義しましょう。
UseCase の実装
php<?php
namespace App\Application\UseCase;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\Service\UserDomainService;
// アプリケーション層:ユーザー登録のユースケース
class RegisterUserUseCase
{
public function __construct(
private UserRepositoryInterface $userRepository,
private UserDomainService $userDomainService
) {}
UseCase のコンストラクタでは、必要な Repository と DomainService を依存性注入で受け取ります。
php<?php
public function execute(RegisterUserRequest $request): RegisterUserResponse
{
// 1. メールアドレスの重複チェック(ドメインサービスに委譲)
if ($this->userDomainService->isEmailDuplicated($request->email)) {
throw new \DomainException('Email already exists');
}
// 2. User エンティティの生成
$user = User::create(
email: $request->email,
plainPassword: $request->password
);
// 3. ユーザーの永続化
$this->userRepository->save($user);
// 4. レスポンスの生成
return new RegisterUserResponse(
userId: $user->getId(),
email: $user->getEmail()
);
}
}
UseCase の execute メソッドは、ビジネスロジックの実行手順を明確に表現しています。各ステップがコメントで説明され、処理の流れが理解しやすくなっていますね。
具体例
完全な実装例
ここまでの解説を踏まえて、完全な実装例を見ていきましょう。ユーザー登録機能を例に、各層のコードを示します。
ドメイン層の実装
php<?php
namespace App\Domain\Entity;
// ドメイン層:User エンティティ
class User
{
private ?int $id;
private string $email;
private string $passwordHash;
private function __construct(
?int $id,
string $email,
string $passwordHash
) {
$this->id = $id;
$this->email = $email;
$this->passwordHash = $passwordHash;
}
Entity のコンストラクタは private にして、ファクトリメソッドから生成するようにします。
php<?php
// 新規ユーザーを作成するファクトリメソッド
public static function create(string $email, string $plainPassword): self
{
// バリデーション
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email format');
}
if (strlen($plainPassword) < 8) {
throw new \InvalidArgumentException(
'Password must be at least 8 characters'
);
}
// パスワードのハッシュ化
$passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT);
return new self(null, $email, $passwordHash);
}
ファクトリメソッドでは、バリデーションとビジネスルールの適用を行います。
php<?php
// 既存ユーザーを再構築するファクトリメソッド
public static function reconstruct(
int $id,
string $email,
string $passwordHash
): self {
return new self($id, $email, $passwordHash);
}
// ゲッターメソッド
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function getPasswordHash(): string
{
return $this->passwordHash;
}
}
Entity は不変オブジェクトとして設計し、状態の変更は新しいインスタンスを返すメソッドで行います。
ドメインサービスの実装
php<?php
namespace App\Domain\Service;
use App\Domain\Repository\UserRepositoryInterface;
// ドメイン層:ドメインサービス
class UserDomainService
{
public function __construct(
private UserRepositoryInterface $userRepository
) {}
/**
* メールアドレスの重複チェック
* 複数のエンティティにまたがるロジックはドメインサービスに記述
*/
public function isEmailDuplicated(string $email): bool
{
$existingUser = $this->userRepository->findByEmail($email);
return $existingUser !== null;
}
}
ドメインサービスは、単一の Entity では表現できないビジネスロジックを担当します。
アプリケーション層の実装(詳細版)
php<?php
namespace App\Application\UseCase;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\Service\UserDomainService;
use Psr\Log\LoggerInterface;
// アプリケーション層:ユーザー登録ユースケース(完全版)
class RegisterUserUseCase
{
public function __construct(
private UserRepositoryInterface $userRepository,
private UserDomainService $userDomainService,
private LoggerInterface $logger
) {}
ロギングなどのインフラストラクチャ的関心事も、インターフェース経由で依存性注入します。
php<?php
public function execute(RegisterUserRequest $request): RegisterUserResponse
{
$this->logger->info('User registration started', [
'email' => $request->email
]);
try {
// メールアドレスの重複チェック
if ($this->userDomainService->isEmailDuplicated($request->email)) {
throw new \DomainException(
"Email '{$request->email}' is already registered"
);
}
// User エンティティの生成
$user = User::create(
email: $request->email,
plainPassword: $request->password
);
// ユーザーの永続化
$this->userRepository->save($user);
$this->logger->info('User registration completed', [
'user_id' => $user->getId()
]);
return new RegisterUserResponse(
userId: $user->getId(),
email: $user->getEmail()
);
} catch (\Exception $e) {
$this->logger->error('User registration failed', [
'email' => $request->email,
'error' => $e->getMessage()
]);
throw $e;
}
}
}
UseCase では、処理の流れを調整し、適切なロギングとエラーハンドリングを行います。
プレゼンテーション層の実装(完全版)
php<?php
namespace App\Presentation\Controller;
use App\Application\UseCase\RegisterUserUseCase;
use App\Application\UseCase\RegisterUserRequest;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
// プレゼンテーション層:Controller(完全版)
class UserController
{
public function __construct(
private RegisterUserUseCase $registerUserUseCase
) {}
Controller では UseCase のみに依存し、他の層への直接的な依存を避けます。
php<?php
public function register(Request $request): JsonResponse
{
// 1. リクエストのバリデーション(フレームワーク固有の処理)
$validated = $request->validate([
'email' => 'required|email',
'password' => 'required|string|min:8',
]);
try {
// 2. UseCase リクエストの生成
$useCaseRequest = new RegisterUserRequest(
email: $validated['email'],
password: $validated['password']
);
// 3. UseCase の実行
$response = $this->registerUserUseCase->execute($useCaseRequest);
// 4. HTTP レスポンスの生成
return response()->json([
'data' => [
'user_id' => $response->userId,
'email' => $response->email,
],
'message' => 'User registered successfully'
], 201);
} catch (\DomainException $e) {
// ドメイン例外はクライアントエラーとして扱う
return response()->json([
'error' => $e->getMessage()
], 400);
} catch (\Exception $e) {
// その他の例外はサーバーエラーとして扱う
return response()->json([
'error' => 'Internal server error'
], 500);
}
}
}
Controller では、HTTP に関する処理(バリデーション、レスポンス生成、ステータスコード設定)のみを担当します。
テストコードの例
適切にレイヤー分離されたコードは、テストが容易になります。
php<?php
namespace Tests\Application\UseCase;
use App\Application\UseCase\RegisterUserUseCase;
use App\Application\UseCase\RegisterUserRequest;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\Service\UserDomainService;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
// UseCase のテスト
class RegisterUserUseCaseTest extends TestCase
{
public function test_ユーザー登録が成功する(): void
{
// モックの作成
$repository = $this->createMock(UserRepositoryInterface::class);
$repository->method('findByEmail')->willReturn(null);
$repository->expects($this->once())->method('save');
$domainService = new UserDomainService($repository);
$useCase = new RegisterUserUseCase(
$repository,
$domainService,
new NullLogger()
);
モックを使用することで、データベースなしでテストを実行できます。
php<?php
// テストの実行
$request = new RegisterUserRequest(
email: 'test@example.com',
password: 'password123'
);
$response = $useCase->execute($request);
// アサーション
$this->assertNotNull($response->userId);
$this->assertEquals('test@example.com', $response->email);
}
public function test_メールアドレスが重複している場合は例外が発生する(): void
{
// 重複ユーザーが存在する状態をモック
$repository = $this->createMock(UserRepositoryInterface::class);
$repository->method('findByEmail')->willReturn(
\App\Domain\Entity\User::reconstruct(
1,
'test@example.com',
'hash'
)
);
$domainService = new UserDomainService($repository);
$useCase = new RegisterUserUseCase(
$repository,
$domainService,
new NullLogger()
);
// 例外が発生することを期待
$this->expectException(\DomainException::class);
$request = new RegisterUserRequest(
email: 'test@example.com',
password: 'password123'
);
$useCase->execute($request);
}
}
テストケースごとに異なるモックの振る舞いを設定することで、様々なシナリオを検証できますね。
ディレクトリ構造の例
以下は、レイヤードアーキテクチャに基づいた推奨ディレクトリ構造です。
bashapp/
├── Domain/ # ドメイン層
│ ├── Entity/
│ │ └── User.php
│ ├── ValueObject/
│ │ └── Email.php
│ ├── Repository/
│ │ └── UserRepositoryInterface.php
│ └── Service/
│ └── UserDomainService.php
│
├── Application/ # アプリケーション層
│ └── UseCase/
│ ├── RegisterUserUseCase.php
│ ├── RegisterUserRequest.php
│ └── RegisterUserResponse.php
│
├── Infrastructure/ # インフラストラクチャ層
│ ├── Repository/
│ │ ├── MySQLUserRepository.php
│ │ └── InMemoryUserRepository.php
│ └── External/
│ └── MailService.php
│
└── Presentation/ # プレゼンテーション層
├── Controller/
│ └── UserController.php
└── Request/
└── RegisterUserHttpRequest.php
この構造により、各層の責務が明確になり、コードの配置場所が直感的に理解できます。
まとめ
本記事では、PHP アプリケーションにおける 3 つの重要なアーキテクチャ設計手法を解説しました。
レイヤリングにより、アプリケーションを責務ごとに分離し、各層の役割を明確にできます。プレゼンテーション層、アプリケーション層、ドメイン層、インフラストラクチャ層という 4 層構造を採用することで、変更の影響範囲を限定できるでしょう。
依存逆転の原則を適用することで、上位層が具体的な実装に依存せず、抽象(インターフェース)に依存するようになります。これにより、実装の切り替えが容易になり、テスタビリティが大幅に向上しますね。
ユースケース分離では、各ビジネスユースケースを独立したクラスとして実装します。これにより、ビジネスロジックの再利用性が高まり、コードの意図が明確になります。
これらの手法を組み合わせることで、以下のメリットが得られます。
- 変更に強い設計:1 つの層の変更が他の層に影響しにくい
- テストしやすい:モックを使用した単体テストが容易
- 理解しやすい:各クラスの責務が明確で、コードの意図が読み取りやすい
- 拡張しやすい:新機能の追加や既存機能の修正がスムーズ
最初は複雑に感じるかもしれませんが、小さな機能から段階的に適用していくことをお勧めします。プロジェクトの成長とともに、適切なアーキテクチャ設計の価値を実感できるはずですよ。
関連リンク
articlePHP アーキテクチャ設計入門:レイヤリング・依存逆転・ユースケース分離
articlePHP 構文チートシート:配列・クロージャ・型宣言・match を一枚で把握
articlePHP 開発環境の作り方【完全ガイド】:macOS/Windows/Linux 別最適解
articlePHP とは?2025 年版の特徴・強み・できることを徹底解説【保存版】
articleCodeIgniterで接続しているデータベースにPHPからテーブルを作成するサンプルコード
article「Codeigniter」トラックバック受信処理について使い方とサンプル
articleNuxt × Vercel/Netlify/Cloudflare:デプロイ先で変わる性能とコストを実測
articleRemix で「Hydration failed」を解決:サーバ/クライアント不整合の診断手順
articlePreact 本番最適化運用:Lighthouse 95 点超えのビルド設定と監視 KPI
articleNginx microcaching vs 上流キャッシュ(Varnish/Redis)比較:TTFB と整合性の最適解
articleNestJS × TypeORM vs Prisma vs Drizzle:DX・性能・移行性の総合ベンチ
articlePlaywright × Allure レポート運用:履歴・トレンド・失敗分析を見える化する
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来