Laravelのサービスコンテナを理解して設計を強くする:依存解決・バインド・スコープ運用まで

Laravelのサービスコンテナを理解して設計を強くする:依存解決・バインド・スコープ運用まで

Laravelのサービスコンテナは「クラス同士の依存関係を組み立てる仕組み」。ControllerやJob、Commandなどが必要とする依存(サービス/リポジトリ/クライアント)を、コンテナが自動で解決して注入する。これにより new の乱立や密結合を減らし、テスト容易性・置換可能性・環境差分(本番/開発)を吸収しやすい構造になる。重要なのは、何でもコンテナに突っ込むことではなく、責務の境界とライフサイクル(singleton/scoped/毎回生成)を決めて運用すること。

サービスコンテナの役割:依存性注入(DI)をアプリ全体に通す

・依存解決:必要なクラスを再帰的に組み立てる
・DI:コンストラクタやメソッド引数に依存を渡す
・置換:実装を差し替える(インターフェース→具象)
・ライフサイクル管理:同一インスタンスを使い回す/毎回作る
「どのクラスが、何に依存しているか」を見える化し、設計の自由度を上げるのが主目的。

まず押さえる用語:IoC / DI / Binding / Resolution

・IoC(Inversion of Control):生成や依存解決をフレームワーク側に委ねる
・DI(Dependency Injection):依存を外から注入する
・Binding:コンテナに「抽象→実装」「キー→生成方法」を登録する
・Resolution:コンテナが登録情報や型情報を使ってインスタンス化する
この4つが分かると、ServiceProviderの意味が一気にクリアになる。

自動解決(Auto-Wiring):型ヒントだけで依存が注入される仕組み

Laravelは、コンストラクタ引数にクラス型が書かれていれば、基本的に自動で解決できる(依存がさらに依存していても再帰的に解決)。

namespace App\Http\Controllers;

use App\Services\BillingService;

class BillingController
{
    public function __construct(private BillingService $billing)
    {
    }

    public function store()
    {
        $this->billing->charge();
    }
}

発生しやすい問題:
・コンストラクタにスカラー値(string/int)を置いている→自動解決できない
・インターフェース型を要求しているが、バインドが無い→解決できない

バインドの基本:bind / singleton / scoped を使い分ける

・bind:解決のたびに新しいインスタンス
・singleton:アプリ(プロセス)内で同一インスタンスを使い回す
・scoped:リクエスト単位で同一、次リクエストは別(HTTPリクエスト境界で便利)
どれを使うかは「状態を持つか」「スレッド/プロセス境界」「接続の使い回し」次第。

use Illuminate\Support\ServiceProvider;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // 毎回生成
        $this->app->bind(PaymentGateway::class, StripePaymentGateway::class);

        // 使い回し(状態を持たせない前提)
        $this->app->singleton('feature.flags', function () {
            return new \App\Services\FeatureFlags();
        });

        // リクエスト単位で使い回し
        $this->app->scoped(\App\Services\RequestContext::class, function () {
            return new \App\Services\RequestContext();
        });
    }
}

// ServiceProviderで実装を決める
$this->app->bind(\App\Contracts\MailClient::class, \App\Infra\SmtpMailClient::class);

インターフェースを軸にする:実装差し替えとテストのための基本形

依存先を具象クラスではなく契約(interface)に寄せると、実装の差し替えが簡単になる。

use App\Contracts\SmsClient;
use App\Infra\TwilioSmsClient;

$this->app->singleton(SmsClient::class, function ($app) {
    $config = $app['config']->get('services.twilio');

    return new TwilioSmsClient(
        accountSid: $config['sid'],
        authToken: $config['token'],
        from: $config['from'],
    );
});

クロージャバインド:生成時に引数や設定を注入する

環境変数や設定値を読み込んでクライアントを作る場合、クロージャで生成式を明示すると安定する。

発生しやすい問題:
・config:cache の影響で設定変更が反映されない
・サービス生成時に例外が起き、アプリ全体が起動不能になる(register内で外部通信しない)

コンテナに入れるもの・入れないもの:責務の境界を崩さない

