iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Modern Web

我推的Laravel S2!系列 第 24

我推的Laravel S2|Day 23:Service Container 與 Service Provider:依賴注入實戰

  • 分享至 

  • xImage
  •  

一句話破題

你在 Controller 方法簽名寫一個 Request $request,它就自己出現了——這件事從 Day 13 到現在被當成理所當然用了十天,今天終於要拆開來看背後是誰在做這件事。Service Container 是整個 Laravel 的地基,理解它之後,昨天講的依賴反轉原則才會從口號變成你真的寫得出來的程式碼。

前情提要

Day 13 我們直接在 Controller 方法寫 Request $request 就自動拿到請求物件,當時說「原理留到後面講」。Day 22 剛複習完依賴反轉原則(DIP)。今天把這兩條線收攏:Service Container 就是 Laravel 落實依賴反轉的核心機制,Service Provider 是註冊這些綁定關係的地方,最後用一個實戰範例把兩者串起來。

依賴注入原理:從手動 new 到自動注入

原生 PHP 如果 PostController 需要一個 PostService,最直接的寫法:

class PostController extends Controller
{
    public function index()
    {
        $service = new PostService(new PostRepository(new Post()));
        // ...
    }
}

這樣寫的問題:PostController 要知道 PostService 完整的建構細節,PostService 換了建構子簽名,所有手動 new 它的地方都要跟著改。Laravel 的 Service Container 讓你不用手動組裝這串依賴:

class PostController extends Controller
{
    public function __construct(
        protected PostService $postService,
    ) {}

    public function index()
    {
        $posts = $this->postService->getPublishedPosts();
    }
}

PostController 只需要宣告「我需要一個 PostService」,容器會自動分析 PostService 建構子需要什麼、PostRepository 建構子又需要什麼,一路遞迴解析下去,全部組裝好再交給你。這就是 Day 13 那個 Request $request 能直接用的真正原因:容器自動幫你 new 好了一個 Request 實例(實際上更精確地說是把當下這次請求的 Request 實例注入進來)並傳進方法參數。

底層機制其實是 PHP 的反射(Reflection)——容器在解析 PostController 時,會用反射 API 讀取建構子簽名,看到型別提示是 PostService,就遞迴地去解析 PostService,如此反覆直到所有依賴都組裝完成。這也解釋了為什麼型別提示(Day 22 提過的回傳型別宣告,這裡則是參數型別提示)在 Laravel 裡不只是文件用途,而是容器實際運作依賴的機制本身。

容器解析 PostController 時用反射讀出它需要 PostService,PostService 又需要 PostRepository,一路遞迴到沒有更多依賴為止,再由內而外組裝回去

Facade 到底是什麼:靜態方法的假象

Day 21 提過 Facade(Route::Log::Cache:: 這些)背後其實是容器解析,今天可以完整解開這個謎題。以 Log::info('...') 為例,它看起來像呼叫一個靜態方法,實際上發生的事情是:

// Log::info('文章已建立') 實際上等同於:
app('log')->info('文章已建立');

Illuminate\Support\Facades\Log 這個類別本身幾乎是空的,只定義了一個 getFacadeAccessor() 方法回傳字串 'log'。當你呼叫 Log::info(),PHP 的魔術方法 __callStatic() 會攔截這個呼叫,向容器詢問「幫我解析 'log' 這個綁定」,拿到真正的 Logger 實例後,再把 info() 呼叫轉發過去。這代表每一個 Facade 呼叫,底層都是一次容器解析——理解這一點之後,Day 27 測試章節裡「為什麼 Log::spy()Queue::fake() 這些方法能夠攔截 Facade 呼叫」也就不再神秘:它們只是暫時把容器裡對應的綁定換成一個假的替身。

Log::info() 呼叫被 __callStatic() 攔截,轉成向容器詢問 'log' 綁定,拿到真正的 Logger 實例後再轉發 info() 呼叫

無須配置的依賴注入

多數情況下,只要一個類別的依賴是「具體類別」(不是介面),容器根本不需要你額外設定任何綁定規則,就能自動解析:

class PostService
{
    public function __construct(
        protected PostRepository $repository,
    ) {}
}

上面這個例子,只要 PostRepository 本身也是可以被正常建構的類別,容器會自動遞迴組裝,不需要在任何地方寫 $this->app->bind(...)

綁定:什麼時候需要手動介入

當依賴是一個介面(Day 22 提到的 NotificationChannel),容器沒辦法自己猜你想要哪個實作,這時候需要明確綁定:

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(NotificationChannel::class, EmailNotificationChannel::class);
}

常用的綁定方法:

方法 行為
bind() 每次解析都建立新實例
singleton() 第一次解析後快取,之後都回傳同一個實例
scoped() singleton() 類似,但在 Octane 等長駐行程環境裡會在每個請求週期重置
instance() 直接把一個已經建立好的物件實例綁進容器

綁定原語(Primitives)

有時候類別建構子除了物件依賴,還需要一個純值(字串、數字),可以用 when()->needs()->give() 精準指定:

