Laravel Service層とRepositoryパターン設計完全ガイド|Fat Controller解消とテスタビリティ向上

Laravel入門実装・応用テクニック

Laravelは表現力豊かなEloquent ORMや直感的なルーティング、強力なエコシステムを備えており、迅速なWebアプリケーション開発を実現できます。しかし、プロジェクトの規模が中規模・大規模へと拡大するにつれて、多くの開発現場で共通の深刻な問題に直面します。それが 「Fat Controller(コントローラー肥大化)」 です。

コントローラーのアクション内に、リクエストの検証、複数のデータベースクエリ、ビジネスロジックの判定、外部API通信、トランザクション制御などをすべて直接記述してしまうと、メソッドが数百行に膨れ上がり、可読性の低下やコードの重複、単体テスト作成の困難化を招きます。

この設計課題を根本から解決するための代表的なアーキテクチャパターンが、「Service層(サービスクラス)」 によるビジネスロジックの集約と、「Repositoryパターン」 によるデータアクセス層の抽象化です。これらを適切に導入することで、単一責任の原則(SRP)と依存性逆転の原則(DIP)を満たし、変更に強くテスタビリティの高いクリーンなLaravelアプリケーションを構築できます。

この記事では、LaravelにおけるService層とRepositoryパターンの設計基準、ディレクトリ配置と命名規則、インターフェースを活用したDI(依存性の注入)、DB::transaction による安全なトランザクション管理、過剰設計(Over-engineering)を防ぐ使い分けの基準、そしてPestによる高速モックテスト実践 まで、実践的なコード例とともに徹底解説します。

📌 本記事で学べること:

  • Controller・FormRequest・Service・Repository・Model・API Resourceの明確な責務分担と早見表
  • Fat Controllerがもたらす3大弊害(重複コード、テスト困難化、データ不整合)と解決方針
  • Serviceクラスの設計手法(app/Services、命名規則、トランザクション集約、DI)
  • Repositoryパターンの実装手順(インターフェース設計、具象クラス、ServiceProviderでのバインド)
  • 単純なCRUDにRepositoryは不要?過剰設計を防ぐ「軽量アーキテクチャ」の選定基準
  • PestによるService層の高速モックテスト(DB接続なしでの正常系・異常系検証)
  • 保守性の高いLaravel設計チェックリスト
  1. 【早見表】Laravelレイヤードアーキテクチャの役割分担一覧
    1. Controller、FormRequest、Service、Repository、Model、API Resourceの責務比較マトリクス
  2. 1. なぜFat Controllerは生まれるのか?密結合がもたらす3大弊害
    1. 弊害1:ビジネスロジックの散在と重複コードの発生
    2. 弊害2:ControllerとEloquentの密結合による単体テスト(Unit Test)の困難化
    3. 弊害3:トランザクション境界の曖昧化とデータ不整合リスク
  3. 2. Service層(サービスクラス)の設計と実装手順
    1. Serviceクラスの作成場所(app/Services)と命名規則
    2. ビジネスロジックとトランザクション(DB::transaction)の集約
    3. Controllerへのコンストラクタインジェクション(DI)による疎結合化
  4. 3. Repositoryパターンの設計とインターフェースの活用
    1. なぜインターフェース(Interface)を挟むのか?(DBアクセスの抽象化)
    2. UserRepositoryInterface と EloquentUserRepository の作成手順
    3. AppServiceProvider でのインターフェースと具象クラスのバインド設定
  5. 4. 過剰設計(Over-engineering)を防ぐ使い分けの基準
    1. 単純なCRUDにRepositoryパターンは不要?小規模と中大規模での選定判断
    2. Service層のみを導入する「軽量アーキテクチャ」の推奨ケース
  6. 5. テスタビリティの劇的向上:PestによるService層のモックテスト実践
    1. Repositoryをモック化(Mockery)してDB接続なしで超高速テストを実行するコード例
    2. 正常系・異常系(例外発生時のロールバック)のテストケース設計
  7. 6. まとめ:保守性の高いLaravelアプリケーション設計チェックリスト
  8. 関連記事

【早見表】Laravelレイヤードアーキテクチャの役割分担一覧

Laravelで保守性の高いクリーンなコードベースを維持するためには、各レイヤーが担うべき責務を明確に定義し、不要な処理を別のレイヤーへ漏れ出させないことが不可欠です。

