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

# 套件的情境屬性與相依解析

> 解讀 Laravel 13 的 ContextualAttribute，說明注入套件設定的屬性、解析的優先順序、make 與 call 的差異，以及長壽命服務的測試與維護。

有時你會希望套件的使用者不必在服務提供者中逐一列出設定鍵，就能注入所需的值。公開情境屬性後，就能依據附加在引數上的中繼資料，解析出套件專屬的設定或服務。

本頁確認了 Laravel Framework `v13.39.0` 的官方原始碼，並實作一個讀取 `courier` 設定的自訂屬性。如果內建的 `#[Config('courier.timeout')]` 就能達成目的，則不需要另外新增自訂屬性。只有在要將自有的鍵名慣例或解析方針作為套件 API 提供時，才需要考慮。

## 屬性的角色是決定要注入的值

實作了 `Illuminate\Contracts\Container\ContextualAttribute` 的屬性，會在 Laravel 容器解析引數時被讀取。傳給使用端類別的不是屬性物件本身，而是 resolver 回傳的值。

| 方法 | 設定位置 | 適合的用途 |
| - | - | - |
| `when()->needs()->give()` | 服務提供者 | 不修改使用端類別，由應用程式端切換相依 |
| 內建的 `Config`、`Storage`、`Tag` 屬性 | 使用端類別的引數 | 宣告 Laravel 提供的解析方式 |
| 套件自訂的情境屬性 | 使用端類別的引數與屬性類別 | 以套件專屬的慣例解析值 |