入れると効果が大きい:
・外部APIクライアント(HTTPクライアントラッパ)
・ドメインサービス(決済、請求、通知、権限)
・リポジトリ(DBアクセスの抽象化)
・設定済みのユーティリティ(署名、暗号、ID生成)
入れない方が良いことが多い:
・状態を大量に持つDTOやEntityをコンテナから取る
・その場限りの計算結果
・リクエストデータそのもの(RequestContext等に切り出すなら別)

Service Providerの位置づけ:registerとbootの使い分け

・register:バインド定義、依存の登録(軽い処理だけ)
・boot:他サービスが登録された後に行う設定(イベント登録、ルート、ポリシー、ビュー共有)
registerで外部通信・重い処理をすると、全リクエストが遅くなる原因になる。

コンテナから取り出す方法:resolve / app() / make()

基本はDI(コンストラクタ注入)が最優先。どうしても必要ならコンテナから明示的に取り出す。

use App\Contracts\PaymentGateway;

$gateway = app(PaymentGateway::class);
// または
$gateway = resolve(PaymentGateway::class);
// または
$gateway = app()->make(PaymentGateway::class);

注意点:
・どこでも app() し始めると、依存が見えなくなりテストが壊れる
・取り出しは「境界(Factory、ServiceProvider、Bootstrap)」に寄せる

コンテキスト別の実装差し替え:環境(local/staging/prod)で切り替える

例:本番は外部SMS、開発はログ出力だけにする。環境差分をコンテナで吸収するとコードが散らからない。

use App\Contracts\SmsClient;
use App\Infra\TwilioSmsClient;
use App\Infra\LogSmsClient;

$this->app->bind(SmsClient::class, function ($app) {
    if ($app->environment('production')) {
        return $app->make(TwilioSmsClient::class);
    }
    return new LogSmsClient();
});

デコレータ/ラッパで強化する:ログ・リトライ・キャッシュを後付けする

既存の実装に、横断的関心事(ログ、メトリクス、キャッシュ)を包むと、呼び出し元のコードを触らず強化できる。

use App\Contracts\SmsClient;
use App\Infra\TwilioSmsClient;
use App\Infra\LogSmsClient;

$this->app->bind(SmsClient::class, function ($app) {
    if ($app->environment('production')) {
        return $app->make(TwilioSmsClient::class);
    }
    return new LogSmsClient();
});

テストでの置換:mock・fakeをコンテナにバインドする

Laravelのテストは、コンテナ差し替えと相性が良い。外部通信を潰し、期待通り呼ばれたかを検証できる。

use App\Contracts\SmsClient;
use Mockery;

public function test_sms_is_sent()
{
    $mock = Mockery::mock(SmsClient::class);
    $mock->shouldReceive('send')->once();

    $this->app->instance(SmsClient::class, $mock);

    // ここで対象処理を実行
}

アンチパターン:サービスロケータ化と密結合の加速

避けたい形:
・あらゆる場所で app() / resolve() を呼びまくる
・Controllerが巨大になり、何でもコンテナから取る
・ServiceProviderで外部APIへ接続して初期化する
改善指針:
・依存はコンストラクタで宣言する
・境界で組み立て、内部は純粋なオブジェクトに寄せる
・重い初期化は遅延評価にする(必要時に作る)

実務の型:Controller→UseCase→Service→Client をコンテナで繋ぐ

役割分担の例:
・Controller:入出力とHTTP責務だけ
・UseCase:アプリの手順(取引の流れ)
・Service:ドメイン寄りの処理
・Client/Repository:外部・DBアクセス
この構造をコンテナに通すと、差し替えとテストが圧倒的に楽になる。

まとめ:サービスコンテナを“便利機能”で終わらせないための要点

・DIで依存を宣言し、自動解決を基本にする
・抽象(interface)を軸にして実装をバインドする
・bind/singleton/scopedをライフサイクルで選ぶ
・ServiceProviderのregister/bootを使い分け、重い処理を置かない
・横断的関心事はデコレータで後付けし、呼び出し元を汚さない
・テストではinstanceで差し替え、外部依存を切る