← Back to list

Laravel: DI / IoC / Container 完整指南

記得剛開始使用 Laravel 開發時,我對 Service Container 的理解大概只有:「反正 type hint 就能用。」,直到專案越來越大,Controller 裡開始出現new A(), new B(), new C()… 那時候我才意識到,Container…

Chang Yu Cheng · 2026-02-28 15:46 · 8 claps · 16.1 min read
#laravel #ioc-container
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Laravel: DI / IoC / Container 完整指南

記得剛開始使用 Laravel 開發時,我對 Service Container 的理解大概只有:「反正 type hint 就能用。」,直到專案越來越大,Controller 裡開始出現new A(), new B(), new C()… 那時候我才意識到,Container 不是語法糖,而是一種「架構選擇」。

本篇文章不是教怎麼寫 Container,而是想整理:什麼時候該注入、什麼時候不該用。

本篇文章的目的不是要把 DI Container 講成萬能解藥,也不是要用一套規則去定義所有專案。

更現實的是,Container 本身也不是單一題目,它牽涉到依賴方向、抽象層次、可測試性、可替換性、模組邊界與團隊協作成本;你用得越深,越會發現它不是語法技巧,而是「你選擇怎麼讓系統長大」的方式。

因此,文中提到的建議與判斷框架,都是基於我在專案變大 new A(), new B(), new C() 開始蔓延、維護成本逐漸上升時,所累積的一套整理與取捨方式 — — 它更像一張地圖,而不是法律條文。

你可以把它當作「決策輔助」:幫你更快看見哪些情境 DI Container 真的有價值、哪些情境反而會帶來過度設計。

最後也是要提醒大家與我自己:架構永遠是 trade-off。

如果你在閱讀過程中覺得某些段落不夠嚴謹、或案例無法完全對上你的情境,先抓住背後的原則與思路就好 — — 不要糾結每一個細節,更不要把它當成對錯題。這篇文章目的不是在提供標準答案,而是幫助我們做出更正確的判斷。

我們先從一個現實的問題開始:當專案開始變大時,最先感覺到的不會是「不知道怎麼 DI Container」,而是「我開始寫得很痛苦

那種痛苦通常長這樣 ,我們只是想把一個 store() 寫完,結果 Controller 裡開始出現越來越多的 new:

public function store()
{
    $service = new OrderService(
        new LineService(),
        new EmailService()
    );
}

這段看起來沒什麼對吧?但可以思考後回答一下這個問題: 如果這段依賴從 2 個變成 10 個,這樣還能維護嗎?如果 OrderService 之後又依賴更多東西,要一路把 new 往下接嗎?如果今天 PM 說「通知要從 Email 改成 Line,順便加 Slack」,我們要改哪裡?改幾個檔案?會不會漏?

在沒有 DI(或沒有正確使用 DI)的情境下,最常見的後果有四個:

  1. 強耦合: 上層(Controller/Service)直接依賴具體實作(EmailService/LineService),改任何一個都會牽動很多地方
  2. 無法測試: 想 mock,但依賴都是 new 出來的,測試只能變成整合測試或硬上
  3. 無法替換: 需求一來「換通知渠道」,你不是換一個元件,而是在拆炸彈
  4. 無法擴充: 你想加 Slack、SMS、Push,不知不覺走向 if/else 地獄或 constructor 爆炸

我們先講結論:現在遇到的痛,是因為依賴被寫死了

在 store() 裡面 new OrderService(new LineService(), new EmailService()) 的本質是: 「上層決定了下層要用誰,而且決定得非常具體。」

當這個具體決定越來越多,耦合就會越來越重,測試越來越難,替換越來越痛。 所以我們需要一個做法,把這個決策移出去 — — 通常用三句話就能理解:

  1. 不自己 new:需要什麼,不在這裡直接 new
  2. 由外部提供:依賴由外部建立後「塞進來」
  3. 降低耦合:上層只依賴抽象(需求),不綁死具體實作