一般的綁定與內建屬性請參閱[服務容器](https://kawax.biz/zh-TW/service-container.md)。情境屬性與 `Bind`、`Singleton`、`Scoped` 這類宣告類別或介面綁定的屬性是不同的機制。

## 建立讀取套件設定的屬性

### 註冊預設設定

首先，在套件的 `config/courier.php` 中準備預設值。

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

return [
    'timeout' => 30,
    'retries' => 3,
];
```

在 `src/CourierServiceProvider.php` 的 `register()` 中合併設定。提供者的自動註冊請參閱[套件自動偵測的內部結構](https://kawax.biz/zh-TW/advanced/package-discovery.md)，發佈與設定快取的處理請參閱[套件設定的合併與快取](https://kawax.biz/zh-TW/advanced/package-config-merging.md)。

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

namespace Acme\Courier;

use Illuminate\Support\ServiceProvider;

class CourierServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(
            __DIR__.'/../config/courier.php', 'courier'
        );
    }
}
```

### 實作 `ContextualAttribute`

在 `src/Attributes/CourierConfig.php` 中，定義一個會在設定鍵前加上 `courier.` 的屬性。

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

namespace Acme\Courier\Attributes;

use Attribute;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Container\ContextualAttribute;
use ReflectionParameter;

#[Attribute(Attribute::TARGET_PARAMETER)]
final class CourierConfig implements ContextualAttribute
{
    public function __construct(
        public ?string $key = null,
        public mixed $default = null,
    ) {}

    public static function resolve(
        self $attribute,
        Container $container,
        ReflectionParameter $parameter,
    ): mixed {
        $key = $attribute->key ?? $parameter->getName();

        return $container->make('config')->get(
            'courier.'.$key, $attribute->default
        );
    }
}
```

在確認的實作中，resolver 會收到以下 3 個引數。

1. 附加在引數上的屬性實例。
2. 正在解析相依的容器。
3. 代表解析對象的 `ReflectionParameter`。

此範例在省略鍵名時會使用引數名稱。`#[CourierConfig(default: 30)] int $timeout` 會參照 `courier.timeout`。如果希望即使使用端變更引數名稱也能讀取相同的設定，請像 `#[CourierConfig('timeout', default: 30)]` 這樣明確指定鍵名。

在確認的時間點，`ContextualAttribute` 是一個不宣告任何方法的標記介面。因此光是 `implements` 並不能保證 resolver 存在或其簽章正確。只要像上面這樣準備 `resolve()`，就不需要為這個屬性另外註冊一般的 `bind()`。

> [!WARNING]
>
> 屬性的建構子只用來保存中繼資料。容器會在解析時與屬性的後處理時分別呼叫 `newInstance()`，因此這裡並不是「只執行一次的初始化」的地方。請不要在其中進行外部通訊或保存可變狀態。

### 從容器建立使用端類別

在 `src/CourierOptions.php` 中加上屬性。

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

namespace Acme\Courier;

use Acme\Courier\Attributes\CourierConfig;

final class CourierOptions
{
    public function __construct(
        #[CourierConfig('timeout', default: 30)] public int $timeout,
        #[CourierConfig('retries', default: 3)] public int $retries,
    ) {}
}
```

從使用端應用程式覆寫設定後，下一次解析時就會注入該值。

```php theme={null}
use Acme\Courier\CourierOptions;

config(['courier.timeout' => 45]);

$options = app()->make(CourierOptions::class);
```

此範例中 `$options->timeout` 為 `45`，`$options->retries` 為 `3`。屬性只負責取得設定，不會進行「是否為正整數」、「是否在允許的最大值以內」等業務上的驗證。型別宣告也無法取代設定值的驗證處理。

## 解讀建構子中的解析順序

`Container::resolveDependencies()` 會針對每個引數，依以下順序決定值。

1. 使用透過 `makeWith()` 等方式傳入、與引數名稱相符的覆寫值。
2. 以 `Util::getContextualAttributeFromDependency()` 尋找情境屬性並解析。
3. 若屬性的結果為 `null`，則進入一般的原始值或類別解析。
4. 經過屬性的後處理後，將決定的值傳給建構子。

第 1 步的覆寫路徑會略過該引數的屬性解析與後處理。

```mermaid theme={null}
flowchart TD
    A["建構子引數"] --> B{"已依引數名稱覆寫？"}
    B -->|Yes| C["使用覆寫值"]
    B -->|No| D{"有 ContextualAttribute？"}
    D -->|Yes| E["呼叫屬性的 resolver"]
    E --> F{"結果為 null？"}
    F -->|No| G["使用屬性的值"]
    F -->|Yes| H["一般的型別、原始值解析"]
    D -->|No| H
    G --> I["屬性的後處理"]
    H --> I
    C --> J["傳給建構子"]
    I --> J
```

`makeWith()` 的鍵不是設定鍵，而是使用端類別的引數名稱。

```php theme={null}
use Acme\Courier\CourierOptions;

$options = app()->makeWith(CourierOptions::class, [
    'timeout' => 5,
]);
```

在此情況下，無論設定值為何，`timeout` 都是 `5`。另一方面，當屬性回傳 `null` 以外的值時，屬性的結果會優先於一般的 `when()->needs('$timeout')->give(...)`。也請測試在套件中新增屬性，是否可能改變既有使用者的情境綁定。

`Util` 會透過 Reflection 取得實作了 `ContextualAttribute` 的屬性，並選擇其中的第一個用於解析。即使並列多個不同的情境屬性，resolver 也不會依序組合。

### 已註冊的 handler 優先於 `resolve()`

`Container::resolveFromAttribute()` 會以屬性類別名稱為鍵，先尋找以 `whenHasAttribute()` 註冊的 handler。若沒有，則使用屬性的 `resolve()`；兩者都沒有時，會拋出 `BindingResolutionException`。

`whenHasAttribute()` 不是針對個別使用端類別，而是針對屬性種類的設定。對同一個屬性類別重新註冊，會覆寫先前的 handler。若套件無條件變更其他套件或 Laravel 內建屬性的 handler，會影響整個使用端應用程式。請將自訂屬性的標準行為放在 `resolve()` 中，並將透過 handler 的變更視為明確的擴充點。

## `make()`、`call()` 與直接呼叫並不相同

`app()->call()` 也會解析屬性，但一般的 `BoundMethod` 路徑在找到屬性後，會直接將其結果加入引數。與建構子的解析不同，即使屬性的結果為 `null`，也不會回到預設值的解析。

以下是在沒有其他情境綁定的狀態下，讀取不存在的 `courier.missing` 的範例。

```php theme={null}
use Acme\Courier\Attributes\CourierConfig;

final class NullableTimeoutOptions
{
    public function __construct(
        #[CourierConfig('missing')] public ?int $timeout = 99,
    ) {}
}

$readTimeout = function (
    #[CourierConfig('missing')] ?int $timeout = 99,
): ?int {
    return $timeout;
};
```

| 呼叫 | 結果 | 原因 |
| - | - | - |
| `app()->make(NullableTimeoutOptions::class)->timeout` | `99` | 屬性回傳 `null`，進入原始值的預設值 |
| `app()->call($readTimeout)` | `null` | 直接傳遞屬性的結果 |
| `$readTimeout()` | `99` | 這是 PHP 的一般呼叫，不會解析屬性 |

`new CourierOptions(timeout: 5, retries: 1)` 也是一般的 PHP 建立方式。光是加上屬性，並不會為直接的 `new` 或方法呼叫加入相依注入。

> [!NOTE]
>
> 如果建構子與 callable 都需要相同的預設行為，請不要只依賴引數的預設值，而是像 `#[CourierConfig('timeout', default: 30)]` 這樣在屬性端定義預設值。此 `default` 會在設定鍵不存在時使用；若鍵存在但值為 `null`，則不會被取代。在將 `null` 本身視為有意義值的 API 中，請分別驗證這兩種解析路徑。

## 區分值的重新注入與服務的壽命

屬性讀取的是解析引數當下的設定。它不是用來監看或更新已建立的 `CourierOptions` 屬性的功能。

例如將 `CourierOptions` 設為 singleton 時，最初注入的值會保留在該實例中。之後即使變更設定並呼叫一般的 `make()`，同一個實例的值也不會改變。

建立讀取每個請求資訊的屬性時，屬性本身同樣不會決定 singleton 或 scoped 的壽命。請依照[套件服務的生命週期設計](https://kawax.biz/zh-TW/advanced/package-service-lifetimes.md)，選擇保存注入值或物件之服務的壽命。也請將設定快取的重新產生，與長壽命行程或已建立服務的更新分開考量。

## 測試解析路徑

屬性的單元測試可以像官方測試一樣，使用新的 `Container` 與 `Illuminate\Config\Repository` 撰寫。以下 PHPUnit 範例以已載入上面定義的 `CourierConfig`、`CourierOptions`、`NullableTimeoutOptions` 的測試環境為前提。

```php theme={null}
use Acme\Courier\Attributes\CourierConfig;
use Acme\Courier\CourierOptions;
use Illuminate\Config\Repository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Container\Container as ContainerContract;
use PHPUnit\Framework\TestCase;

class CourierConfigTest extends TestCase
{
    private function container(array $settings = []): Container
    {
        $container = new Container;
        $container->instance('config', new Repository([
            'courier' => $settings,
        ]));

        return $container;
    }

    public function testConfigDefaultsAndNamedOverrides(): void
    {
        $container = $this->container();
        $this->assertSame(30, $container->make(CourierOptions::class)->timeout);

        $container->make('config')->set('courier.timeout', 45);
        $container->when(CourierOptions::class)->needs('$timeout')->give(77);
        $this->assertSame(45, $container->make(CourierOptions::class)->timeout);

        $options = $container->makeWith(CourierOptions::class, ['timeout' => 5]);
        $this->assertSame(5, $options->timeout);
        $this->assertSame(3, $options->retries);
    }

    public function testParameterNameIsAvailableToTheResolver(): void
    {
        $container = $this->container(['timeout' => 45]);

        $value = $container->call(function (
            #[CourierConfig(default: 30)] int $timeout,
        ): int {
            return $timeout;
        });

        $this->assertSame(45, $value);
    }

    public function testRegisteredHandlerOverridesResolve(): void
    {
        $container = $this->container(['timeout' => 45]);
        $container->whenHasAttribute(
            CourierConfig::class,
            static fn (CourierConfig $attribute, ContainerContract $app) => 7,
        );

        $this->assertSame(7, $container->make(CourierOptions::class)->timeout);
    }

    public function testNullDiffersBetweenMakeAndCall(): void
    {
        $container = $this->container();
        $readTimeout = function (
            #[CourierConfig('missing')] ?int $timeout = 99,
        ): ?int {
            return $timeout;
        };

        $this->assertSame(99, $container->make(NullableTimeoutOptions::class)->timeout);
        $this->assertNull($container->call($readTimeout));
        $this->assertSame(99, $readTimeout());
    }

    public function testSingletonKeepsItsResolvedValues(): void
    {
        $container = $this->container(['timeout' => 45]);
        $container->singleton(CourierOptions::class);
        $options = $container->make(CourierOptions::class);

        $container->make('config')->set('courier.timeout', 60);

        $this->assertSame($options, $container->make(CourierOptions::class));
        $this->assertSame(45, $options->timeout);
    }
}
```

將 handler 固定為 `7` 的範例僅用於測試優先順序。在這個容器內，它會影響同一屬性的所有解析，包括 `retries`。

僅靠這個單元測試，無法確認與套件的提供者註冊、`mergeConfigFrom()` 及設定快取的整合。也請在[以 Orchestra Testbench 測試 Laravel 套件](https://kawax.biz/zh-TW/advanced/package-testing.md)的應用程式環境中，驗證發佈前、已發佈、有設定快取等狀態。

### 發佈前的確認項目

* 將屬性的類別名稱、建構子引數名稱、設定鍵、回傳型別視為公開 API。也要注意以具名引數使用的 `key`、`default` 的變更。
* 若規格是在省略鍵名時使用引數名稱，請確認使用端類別變更引數名稱時，不會改變所參照的設定。
* 分別測試沒有屬性的既有綁定、附屬性的解析，以及依引數名稱的覆寫。
* 在 `make()` 與 `call()` 兩者中測試 `null`、設定不足、型別不正確的情況。
* 確認設計不會讓 singleton 或長壽命 worker 保留舊的值。
* 更新 Laravel 支援版本時，請透過官方原始碼與測試重新確認 resolver 的引數、優先順序與屬性的後處理。

## 相關頁面

> [card] [套件設定的合併與快取](https://kawax.biz/zh-TW/advanced/package-config-merging.md)
>
> 確認預設設定、已發佈設定與快取的更新邊界。

> [card] [套件服務的生命週期設計](https://kawax.biz/zh-TW/advanced/package-service-lifetimes.md)
>
> 選擇保存注入值之服務的壽命。

> [card] [套件的容器標籤與外掛彙整](https://kawax.biz/zh-TW/advanced/package-container-tags.md)
>
> 確認內建的 Tag 屬性與已註冊實作的彙整。

> [card] [PHP Reflection API](https://kawax.biz/zh-TW/advanced/php-reflection.md)
>
> 確認讀取屬性與引數中繼資料的機制。

## 參考的第一手資料

* [Laravel 官方文件：Defining custom attributes](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/container.md#defining-custom-attributes)
* [Laravel 官方文件：套件設定](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/packages.md#configuration)
* [Container：resolveDependencies()、resolveFromAttribute()、whenHasAttribute()、屬性的後處理](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/Container.php)
* [BoundMethod：callable 的引數解析](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/BoundMethod.php)
* [Util：透過 Reflection 偵測 ContextualAttribute](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/Util.php)
* [ContextualAttribute：標記介面](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Contracts/Container/ContextualAttribute.php)
* [Config：內建的設定屬性](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/Attributes/Config.php)
* [Config Repository：get() 與設定值的取得](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Config/Repository.php)
* [Arr：get() 的預設值與已存在 null 的處理](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Collections/Arr.php)
* [官方測試：屬性的 resolver、已註冊 handler、ReflectionParameter、call 中的解析](https://github.com/laravel/framework/blob/v13.39.0/tests/Container/ContextualAttributeBindingTest.php)


## Related topics

- [套件的 Artisan 指令註冊與相依性解析](https://kawax.biz/zh-TW/advanced/package-commands.md)
- [套件的容器標籤與外掛彙整](https://kawax.biz/zh-TW/advanced/package-container-tags.md)
- [套件的事件監聽器註冊與快取](https://kawax.biz/zh-TW/advanced/package-events.md)
- [Service Container](https://kawax.biz/zh-TW/service-container.md)
- [套件的服務解析後掛鉤](https://kawax.biz/zh-TW/advanced/package-service-resolution.md)


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