$this->app->when(SlackNotificationChannel::class)
    ->needs('$webhookUrl')
    ->give(fn () => config('services.slack.webhook_url'));

上下文綁定:同一個介面,不同情境給不同實作

前面的例子是「整個應用程式的 NotificationChannel 都綁定成 EmailNotificationChannel」,但有時候你會需要更精細的控制——例如 PostController 需要 Email 通知,但 CommentController 需要 Slack 通知,兩者都依賴同一個 NotificationChannel 介面:

$this->app->when(PostController::class)
    ->needs(NotificationChannel::class)
    ->give(EmailNotificationChannel::class);

$this->app->when(CommentController::class)
    ->needs(NotificationChannel::class)
    ->give(SlackNotificationChannel::class);

這種「依情境給予不同實作」的能力,是純手動 new 完全做不到、卻是容器機制的核心價值之一——呼叫端的程式碼完全不需要知道自己拿到的是哪個具體實作,這正是 Day 22 依賴反轉原則的精髓。

服務標籤(Tagging):一次解析出一整組實作

如果你想收集「所有實作了 NotificationChannel 的類別」,容器提供標籤機制:

$this->app->tag([EmailNotificationChannel::class, SlackNotificationChannel::class], 'notification-channels');

$channels = $this->app->tagged('notification-channels');   // 回傳一個包含兩個實例的集合

這在「使用者可以同時啟用多種通知管道」(例如同時發 Email 又發 Slack)的情境很實用,不需要自己手動維護一份「所有通知管道類別」的陣列清單,容器幫你集中管理。

解析:怎麼從容器裡拿東西出來

多數時候你不需要主動呼叫解析方法,讓建構子/方法注入自動處理即可,但了解怎麼手動解析有助於理解底層運作:

$service = App::make(PostService::class);
$bound = App::bound(NotificationChannel::class); // 檢查是否已綁定
$result = App::call([$service, 'getPublishedPosts']); // 呼叫方法並自動注入其參數

Laravel 12/13:容器對 nullable 預設值的行為調整

這是一個值得知道的細節變化。假設有個類別長這樣:

class Example
{
    public function __construct(public ?Carbon $date = null) {}
}
  • Laravel 11 及以前:容器解析 Example 時,會嘗試自動注入一個 Carbon 實例給 $date,即使它的預設值明明是 null
  • Laravel 12 起:容器改成尊重這個「nullable + 有預設值」的宣告,沒有對應綁定時直接使用 null 這個預設值,不會硬塞一個 Carbon 實例進去。
  • Laravel 13:把同樣的行為調整也套用到 Container::call()(方法呼叫注入),跟建構子注入的行為統一。

多數專案不會直接感受到這個變化,但如果你的程式碼曾經「意外地」依賴容器會自動注入一個 nullable 參數的舊行為,升級後這裡是第一個該檢查的地方。

Service Provider:綁定關係該寫在哪裡

php artisan make:provider PaymentServiceProvider
// app/Providers/PaymentServiceProvider.php
class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(PaymentContract::class, function ($app) {
            return request()->input('method') === 'credit_card'
                ? new CreditPaymentService()
                : new BankPaymentService();
        });
    }
}

register() 只能做綁定,不該在裡面解析(use)容器內的服務——Day 21 提過,這個階段沒辦法保證其他 Provider 的 register() 是否已經跑完。需要用到已綁定服務的邏輯(例如 View Composer、事件監聽器註冊)要寫在 boot()

public function boot(): void
{
    View::composer('posts.sidebar', function ($view) {
        $view->with('popularPosts', Post::orderBy('views', 'desc')->limit(5)->get());
    });
}

大量綁定的簡化寫法,用屬性取代逐一呼叫 bind()

class PaymentServiceProvider extends ServiceProvider
{
    public $bindings = [
        PaymentContract::class => CreditPaymentService::class,
    ];

    public $singletons = [
        PostAnalyticsService::class,
    ];
}

註冊 Provider:bootstrap/providers.php

Day 5 提過,新建立的 Provider 要註冊才會生效,Laravel 11 起的位置是 bootstrap/providers.php

// bootstrap/providers.php
return [
    App\Providers\AppServiceProvider::class,
    App\Providers\PaymentServiceProvider::class,
];

php artisan make:provider 執行時會自動幫你把新 Provider 加進這個檔案,不需要手動編輯(除非你是刪除 Provider 之後要清掉對應項目)。

延遲載入(Deferred Providers)

如果一個 Provider 只負責綁定某個很少用到的服務,可以標記成延遲載入,只有真的需要解析對應服務時才會被實例化,減少每次請求都要載入所有 Provider 的開銷:

class PaymentServiceProvider extends ServiceProvider implements DeferrableProvider
{
    public function provides(): array
    {
        return [PaymentContract::class];
    }
}

實戰範例:Service + Repository 分層架構

把前面的概念串成一個實際情境:blog-app 的文章列表要支援依關鍵字搜尋、依分類篩選、排序,這種「查詢邏輯」跟「業務邏輯」混在 Controller 裡會很快失控,拆成 Service(業務邏輯)+ Repository(資料存取)兩層:

