【実務完全ガイド】Laravelのサービスコンテナと依存性注入――保守しやすい設計、サービス層、インターフェース分離、テスト容易性を高める実践パターン
この記事で学べること(要点)
- Laravelのサービスコンテナと依存性注入の基本を、実務で困らない粒度で理解する方法
- コントローラにロジックが集まりすぎる問題を、サービス層やActionで整理する考え方
- 具象クラスではなくインターフェースへ依存する設計と、
bind/singleton/scopedの使い分け - 外部API、メール、決済、ファイル保存など「差し替えたくなる処理」を疎結合にする実装パターン
- コンテナを使ったテスト容易性の向上、FakeやMockへの置き換え方
- 初学者がやりがちな過剰抽象化を避けながら、読みやすく保守しやすいLaravelアプリへ育てる方針
- 管理画面やフォーム、通知画面におけるアクセシブルなエラーハンドリングや結果表示へつなげる設計
想定読者
- Laravel 初〜中級エンジニア:コントローラやモデルに処理が集まり、整理の仕方に迷っている方
- テックリード:チームで「どこまで抽象化するか」の共通ルールを作りたい方
- QA/保守担当:外部APIや通知処理の差し替え、テスト、障害対応をしやすくしたい方
- デザイナー/CS/運用担当:画面の結果表示やエラーメッセージが安定し、問い合わせが減る土台を整えたい方
アクセシビリティレベル:★★★★☆
主題はバックエンド設計ですが、責務分離が進むと、UIに返す成功・失敗・注意の状態が安定します。結果として、
role="status"やrole="alert"を用いた一貫した通知設計、フォームエラーの明確化、色に依存しないメッセージ設計へつなげやすくなります。
1. はじめに:依存性注入を理解すると、Laravelのコードは急に読みやすくなります
Laravelを始めたばかりの頃は、コントローラにそのまま処理を書いても十分に動きます。登録、更新、メール送信、外部API連携、ログ記録なども、ひとまず1つのメソッドに書けば画面は作れます。ただ、機能が増えてくると、だんだん次のような悩みが出てきます。
- コントローラが長くなり、どこを直せばよいか分かりにくい
- 同じ処理が複数箇所に散っていて、修正漏れが起きる
- 外部APIの差し替えがつらい
- テストでモックやFakeに置き換えたいのに、new していて差し替えにくい
- 決済や通知の障害時に、影響範囲を追いにくい
このときに効いてくるのが、サービスコンテナと依存性注入です。少し抽象的に聞こえるかもしれませんが、要するに「必要な部品を、必要な場所へ、差し替えやすい形で渡す」ための仕組みです。Laravelはこの仕組みを最初から強く持っているので、理解して使えるようになると、保守性もテストしやすさも一段上がります。
2. まず押さえる:サービスコンテナとは何か
Laravelのサービスコンテナは、「このクラスが必要なら、どうやって作るか」を覚えておいてくれる入れ物のような存在です。たとえば、コントローラのコンストラクタでクラスを型宣言すると、Laravelが自動でそのクラスを解決して渡してくれます。
class OrderController extends Controller
{
public function __construct(
private OrderService $orderService
) {}
}
このとき、Laravelは OrderService を生成して注入します。もし OrderService の中でも別のクラスを必要としていれば、その依存もたどって解決してくれます。これが「依存性注入」と呼ばれる考え方です。
重要なのは、コントローラ側が「どう作るか」を知らなくてよいことです。必要なのは「何が必要か」を宣言するだけです。こうしておくと、後から実装を差し替えたり、テスト時にFakeへ置き換えたりしやすくなります。
3. なぜ new を減らすと保守しやすくなるのか
初心者のうちは、つい次のように書きたくなります。
public function store(Request $request)
{
$mailer = new WelcomeMailer();
$mailer->send($request->email);
}
これでも動きます。ただ、この書き方には弱点があります。
WelcomeMailerを別実装に差し替えにくい- テストで偽物に置き換えにくい
- 依存関係がコードの中に埋まってしまい、一覧しづらい
- 設定や環境ごとの分岐が入り始めると、すぐに複雑になる
依存性注入を使うと、次のように変わります。
class RegisterController extends Controller
{
public function __construct(
private WelcomeMailer $welcomeMailer
) {}
public function store(Request $request)
{
$this->welcomeMailer->send($request->email);
}
}
これだけでも、何を使っているかがはっきりします。さらに WelcomeMailer の中身を別の実装に置き換えたくなったときも、コントローラを触らずに済む可能性が高くなります。
4. 依存性注入の基本パターン:コンストラクタ注入を第一候補にする
Laravelではいくつか注入方法がありますが、まず覚えたいのはコンストラクタ注入です。理由は、依存関係がクラスの入口で明示されるからです。
class InvoiceController extends Controller
{
public function __construct(
private InvoiceService $invoiceService,
private ExportService $exportService
) {}
public function store(StoreInvoiceRequest $request)
{
$invoice = $this->invoiceService->create($request->validated());
return redirect()
->route('invoices.show', $invoice)
->with('status', '請求書を作成しました。');
}
}
この書き方の利点は、クラスを見た瞬間に「このコントローラは何に依存しているか」が分かることです。大きなプロジェクトほど、この明示性が効きます。
一方で、アクションごとにしか使わない依存まで何でもコンストラクタに入れると、今度は依存が増えすぎて読みにくくなります。そこで次の使い分けが便利です。
- クラス全体で使うもの:コンストラクタ注入
- 特定メソッドでしか使わないもの:メソッド注入
- LaravelのリクエストやRoute Model Bindingと一緒に使うもの:メソッド注入が自然なことも多い
5. メソッド注入:特定アクションだけで使う依存は軽く渡す
public function export(
Request $request,
CustomerExportService $exportService
) {
$job = $exportService->dispatch($request->user());
return back()->with('status', 'エクスポートを開始しました。');
}
このように、特定のアクションだけで使う依存はメソッド引数で受け取ると、クラス全体がすっきりします。コンストラクタ注入と比べて依存が散るように感じることもありますが、「そのメソッドでしか使わない」という意図が明確なら十分に読みやすいです。
初心者のうちは、全部コンストラクタに入れたくなるかもしれませんが、そこは少し落ち着いて、「このクラス全体に必要か、それともこのメソッドだけか」で判断すると良いです。
6. サービス層を導入する理由:コントローラを“HTTPの入り口”に保つ
Laravelでは、コントローラに全部書いても動きます。ですが、コントローラが次の責務を全部持ち始めるとつらくなります。
- 入力値の受け取り
- バリデーション
- DB保存
- 外部API呼び出し
- 通知送信
- ログ記録
- 例外処理
- リダイレクトやJSON返却
コントローラは本来、HTTPの入り口として「受け取って、適切な処理へ流し、結果を返す」役に寄せる方が読みやすいです。そこで、業務処理はサービス層やActionクラスへ移します。
6.1 例:注文作成サービス
namespace App\Services;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class OrderService
{
public function create(User $user, array $data): Order
{
return DB::transaction(function () use ($user, $data) {
$order = $user->orders()->create([
'total_amount' => $data['total_amount'],
'status' => 'pending',
'note' => $data['note'] ?? null,
]);
foreach ($data['items'] as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $item['price'],
]);
}
return $order;
});
}
}
6.2 コントローラ側
class OrderController extends Controller
{
public function __construct(
private OrderService $orderService
) {}
public function store(StoreOrderRequest $request)
{
$order = $this->orderService->create(
$request->user(),
$request->validated()
);
return redirect()
->route('orders.show', $order)
->with('status', '注文を受け付けました。');
}
}
このように分けると、コントローラは短くなり、業務処理は独立してテストしやすくなります。
7. Service と Action の使い分け:大きくしすぎない工夫
サービス層を入れ始めると、今度は「Serviceが何でも屋になる」問題が起きやすいです。ここで役立つのが Action という考え方です。
- Service:ある領域のまとまりを扱う(OrderService、UserService など)
- Action:1つの目的に絞った処理(CreateOrderAction、SuspendUserAction など)
たとえば、OrderService に create, cancel, refund, export, notify, sync と増えていくと、だんだん読みにくくなります。そんなときは、「よく使う1目的処理」を Action に分けるとすっきりします。
namespace App\Actions;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class CreateOrderAction
{
public function execute(User $user, array $data): Order
{
return DB::transaction(function () use ($user, $data) {
$order = $user->orders()->create([
'total_amount' => $data['total_amount'],
'status' => 'pending',
]);
foreach ($data['items'] as $item) {
$order->items()->create($item);
}
return $order;
});
}
}
この粒度だと、テストも読みやすく、失敗時の責任範囲もはっきりします。何でもかんでも Action にする必要はありませんが、「大きくなりすぎたサービスを分ける先」として覚えておくと便利です。
8. インターフェース分離:差し替えたくなる処理は抽象化の価値があります
依存性注入の真価が出るのは、「後から差し替えたい処理」を分離したときです。たとえば次のようなものです。
- 決済
- メール送信
- SMS送信
- 外部API連携
- ファイル保存
- 検索エンジン
- レポート出力
これらは将来、別ベンダへ乗り換えたり、テストでFakeに差し替えたりしたくなりやすいです。そういう処理は、インターフェースに依存すると柔軟になります。
8.1 例:請求通知
namespace App\Contracts;
interface BillingNotifier
{
public function sendInvoiceReady(int $userId, int $invoiceId): void;
}
namespace App\Services;
use App\Contracts\BillingNotifier;
use App\Mail\InvoiceReadyMail;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
class MailBillingNotifier implements BillingNotifier
{
public function sendInvoiceReady(int $userId, int $invoiceId): void
{
$user = User::findOrFail($userId);
$invoice = Invoice::findOrFail($invoiceId);
Mail::to($user->email)->queue(new InvoiceReadyMail($invoice));
}
}
これで、将来 Slack 通知や外部通知サービスに切り替えたくなっても、利用側は BillingNotifier を見ていればよくなります。
9. ServiceProviderでの登録:bind と singleton の考え方
インターフェースを使う場合、Laravelに「この契約なら、この実装を使う」と教える必要があります。そこで ServiceProvider を使います。
namespace App\Providers;
use App\Contracts\BillingNotifier;
use App\Services\MailBillingNotifier;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(BillingNotifier::class, MailBillingNotifier::class);
}
}
9.1 bind
呼ばれるたびに新しいインスタンスを作るのが基本です。状態を持たない普通のサービスなら、まずは bind で十分なことが多いです。
9.2 singleton
1リクエスト中で同じインスタンスを使い回したい場合に向いています。ただし、状態を持つクラスを singleton にすると、意図しない副作用の原因になりやすいです。初学者のうちは、必要が明確な場合だけに絞ると安心です。
9.3 scoped
リクエスト単位・ライフサイクル単位の解決が欲しいときに使えますが、まずは bind と singleton の理解を優先すると十分です。
10. 過剰抽象化を避ける:何でもインターフェースにしない方がよい理由
依存性注入を学ぶと、すべてのクラスに interface を作りたくなることがあります。でも、これはやりすぎると逆に読みにくくなります。抽象化の価値が高いのは、主に次のような場面です。
- 実装を差し替える可能性が高い
- 外部サービスに依存している
- テストでFakeにしたい
- 契約として意味がある
逆に、プロジェクト内だけで完結するシンプルなサービスに毎回 interface を作ると、ファイル数だけ増えて読みづらくなります。ですので、最初から全部抽象化するのではなく、「差し替える未来が見えるもの」から始める方が実務的です。
11. コンテナとテスト:Fakeへ置き換えやすくする
サービスコンテナの大きな利点は、テストで差し替えやすいことです。たとえば通知部分を Fake にしたいとき、コンテナへ差し替えを登録できます。
11.1 Fake 実装
namespace Tests\Fakes;
use App\Contracts\BillingNotifier;
class FakeBillingNotifier implements BillingNotifier
{
public array $sent = [];
public function sendInvoiceReady(int $userId, int $invoiceId): void
{
$this->sent[] = compact('userId', 'invoiceId');
}
}
11.2 テスト側で差し替え
public function test_invoice_ready_notification_is_dispatched()
{
$fake = new \Tests\Fakes\FakeBillingNotifier();
$this->app->instance(\App\Contracts\BillingNotifier::class, $fake);
$user = User::factory()->create();
$invoice = Invoice::factory()->create(['user_id' => $user->id]);
app(\App\Contracts\BillingNotifier::class)
->sendInvoiceReady($user->id, $invoice->id);
$this->assertCount(1, $fake->sent);
$this->assertSame($invoice->id, $fake->sent[0]['invoiceId']);
}
このように、コード側が具象クラスに直接依存していないと、テストが柔らかくなります。外部APIの障害を模したテストもしやすくなりますね。
12. 例外と戻り値:サービス層がUIへ返す情報を整える
サービス層を入れると、画面側へ「何を返すか」も重要になります。おすすめは次のような考え方です。
- 成功時:モデルやDTOなど、意味のある結果を返す
- 想定内失敗:業務例外やバリデーション例外として扱う
- 想定外失敗:例外を投げ、Handler で統一的に処理する
たとえば、在庫不足のような業務上の失敗は、ただ false を返すよりも、意味が伝わる例外や結果オブジェクトにした方が扱いやすいです。
namespace App\Exceptions;
use RuntimeException;
class OutOfStockException extends RuntimeException
{
}
if ($product->stock < $qty) {
throw new OutOfStockException('在庫が不足しています。');
}
コントローラや Handler でこの例外を拾い、画面には分かりやすいメッセージを返します。責務分離が進むほど、UIのエラーメッセージも一貫しやすくなります。
13. フォームや通知UIへのつながり:バックエンド設計は画面の分かりやすさに直結します
一見すると、サービスコンテナや依存性注入は画面とは離れた話に見えるかもしれません。でも実際には、ここが整うとUIのメッセージが安定します。
たとえば、注文作成処理がコントローラにべったり書かれていると、成功/失敗の分岐が画面ごとにばらつきやすくなります。逆に、Action や Service が整理されていると、コントローラは「成功ならこの通知」「業務例外ならこのエラー表示」と揃えやすくなります。
try {
$order = $this->createOrderAction->execute(
$request->user(),
$request->validated()
);
return redirect()
->route('orders.show', $order)
->with('status', '注文を受け付けました。');
} catch (OutOfStockException $e) {
return back()
->withInput()
->withErrors(['items' => '在庫が不足している商品があります。']);
}
このようにしておくと、画面側では role="status" や role="alert" を一貫して使いやすくなります。アクセシブルな通知設計は、実はバックエンドの責務整理とも相性が良いのです。
14. Repositoryパターンは必要か:Laravelでは“目的”が大事です
Laravelの設計でよく話題になるのが Repository パターンです。結論から言うと、何でも Repository にする必要はありません。Eloquent 自体が十分に扱いやすいので、単純な CRUD を全部 Repository 越しにすると、かえって複雑になることがあります。
ただし、次のような場合は Repository 的な分離が役立つことがあります。
- 複雑な検索条件を再利用したい
- DB以外の保存先と切り替える可能性がある
- 集計クエリや横断的な取得ロジックを整理したい
- Eloquent 依存をアプリケーション層から少し遠ざけたい
つまり、Repository は「パターンだから使う」のではなく、「整理したい複雑さがあるときに使う」とちょうどよいです。初学者の段階では、まずは Eloquent + Service/Action + Resource の分離を身につける方が効果が出やすいです。
15. 実務でおすすめの最小構成:まずはこの3つで十分です
すべてを一気に抽象化しなくても、次の3つを徹底するだけでコードはかなり整います。
- コントローラを薄くする
- 再利用したい業務処理を Service / Action へ出す
- 差し替えたくなる処理だけインターフェース化する
たとえば以下のような構成です。
StoreOrderRequest:入力の責務CreateOrderAction:業務処理の責務BillingNotifier:外部依存の契約OrderController:HTTPの入口と出口OrderResource:API整形の責務
このくらいに分けるだけでも、見通しがかなり良くなります。最初から巨大なアーキテクチャを目指さず、困りやすい場所から1つずつ切り出すのが実務的です。
16. よくある落とし穴と回避策
16.1 コントローラが薄くなった代わりにServiceが巨大になる
回避策として、1目的の Action へ分けるか、責務ごとに Service を分けます。UserService に何でも入れるのは避けたいところです。
16.2 何でも interface を作ってしまう
差し替える見込みが薄いものまで抽象化すると、ファイルが増えて追いにくくなります。外部依存や契約価値の高いものから始めると良いです。
16.3 container を使わず new が散らばる
あとで差し替えやテストがしづらくなります。まずはコンストラクタ注入を習慣にすると改善しやすいです。
16.4 Service がレスポンス整形まで始める
HTML や API の見せ方は Resource や ViewModel 側へ寄せると、責務が崩れにくいです。
16.5 例外設計が曖昧で画面メッセージがばらつく
想定内失敗と想定外失敗を分けると、UIの通知が安定します。
17. チェックリスト(配布用)
設計
- [ ] コントローラはHTTPの入口/出口に寄せている
- [ ] 再利用したい業務処理を Service / Action に分けている
- [ ] 差し替えたい処理だけ interface を導入している
- [ ]
newがコントローラやサービス内部に散らばっていない
コンテナ
- [ ]
bind/singletonの使い分けが整理されている - [ ] AppServiceProvider などで依存関係の登録を明示している
- [ ] テストで Fake や Mock に差し替えやすい構成になっている
テスト
- [ ] 重要な Action / Service のテストがある
- [ ] 外部依存は Fake に置き換えられる
- [ ] 例外時の挙動がテストされている
UIとの接続
- [ ] 成功/失敗メッセージが画面ごとにぶれにくい
- [ ] 想定内エラーは利用者に分かる形で返している
- [ ]
role="status"/role="alert"を活かしやすい一貫した戻り方になっている
18. まとめ
Laravelのサービスコンテナと依存性注入は、単なる“おしゃれな設計”ではありません。コードの見通しを良くし、差し替えやテストをしやすくし、結果として運用や障害対応まで楽にしてくれる実務的な仕組みです。最初は難しく感じるかもしれませんが、まずはコンストラクタ注入で new を減らし、コントローラから大きな業務処理を Service や Action へ出すところから始めると十分です。そこに、差し替えたくなる処理だけインターフェースを導入していくと、無理のない形で疎結合なLaravelアプリへ育っていきます。焦らず、1つの処理から整理していきましょうね。