Controller、FormRequest、Service、Repository、Model、API Resourceの責務比較マトリクス

以下のマトリクスは、Webアプリケーションにおける各コンポーネントの「主な責務」「記述して良いコード(DO)」「記述してはいけないコード(DON’T)」を整理した早見表です。

レイヤー 主な責務 記述して良いコード(DO) 記述してはいけないコード(DON’T)
Controller
HTTP入出力の指揮
HTTPリクエストを受け取り、適切なServiceやModelを呼び出してレスポンス(ViewやJSON)を返却する。 ・HTTPステータスコードの設定
・リダイレクトやビューの返却
・Service層メソッドの呼び出し
・API Resourceへの受け渡し
✖ 複雑なビジネス計算
✖ 直接のSQL/Eloquent発行
✖ トランザクション制御
✖ 外部API直接通信
FormRequest
入力検証・認可
クライアントから送信された入力値の妥当性検証(Validation)および操作実行の権限認可(Authorization)。 ・rules() によるルール定義
・authorize() での権限判定
・prepareForValidation() での入力サニタイズ
✖ データのDB永続化
✖ ビジネスロジックの実行
✖ メール送信等の副作用
Service
★ ビジネスロジック
業務固有のルール、複数処理のオーケストレーション、トランザクション境界の管理、外部サービス連携。 ・DB::transaction() 制御
・Repository経由でのデータ操作
・ドメインイベント・Job発行
・外部API連携(決済・通知等)
✖ HTTPリクエスト/レスポンス直接参照($request->input() や response())
✖ HTML/View生成
Repository
★ データアクセス抽象化
データベースやキャッシュなどの永続化層との通信・データ取得・保存処理をカプセル化する。 ・Eloquentクエリの構築・実行
・複雑なWHERE句やJOIN、集計
・キャッシュ層との連携取得
・モデルインスタンスの返却
✖ ビジネスルールの判定
✖ トランザクションの開始・コミット
✖ 外部メールや通知の送信
Model (Eloquent)
データ構造・関係性
単一エンティティのデータ表現、テーブル間のリレーションシップ定義、属性の型キャスト・アクセサ。 ・テーブル名・型キャスト定義
・リレーション(hasMany, belongsTo等)
・アクセサ / ミューテータ
・ローカルスコープ(scopeActive等)
✖ 複数モデルにまたがる業務フロー
✖ 巨大なトランザクション管理
✖ コントローラー依存の処理
API Resource
出力整形・隠蔽
モデルやコレクションをクライアント向けのJSONスキーマへ変換し、機密情報を隠蔽して整形返却する。 ・配列へのシリアライズ定義
・whenLoaded() によるN+1防止
・条件付きフィールド出力
・ページネーションメタ情報の付与
✖ データベースへの書き込み
✖ 業務ロジックの実行
✖ 重い集計・クエリ発行

入力層の検証ルール分離については Laravel FormRequest(フォームリクエスト)完全ガイド|バリデーション分離・認可・再利用テクニック を、出力層のレスポンス整形については Laravel APIリソース(Eloquent API Resources)完全ガイド|JsonResourceの使い方・ネスト・ページネーション整形 を合わせて確認することで、入力から出力まで一貫したクリーンアーキテクチャの全体像を把握できます。

全体のデータフローを図解すると以下のようになります。

[ クライアント (HTTP Request) ]
    │
    ▼
【 FormRequest 】 ──▶ 入力バリデーション・認可チェック(失敗時は即422返却)
    │ (安全な検証済みデータ $request->validated())
    ▼
【 Controller 】 ──▶ パラメータ受け取り ➔ Service呼び出し ➔ レスポンス返却のみ
    │ (配列やDTOを渡す)
    ▼
【 Service層 】 ──▶ ビジネスロジック実行・DBトランザクション(DB::transaction)管理
    │ (永続化処理をRepositoryに委譲)
    ▼
【 Repository層 】 ──▶ データアクセスのカプセル化(Eloquent / Query Builder)
    │
    ▼
【 Model (DB) 】 ──▶ データの取得・保存・リレーション解決
    │
    ▼
【 API Resource / View 】 ──▶ クライアント向けレスポンス(JSON / Blade)整形
    │
    ▼
