绝大多数应用都始于几个简单的决策,藏在普通的代码里:控制器调用服务、命令行有固定签名、模型自带查询作用域、Webhook 事件对应各自的处理器。
但随着应用不断膨胀,我们需要给类、方法、属性、参数附加一些规则——这些规则本身并不是业务实现的一部分。 于是我们开始在服务提供者里写配置数组、靠命名约定隐式关联、用 PHPDoc 标签标记,甚至写大量 switch 分支。这些零散的约定,慢慢就成了应用契约的一部分。
举个例子:一个 Webhook 分发器需要知道哪个处理器对应 invoice.paid 事件。我们可以在一个集中的数组里维护映射关系:
$handlers = [
'invoice.paid' => RecordInvoicePayment::class,
'subscription.cancelled' => CancelSubscription::class,
];
这能跑通,但问题在于:元信息和它描述的处理器是分离的。新增一个处理器时,开发者必须记得还要去另一个地方补配置。这是一种隐性耦合,当映射规则越来越复杂时,维护成本会越来越高。
PHP 属性(Attributes)给了我们另一种选择:它允许我们把结构化、机器可读的元数据直接挂载到代码上。框架或应用可以读取这些元数据,再把它转化为实际行为。
从 PHP 8 开始,属性成为了语言一级特性。Laravel 已经在用它实现 Eloquent 作用域、上下文依赖注入等能力,但它的价值远不止于单个框架。
本文我们就来深入拆解 PHP 属性:它到底是什么、价值在哪、如何定义与使用,以及同样重要的——什么时候不该用它。
建立正确认知最重要的一点是:
属性用来描述代码,它本身不会执行任何逻辑。
属性是挂载在 PHP 声明上的元数据,可以挂载到类、方法、函数、类属性、类常量、方法参数上。它是结构化的——由一个真实的 PHP 类承载,自带构造函数和强类型属性。
一个最简单的例子:
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class Audit
{
public function __construct(
public string $stream,
) {}
}
#[Audit('orders')]
final class OrderPlaced
{
}
加上 #[Audit] 之后,OrderPlaced 类的行为和之前没有任何区别。PHP 不会因为加了这个属性,就自动生成审计日志、发布事件、调用日志器。
这个属性只表达了一件事:
这个类带有审计元数据,对应的审计流是
orders。
必须有另一方去读取这个声明,再决定要做什么。读取方可以是你的应用、框架、扩展包、测试运行器,或者静态分析工具。
这也是为什么属性属于元编程的范畴:它让代码可以「自省」,去描述其他代码。消费者通过反射拿到类的结构与元数据,再基于这些信息构建注册表、解析依赖、注册路由、应用查询作用域,或者改变运行时的其他行为。
PHP 官方手册对属性的定义是「以声明式方式添加结构化、机器可读的元数据」——这比「方括号版注解」的说法要准确得多。
属性常被拿来和 PHPDoc 注解对比,因为两者都紧贴代码、都用来描述代码。但它们擅长的场景完全不同。
PHPDoc 主要面向文档与静态分析:
/**
* @return array<string, int>
*/
public function totals(): array
{
// ...
}
PHPStan、IDE、文档生成工具可以读取这些信息,但 PHP 本身不会把注释块解析成运行时对象。
属性是 PHP 原生结构,自带类定义、构造参数、目标限制,并有完整的反射 API:
#[Cacheable(key: 'dashboard.summary', seconds: 60)]
public function summary(): array
{
// ...
}
因此,当应用或框架需要在运行时消费元数据时,属性是更合适的选择。
配置文件又是另一回事。配置用来存放会随环境、部署变化的值:
// config/services.php
'partner_api' => [
'base_url' => env('PARTNER_API_URL'),
'timeout' => env('PARTNER_API_TIMEOUT', 10),
],
属性不应该试图替代配置:
// 不要这么写
#[PartnerApi(baseUrl: config('services.partner_api.base_url'))]
final class PartnerClient
{
}
属性的参数必须是源码级的静态值——标量、数组、常量、枚举、类名这类 PHP 可以在声明阶段直接求值的内容。它不适合放运行时函数调用、租户配置、环境密钥这类动态值。
一个实用的划分原则:
三者是互补关系,试图用一个替代另外两个,通常只会让 API 变得混乱难用。
一个属性本质上就是一个普通类,加上 PHP 内置的 Attribute 标记:
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class HandlesWebhook
{
public function __construct(
public string $event,
public bool $verifySignature = true,
) {}
}
这里有三个关键细节:
#[Attribute(...)] 声明告诉 PHP 这个类可以作为属性使用。没有这个声明,它就是一个普通类。Attribute::TARGET_CLASS 限制这个属性只能用在类上。PHP 提供了多组目标常量,分别对应类、函数、方法、属性、类常量、参数,可以用位运算组合多个目标:#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
final readonly class Retries
{
public function __construct(
public int $times,
) {}
}
建议加上目标限制。它能让意图更清晰,也能避免有人把属性错用在不适合的位置(比如把方法级属性写到类属性上)。
#[RateLimit(
name: 'partner-api',
attempts: 60,
perSeconds: 60,
)]
public function store(PartnerRequest $request): Response
{
// ...
}
被装饰的代码不需要感知属性的存在。关系是单向的:消费者知道属性,并去读取被装饰代码的元数据。
生命周期本身很简单,但理解每一步能避免很多认知误区:
ReflectionAttribute)元数据只有被消费者反射读取、并主动转化为运行时规则后,才会产生实际作用。
被装饰的代码只是声明了元数据:
#[HandlesWebhook('invoice.paid')]
final class RecordInvoicePayment
{
}
消费者通过反射来读取它:
$class = new ReflectionClass(RecordInvoicePayment::class);
$attributes = $class->getAttributes(HandlesWebhook::class);
此时 $attributes 里是 ReflectionAttribute 对象,还不是 HandlesWebhook 的实例。这个设计很有用:我们可以先扫描声明,不必立刻实例化所有属性,减少不必要的开销。
当消费者需要强类型的元数据对象时,再调用 newInstance():
$metadata = $attributes[0]->newInstance();
$metadata->event; // invoice.paid
这一步才会真正调用属性的构造函数。因此属性构造函数应该保持轻量,只承载数据,不要有副作用。如果构造函数里去连数据库、写日志,会让元数据发现过程变得不可控且昂贵。
属性最核心的价值是就近性:元数据可以直接放在它所描述的声明旁边,不用散落在遥远的注册表里。
以前的 Webhook 处理器,映射关系要单独维护;现在处理器可以自己描述自己:
#[HandlesWebhook('invoice.paid')]
final class RecordInvoicePayment implements WebhookHandler
{
public function handle(array $payload): void
{
// 记录支付
}
}
阅读 RecordInvoicePayment 的人,一眼就能看出它是 Webhook 处理器、对应哪个事件。新增处理器时,也只需要在类上声明,不用记着去另一个文件改配置。
其次是强类型契约。对比数组式的映射:
$handlers = [
'invoice.paid' => [
'handler' => RecordInvoicePayment::class,
'verify_signature' => true,
],
];
和属性式声明:
#[HandlesWebhook(event: 'invoice.paid', verifySignature: true)]
final class RecordInvoicePayment implements WebhookHandler
{
// ...
}
属性的构造函数明确定义了接受哪些字段、分别是什么类型。应用在构建注册表时就可以校验合法性,而不是靠容易拼错、容易遗漏的数组键。
由此延伸出更多好处:
但这些好处不是自动生效的。只有当元数据天然属于对应声明、且消费者逻辑保持清晰易懂时,属性才会真正改善设计。
我们把上面的 Webhook 例子补成完整的最小实现。
首先定义行为接口——接口才是行为契约,属性只是元数据,不能替代契约:
interface WebhookHandler
{
public function handle(array $payload): void;
}
然后定义属性,限制只能挂载在类上:
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class HandlesWebhook
{
public function __construct(
public string $event,
public bool $verifySignature = true,
) {}
}
现在,一个处理器就同时拥有了两部分契约:
#[HandlesWebhook(event: 'invoice.paid')]
final class RecordInvoicePayment implements WebhookHandler
{
public function handle(array $payload): void
{
// 保存支付记录,更新订单状态
}
}
接口表达「这个类可以处理 Webhook 载荷」,属性表达「它处理的具体事件是什么、是否需要验签」。
这个区分很重要:如果一个类必须有 handle() 方法,就用接口或抽象类;如果框架需要额外的描述信息,再用属性。
消费者可以在应用启动时,扫描指定的处理器列表,读取属性、校验重复、构建查找表,后续请求直接查表即可。
use LogicException;
use ReflectionClass;
final class WebhookHandlerRegistry
{
/** @var array<string, class-string<WebhookHandler>> */
private array $handlers = [];
/**
* @param list<class-string<WebhookHandler>> $handlerClasses
*/
public function __construct(array $handlerClasses)
{
foreach ($handlerClasses as $handlerClass) {
$attributes = (new ReflectionClass($handlerClass))
->getAttributes(HandlesWebhook::class);
if ($attributes === []) {
continue;
}
/** @var HandlesWebhook $metadata */
$metadata = $attributes[0]->newInstance();
if (array_key_exists($metadata->event, $this->handlers)) {
throw new LogicException("Webhook 事件 [{$metadata->event}] 存在多个处理器");
}
$this->handlers[$metadata->event] = $handlerClass;
}
}
/** @return class-string<WebhookHandler> */
public function handlerFor(string $event): string
{
return $this->handlers[$event]
?? throw new LogicException("未找到 Webhook 事件 [{$event}] 对应的处理器");
}
}
分发器只需要从注册表拿类名,不用每次都做反射:
$handlerClass = $registry->handlerFor($event);
$handler = app($handlerClass);
$handler->handle($payload);
真正产生行为的是注册表和分发器。HandlesWebhook 本身永远不会主动分发任何东西。
这个例子里有几个刻意的设计选择:
这是我很推荐的属性使用模式:用反射构建显式的运行时数据,核心路径保持简单直接。
目标限制不只是语法细节,它本身就是属性设计的一部分。
比如「从请求头取值」的属性,就应该挂载在参数上,而不是类上:
#[Attribute(Attribute::TARGET_PARAMETER)]
final readonly class FromHeader
{
public function __construct(
public string $name,
) {}
}
默认情况下,同一个属性在一个声明上只能用一次。对于表名、作用域名、Webhook 事件这类单值元数据,这是合理的。
但有些元数据天然就是列表,比如中间件、标签、权限、订阅关系。这种场景可以把属性设为可重复:
#[Attribute(
Attribute::TARGET_CLASS |
Attribute::IS_REPEATABLE,
)]
final readonly class UsesMiddleware
{
/** @param class-string $middleware */
public function __construct(
public string $middleware,
) {}
}
然后被装饰的类就可以声明多个:
#[UsesMiddleware(Authenticate::class)]
#[UsesMiddleware(VerifyWebhookSignature::class)]
final class PartnerWebhookController
{
}
消费者依然要自己决定如何使用这些值、按什么顺序执行。可重复属性不会自动变出中间件管道,它只是让元数据模型更贴合「一个声明可以有多个同类值」的场景。
只有当领域本身就是集合时,才应该用可重复属性。如果集合本身是一组有顺序的完整配置,用单个属性包一个有序数组通常更清晰。
Laravel 13 里有很多很好的例子:它让元数据紧贴代码,同时保留了框架原有的契约。
比如 Eloquent 本地作用域可以用 #[Scope] 标记:
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class Post extends Model
{
#[Scope]
protected function published(Builder $query): void
{
$query->whereNotNull('published_at');
}
}
方法本身依然包含查询逻辑,属性只是告诉 Eloquent「这个受保护的方法是一个本地作用域」。Laravel 在构建模型查询 API 时读取元数据,让 Post::published()->get() 这样的写法保持简洁。
Laravel 也用属性做上下文依赖注入:
use Illuminate\Container\Attributes\Config;
final readonly class ReportExporter
{
public function __construct(
#[Config('reports.timezone')]
private string $timezone,
) {}
}
容器是消费者:它看到构造参数上的属性,就去解析对应的配置值。参数类型依然说明了类接收什么值,属性则说明这个值从哪来。
从框架对属性的用法里,可以总结出一条重要经验:
属性应该减少样板代码,但不应该掩盖真实意图。
#[Scope] 清晰地说明方法是什么,#[Config('reports.timezone')] 清晰地说明注入的是哪个配置项。它们都在框架里有明确、可知的消费者。
属性的参数最好是短小、稳定、易于静态读取的值。
这是一个好的属性 API:
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class Cacheable
{
public function __construct(
public string $key,
public int $seconds,
) {}
}
用法也很清晰:
#[Cacheable(key: 'catalog.featured', seconds: 300)]
public function featuredProducts(): array
{
// ...
}
元数据是声明式的:它告诉消费者缓存规则是什么,类加载时不会执行任何实际工作。
要警惕属性 API 越变越臃肿:
#[Cacheable(
key: 'catalog.featured',
seconds: 300,
store: 'redis',
tags: ['catalog', 'products'],
varyBy: ['tenant', 'locale', 'user'],
lock: true,
staleWhileRevalidate: true,
)]
成熟的缓存系统可能确实需要这些配置,但这也可能是一个信号:相关行为应该抽成独立的服务,或者专门的缓存策略对象。 属性很适合描述简洁的规则,但它未必适合承载一整套配置语言。
如果声明变得难读、消费者分支太多、或者修改需要依赖运行时状态,那就应该把逻辑放回普通代码里。
反射功能强大,但不是零成本。创建 ReflectionClass、查找属性、实例化元数据对象都有开销。通常开销不大,但如果在热点路径上反复执行,就会变得明显。
错误做法:每次进来 Webhook 请求,都遍历所有处理器做反射:
foreach ($allClasses as $class) {
$attributes = (new ReflectionClass($class))
->getAttributes(HandlesWebhook::class);
// 每个请求都去匹配事件...
}
更合理的做法,就是我们前面注册表例子里的模式:
对于扩展包来说,服务提供者通常是注册已知类的好地方;对于大型应用,可以在部署时生成并缓存元数据映射。具体实现方式各有不同,但核心原则不变:
反射放在边界做,热点路径用普通数据。
缓存也能提升可预测性:应用有一个明确的点去发现无效目标、重复事件名、错误的构造参数。这比用户操作触发线上反射报错要好排查得多。
要测试的是「属性消费者产生的行为」,而不只是「方括号声明是否存在」。
对于我们的注册表,有意义的测试是验证事件能解析到预期的处理器:
it('能正确解析付款事件的处理器', function (): void {
$registry = new WebhookHandlerRegistry([
RecordInvoicePayment::class,
]);
expect($registry->handlerFor('invoice.paid'))
->toBe(RecordInvoicePayment::class);
});
也要覆盖关键的失败场景:
it('会拒绝重复的事件处理器', function (): void {
new WebhookHandlerRegistry([
RecordInvoicePayment::class,
ProcessInvoicePaymentAgain::class,
]);
})->throws(LogicException::class);
这些测试验证的是元数据带来的实际效果。即使以后注册表不用反射、改成读取预生成的元数据文件,这些测试依然有效。
有些场景也适合单独测属性本身,比如扩展包把属性作为公开扩展点时,可以用简单的反射测试验证它的目标和默认值。但这应该是行为测试的补充,而不是替代。
当元数据有天然的归属、且有明确的消费者时,属性就是好选择。
适合使用属性的场景:
一些具体的实践例子:
它们的共同点是:属性在「解释」这个声明,而不是掩盖核心业务输入、替代行为契约。
属性很容易变成「语法更好看的全局状态」——这是最要避免的失败模式。
// 不要把结账的必填输入藏在元数据里
final class ChargeOrder
{
public function handle(Order $order, PaymentMethod $method): void
{
// ...
}
}
订单和支付方式是执行业务的必要参数,就应该放在方法签名里,或者用专门的输入对象承载。用属性会让依赖变得隐蔽,也更难测试。
// 这种应该放在配置或运行时策略里
#[PartnerApi(baseUrl: 'https://api.example.com')]
final class PartnerClient
{
}
基础地址在本地、预发、生产环境可能都不一样。它应该来自配置,而不是源码里的元数据。
如果多个类以不同方式实现同一个行为,接口、策略模式、显式的服务选择通常是更好的工具。
大量堆叠属性会有风险:
#[Authorize('admin')]
#[Retry(3)]
#[Transactional]
#[Cacheable(key: 'reports', seconds: 60)]
public function generate(): Report
{
// ...
}
单个看每个声明可能都合理,但合在一起之后,你会很难回答基础问题:权限在哪校验?什么异常会触发重试?事务包裹了哪些逻辑?缓存 key 对不同用户怎么区分?
属性本身不等于安全。#[Authorize('admin')] 本身保护不了任何东西,必须有真实的消费者在动作执行前做权限校验。要让这个校验链路容易追踪、测试和审计。
不要因为属性方便,就去扫描 app/ 下所有类。显式注册通常更易懂、启动更快、修改也更安全。
新增一个属性之前,我会问自己这几个问题: