引言
PHP 8.0 引入的 Attribute(属性)把「元数据」从注释搬进了语言本身——不再需要 Doctrine 注解那样解析 docblock,而是编译期直接解析、反射期直接读取。Laravel 的路由、Symfony 的依赖注入、Doctrine ORM 的实体映射,如今都建立在属性之上。本文讲清属性的语法、反射读取、内置属性,以及属性驱动的路由/ORM/校验设计与性能缓存。
目录
- 1. 属性语法与声明目标
- 2. 反射读取属性
- 3. PHP 内置属性
- 4. 属性驱动的路由
- 5. 属性驱动的 ORM 映射
- 6. 属性驱动的参数校验
- 7. 属性与注解的对比
- 8. 反射性能与缓存策略
- 9. 实战:构建迷你属性驱动框架
- 10. 速查表与一句话记忆
- 延伸阅读
1. 属性语法与声明目标
1.1 语法与属性类
属性用 #[...] 标注在类、方法、属性、参数、常量上,本质是「附在声明上的结构化数据」。它区别于注释:属性是语法的一部分,php -l 就能校验,反射能读到,错误在编译期而非运行期暴露。属性类就是普通类,构造函数参数即「属性参数」(完整示例见 §4.1),用命名参数调用可读性最好。
1.2 声明目标常量
| 常量 | 可标注位置 |
|---|---|
| TARGET_CLASS | class / interface / trait / enum |
| TARGET_FUNCTION | 函数 |
| TARGET_METHOD | 方法 |
| TARGET_PROPERTY | 属性 |
| TARGET_CLASS_CONSTANT | 类常量 / 枚举 case |
| TARGET_PARAMETER | 函数/方法参数 |
| TARGET_ALL | 以上全部 |
| IS_REPEATABLE | 允许同一位置重复标注 |
目标不匹配时 newInstance() 抛 Error——这层校验是属性相对注解最大的安全优势。
记忆:属性 =
#[...]语法级元数据,属性类就是普通类,#[Attribute(TARGET_* | IS_REPEATABLE)]声明它能贴在哪、能否重复。
2. 反射读取属性
2.1 四类入口与过滤
$rc = new ReflectionClass(UserController::class);
$rm = $rc->getMethod('index');
$rp = $rc->getProperty('name');
// 全部属性
$attrs = $rm->getAttributes();
// 按类名过滤
$routes = $rm->getAttributes(Route::class);
// 按父类/接口过滤(子类也能命中)
$handlers = $rm->getAttributes(HandlerInterface::class, ReflectionAttribute::IS_INSTANCEOF);
2.2 newInstance 与 getArguments
foreach ($rm->getAttributes(Route::class) as $attr) {
$route = $attr->newInstance(); // 实例化属性类
echo $route->path, PHP_EOL;
// 不想实例化时,可先看原始参数
var_dump($attr->getArguments()); // ['/api/users', 'GET']
}
关键点:getAttributes() 默认不会实例化属性类,只有 newInstance() 才触发构造函数——「扫描但不用」的场景几乎零成本。
2.3 典型扫描器
扫描的本质就是「遍历反射方法 → 逐个 getAttributes → newInstance」,把结果收进一张「路径/名称 → 处理器」表(完整版见 §9)。
记忆:反射四入口(Class/Method/Property/Parameter)都提供
getAttributes();按类名或IS_INSTANCEOF过滤;newInstance()才实例化,getArguments()看原始参数。
3. PHP 内置属性
| 属性 | 版本 | 作用 |
|---|---|---|
#[Override] | 8.3 | 声明「确实覆写了父类方法」,写错方法名编译期报错 |
#[Deprecated] | 8.4 | 标记废弃,调用处触发 E_USER_DEPRECATED |
#[SensitiveParameter] | 8.2 | 参数出现在堆栈里时自动脱敏为 Object |
#[AllowDynamicProperties] | 8.2 | 允许动态属性(8.2 起默认废弃) |
#[ReturnTypeWillChange] | 8.1 | 兼容旧代码未声明返回类型的内置接口实现 |
#[NoDiscard] | 8.5 | 返回值未被使用则告警 |
3.1 #[Override] 与 #[SensitiveParameter]
class Child extends Base
{
#[Override]
public function save(): void {} // OK
#[Override]
public function seve(): void {} // 编译期报错:父类没有 seve
}
function login(string $user, #[SensitiveParameter] string $password) {}
// 抛异常时堆栈里 password 显示为 Object,不再明文泄漏
重构时改父类方法名,所有子类立刻暴露——这是静态分析无法完全替代的编译期护栏。
记忆:内置属性顶替 docblock 约定——
#[Override]校验覆写、#[Deprecated]标废弃、#[SensitiveParameter]脱敏堆栈、#[AllowDynamicProperties]放行动态属性。
4. 属性驱动的路由
4.1 定义与使用
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Get
{
public function __construct(
public readonly string $path,
public readonly array $middleware = [],
) {}
}
final class UserController
{
#[Get('/api/users')]
public function index(Request $r): Response { /* ... */ }
#[Get('/api/users/{id}', middleware: ['auth'])]
public function show(int $id): Response { /* ... */ }
}
4.2 生成路由表与参数注入
final class Router
{
private array $routes = [];
public function register(string $class): void
{
foreach ((new ReflectionClass($class))->getMethods() as $rm) {
foreach ($rm->getAttributes(Get::class) as $attr) {
$g = $attr->newInstance();
$this->routes['GET'][$g->path] = [
'handler' => [$class, $rm->getName()],
'middleware' => $g->middleware,
];
}
}
}
}
// 调用时按反射参数名,把 path 变量注入到方法参数
$args = [];
foreach ((new ReflectionMethod($class, $method))->getParameters() as $p) {
$args[] = $params[$p->getName()] ?? null;
}
call_user_func([$instance, $method], ...$args);
Laravel 11 默认仍以 routes/*.php 为主,但 Spatie Laravel Route Attributes 等包把路由搬到属性上,适合「控制器自解释」的中大型项目。
记忆:属性路由 = 自定义
#[Get]属性 + 扫描控制器方法 + 反射生成「路径→处理器」表;路径参数再按反射参数名注入。
5. 属性驱动的 ORM 映射
5.1 Doctrine ORM 的属性写法
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(type: 'string', length: 180, unique: true)]
private string $email;
#[ORM\ManyToOne(targetEntity: Team::class, inversedBy: 'users')]
#[ORM\JoinColumn(nullable: false)]
private Team $team;
}
5.2 元数据如何被读取
Doctrine 的 AttributeDriver 用反射读实体类:对类调 getAttributes(ORM\Entity::class, IS_INSTANCEOF) 拿表名/仓储,对每个属性调 getAttributes(ORM\Column::class) 拿字段类型、长度、nullable,汇总成元数据后交给 MetadataCache。
5.3 与 Eloquent 的差异
| 维度 | Eloquent | Doctrine |
|---|---|---|
| 映射方式 | 约定 + $casts/$fillable 数组 | 属性声明式 |
| 读取时机 | 运行期按约定推断 | 编译元数据缓存 |
| 类型安全 | 弱(数组配置) | 强(属性参数) |
| 迁移工具 | 独立 migrations | doctrine:migrations |
记忆:ORM 属性 = 实体类上用
#[ORM\Entity]/#[ORM\Column]/#[ORM\ManyToOne]声明映射,AttributeDriver 反射读取后缓存成元数据;比 Eloquent 的数组约定更声明式、更类型安全。
6. 属性驱动的参数校验
6.1 定义校验属性
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
final class Length
{
public function __construct(
public readonly int $min,
public readonly int $max,
public readonly string $message = '长度不合法',
) {}
}
// Email 属性同理,只带一个 message 参数
6.2 DTO 声明规则 + 校验器读取
final class RegisterRequest
{
public function __construct(
#[Length(min: 3, max: 20)] public readonly string $username,
#[Email] public readonly string $email,
) {}
}
final class Validator
{
public function validate(object $dto): array
{
$errors = [];
foreach ((new ReflectionClass($dto))->getProperties() as $rp) {
$value = $rp->getValue($dto);
foreach ($rp->getAttributes() as $attr) {
$rule = $attr->newInstance();
if ($rule instanceof Length && mb_strlen($value) < $rule->min) {
$errors[$rp->getName()] = $rule->message;
}
}
}
return $errors;
}
}
Symfony Validator 的 #[Assert\NotBlank]、#[Assert\Length] 正是这套机制;Laravel 侧 spatie/laravel-data 把属性校验带进 DTO。ReflectionParameter::getAttributes() 同样可读,把校验从「控制器里写一堆 if」前移到「声明处」。
记忆:属性校验 = DTO/参数上标
#[Length]/#[Email],校验器反射读取属性并执行对应规则;规则与字段定义同处一地,杜绝校验漂移。
7. 属性与注解的对比
7.1 本质差异
| 维度 | Docblock 注解 | PHP 属性 |
|---|---|---|
| 解析时机 | 运行期解析字符串 | 编译期进 AST |
| 解析器 | 第三方(doctrine/annotations) | 语言内建反射 |
| 语法错误 | 运行期才发现 | php -l 就报错 |
| 类型安全 | 弱(都是字符串) | 强(构造参数有类型) |
| 嵌套结构 | 需自造语法 | 直接 new 对象/数组 |
| IDE 支持 | 靠插件 | 原生 |
7.2 Doctrine 迁移示例
// 旧:注解
/** @ORM\Column(type="string", length=180) */
private string $email;
// 新:属性
#[ORM\Column(type: 'string', length: 180)]
private string $email;
Doctrine ORM 2.9+ 原生支持属性,doctrine/annotations 在新项目已可移除。仍需要注解的场景:元数据必须跨语言共享(OpenAPI 生成器读 docblock)、团队仍在 PHP 7.4、需要动态拼接的复杂表达式。
记忆:属性 vs 注解——属性是编译期 AST、语言内建反射、类型安全;注解是运行期字符串解析、依赖第三方库。新项目一律用属性,注解只留给跨语言/旧版本场景。
8. 反射性能与缓存策略
8.1 反射到底慢不慢
| 操作 | 相对开销 | 说明 |
|---|---|---|
new ReflectionClass | 中 | 首次解析类结构 |
getAttributes | 低 | 仅返回元数据句柄 |
newInstance | 中 | 触发属性类构造 |
getValue/invoke | 高 | 单次调用远慢于直接调用 |
结论:反射「扫描一次」不慢,慢的是「每请求都扫描」。
8.2 生产环境必须缓存元数据
$meta = $cache->rememberForever('routes_meta', fn () => (new Router())->compile($controllers));
Doctrine 用 MetadataCache(Redis/APCu),Symfony 用 cache.system,Laravel 用 php artisan route:cache / config:cache。把编译好的路由表写成 PHP 文件(return [...];),OPcache 会把字节码常驻内存,读取接近零成本。
三条实践准则:只在「构建/预热」阶段做反射扫描;结果写进 OPcache 友好的 PHP 数组文件或 Redis;运行期只读缓存,不碰反射。
记忆:反射别每请求跑——扫描一次、缓存元数据(Redis/APCu/编译成 PHP 数组),运行期只读缓存;OPcache 让「编译好的数组」常驻内存。
9. 实战:构建迷你属性驱动框架
9.1 完整代码
final class Container
{
private array $routes = [];
public function scan(string ...$classes): void
{
foreach ($classes as $class) {
foreach ((new ReflectionClass($class))->getMethods() as $rm) {
foreach ($rm->getAttributes(Route::class) as $attr) {
$r = $attr->newInstance();
$this->routes[$r->method][$r->path] = [$class, $rm->getName()];
}
}
}
}
public function dispatch(string $method, string $path): string
{
[$class, $action] = $this->routes[$method][$path] ?? [null, null];
return $class ? (new $class())->{$action}() : '404 Not Found';
}
}
控制器只需在方法上标 #[Route('GET', '/hello')],$app->scan(HelloController::class) 后 $app->dispatch('GET', '/hello') 即可拿到返回值。
9.2 从玩具到生产
玩具版每次请求都扫描;生产版把「路由扫描」换成编译缓存、「参数绑定」换成类型转换 + DI、「中间件」换成属性链式执行,并给元数据表加 OPcache / Redis 缓存。
记忆:属性驱动框架的核心就三步——反射扫描属性、构建「元数据表」、运行期查表分发;生产化就是给这张表加缓存、给参数加绑定。
10. 速查表与一句话记忆
| 需求 | 做法 |
|---|---|
| 声明属性类 | #[Attribute(TARGET_METHOD)] |
| 读取属性 | $rm->getAttributes(Route::class) |
| 实例化 | $attr->newInstance() |
| 看原始参数 | $attr->getArguments() |
| 接口过滤 | ReflectionAttribute::IS_INSTANCEOF |
| 覆写校验 | #[Override] |
| 堆栈脱敏 | #[SensitiveParameter] |
| ORM 映射 | #[ORM\Column] / #[ORM\ManyToOne] |
| 参数校验 | DTO 属性 + 反射校验器 |
| 性能 | 扫描一次 + 元数据缓存 |
一句话记忆:PHP 属性 = #[...] 语法级元数据(编译期进 AST、类型安全),属性类是普通类、#[Attribute(TARGET_* | IS_REPEATABLE)] 声明可贴位置;反射经 Class/Method/Property/Parameter 四入口 getAttributes() 读取、newInstance() 才实例化;内置属性 #[Override]/#[SensitiveParameter]/#[Deprecated] 顶替 docblock;用它驱动路由/ORM 映射/参数校验,生产环境务必「扫描一次 + 元数据缓存 + OPcache」。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。