[ クライアント (HTTP Response 200 OK / 201 Created) ]

1. なぜFat Controllerは生まれるのか?密結合がもたらす3大弊害

Laravelのチュートリアルに沿って開発を進めていると、小規模なうちはController内に直接クエリを書き、外部APIを呼び出しても問題なく動作します。しかし、要件が増加するにつれて以下のような「Fat Controller」の典型コードが生まれてしまいます。

<?php

namespace App\Http\Controllers;

use App\Models\User;
use App\Models\Profile;
use App\Models\PointHistory;
use App\Mail\WelcomeMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Stripe\StripeClient;

class UserController extends Controller
{
    /**
     * ⚠️ 典型的なFat Controller(肥大化コントローラー)のアンチパターン
     */
    public function register(Request $request)
    {
        // ① 入力バリデーションの直書き
        $validated = $request->validate([
            'name'     => ['required', 'string', 'max:50'],
            'email'    => ['required', 'email', 'unique:users,email'],
            'password' => ['required', 'min:8'],
        ]);

        // ② トランザクション・DB処理・外部API呼び出しの混在
        DB::beginTransaction();
        try {
            $user = User::create([
                'name'     => $validated['name'],
                'email'    => $validated['email'],
                'password' => Hash::make($validated['password']),
            ]);

            Profile::create([
                'user_id' => $user->id,
                'avatar'  => 'default.png',
            ]);

            PointHistory::create([
                'user_id' => $user->id,
                'points'  => 500,
                'type'    => 'signup_bonus',
            ]);

            // ⚠️ 外部決済APIがトランザクション内に混入!
            $stripe = new StripeClient(config('services.stripe.secret'));
            $customer = $stripe->customers->create([
                'email' => $user->email,
                'name'  => $user->name,
            ]);
            $user->update(['stripe_id' => $customer->id]);

            DB::commit();
        } catch (\Exception $e) {
            DB::rollBack();
            return back()->withErrors(['error' => '登録処理に失敗しました。']);
        }

        Mail::to($user->email)->send(new WelcomeMail($user));

        return redirect()->route('dashboard')->with('success', '登録完了');
    }
}

一見動作しているように見えるこのコードは、Controllerがシステムの全責任を抱え込む「神クラス(God Object)」化しており、実務で以下の3大弊害を引き起こします。

弊害1:ビジネスロジックの散在と重複コードの発生

実際のWeb開発では、「Web画面からの登録」だけでなく、「REST APIからの登録」「バッチ処理(Artisanコマンド)での一括インポート」「外部Webhookからの登録」など、同じ会員登録フローを複数の経路から呼び出す要件が日常的に発生します。

ロジックがControllerのアクションメソッド内に直書きされていると、他のクラスから呼び出せず、コピペで重複コードが量産されます。その結果、「ポイント付与ルールが変更された」「Stripeの仕様が変わった」といった仕様変更の際に修正漏れが発生し、データ不整合の温床になります。

弊害2:ControllerとEloquentの密結合による単体テスト(Unit Test)の困難化

Controller内に User::create() や new StripeClient() が直接記述されていると、ビジネスロジックのみを対象とした単体テストが極めて困難になります。

テストを実行するたびに実際のデータベースへの接続やマイグレーション、ロールバック(RefreshDatabase)が必要になり、テスト実行時間が大幅に遅延します。さらに外部API通信のモック差し替えも困難になり、CIパイプラインの実行速度低下やテストの不安定化(Flaky Test)を招きます。

弊害3:トランザクション境界の曖昧化とデータ不整合リスク

上記のアンチパターンでは、DB::beginTransaction() の内部で外部サービス通信(Stripe API)を行っています。外部ネットワークの遅延やタイムアウトが発生している間、データベースの接続と行ロックが保持され続け、接続プール枯渇やデッドロックを引き起こす原因になります。

安全なトランザクション管理やデッドロック時の再試行テクニックについては、Laravel トランザクションの使い方完全ガイド|手動ロールバック・デッドロック再試行 で詳しく解説しています。

2. Service層(サービスクラス)の設計と実装手順

Fat Controllerの弊害を断ち切る第1歩は、「ビジネスロジックとオーケストレーションをControllerから切り離し、専用のServiceクラスに集約すること」 です。

Serviceクラスの作成場所(app/Services)と命名規則

Laravelのデフォルト構成には app/Services ディレクトリは含まれていませんが、中規模以上の開発では独自に作成して配置するのがデファクトスタンダードです。

app/
├── Http/
│   ├── Controllers/
│   └── Requests/          # バリデーション層(FormRequest)
├── Services/              # ★ ビジネスロジック層(Serviceクラス)
│   ├── User/
│   │   ├── UserRegistrationService.php
│   │   └── UserProfileService.php
│   └── Payment/
│       └── StripePaymentService.php

サービスクラスの命名には、ドメイン単位で複数操作をまとめる「ドメイン集約型(UserService, UserRegistrationService)」と、単一ユースケースに特化する「単一アクション型(RegisterUserAction)」の2通りがあります。中規模までのCRUDや業務システムでは、直感的に扱えるドメイン集約型サービスクラスの採用が一般的です。

ビジネスロジックとトランザクション(DB::transaction)の集約

会員登録処理を専用の UserRegistrationService に集約します。外部通信はトランザクション開始前に完了させ、DB操作のみを DB::transaction のクロージャで安全に囲みます。

<?php

namespace App\Services\User;

use App\Models\User;
use App\Models\Profile;
use App\Models\PointHistory;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Stripe\StripeClient;
use Exception;

class UserRegistrationService
{
    public function __construct(
        protected StripeClient $stripe
    ) {}

    /**
     * 新規ユーザーの本登録を実行
     *
     * @param array<string, mixed> $data 検証済み入力データ
     * @return User 作成されたユーザー
     * @throws Exception
     */
    public function register(array $data): User
    {
        // ① 外部通信をDBロック取得前に完了させる
        $customer = $this->stripe->customers->create([
            'email' => $data['email'],
            'name'  => $data['name'],
        ]);

        // ② DBトランザクション内で不可分なデータ作成を実行
        $user = DB::transaction(function () use ($data, $customer) {
            $user = User::create([
                'name'      => $data['name'],
                'email'     => $data['email'],
                'password'  => Hash::make($data['password']),
                'stripe_id' => $customer->id,
            ]);

            Profile::create([
                'user_id' => $user->id,
                'avatar'  => 'default.png',
            ]);

            PointHistory::create([
                'user_id' => $user->id,
                'points'  => 500,
                'type'    => 'signup_bonus',
            ]);

            return $user;
        });

        // ③ DBコミット成功後に安全にメール送信
        Mail::to($user->email)->send(new WelcomeMail($user));

        return $user;
    }
}

Controllerへのコンストラクタインジェクション(DI)による疎結合化

Serviceクラスを作成したら、Controllerへ 依存性の注入(DI) を行います。Laravelのサービスコンテナが型宣言を自動解決してインスタンスを注入します。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\UserRegisterRequest;
use App\Services\User\UserRegistrationService;
use Illuminate\Http\RedirectResponse;
use Exception;

class UserController extends Controller
{
    public function __construct(
        protected UserRegistrationService $registrationService
    ) {}

    public function register(UserRegisterRequest $request): RedirectResponse
    {
        try {
            // FormRequestで検証済みのクリーンな配列を渡すだけ
            $user = $this->registrationService->register(
                $request->validated()
            );

            return redirect()
                ->route('dashboard')
                ->with('success', "{$user->name} さん、会員登録が完了しました!");

        } catch (Exception $e) {
            report($e);

            return back()
                ->withInput()
                ->withErrors(['error' => '登録処理中にエラーが発生しました。']);
        }
    }
}

Controllerのアクションが「入力受け取り ➔ Service呼び出し ➔ 画面遷移」の数行だけに短縮され、可読性と再利用性が劇的に向上します。

3. Repositoryパターンの設計とインターフェースの活用

Service層の導入によりControllerの肥大化は解消されますが、Serviceクラス内に User::create() などのEloquent直接アクセスが残っていると、単体テストで依然として実DB接続が必要になります。

データアクセス層を抽象化し、モックへの差し替えを自在にするデザインパターンが 「Repositoryパターン」 です。

なぜインターフェース(Interface)を挟むのか?(DBアクセスの抽象化)

