PHP 8.1的attribute函数怎么给类添加元数据 前言先纠两处事实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, typeint, min1)解析器要自己写一套迷你语法注释是字符串没有类型也没有工具能校验。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_types1); // 最低版本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()目标反射类方法类ReflectionClassgetAttributes()方法ReflectionMethodgetAttributes()属性ReflectionPropertygetAttributes()参数ReflectionParametergetAttributes()类常量ReflectionClassConstantgetAttributes()函数ReflectionFunctiongetAttributes()它们返回的是ReflectionAttribute对象的数组这个对象只有三个方法方法返回说明getName()string属性的完整类名getArguments()array使用处写了的那几个参数不做默认值填充、不做类型转换newInstance()object真正实例化属性类这一步才会做类型检查、套用构造函数的默认值getAttributes()的第一个参数可以传一个类名做过滤第二个参数可以传ReflectionAttribute::IS_INSTANCEOF表示按 instanceof 匹配——这个常量是PHP 8.0引入的配合属性的类可以被继承使用。四、实战路由 字段映射下面这份代码可以在 PHP 8.0 上直接运行。它演示三件事用 Attribute 收集路由、用 Attribute 做数据库字段到对象属性的映射、以及newInstance()到底在什么时候才真正执行。?php declare(strict_types1); /** * 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 ab.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 id42 nickname张三 emailab.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]一个元素构造函数的 requiredfalse 没有被填进来✅ 正确写法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 都要配一段反射读取的代码否则它就真的只是注释。