你在 Controller 方法簽名寫一個 Request $request,它就自己出現了——這件事從 Day 13 到現在被當成理所當然用了十天,今天終於要拆開來看背後是誰在做這件事。Service Container 是整個 Laravel 的地基,理解它之後,昨天講的依賴反轉原則才會從口號變成你真的寫得出來的程式碼。
Day 13 我們直接在 Controller 方法寫 Request $request 就自動拿到請求物件,當時說「原理留到後面講」。Day 22 剛複習完依賴反轉原則(DIP)。今天把這兩條線收攏:Service Container 就是 Laravel 落實依賴反轉的核心機制,Service Provider 是註冊這些綁定關係的地方,最後用一個實戰範例把兩者串起來。
原生 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 裡不只是文件用途,而是容器實際運作依賴的機制本身。

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 呼叫」也就不再神秘:它們只是暫時把容器裡對應的綁定換成一個假的替身。

多數情況下,只要一個類別的依賴是「具體類別」(不是介面),容器根本不需要你額外設定任何綁定規則,就能自動解析:
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() |
直接把一個已經建立好的物件實例綁進容器 |
有時候類別建構子除了物件依賴,還需要一個純值(字串、數字),可以用 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 依賴反轉原則的精髓。
如果你想收集「所有實作了 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']); // 呼叫方法並自動注入其參數
這是一個值得知道的細節變化。假設有個類別長這樣:
class Example
{
public function __construct(public ?Carbon $date = null) {}
}
Example 時,會嘗試自動注入一個 Carbon 實例給 $date,即使它的預設值明明是 null。null 這個預設值,不會硬塞一個 Carbon 實例進去。Container::call()(方法呼叫注入),跟建構子注入的行為統一。多數專案不會直接感受到這個變化,但如果你的程式碼曾經「意外地」依賴容器會自動注入一個 nullable 參數的舊行為,升級後這裡是第一個該檢查的地方。
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,
];
}
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 之後要清掉對應項目)。
如果一個 Provider 只負責綁定某個很少用到的服務,可以標記成延遲載入,只有真的需要解析對應服務時才會被實例化,減少每次請求都要載入所有 Provider 的開銷:
class PaymentServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function provides(): array
{
return [PaymentContract::class];
}
}
把前面的概念串成一個實際情境: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()。blog-app 這種規模,簡單的 CRUD 頁面直接在 Controller 用 Eloquent 也完全合理,Service+Repository 分層適合「查詢/業務邏輯本身有一定複雜度」的情境,過度套用只會增加不必要的間接層。bootstrap/providers.php:手動建立 Provider 檔案(沒用 make:provider 指令)容易漏掉這一步,導致 register()/boot() 根本沒被執行。今天把依賴注入的原理(反射機制)、Facade 背後的容器解析、Service Container 的綁定與解析(含上下文綁定、服務標籤)、Service Provider 的組織方式串成一條線,並用 Service+Repository 分層架構收尾,實際示範依賴反轉原則怎麼在 blog-app 裡落地,以及這種分層帶來的可測試性好處。Laravel 12/13 容器對 nullable 預設值的行為調整,是這塊少數需要注意的版本差異。
授人以魚,不如授人以漁 — 中國諺語
Day 24 處理 Session 與 Cookie,這是另一個版本差異很小、可以專心練基本功的章節。