Repositoryパターンの要は、具象クラスの前に インターフェース(契約) を定義することです。SOLID原則の 依存性逆転の原則(DIP) に従い、「上位モジュール(Service)も下位モジュール(Eloquent)も、抽象(Interface)に依存する」構造を作ります。

構成 依存の方向 テスタビリティ・疎結合度
インターフェースなし(密結合) Service ──▶ EloquentUserRepository(DB直結) ✖ 低い(テストにDB必須)
Repositoryパターン(疎結合) Service ──▶ UserRepositoryInterface ◀── EloquentUserRepository ◯ 極めて高い(Mock差し替え自由)

UserRepositoryInterface と EloquentUserRepository の作成手順

ディレクトリは契約(Contracts)と具象(Eloquent)に分けて配置します。

app/
├── Repositories/
│   ├── Contracts/             # ★ インターフェース置き場
│   │   ├── UserRepositoryInterface.php
│   │   └── ProfileRepositoryInterface.php
│   └── Eloquent/              # ★ Eloquent具象実装置き場
│       ├── EloquentUserRepository.php
│       └── EloquentProfileRepository.php

インターフェースでメソッドの契約を定義します。

<?php

namespace App\Repositories\Contracts;

use App\Models\User;

interface UserRepositoryInterface
{
    public function findById(int $id): ?User;
    public function findByEmail(string $email): ?User;
    public function create(array $attributes): User;
    public function update(User $user, array $attributes): bool;
}

続いて、インターフェースを実装する具象Eloquentリポジトリを作成します。

<?php

namespace App\Repositories\Eloquent;

use App\Models\User;
use App\Repositories\Contracts\UserRepositoryInterface;

class EloquentUserRepository implements UserRepositoryInterface
{
    public function findById(int $id): ?User
    {
        return User::query()->find($id);
    }

    public function findByEmail(string $email): ?User
    {
        return User::query()->where('email', $email)->first();
    }

    public function create(array $attributes): User
    {
        return User::query()->create($attributes);
    }

    public function update(User $user, array $attributes): bool
    {
        return $user->update($attributes);
    }
}

AppServiceProvider でのインターフェースと具象クラスのバインド設定

サービスプロバイダで、インターフェースが要求された際に具象クラスを注入するようバインド登録します。

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Repositories\Contracts\UserRepositoryInterface;
use App\Repositories\Eloquent\EloquentUserRepository;
use App\Repositories\Contracts\ProfileRepositoryInterface;
use App\Repositories\Eloquent\EloquentProfileRepository;

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

        $this->app->bind(
            ProfileRepositoryInterface::class,
            EloquentProfileRepository::class
        );
    }
}

バインド完了後、Serviceクラスのコンストラクタでインターフェースを受け取るよう改修します。

<?php

namespace App\Services\User;

use App\Models\User;
use App\Repositories\Contracts\UserRepositoryInterface;
use App\Repositories\Contracts\ProfileRepositoryInterface;
use App\Models\PointHistory;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Stripe\StripeClient;

class UserRegistrationService
{
    public function __construct(
        protected UserRepositoryInterface $userRepo,
        protected ProfileRepositoryInterface $profileRepo,
        protected StripeClient $stripe
    ) {}

    public function register(array $data): User
    {
        $customer = $this->stripe->customers->create([
            'email' => $data['email'],
            'name'  => $data['name'],
        ]);

        $user = DB::transaction(function () use ($data, $customer) {
            // Eloquent直接呼び出しを排除し、Repository経由で保存
            $user = $this->userRepo->create([
                'name'      => $data['name'],
                'email'     => $data['email'],
                'password'  => Hash::make($data['password']),
                'stripe_id' => $customer->id,
            ]);

            $this->profileRepo->create([
                'user_id' => $user->id,
                'avatar'  => 'default.png',
            ]);

            PointHistory::create([
                'user_id' => $user->id,
                'points'  => 500,
                'type'    => 'signup_bonus',
            ]);

            return $user;
        });

        Mail::to($user->email)->send(new WelcomeMail($user));

        return $user;
    }
}

4. 過剰設計(Over-engineering)を防ぐ使い分けの基準

すべてのモデルに対して機械的にRepositoryインターフェースを作成すると、ファイル数が激増し、Eloquentの強力な機能(Eager Loadingやスコープ)が扱いづらくなる 「過剰設計(Over-engineering)」 の罠に陥ります。