// app/Repositories/PostRepository.php
class PostRepository
{
    public function search(array $filters): LengthAwarePaginator
    {
        return Post::query()
            ->when($filters['keyword'] ?? null, fn ($q, $keyword) => $q->where('title', 'like', "%{$keyword}%"))
            ->when($filters['category_id'] ?? null, fn ($q, $categoryId) => $q->where('category_id', $categoryId))
            ->when($filters['sort'] ?? null, fn ($q, $sort) => $q->orderBy($sort))
            ->paginate(10);
    }
}
// app/Services/PostService.php
class PostService
{
    public function __construct(
        protected PostRepository $repository,
    ) {}

    public function listPosts(array $queryParams): LengthAwarePaginator
    {
        $filters = [
            'keyword' => $queryParams['q'] ?? null,
            'category_id' => $queryParams['category'] ?? null,
            'sort' => in_array($queryParams['sort'] ?? null, ['title', 'published_at']) ? $queryParams['sort'] : 'published_at',
        ];

        return $this->repository->search($filters);
    }
}
// app/Http/Controllers/PostController.php
class PostController extends Controller
{
    public function __construct(protected PostService $postService) {}

    public function index(Request $request)
    {
        $posts = $this->postService->listPosts($request->query());

        return view('posts.index', ['posts' => $posts]);
    }
}

Controller 完全不知道搜尋條件怎麼組成、SQL 怎麼查,只負責「把 HTTP 請求轉成呼叫 Service」跟「把結果轉成回應」——這正是 Day 22 SRP(單一職責原則)的具體落地,而 PostController 依賴 PostService(而不是直接依賴 Post Model 或 PostRepository)則呼應了 DIP(依賴反轉原則):Controller 依賴的是一個代表「業務邏輯」的抽象層次,不是最底層的實作細節。

這個分層架構帶來的實際好處:可測試性

Service + Repository 分層除了讓程式碼職責更清楚,還有一個容易被忽略的好處:測試時可以把 Repository 換成假的替身,不需要真的碰資料庫(儘管實務上 Laravel 專案測試資料庫操作的成本通常不高,Day 27 會示範直接用真實測試資料庫的做法更常見)。如果 PostRepository 改成依賴一個介面:

interface PostRepositoryContract
{
    public function search(array $filters): LengthAwarePaginator;
}

class PostRepository implements PostRepositoryContract { /* ... */ }
$this->app->bind(PostRepositoryContract::class, PostRepository::class);

測試 PostService::listPosts() 的邏輯(篩選條件怎麼組成)時,可以綁定一個假的 Repository 實作,只驗證「傳給 Repository 的篩選條件對不對」,不需要真的建立測試資料、跑真實查詢:

$this->app->instance(PostRepositoryContract::class, new class implements PostRepositoryContract {
    public array $receivedFilters = [];
    public function search(array $filters): LengthAwarePaginator
    {
        $this->receivedFilters = $filters;
        return new LengthAwarePaginator([], 0, 10);
    }
});

這種「介面 + 綁定」的模式讓測試在需要隔離特定邏輯時有這個選項,但也呼應了下面常見錯誤提到的——不是每個 Repository 都需要先包一層介面,這是「當你真的需要這種程度的隔離測試時」才值得付出的額外複雜度。

常見錯誤與踩雷點

  • register() 裡呼叫 resolve()/app()->make():容易因為 Provider 啟動順序不保證而拿到還沒綁定好的服務,這類邏輯要放進 boot()
  • 為每個 Model 都硬套一層 Service+Repositoryblog-app 這種規模,簡單的 CRUD 頁面直接在 Controller 用 Eloquent 也完全合理,Service+Repository 分層適合「查詢/業務邏輯本身有一定複雜度」的情境,過度套用只會增加不必要的間接層。
  • 忘記新 Provider 沒註冊進 bootstrap/providers.php:手動建立 Provider 檔案(沒用 make:provider 指令)容易漏掉這一步,導致 register()/boot() 根本沒被執行。
  • 每個 Repository 都先包一層介面,即使只有一個實作、永遠不會替換:介面的價值在於「有多種可能的實作」或「需要在測試中替換」,如果一個 Repository 從頭到尾只有一個實作、也不打算測試時替換,額外的介面層只是增加要維護的檔案數量,沒有實質好處。

小結

今天把依賴注入的原理(反射機制)、Facade 背後的容器解析、Service Container 的綁定與解析(含上下文綁定、服務標籤)、Service Provider 的組織方式串成一條線,並用 Service+Repository 分層架構收尾,實際示範依賴反轉原則怎麼在 blog-app 裡落地,以及這種分層帶來的可測試性好處。Laravel 12/13 容器對 nullable 預設值的行為調整,是這塊少數需要注意的版本差異。

授人以魚,不如授人以漁 — 中國諺語

明日預告

Day 24 處理 Session 與 Cookie,這是另一個版本差異很小、可以專心練基本功的章節。


上一篇
我推的Laravel S2|Day 22:寫出乾淨的 Laravel 程式碼:PSR、命名規範、OOP 與 SOLID 原則
系列文
我推的Laravel S2!24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言