> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# 套件的服務裝飾與 extend

> 解讀 Laravel 13 的 Container::extend()，說明維持契約的裝飾器、解析前後的註冊、singleton 的保留範圍、雙重裝飾以及更新時的測試。

有時你希望在保留使用端應用程式所選服務實作的同時，從套件追加處理。使用容器的 `extend()`，不需重新定義原本的建立邏輯，就能用裝飾器包裝已解析的服務。

本頁參考 Laravel 官方文件的預設分支 `13.x`，以及 2026 年 11 月 9 日確認的 Framework 最新版本 `v13.40.0` 的官方實作與測試。除了與[套件的服務解析後掛鉤](https://kawax.biz/zh-TW/advanced/package-service-resolution.md)的差異之外，也會說明隨 **註冊時實例是否已被儲存** 而改變的保留範圍。

## 區分新增設定與替換物件

| API | 用途 | 回呼的回傳值 |
| - | - | - |
| `bind()`・`singleton()` | 註冊服務的建立方式與是否共用 | 要建立的服務 |
| `afterResolving()` | 對已解析的服務套用設定等 | 不會用於替換服務 |
| `extend()` | 保留建立邏輯，對服務進行裝飾或變更 | 必須回傳變更後的服務 |

`extend()` 的回呼會接收目標服務與容器。可以設定同一個物件後回傳，也可以回傳新的裝飾器。若忘記 `return`，後續的解析處理會收到 `null`。

> [!NOTE]
>
> 這裡討論的是 `Illuminate\Container\Container::extend()`。它與 Cache、Mail 等 Manager 所具有、用來註冊驅動程式名稱與建立邏輯的 `extend()` 是不同的 API。

## 建立實作相同契約的裝飾器

### 不替換原本的實作而是加以包裝

為了說明，將以下用來格式化訊息的契約放在 `src/MessageFormatter.php`。以下各個類別也假設分別在 `src/` 中建立同名的 PHP 檔案。

```php theme={null}
<?php

namespace Acme\Messages;

interface MessageFormatter
{
    public function format(string $message): string;
}
```

裝飾前的實作會原樣回傳輸入。

```php theme={null}
<?php

namespace Acme\Messages;

final class PlainMessageFormatter implements MessageFormatter
{
    public function format(string $message): string
    {
        return $message;
    }
}
```

裝飾器實作相同的介面，並將處理委派給傳入的服務。本例會在委派對象的回傳值前加上套件特有的前綴。

```php theme={null}
<?php

namespace Acme\Messages;

final class PrefixedMessageFormatter implements MessageFormatter
{
    public function __construct(
        private readonly MessageFormatter $inner,
    ) {}

    public function format(string $message): string
    {
        return '[Acme] '.$this->inner->format($message);
    }
}
```

若使用端以具體類別 `PlainMessageFormatter` 作為型別宣告，僅實作相同介面的裝飾器就無法取代它。請將公開的相依對象統一為 `MessageFormatter`，並維持引數、回傳值、例外等契約。僅靠動態的方法轉送，無法取代 PHP 的介面實作。

### 在提供者中註冊裝飾

提供裝飾的 `src/PrefixServiceProvider.php` 不會覆寫建立方式，只會向容器註冊擴充。關於提供者的載入，請參閱[套件自動偵測的內部結構](https://kawax.biz/zh-TW/advanced/package-discovery.md)。

```php theme={null}
<?php

namespace Acme\Messages;

use Illuminate\Support\ServiceProvider;

final class PrefixServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->extend(
            MessageFormatter::class,
            static fn (MessageFormatter $formatter): MessageFormatter =>
                new PrefixedMessageFormatter($formatter),
        );
    }
}
```

回呼的第 2 個引數也會傳入容器，但本例不需要，因此沒有宣告。目標服務使用引數 `$formatter`。若在回呼內再次對同一個鍵呼叫 `make()`，會造成解析循環。

在使用端應用程式的 `AppServiceProvider::register()` 等處綁定原本的實作。

```php theme={null}
use Acme\Messages\MessageFormatter;
use Acme\Messages\PlainMessageFormatter;

$this->app->bind(MessageFormatter::class, PlainMessageFormatter::class);
```

`extend()` 也可以在目標的 `bind()` 之前註冊。若目標尚未解析且沒有已儲存的實例，僅註冊並不會建立原本的服務。官方測試中也驗證了延遲建立與綁定前的註冊。

之後，`app(MessageFormatter::class)->format('已出貨。')` 會回傳 `[Acme] 已出貨。`。即使將原本的實作換成其他 `MessageFormatter` 實作，裝飾器也會包裝該實作。

> [!WARNING]
>
> 原則上請在首次解析之前註冊。其他提供者可能會先使用該服務，因此僅僅移到 `boot()` 並無法防止註冊前的解析。此外，若輕易將只負責註冊擴充的提供者設為延遲提供者，註冊本身可能會失去觸發條件。

## 理解套用目標與裝飾順序

在一般的解析中，容器會建立原本的服務，依註冊順序替換為各 extender 的回傳值，接著作為共用實例儲存，並呼叫解析後掛鉤。

```mermaid theme={null}
flowchart LR
    A["原本的建立邏輯"] --> B["服務"]
    B --> C["extender A<br>以 Wrapper A 包裝"]
    C --> D["extender B<br>以 Wrapper B 包裝"]
    D --> E["若為共用則儲存"]
    E --> F["解析後掛鉤・使用端"]
```

依 A、B 的順序註冊時，結果為 `B(A(原本的服務))`。使用端的方法呼叫會從外側的 B 開始，因此不要混淆註冊順序與處理開始順序。若依賴多個套件的裝飾順序，請在使用端應用程式中明確指定順序，並測試組合。

extender 會套用於容器將 alias 正規化後的 **解析鍵**。對 `MessageFormatter::class` 的註冊，並不會僅因型別一致就套用到直接解析 `PlainMessageFormatter::class` 的情況。請統一透過介面使用。

## 對已儲存實例的裝飾，保留方式不同

`v13.40.0` 的 `Container::extend()` 會依目標實例是否已儲存在容器中而分支。

| 註冊時的狀態 | `extend()` 的行為 | 對日後新建立的保留 |
| - | - | - |
| 尚未解析，也沒有已儲存的實例 | 加入 extender 陣列 | 在之後的一般建立時套用 |
| 過去曾解析一般的 `bind()`，但沒有已儲存的實例 | 加入 extender 並嘗試發送 rebound 通知 | 在之後的一般建立時套用 |
| 已儲存 singleton 或 `instance()` 的物件 | 當場裝飾，將儲存值替換為回傳值並發送 rebound 通知 | **本次的 extender 不會加入陣列** |

最後一列很重要。裝飾已解析的 singleton 之後，即使呼叫 `forgetInstance()` 或重新綁定同一個鍵，**僅當場套用的裝飾** 也不會在下次建立時重新套用。這與在首次解析前註冊到 extender 陣列的情況不同。

`scoped()` 也會使用已儲存的實例，因此不要以為在範圍中途延遲註冊的裝飾也會保留到下一個範圍。請搭配[套件服務的生命週期設計](https://kawax.biz/zh-TW/advanced/package-service-lifetimes.md)一併確認。

此外，若之後以 `instance()` 直接儲存物件，它不會經過一般建立時套用 extender 的迴圈。包含測試中的替換在內，並不一定是「已經註冊了，所以傳入的實體也會自動被裝飾」。

### 已分發出去的參照不會自動被替換

像本例這樣回傳新的包裝器時，即使容器的儲存值改變，已儲存在其他服務屬性中的原物件也不會改變。`rebinding()` 的通知是將新服務傳給已註冊回呼的機制，並不會搜尋並改寫所有相依對象。

此外，透過 `rebinding()` 註冊通知回呼也不一定總是延遲完成。若目標已綁定，註冊時也會回傳 `make()` 的結果。在對過去解析過的一般綁定執行 `extend()` 的路徑中，若存在 rebound 回呼，也可能為了通知而執行 `make()`。不要一概認定初始化與副作用的時機是「下次使用時」。

## 避免雙重裝飾與狀態延續

`extend()` 不會自動對同一個閉包或同一個裝飾器的註冊去除重複。若對同一個容器套用兩次此提供者的 `register()`，前綴也可能被加上兩次。

* 將裝飾註冊集中在提供者固定的啟動路徑，不要在每個請求或工作中重複。
* 不要為了防止重複而使用所有容器共用的單一靜態旗標。測試或新的應用程式會缺少必要的註冊。
* singleton 的裝飾也會在相同的共用期間內保留。不要將目前的使用者或請求等固定到包裝器中。
* `forgetExtenders()` 只會清除註冊陣列，並不是將已建立的包裝器還原的操作。

本例變更了輸出字串。前綴、委派次數與例外處理也會成為套件的公開行為，因此不要單純視為「實作的內部變更」，請確認相容性。

## 以同一個容器測試註冊時機

以下 PHPUnit 範例假設上述類別可以透過套件的 autoload 載入。它將提供者的 `register()` 直接套用到 Container，在不使用 HTTP 或外部服務的情況下確認裝飾的保留範圍。自動偵測與整個應用程式的啟動，請另外透過[以 Orchestra Testbench 測試 Laravel 套件](https://kawax.biz/zh-TW/advanced/package-testing.md)驗證。

```php theme={null}
<?php

namespace Acme\Messages\Tests;

use Acme\Messages\MessageFormatter;
use Acme\Messages\PlainMessageFormatter;
use Acme\Messages\PrefixedMessageFormatter;
use Acme\Messages\PrefixServiceProvider;
use Illuminate\Container\Container;
use PHPUnit\Framework\TestCase;

final class ServiceDecorationTest extends TestCase
{
    public function test_extension_is_lazy_and_can_precede_binding(): void
    {
        $container = new Container;
        $builds = 0;

        (new PrefixServiceProvider($container))->register();
        $container->bind(MessageFormatter::class, function () use (&$builds) {
            $builds++;

            return new PlainMessageFormatter;
        });

        $this->assertSame(0, $builds);

        $first = $container->make(MessageFormatter::class);
        $second = $container->make(MessageFormatter::class);

        $this->assertSame('[Acme] ready', $first->format('ready'));
        $this->assertSame('[Acme] ready', $second->format('ready'));
        $this->assertNotSame($first, $second);
        $this->assertSame(2, $builds);
    }

    public function test_early_extension_survives_singleton_recreation(): void
    {
        $container = new Container;
        $container->singleton(MessageFormatter::class, PlainMessageFormatter::class);
        (new PrefixServiceProvider($container))->register();

        $first = $container->make(MessageFormatter::class);

        $this->assertInstanceOf(PrefixedMessageFormatter::class, $first);
        $this->assertSame($first, $container->make(MessageFormatter::class));

        $container->forgetInstance(MessageFormatter::class);
        $second = $container->make(MessageFormatter::class);

        $this->assertNotSame($first, $second);
        $this->assertSame('[Acme] ready', $second->format('ready'));
    }

    public function test_late_singleton_extension_is_not_retained_as_a_callback(): void
    {
        $container = new Container;
        $container->singleton(MessageFormatter::class, PlainMessageFormatter::class);
        $original = $container->make(MessageFormatter::class);

        (new PrefixServiceProvider($container))->register();
        $decorated = $container->make(MessageFormatter::class);

        $this->assertInstanceOf(PrefixedMessageFormatter::class, $decorated);
        $this->assertNotSame($original, $decorated);
        $this->assertSame('ready', $original->format('ready'));
        $this->assertSame('[Acme] ready', $decorated->format('ready'));

        $container->forgetInstance(MessageFormatter::class);
        $recreated = $container->make(MessageFormatter::class);

        $this->assertInstanceOf(PlainMessageFormatter::class, $recreated);
        $this->assertSame('ready', $recreated->format('ready'));
    }
}
```

最後一個測試並不是推薦延遲註冊，而是明確指出所確認版本的界線。不要將 `forgetInstance()` 單獨設計為解除或重新註冊裝飾的 API。

發布前，也請確認多個 extender 的順序、重複註冊、透過 `instance()` 的替換，以及使用端已持有的參照。增加支援的 Laravel 版本時，除了解析鍵與型別契約之外，也要重新閱讀 `extend()` 的分支與官方測試。

## 相關頁面

> [card] [套件的服務解析後掛鉤](https://kawax.biz/zh-TW/advanced/package-service-resolution.md)
>
> 確認不替換物件而進行設定的 callAfterResolving()，以及套用次數的注意事項。

> [card] [套件服務的生命週期設計](https://kawax.biz/zh-TW/advanced/package-service-lifetimes.md)
>
> 確認 bind・singleton・scoped，以及已建立服務所持有的相依關係。

## 參考的第一手資料

* [Laravel 官方文件：Extending bindings・Rebinding](https://github.com/laravel/docs/blob/13.x/container.md#extending-bindings)
* [Laravel 官方文件：Service Providers](https://github.com/laravel/docs/blob/13.x/providers.md)
* [Laravel 官方文件：套件開發與自動偵測](https://github.com/laravel/docs/blob/13.x/packages.md)
* [Container：extend()・resolve()・rebound()・instance()・forgetExtenders()](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Container/Container.php)
* [官方測試：延遲建立・綁定前的註冊・alias・多個 extender](https://github.com/laravel/framework/blob/v13.40.0/tests/Container/ContainerExtendTest.php)
* [ServiceProvider：建構函式與 register()](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Support/ServiceProvider.php)


## Related topics

- [套件的服務解析後掛鉤](https://kawax.biz/zh-TW/advanced/package-service-resolution.md)
- [進階主題](https://kawax.biz/zh-TW/advanced/index.md)
- [套件的郵件傳輸註冊與測試](https://kawax.biz/zh-TW/advanced/package-mail-transports.md)
- [Laravel 套件開發](https://kawax.biz/zh-TW/advanced/package-development.md)
- [套件的回應 Macro 與 HTTP 契約](https://kawax.biz/zh-TW/advanced/package-response-macros.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.