単純なCRUDにRepositoryパターンは不要?小規模と中大規模での選定判断

単純にIDで1件取得して返すだけの処理にリポジトリを挟むと、単なるEloquentのパススルー(素通り)コードになり、開発コストだけが増大します。

規模 推奨構成 適用シーン 特徴
小規模 / MVP Controller ➔ Eloquent ・簡易社内ツール
・プロトタイプ検証
開発速度最優先
中規模 / 実務標準
★ 最も推奨
FormRequest ➔ Controller ➔ Service ➔ Eloquent ・一般的なWebサービス / SaaS
・画面とAPIでロジック共通化
高コスパ
保守性と生産性の最良バランス
大規模 / ドメイン駆動 FormRequest ➔ Controller ➔ Service ➔ Repository ➔ Eloquent ・厳格な決済・計算ロジック
・DB接続ゼロでの超高速単体テスト必須
テスタビリティ最大化

Service層のみを導入する「軽量アーキテクチャ」の推奨ケース

実務の現場では、「Service層のみを導入し、Service内で直接Eloquentを使う軽量アーキテクチャ」 が最も高い開発生産性を発揮します。Fat Controllerの解消、トランザクションの集約、ロジック共通化という恩恵をすべて享受しつつ、不要なインターフェース定義の手間を削減できます。

最初から全機能にRepositoryを導入するのではなく、CIのテスト実行速度が課題になった中核機能や、複雑な集計クエリが集中するドメインに絞って段階的にRepositoryを適用するのが実務における最適解です。

5. テスタビリティの劇的向上:PestによるService層のモックテスト実践

Repositoryをインターフェース化しておく最大のメリットは、「データベースに一切接続せず、ミリ秒単位で超高速に単体テストを実行できる点」 にあります。

モダンテストフレームワーク Pest と Mockery を組み合わせた単体テストの実装例を見ていきましょう。Pestの基本構文や導入方法は Laravel Pest入門ガイド|書き方・モックテストの基本 をご参照ください。

Repositoryをモック化(Mockery)してDB接続なしで超高速テストを実行するコード例

tests/Unit/Services/UserRegistrationServiceTest.php で、RefreshDatabase を使用せずにモックテストを記述します。

<?php

namespace Tests\Unit\Services;

use App\Models\User;
use App\Models\Profile;
use App\Repositories\Contracts\UserRepositoryInterface;
use App\Repositories\Contracts\ProfileRepositoryInterface;
use App\Services\User\UserRegistrationService;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Hash;
use Stripe\StripeClient;
use Stripe\Service\CustomerService;
use Stripe\Customer;
use Mockery;
use Mockery\MockInterface;

describe('UserRegistrationService', function () {
    beforeEach(function () {
        Mail::fake();
        Hash::shouldReceive('make')->andReturn('hashed_password');
    });

    test('正常系: 正しいデータが渡された場合、顧客登録・ユーザー作成・メール送信が行われる', function () {
        // Arrange: モックのセットアップ
        $mockCustomer = new Customer('cus_12345');
        $mockCustomerService = Mockery::mock(CustomerService::class);
        $mockCustomerService->shouldReceive('create')
            ->once()
            ->with(['email' => 'taro@example.com', 'name' => 'テスト太郎'])
            ->andReturn($mockCustomer);

        $mockStripe = Mockery::mock(StripeClient::class);
        $mockStripe->customers = $mockCustomerService;

        $expectedUser = new User([
            'id'        => 1,
            'name'      => 'テスト太郎',
            'email'     => 'taro@example.com',
            'stripe_id' => 'cus_12345',
        ]);
        $expectedUser->id = 1;

        /** @var UserRepositoryInterface|MockInterface $mockUserRepo */
        $mockUserRepo = Mockery::mock(UserRepositoryInterface::class);
        $mockUserRepo->shouldReceive('create')
            ->once()
            ->andReturn($expectedUser);

        /** @var ProfileRepositoryInterface|MockInterface $mockProfileRepo */
        $mockProfileRepo = Mockery::mock(ProfileRepositoryInterface::class);
        $mockProfileRepo->shouldReceive('create')
            ->once()
            ->andReturn(new Profile());

        $service = new UserRegistrationService($mockUserRepo, $mockProfileRepo, $mockStripe);

        // Act: 実行
        $result = $service->register([
            'name'     => 'テスト太郎',
            'email'    => 'taro@example.com',
            'password' => 'secret123',
        ]);

        // Assert: 検証
        expect($result)->toBeInstanceOf(User::class)
            ->id->toBe(1)
            ->email->toBe('taro@example.com');

        Mail::assertSent(WelcomeMail::class, fn ($mail) => $mail->hasTo('taro@example.com'));
    });
});