我們可以把它想像成: 你要喝咖啡,你不需要在客廳裡種咖啡樹、烘豆、磨豆、煮水、沖煮。你只要說:「我想喝一杯拿鐵」,然後由外部把咖啡交給你。你更關心的是「我要咖啡」,不是「咖啡怎麼被做出來」。到這裡其實你已經懂 DI 的精神了。

接下來才是落地問題:在 Laravel 裡,誰負責把這些依賴準備好、送進來?

目前為止,我們一直在講一件事:不要自己 new,讓外部把依賴提供進來。這個「外部提供」的做法叫 DI,但其實背後更核心的事情是在翻轉控制權 --IoC

「誰決定誰」控制權在誰手上?

傳統寫法(沒有 IoC):(Controller / 上層)決定我要用誰、什麼時候建立、怎麼組裝。

public function store()
{
    $service = new OrderService(
        new LineService(),
        new EmailService()
    );
}

這段程式碼的意思其實是: Controller 不只「使用」OrderService Controller 還「負責組裝」OrderService 的所有零件 控制權在 Controller 手上(說用誰就用誰)

IoC(Inversion of Control)反過來: 只宣告「我需要什麼」,至於「誰來提供、怎麼組裝」,交給系統。 所以 IoC 的核心不是什麼很玄的東西,而是一句話: 從「我決定依賴」→ 變成「我被注入依賴」你不是變弱,而是把不該你管的事情交出去,你只管「業務流程」、組裝由「系統」負責。

傳統(控制在自己)

Controller
  ├─ new LineService
  ├─ new EmailService
  └─ new OrderService(Line, Email)
       └─ do business

IoC(控制交出去)

Controller ──(只宣告需要 OrderService)──▶ Container / System
                                            ├─ 建立 LineService
                                            ├─ 建立 EmailService
                                            └─ 組裝成 OrderService
                                                     │
                                                     ▼
                                             回傳給 Controller 使用

很多人會把 IoC 和 DI 混在一起,其實可以這樣記: IoC 是結果:控制權反轉了(誰決定誰) DI 是常見的實作方式:用「注入」把控制權交出去

換句話說: 你可以把 IoC 想成「公司制度」,DI 是「制度下的一種工作流程」。

到這裡你理解 IoC 的本質了。但接下來問題就很實際: 在 Laravel 裡,這個負責「建立、組裝、交付」的系統是誰? 答案就是:Service Container。 它不是語法糖,而是 Laravel 用來落實 IoC/DI 的核心機制。

很多人第一次接觸 Container,會把它想成一個「自動 new 機器」: 我需要 OrderService,Container 幫我 new OrderService(),但這個理解只對了一半。 真正麻煩的從來不是 new OrderService(),而是 OrderService 背後那串依賴關係: → OrderService 需要 Notifier → Notifier 可能需要 LineClient、Logger → LineClient 可能需要 HttpClient、config

當依賴開始有層級,你手寫 new 就會爆炸,因為你其實在手動維護一棵「依賴樹」。

Service Container 的價值就在這裡,它替你管理這棵樹,並且提供一套一致的方法去決定:

  1. 這棵樹怎麼組裝
  2. 哪些節點可以替換
  3. 哪些節點要共用、哪些要每次新建

依賴樹開始爆炸

// Controller
public function store()
{
    $http = new HttpClient(
        baseUrl: config('services.line.base_url'),
        timeout: 3,
    );

    $lineClient = new LineClient(
        http: $http,
        token: config('services.line.token'),
    );

    $logger = new Logger(
        channel: 'notifications'
    );

    $notifier = new LineNotifier(
        client: $lineClient,
        logger: $logger,
    );

    $service = new OrderService(
        notifier: $notifier
    );

    $service->createOrder();
}

需求加 Slack → Controller 再多一套 new SlackNotifier(...),測試要 mock Notifier → 你根本很難替換,因為它在 controller 裡被 new 死了, 你以為你在寫 store(),其實你在手動組裝一棵依賴樹,而且這棵樹每加一個節點,就要回來這裡改一次。

DI + Container 的基本落地

Controller(不再組裝)

