跳转到主要内容
有时你希望包的使用者无需在服务提供者中罗列配置键,就能注入所需的值。公开上下文属性后,就可以根据附加在参数上的元数据解析包特有的配置或服务。 本页基于 Laravel Framework v13.39.0 的官方源码,实现一个读取 courier 配置的自定义属性。如果内置的 #[Config('courier.timeout')] 已能满足需求,就没有必要再增加自定义属性。只有当你要把自有的键约定或解析策略作为包的 API 提供时,才值得考虑。

属性的作用是决定注入的值

实现了 Illuminate\Contracts\Container\ContextualAttribute 的属性,会在 Laravel 容器解析参数时被读取。传给使用方类的不是属性对象本身,而是 resolver 返回的值。 常规绑定和内置属性请参阅服务容器。上下文属性与 Bind、Singleton、Scoped 这类声明类或接口绑定的属性是不同的机制。

创建读取包配置的属性

注册默认配置

首先,在包的 config/courier.php 中准备默认值。
在 src/CourierServiceProvider.php 的 register() 中合并配置。提供者的自动注册请参阅包自动发现的内部结构,发布与配置缓存的处理请参阅包配置的合并与缓存。

实现 ContextualAttribute

在 src/Attributes/CourierConfig.php 中定义一个为配置键加上 courier. 前缀的属性。
在所确认的实现中,resolver 会收到以下 3 个参数。
  1. 附加在参数上的属性实例。
  2. 正在解析依赖的容器。
  3. 表示解析目标的 ReflectionParameter。
本例中,省略键时会使用参数名。#[CourierConfig(default: 30)] int $timeout 会引用 courier.timeout。如果希望即使使用方修改参数名也能读取同一配置,请像 #[CourierConfig('timeout', default: 30)] 这样显式指定键。 在确认时,ContextualAttribute 是一个不声明任何方法的标记接口。因此,仅靠 implements 并不能保证 resolver 的存在及其签名。只要像上面这样提供 resolve(),就无需为该属性注册常规的 bind()。
属性的构造函数只用于保存元数据。容器会在解析时和属性后处理时分别调用 newInstance(),因此它并不是执行“只运行一次的初始化”的地方。请不要在其中进行外部通信或持有可变状态。

通过容器创建使用方类

在 src/CourierOptions.php 中添加属性。
当使用方应用覆盖配置后,下一次解析时就会注入该值。
本例中 $options->timeout 为 45,$options->retries 为 3。属性只负责获取配置,不会进行诸如是否为正整数、是否超过允许的最大值之类的业务校验。类型声明也不能替代配置值的校验处理。

理解构造函数中的解析顺序

Container::resolveDependencies() 会按以下顺序为每个参数确定值。
  1. 使用通过 makeWith() 等传入的、与参数名匹配的覆盖值。
  2. 通过 Util::getContextualAttributeFromDependency() 查找并解析上下文属性。
  3. 如果属性的结果为 null,则进入常规的基本类型值或类解析。
  4. 经过属性后处理后,将确定的值传给构造函数。
第 1 步的覆盖路径会跳过该参数的属性解析和后处理。 makeWith() 的键不是配置键,而是使用方类的参数名。
此时无论配置值如何,timeout 都为 5。另一方面,当属性返回非 null 的值时,属性的结果优先于常规的 when()->needs('$timeout')->give(...)。也要测试向包中添加属性是否可能改变现有使用者的上下文绑定。 Util 通过 Reflection 获取实现了 ContextualAttribute 的属性,并选取其中第 1 个用于解析。即使并列多个不同的上下文属性,resolver 也不会按顺序组合。

已注册的 handler 优先于 resolve()

Container::resolveFromAttribute() 以属性类名为键,先查找通过 whenHasAttribute() 注册的 handler。如果没有,则使用属性的 resolve();两者都不存在时,抛出 BindingResolutionException。 whenHasAttribute() 是按属性种类而非按使用方类进行的设置。对同一属性类重新注册会覆盖之前的 handler。如果包无条件地修改其他包或 Laravel 内置属性的 handler,就会影响整个使用方应用。请将自定义属性的标准行为放在 resolve() 中,把通过 handler 进行的修改视为显式的扩展点。

make()、call() 与直接调用并不相同

app()->call() 也会解析属性,但常规的 BoundMethod 路径在找到属性后会直接把其结果添加到参数中。与构造函数解析不同,即使属性结果为 null,也不会回退到默认值解析。 以下示例在没有其他上下文绑定的情况下读取不存在的 courier.missing。
new CourierOptions(timeout: 5, retries: 1) 同样是普通的 PHP 实例化。仅仅添加属性,并不会为直接的 new 或方法调用增加依赖注入。
如果构造函数和 callable 都需要相同的默认行为,不要只依赖参数的默认值,而应像 #[CourierConfig('timeout', default: 30)] 这样在属性侧定义默认值。该 default 在配置键不存在时使用,若键存在而值为 null,则不会替换。对于把 null 本身视为有意义值的 API,请分别验证这两条解析路径。

区分值的重新注入与服务的生命周期

属性读取的是解析参数那一刻的配置。它并不具备监视或更新已创建的 CourierOptions 属性的功能。 例如,将 CourierOptions 设为 singleton 后,最初注入的值会保存在该实例中。之后即使修改配置再调用常规的 make(),同一实例的值也不会改变。 即使创建读取每个请求信息的属性,属性本身也不会决定 singleton 或 scoped 的生命周期。请按照包服务的生命周期设计来选择持有注入值或对象的服务的生命周期。同时,要把配置缓存的重新生成,与长生命周期进程或已创建服务的更新分开考虑。

测试解析路径

属性的单元测试可以像官方测试那样,使用新的 Container 和 Illuminate\Config\Repository 编写。以下 PHPUnit 示例以已加载上文定义的 CourierConfig、CourierOptions、NullableTimeoutOptions 的测试环境为前提。
将 handler 固定返回 7 的示例仅用于测试优先级。在该容器内,它会影响同一属性的所有解析,包括 retries。 仅靠这个单元测试,无法确认与包的提供者注册、mergeConfigFrom() 以及配置缓存的集成。还要在使用 Orchestra Testbench 测试 Laravel 包的应用环境中,验证发布前、已发布以及启用配置缓存的状态。

发布前的检查项

  • 将属性的类名、构造函数参数名、配置键和返回值类型视为公开 API。也要注意以命名参数方式使用的 key、default 的变更。
  • 对于省略键时使用参数名的规范,确认使用方类修改参数名时不会改变所引用的配置。
  • 分别测试不带属性的现有绑定、带属性的解析以及按参数名覆盖。
  • 在 make() 和 call() 两种方式下测试 null、配置缺失和不恰当的类型。
  • 确认设计不会让 singleton 或长生命周期 worker 持有过时的值。
  • 更新所支持的 Laravel 版本时,通过官方源码和测试重新确认 resolver 的参数、优先级以及属性后处理。

相关页面

包配置的合并与缓存

了解默认配置、已发布配置和缓存的更新边界。

包服务的生命周期设计

为持有注入值的服务选择生命周期。

包的容器标签与插件聚合

了解内置的 Tag 属性以及已注册实现的聚合。

PHP Reflection API

了解读取属性和参数元数据的机制。

参考的一手资料

最后修改于 2026年10月31日