正常系・異常系(例外発生時のロールバック)のテストケース設計

外部通信の障害やDBエラーといった異常系の振る舞いも、モックを使えば簡単にシミュレーションできます。

<?php

use Stripe\Exception\ApiConnectionException;

test('異常系: 外部決済APIで通信障害が発生した場合、例外がスローされDB保存とメール送信は実行されない', function () {
    $mockCustomerService = Mockery::mock(CustomerService::class);
    $mockCustomerService->shouldReceive('create')
        ->once()
        ->andThrow(new ApiConnectionException('Stripe unreachable'));

    $mockStripe = Mockery::mock(StripeClient::class);
    $mockStripe->customers = $mockCustomerService;

    // リポジトリは一度も呼び出されてはならない
    $mockUserRepo = Mockery::mock(UserRepositoryInterface::class);
    $mockUserRepo->shouldNotReceive('create');

    $mockProfileRepo = Mockery::mock(ProfileRepositoryInterface::class);
    $mockProfileRepo->shouldNotReceive('create');

    $service = new UserRegistrationService($mockUserRepo, $mockProfileRepo, $mockStripe);

    expect(fn () => $service->register([
        'name'     => 'エラー検証',
        'email'    => 'err@example.com',
        'password' => 'password123',
    ]))->toThrow(ApiConnectionException::class);

    Mail::assertNothingSent();
});

6. まとめ:保守性の高いLaravelアプリケーション設計チェックリスト

アーキテクチャ設計の目的は、ルールに縛られることではなく、「チーム全員が迷わず安全に変更でき、高速にテストを回せる開発基盤を作ること」 です。

✅ 保守性の高いLaravel設計 チェックリスト:

  • [ ] Controllerの責務制限:アクションメソッドは「入力受け取り ➔ Service呼び出し ➔ レスポンス返却」のみに専念しているか?
  • [ ] 入力検証の分離:$request->validate() をControllerに直書きせず、FormRequestクラスへ委譲しているか?
  • [ ] ビジネスロジックの集約:複数モデルの更新や外部通信を伴う処理が、app/Services 配下のサービスクラスに集約されているか?
  • [ ] トランザクション境界の安全管理:DB::transaction() をService層で管理し、外部API通信などの遅延処理をトランザクション内部から排除しているか?
  • [ ] YAGNI原則の遵守:単純なCRUDテーブルに不要なRepositoryを作らず、まずは「Service層のみの軽量アーキテクチャ」からスタートしているか?
  • [ ] インターフェースの戦略的導入:厳格な計算・決済や、単体テストを爆速化したい中核ドメインに絞ってRepositoryパターンを適用しているか?
  • [ ] 単体テストの高速化:PestやMockeryを活用し、DB接続なしで数ミリ秒で完走するUnit Testを整備しているか?

まずは今日から、肥大化したコントローラーのアクションを1つ選び、ビジネスロジックを app/Services に切り出すリファクタリングから始めてみてください。コードの見通しの良さとテスタビリティの劇的な向上を実感できるはずです。

レン (Wren)

こんにちは。レンです。

Laravelのコードの森に住んでいる、小さな案内役です。
ルーティングの枝やクラスの影を歩きながら、コードの流れや仕組みを眺めています。

このサイトでは、Laravelの基本から実装のコツまで、開発で役立つポイントを静かに整理しています。
難しいことを増やすのではなく、コードの見通しが少し良くなるヒントを届けるのが役目です。

「この処理はどこに書くのがいいのか」
「Laravelではどう考えると整理できるのか」

そんな疑問に、小さなメモを残すような気持ちで記事を書いています。

コードを書いている途中で迷ったとき、
このサイトが少し立ち止まって整理できる場所になればうれしいです。

レン (Wren)をフォローする

コメント