前言
先纠两处事实:
- Attribute(属性,也有人译作"注解")是 PHP 8.0 引入的,不是 8.1。RFC 的名字是 "Attributes v2",随 PHP 8.0 落地。PHP 8.1 增加的是内置属性里的第一个成员
#[\ReturnTypeWillChange],机制本身在 8.0 就有了。本文按PHP 8.0讲机制,末尾单独列一张"内置属性与版本"对照表。 - 它不是一个函数。Attribute 是一套语法(
#[...]加反射读取接口),没有任何一个叫attribute()的函数。它要做的事,以前是靠 docblock 注释做的。
说说"以前那种做法"为什么会出问题。老框架里给控制器打路由,写的是注释:
<?php /** * @Route("/users/{id}") * @Method("GET") */ public function show(int $id) {}框架启动时用正则去扫这些注释。症状就很明确了:
- 类名写错了(
@Rout、@Route("/x"少个括号)不报任何错,只是这条路由神秘消失; - 注释里的类名在 IDE 里不能跳转,重构重命名之后注释里的引用全部变成"死链",只能靠人工全局搜索;
- 想给参数加类型约束(比如
@Param("id", type="int", min=1)),解析器要自己写一套迷你语法; - 注释是字符串,没有类型,也没有工具能校验。
Attribute 把这些"藏在字符串里的元数据"变成了语法结构:写错了是语法错误,参数有类型,IDE 能补全、能跳转,反射 API 能直接读。
一、Attribute 的两段式:编译期挂载、运行期读取
Attribute 的语法很简单:#[开头,]结尾,里面是一个或多个"可以用在常量表达式位置"的类实例化写法。
<?php #[Route('/users', ['GET'])] // 位置参数 #[Route('/users/create', ['POST'])] // 同一个目标上可以叠多个(需要 IS_REPEATABLE) #[Middleware(Auth::class, priority: 10)] // 命名参数(8.0 也支持) public function users() {}它是一个两段式机制,这一点是理解一切坑点的前提:
| 阶段 | 发生了什么 | 会不会报错 |
|---|---|---|
| 编译期 | 编译器把#[...]里的内容记成一个"待实例化的描述",挂到对应的语法节点上 | 只检查语法。类名不存在、目标类型不匹配,都不报错 |
| 运行期 | 你主动调用ReflectionAttribute::newInstance()时,才真正new出那个类的实例 | 这时才会报"类不存在""不能用在方法上"之类的错 |
和注释解析相比,它的收益是:
| 对比项 | docblock 注释 | Attribute |
|---|---|---|
| 语法校验 | 无,写错静默失效 | 有,括号不配对直接语法错误 |
| 类型 | 全是字符串 | 构造函数有类型声明 |
| 重构重命名 | 不会跟着改 | IDE 能识别、能跳转 |
| 参数校验 | 自己写解析器 | 构造函数自己校验 |
| 读取方式 | 正则匹配 | getAttributes() |
| 生效时机 | 解析到就生效(框架自定) | 只有newInstance()时才实例化 |
二、定义一个 Attribute 类
Attribute 的"定义"就是一个普通的 PHP 类,只是必须在类上再打一个#[Attribute]:
<?php declare(strict_types=1); // 最低版本:PHP 8.0 use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class Route { /** * 构造函数的参数,就是使用处 #[Route(...)] 里能写的东西 * @param string[] $methods */ public function __construct( public string $path, public array $methods = ['GET'], public string $name = '', ) {} }#[Attribute]自己的参数是两个位掩码:
| 常量 | 含义 |
|---|---|
Attribute::TARGET_CLASS | 只能用在类、接口、trait、枚举上 |
Attribute::TARGET_FUNCTION | 只能用在函数上 |
Attribute::TARGET_METHOD | 只能用在方法上 |
Attribute::TARGET_PROPERTY | 只能用在属性上 |
Attribute::TARGET_CLASS_CONSTANT | 只能用在类常量上 |
Attribute::TARGET_PARAMETER | 只能用在参数上 |
Attribute::TARGET_ALL | 全部允许(这是不写参数时的默认值) |
Attribute::IS_REPEATABLE | 同一个目标上可以重复使用多次 |
两点必须记住:
TARGET_ALL是默认值。不写参数不等于"严格",恰恰相反,等于"哪儿都能用"。要限制用途必须显式写。- 目标类型不匹配不是编译错误。你把一个标了
TARGET_METHOD的属性用在属性上,PHP 编译时不会拦你,只有在newInstance()时才会抛Error。所以"写错了却一直没发现"是完全可能的——只要那段代码路径没被反射读到。
三、读出来:反射 API
读取入口分布在各个反射类上,名字统一叫getAttributes():
| 目标 | 反射类 | 方法 |
|---|---|---|
| 类 | ReflectionClass | getAttributes() |
| 方法 | ReflectionMethod | getAttributes() |
| 属性 | ReflectionProperty | getAttributes() |
| 参数 | ReflectionParameter | getAttributes() |
| 类常量 | ReflectionClassConstant | getAttributes() |
| 函数 | ReflectionFunction | getAttributes() |
它们返回的是ReflectionAttribute对象的数组,这个对象只有三个方法:
| 方法 | 返回 | 说明 |
|---|---|---|
getName() | string | 属性的完整类名 |
getArguments() | array | 使用处写了的那几个参数,不做默认值填充、不做类型转换 |
newInstance() | object | 真正实例化属性类,这一步才会做类型检查、套用构造函数的默认值 |
getAttributes()的第一个参数可以传一个类名做过滤,第二个参数可以传ReflectionAttribute::IS_INSTANCEOF,表示"按 instanceof 匹配"——这个常量是PHP 8.0引入的,配合"属性的类可以被继承"使用。
四、实战:路由 + 字段映射
下面这份代码可以在 PHP 8.0 上直接运行。它演示三件事:用 Attribute 收集路由、用 Attribute 做数据库字段到对象属性的映射、以及newInstance()到底在什么时候才真正执行。
<?php declare(strict_types=1); /** * Attribute 实战:路由收集 + 字段映射 * 最低版本:PHP 8.0 * Attribute 语法本身是 8.0 引入的;示例里没有使用 8.1 的枚举与只读属性 */ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class Route { /** @param string[] $methods */ public function __construct( public string $path, public array $methods = ['GET'], public string $name = '', ) { foreach ($methods as $m) { if (!in_array($m, ['GET', 'POST', 'PUT', 'DELETE'], true)) { throw new InvalidArgumentException("不支持的 HTTP 方法: {$m}"); } } } } #[Attribute(Attribute::TARGET_PROPERTY)] final class Column { public function __construct( public string $name, public bool $required = false, ) {} } /* ---------------- 被标记的类 ---------------- */ final class UserController { #[Route('/users', ['GET'], name: 'user.index')] #[Route('/users/create', ['POST'], name: 'user.create')] public function users(): string { return '用户列表'; } #[Route('/users/{id}', ['GET'])] public function show(int $id = 0): string { return '用户 ' . $id; } } final class UserDto { #[Column('user_id')] public int $id = 0; #[Column('nickname', required: true)] public string $nickname = ''; #[Column('email')] public string $email = ''; } /* ---------------- 读取 Attribute 的工具 ---------------- */ /** 收集一个控制器上的全部路由 */ function collectRoutes(string $controllerClass): array { $ref = new ReflectionClass($controllerClass); $routes = []; foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { // 第二个参数用 IS_INSTANCEOF,子类化的属性也能被匹配到 $attributes = $method->getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF); foreach ($attributes as $attribute) { /** @var Route $route */ $route = $attribute->newInstance(); // 到这里才真正 new Route(...) foreach ($route->methods as $verb) { $routes[] = sprintf( '%-6s %-16s -> %s::%s', $verb, $route->path, $ref->getShortName(), $method->getName() ); } } } return $routes; } /** 把一行数据库数据填进 DTO,字段名按 #[Column] 映射 */ function hydrate(string $class, array $row): object { $ref = new ReflectionClass($class); $obj = $ref->newInstanceWithoutConstructor(); foreach ($ref->getProperties() as $prop) { $attributes = $prop->getAttributes(Column::class); if ($attributes === []) { continue; } /** @var Column $column */ $column = $attributes[0]->newInstance(); $value = $row[$column->name] ?? null; if ($value === null) { if ($column->required) { throw new InvalidArgumentException("字段 {$column->name} 不能为空"); } continue; // 非必填字段缺失就跳过,保留属性默认值 } $type = $prop->getType(); if ($type instanceof ReflectionNamedType && $type->getName() === 'int') { $value = (int) $value; } $prop->setValue($obj, $value); } return $obj; } /* ---------------- 演示 ---------------- */ foreach (collectRoutes(UserController::class) as $line) { echo $line, PHP_EOL; } echo PHP_EOL; $user = hydrate(UserDto::class, ['user_id' => '42', 'nickname' => '张三', 'email' => 'a@b.c']); printf("id=%d nickname=%s email=%s\n", $user->id, $user->nickname, $user->email); try { hydrate(UserDto::class, ['user_id' => '42']); // 缺 nickname } catch (InvalidArgumentException $e) { echo '捕获: ', $e->getMessage(), PHP_EOL; } // 只读参数:不实例化属性类也不会报错,拿到的是"写出来的那几个参数" $prop = new ReflectionProperty(UserDto::class, 'nickname'); var_dump($prop->getAttributes(Column::class)[0]->getArguments());输出:
GET /users -> UserController::users POST /users/create -> UserController::users GET /users/{id} -> UserController::show id=42 nickname=张三 email=a@b.c 捕获: 字段 nickname 不能为空 array(2) { [0]=> string(8) "nickname" [1]=> bool(true) }注意最后一段:getArguments()拿到的是使用处写出来的参数('nickname'和true),而不是实例化之后的属性值。要拿到构造函数处理过的对象,必须走newInstance()。
五、内置属性与版本对照
内置属性的版本分布很分散,这张表经常被记混:
| 内置属性 | 引入版本 | 用途 |
|---|---|---|
#[\ReturnTypeWillChange] | PHP 8.1 | 给实现了内部接口的方法"豁免"临时返回类型检查 |
#[\AllowDynamicProperties] | PHP 8.2 | 允许类使用动态属性(配合 8.2 的动态属性弃用) |
#[\SensitiveParameter] | PHP 8.2 | 在堆栈跟踪里隐藏敏感参数的值 |
#[\Override] | PHP 8.3 | 声明"这个方法覆写了父类/接口的方法",写错会报错 |
#[\Deprecated] | PHP 8.4 | 标记函数/方法/常量已弃用 |
所以"PHP 8.1 的 attribute"这个说法可以这样理解:机制是 8.0 的,8.1 贡献的是第一个内置属性。真正让 Attribute 变得好用(有框架统一注册、有 IDE 支持)的是各框架自己的实现,而不是 PHP 的版本号。
常见坑点
1. 忘了给属性类加#[Attribute]
❌ 错误写法:
<?php final class Route // 少了 #[Attribute] { public function __construct(public string $path) {} } $attr = (new ReflectionMethod(Foo::class, 'bar'))->getAttributes(Route::class)[0]; $attr->newInstance(); // Error: Attempting to use non-attribute class "Route"✅ 正确写法:
<?php #[Attribute(Attribute::TARGET_METHOD)] final class Route { public function __construct(public string $path) {} }这个错误只在你主动读取的时候才出现,所以"属性类写完了、代码也跑得通、就是功能没生效"这种症状,八成就是漏了这一行。
2. 直接取getAttributes()[0]而不判断为空
❌ 错误写法:
<?php $route = (new ReflectionMethod($c, $m))->getAttributes(Route::class)[0]->newInstance(); // 没有这个属性时:Warning: Undefined array key 0,然后在对 null 调方法✅ 正确写法:
<?php $attributes = (new ReflectionMethod($c, $m))->getAttributes(Route::class); if ($attributes === []) { continue; // 没标记就跳过 } $route = $attributes[0]->newInstance();3. 以为目标不匹配会在编译期报错
❌ 错误认知:给一个标了TARGET_PROPERTY的属性写到方法上,以为 PHP 会立刻报错。
✅ 事实:编译期不报错,只有newInstance()时才抛Error。所以这类错误可能潜伏很久——直到某天有个新接口开始反射这个方法。写完属性后,第一时间写一段反射读取的冒烟测试,比等框架启动时才发现要快得多。
4. 以为子类会继承父类上的 Attribute
❌ 错误写法:
<?php #[Entity] class BaseModel {} final class User extends BaseModel {} $attrs = (new ReflectionClass(User::class))->getAttributes(Entity::class); var_dump($attrs); // array(0) {} —— 一个都没有✅ 正确写法:需要"继承"语义就自己沿父类链往上找:
<?php function findAttribute(ReflectionClass $ref, string $name): ?ReflectionAttribute { do { $found = $ref->getAttributes($name); if ($found !== []) { return $found[0]; } $ref = $ref->getParentClass(); } while ($ref !== false); return null; }$ref->getParentClass()在没有父类时返回false,循环条件写!== false才是对的(写!== null会死循环)。
5. 在#[...]里写函数调用或变量
❌ 错误写法:
<?php #[Route('/users/' . $version)] // 变量不行 #[Route(strtoupper('/users'))] // 函数调用不行 #[Route(null ?? '/x')] // 表达式不行✅ 正确写法:#[...]里只允许常量表达式——字面量、常量、类常量、数组字面量、::class,以及 PHP 8.1 起允许的new(是的,new出现在初始值里是 8.1 的特性,不是 8.0)。需要动态路径就写到配置文件里,别塞进属性。
6. 用getName() === 'Route'做匹配
❌ 错误写法:
<?php foreach ($method->getAttributes() as $attr) { if ($attr->getName() === 'Route') { // 字符串比较,子类化属性匹配不到 // ... } }✅ 正确写法:用IS_INSTANCEOF过滤,让框架支持"用户继承 Route 做扩展"这种常见需求:
<?php $routes = $method->getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF);注意getName()返回的是完整类名(带命名空间),拿短名去比一定不相等。
7. 在循环里反复newInstance()
❌ 错误写法:
<?php foreach ($methods as $method) { foreach ($method->getAttributes() as $attr) { // 每次都给同一个属性创建一个新对象 $obj = $attr->newInstance(); } }✅ 正确写法:newInstance()每次调用都返回一个新对象,而且会执行构造函数——构造函数里如果有校验逻辑、有 I/O、有缓存写入,成本就上去了。需要复用就自己建立一次并缓存:
<?php $cache = []; $key = $declaringClass . '::' . $property; $cache[$key] ??= $attributes[0]->newInstance();8. 只读getArguments()却依赖构造函数的默认值
❌ 错误写法:
<?php #[Column('nickname')] // required 参数没写,指望它等于默认值 true public string $nickname; $args = $prop->getAttributes(Column::class)[0]->getArguments(); // 拿到的是 ['nickname'],一个元素;构造函数的 required=false 没有被"填"进来✅ 正确写法:getArguments()给的是"写出来的原始参数",不做默认值填充、也不做类型转换。要拿到处理后的结果,必须newInstance()再读属性。反过来说,如果只是想"看看写了什么",用getArguments()更快、也不会触发构造函数的副作用。
总结
| 需求 | 做法 | 版本要点 |
|---|---|---|
| 给类/方法/属性加元数据 | #[Foo(...)] | Attribute 机制是PHP 8.0 |
| 定义可用的属性类 | 类上再打#[Attribute(...)] | 漏了就报Attempting to use non-attribute class |
| 限制使用位置 | Attribute::TARGET_*位掩码 | 不写等于TARGET_ALL,不写反而更宽松 |
| 允许重复标记 | 加Attribute::IS_REPEATABLE | 否则同一个目标上叠两个会报错 |
| 读取 | 各反射类的getAttributes() | 类/方法/属性/参数/常量/函数都有 |
| 实例化 | ReflectionAttribute::newInstance() | 只有这一步才做类型校验与默认值填充 |
| 按"是不是某类的子类"匹配 | 传ReflectionAttribute::IS_INSTANCEOF | 该常量是PHP 8.0引入的 |
| 内置属性 | 见上面那张表 | 8.1 / 8.2 / 8.3 / 8.4 各有一个 |
回头再看标题里的两个说法:Attribute 不是函数,是一套"语法 + 反射接口"的机制;它属于 PHP 8.0,不属于 8.1——8.1 带来的是#[\ReturnTypeWillChange]这个内置属性。搞清这两点之后,用起来其实只有一条核心规则:#[...]只是"挂上去",真正的语义全在newInstance()那一刻才发生,所以任何 Attribute 都要配一段反射读取的代码,否则它就真的只是注释。