public function store(OrderService $service)
{
    $service->createOrder();
}

OrderService(透過建構注入依賴)

class OrderService
{
    public function __construct(
        private Notifier $notifier
    ) {}

    public function createOrder(): void
    {
        // ...建立訂單
        $this->notifier->notify('訂單建立成功');
    }
}

Notifier 介面 + 實作

interface Notifier
{
    public function notify(string $message): void;
}

class LineNotifier implements Notifier
{
    public function __construct(
        private LineClient $client,
        private Logger $logger,
    ) {}

    public function notify(string $message): void
    {
        $this->client->push($message);
        $this->logger->info("LINE sent: {$message}");
    }
}

關鍵:把「用哪個 Notifier」的決策集中到 Container(Service Provider)

use Illuminate\Support\ServiceProvider;

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

到這裡,我們已經把第一個核心決策集中起來了:

「我要用哪個 Notifier」 不再由 Controller 決定,而是交給 Container(Service Provider)統一管理,但這只解決了一半。

因為 Container 不只要回答「給你誰」,還要回答另一個更容易踩雷、也更影響系統穩定性的問題: 「這個東西要不要共用?要共用多久?

同樣是 bind 一個東西,有些物件你希望每次都是新的(避免狀態污染),有些物件你希望全站共用(避免重複建立、節省資源),還有一些你希望「同一個 request 裡共用,但 request 結束就丟掉」(多租戶、request state 最常見)。這就是為什麼 Laravel 會提供 bind / singleton / scoped。

它們不是不同語法,而是在定義生命週期策略

  1. bind:每次都給你新的(安全、預設選項)
  2. singleton:大家都用同一份(省資源,但最怕狀態污染)
  3. scoped:同一個 request 共用(介於兩者之間,處理 request/租戶狀態很好用)

例如:

Notifier 本身通常可以 bind(它可能只是協調通知流程) 但它底下的 LineClient / HttpClient 很可能更適合 singleton(建立成本高、可重用) 如果你有 TenantContext 這種 request 內會一直用到的東西,就很適合 scoped

換句話說:Container 的精髓不是「把 new 移走」,而是「把決策移到正確的位置」,而生命週期就是最重要的決策之一。

我們解決了 Container 的問題,物件要不要共用?共用多久?(bind / singleton / scoped),但 Container 還有一個、也是最根本的問題:

我要給你「誰」?

很多人到這裡會卡住:「我都已經用 DI 了啊,我 constructor 也注入了,為什麼還是很難換?」

答案是:你注入的是 class,不是抽象。

Interface Binding 的核心是:把「依賴的方向」修正成可替換的形狀。 不用 interface 會怎樣?(會在這三個地方死)如果這樣寫:

class CheckoutService
{
    public function __construct(
        private StripePayment $payment
    ) {}
}
  1. 無法替換:需求一變就全專案掃地雷,其實在說「我這輩子只接受 StripePayment。」,當 PM 說「新增 PayPal / 綁定不同金流 / 依國家切換」,你只能改 constructor 型別、改所有注入點、改所有測試、然後祈禱沒漏掉。
  2. 測試地獄:你很難 clean mock,當依賴是具體 class,你可以 mock,但通常會遇到兩種尷尬:你不得不依賴該 class 的建構子細節(mock 還要餵一堆參數) 或你最後乾脆不 mock,變成整合測試一路打到外部 SDK(慢、脆、難除錯)
  3. 維護爆炸:修改實作細節,上層全部被牽動,上層依賴具體類別,代表下層只要 constructor 一改、方法一改,上層一排紅字。

你以為你在改 Payment,實際上你在震央引爆整個系統。

把依賴「抽象化」:上層只依賴能力,不依賴做法

先抽一個介面:

interface PaymentInterface
{
    public function charge(int $amount, string $orderNo): PaymentResult;
}

Stripe / PayPal 只是能力的不同實作:

class StripePayment implements PaymentInterface
{
    public function charge(int $amount, string $orderNo): PaymentResult
    {
        // call Stripe SDK...
        return new PaymentResult(true, 'stripe_txn_id');
    }
}

上層(Service)改成依賴介面:

class CheckoutService
{
    public function __construct(
        private PaymentInterface $payment
    ) {}

    public function checkout(int $amount, string $orderNo): void
    {
        $result = $this->payment->charge($amount, $orderNo);

        if (! $result->success) {
            throw new \RuntimeException('Payment failed');
        }
    }
}

你會發現此刻開始,上層的語意變了: 「我需要的是付款能力」,不是「我需要 Stripe」。

關鍵:用 Container 把「介面 → 實作」的決策集中起來 這一步才叫 Interface Binding

$this->app->bind(
    PaymentInterface::class,
    StripePayment::class
);

到這裡,我們就得到三個很爽的結果:

  1. 替換:要換 PayPal?只改 binding(或依 config 決定),CheckoutService 不動。
  2. 測試:測試時你可以把介面綁成 Fake/Mock,不會碰到外部 SDK。
  3. 維護:StripePayment 內部怎麼改,只要符合 interface,上層不用跟著抖。

Interface 是「合約」,Binding 是「決策」。

合約讓上層穩定,決策讓你可以替換。

講到這裡,你可能會有一種衝動: 「既然 Container 這麼好,那我是不是應該全部都丟進去?全部用 interface?全部用 bind?」,先停一下!

  1. 如果你的專案是小型專案 / 需求很穩:先別急著抽象,因為你一開始就抽一堆 interface、做一堆 binding,通常只會得到更多檔案、更多命名要想、更多「這段到底在哪 bind 的?」的追查時間,這不是在「設計」,比較像在「預支複雜度」。
  2. 一次性腳本 / 資料修正:不要把短命問題做成長命架構,因為如果只是在寫一次性匯入資料(import)、一次性修正資料(fix script)、臨時的報表、轉檔工具,這種場景最重要的是:清楚、可跑、可回溯。卻把它做成「像主系統一樣的 Container + interface 架構」,大多只是增加閱讀成本。這裡的判斷很簡單會不會再用第二次?會不會有人接手維護?不會的話,就不要用長期架構綁住短期需求。
  3. 如果只是想「少寫 new」那你用錯理由了:DI/Container 的價值不是省 new,而是 替換成本依賴決策的集中化。所以如果你做的事情只是:把 new A(new B(new C)) 改成 app(A::class),但沒有抽象、沒有替換需求、沒有測試隔離需求,那你得到的不是架構,而是「魔法」。魔法的代價是當新同事看到 app()->make(…)、看到一堆隱性依賴,反而更難理解程式「到底從哪來」。

以上這幾種情境,可以思考一下是否要使用 DI/Container 因為用過頭,它會讓一個專案變成「只是看起來很工程,但讀起來很痛苦。」

Service Container 就像一間餐廳的出餐系統你在桌邊點餐(type hint / interface),不需要自己走進廚房把鍋碗瓢盆組起來(一路 new)。 廚房會負責備料、組裝、出餐(resolve dependencies),必要時還能換主廚、換食材品牌(binding / interface),決定哪些東西要共用、哪些要現做(singleton / bind / scoped)。

真正的重點不是「少寫 new」,而是『把決策放到對的位置』:

讓業務流程只關心「我需要什麼」,讓系統集中管理「怎麼給你、給哪個、活多久」,我們不需要每一段程式都完美 IoC/DI;只需要在會痛的地方做對選擇。


메타데이터
post_id
1ada6a3dc349
slug
laravel-di-ioc-container-深入淺出-1ada6a3dc349
url
https://medium.com/@johnny31258/laravel-di-ioc-container-%E6%B7%B1%E5%85%A5%E6%B7%BA%E5%87%BA-1ada6a3dc349
canonical_url
https://medium.com/@johnny31258/laravel-di-ioc-container-%E6%B7%B1%E5%85%A5%E6%B7%BA%E5%87%BA-1ada6a3dc349
author_url
https://medium.com/@johnny31258
status
ok
fetched_at
2026-07-29 13:30:38