PHP 8.2 readonly class 与 Doctrine ORM 3.4 如何终结实体类中的 getter/setter
2026/9/15 6:51:45 网站建设 项目流程

做了八年PHP开发,我在实体类里老老实实写getter/setter的时间大概比很多同行写代码的总时长都长。说实话,以前真没觉得这有什么问题,IDE一键生成,工具链也顺手,顶多就是实体类看起来胖一点。直到这半年,借着一次技术债清理,我把项目从Doctrine ORM 2.x一路升级到3.4.0,顺手用PHP 8.2的readonly class + 构造函数属性提升把大部分实体重构了一遍,才真正意识到:过去八年里大量getter/setter根本不是必需品,而是语言和ORM能力不到位时不得不接受的妥协。

这篇文章我会按这次重构的完整顺序来写:起初为什么觉得非改不可、中间基于什么原则设计方案、具体怎么一步步改、以及落地时踩到的几个重要坑。如果你也正在纠结“实体类到底该不该去掉getter/setter”,或者准备把项目升级到Doctrine ORM 3.x,这篇应该能帮你省下不少试错时间。

1. 八年getter/setter,我到底在烦什么

1.1 实体类越来越胖,一半代码都是无脑传递

先给大家看一个非常普通的实体,这是这次重构前我在项目里随手打开的一个类:

#[ORM\Entity] #[ORM\Table(name: 'audit_logs')] class AuditLog { #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue] private int $id; #[ORM\Column(type: 'string', length: 32)] private string $action; #[ORM\Column(type: 'json')] private array $context; #[ORM\Column(type: 'datetime_immutable')] private DateTimeImmutable $createdAt; public function getId(): int { return $this->id; } public function getAction(): string { return $this->action; } public function setAction(string $action): void { $this->action = $action; } public function getContext(): array { return $this->context; } public function setContext(array $context): void { $this->context = $context; } public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; } }

一个只有4个字段的实体,却有7个访问器方法。如果字段数到了15到20个,这个类里一半以上代码都是在做同一件事:把属性原封不动搬出去,或者把参数原封不动塞进来。写的时候很机械,看的时候又很干扰,真正想找业务逻辑,还得在一大堆getter/setter里翻。

可能有人会说,封装性好呀,以后可以在setter里加校验。但我在真实代码里看到的真相是:绝大部分setter从入职写到离职都没有加过任何校验,就是一个裸赋值。既然setter本身没有逻辑,那它就只是一个语法层面的“闸门”,不仅挡不住不合理变更,反而给了每个调用方一个名正言顺的写入口。

1.2 可变实体是隐蔽的bug温床

比样板代码更要命的,是可变实体带来的业务规则失控。举个例子,订单状态本来应该走状态机流转,先是pending,支付成功后变成paid,发货后变成shipped。但因为实体上有一个setStatus(string $status),谁都可以在任何地方写一行$order->setStatus('done')把状态改掉。

这个问题的本质是:实体把“写”的能力公开给了所有调用方,而业务规则又没法在每个setter里真正落地。结果就是,团队的领域逻辑被迫放在了Service层,可Service层并不可控,新来的同事不看文档根本不知道这里应该调用$order->markAsPaid()而不是setStatus('paid')

集合属性也是重灾区。我以前见过很多实体直接暴露getItems()返回一个可变的Collection,调用方随手$order->getItems()->add($item),绕过聚合根上本该有的addItem方法,价格计算、库存校验全被跳过。等到出了问题,查代码的时候根本找不到是哪一行往集合里塞了数据。

1.3 为什么拖了八年才动手

不是不知道这些问题,是真的没得选。很长一段时间里,PHP语言本身没有不可变属性,Doctrine ORM也不支持把实体类设计成完全只读的形态。如果硬要自己搞一套“对象创建后不允许修改”的约定,只能靠团队纪律,而团队纪律这种东西,在排期紧张的时候永远是第一个被牺牲的。

真正让我下决心的,是这两年PHP和Doctrine同时走到了一个成熟节点:PHP 8.0的构造函数属性提升、8.1的readonly属性、8.2的readonly class,加上Doctrine ORM 3.x对只读实体的支持越来越稳。到了3.4.0这个版本,我测试了几个之前会卡住的组合场景,发现都跑得挺顺,于是才正式启动了这次重构。

2. 转变契机:PHP 8.2 readonly class与Doctrine ORM 3.4.0的配合

2.1 语言层面终于给出了一个像样的答案

PHP 8.0开始,构造器属性提升能把属性声明、构造函数参数、赋值三件事合成一行:

class Money { public function __construct( public int $amount, public string $currency, ) {} }

PHP 8.1的readonly属性则让属性在初始化之后彻底变成只读,一旦在构造函数里赋过值,后续任何形式的修改都会直接抛出Error。到了PHP 8.2,readonly class进一步把这个约束推向整个类:类里所有属性自动变成readonly。

这三个特性叠在一起,意味着价值对象和不可变实体第一次有了原生语言支持。以前需要手写getter来“保护”的属性,现在直接声明成public readonly就行,外部能读但不能写;以前需要setter来制造一个可变的写入口,现在构造器把参数收进来,整个对象从一开始就是完整且固定的。

2.2 Doctrine ORM 3.x这些年做了什么

另一个关键变量是Doctrine ORM本身。3.0版本把PHP版本下限提到了8.1,映射方式也全面转向PHP attributes,annotation、XML、YAML这些老配置方式逐步退出主流程。这个变化在初期对老项目有点折腾,但也倒逼我们把实体定义彻底翻新了一遍。

3.2版本开始,Doctrine官方对只读实体给出了正式支持方案,也就是允许把实体类或实体属性设计成readonly。不过作为一个“写熟了就不想折腾”的人,我没有在最早期就冲上去。真正让我判断可以动手的,是3.4.0这个时间点:

  • 只读实体在日常CRUD、查询、结果集映射这几个核心路径上已经很稳定,没有那种“demo能跑但一上业务就报错”的感觉;
  • 和PHP 8.1的枚举、自定义类型、Embeddable值对象组合使用都很顺畅;
  • 社区里关于readonly实体踩坑的讨论已经足够多,哪些能做哪些不能做,基本都有明确结论。

对一次要交到团队手上的框架升级来说,基础设施稳了才是动手的信号。

2.3 为什么我不建议再等

也有同事问过我,要不要等PHP 8.4或者Doctrine ORM 4.0出来再说。我的想法是,如果真想用只读实体这套设计,现在的PHP 8.2/8.3 + Doctrine ORM 3.4.0已经是够用的组合。语言底层的readonly模型从8.1到现在没有大的语义变化,Doctrine 3.x的只读实体支持也经历了几个版本的迭代,指望下一个大版本突然冒出什么翻天覆地的改进,不如先把眼前的样板代码清掉。

当然,这不等于所有代码都能无脑改成只读,具体哪些实体能改、哪些实体千万别改,下面这部分才是这次重构里真正的核心。

3. 重构设计:不是“把所有实体改成readonly”这么简单

3.1 先分类:哪些适合只读,哪些不适合

我这次重构的第一步,是把项目里的实体分成三类。

第一类是完全适合改成readonly class的,典型就是日志、流水、审计记录这类创建后就不再变化的实体。它们从诞生到归档都处于“只读”状态,唯一的写动作就是创建本身,天然和不可变模型完美契合。

第二类是部分适合的,比如用户、订单这类核心业务实体。它们虽然整体是变动的,但其中某些字段是创建后就不会再改的,比如用户注册时的用户名、订单创建时的快照价格。这些字段可以单独声明成public readonly,可变字段继续保留行为方法。

第三类是完全不适合的,典型是草稿、配置、高频局部更新的大对象。如果一个实体本质上就是一个长期可变的状态容器,每个字段都可能被单独修改,那强行改成只读只会让你的代码变成一团用withXxx()方法拼出来的灾难。

3.2 主键设计:先放弃自增ID,这是最容易被忽略的坑

这是我这次重构里最想提醒大家的一点。很多实体用的是数据库自增主键,这在传统getter/setter时代毫无问题,但一旦想把实体类声明为readonly,自增主键会立刻变成绊脚石。

原因很简单:readonly属性一旦在对象创建时被初始化,之后就不能再被修改。数据库自增主键的ID是insert之后才生成的,Doctrine在flush之后需要把这个生成的主键回填到内存中的实体对象上,这个回填动作本质上就是第二次写入一个readonly属性,直接触发PHP的Error。

解决方案也不复杂:把主键改成应用层生成,最常见的就是UUID。你在构造函数里先生成好ID,这个值在进入数据库之前就已经确定,insert之后Doctrine不需要再回填,readonly就不再冲突。如果项目已经有很多自增ID实体,迁移时要把主键切换作为独立步骤先做掉。

3.3 集合关联:从实体里移走,而不是硬塞进只读类

一对多集合是另一块硬骨头。以前我习惯在User里放一个Collection $orders,用Doctrine的OneToMany映射自动管理。但在readonly class的语境下,这种设计会有两个问题:

一方面,Doctrine加载实体的过程中,集合属性往往需要被替换成持久化集合,而readonly属性不允许这种替换。另一方面,集合本身是可变的,调用方一样能拿到集合然后往里塞数据,这和不可变的初衷完全相反。

我在这次重构里的策略是:凡是核心聚合根里的一对多集合,能拆就拆,拆不掉就改成显式查询。简单说,User实体里不再放orders集合,而是通过OrderRepository::findByUserId($userId)去拿订单列表。这样做的好处是两层的:实体变轻了,只读化没有阻碍;同时集合的写操作路径也消失了,所有订单创建都必须走Order这个聚合根来管理。

3.4 审计字段和时间戳怎么处理

还有一个常见问题是审计字段。很多项目习惯在实体上放createdAtupdatedAt,然后通过Doctrine的lifecycle callback在保存前自动更新updatedAt。但readonly属性意味着你无法在生命周期事件里去修改它,因为那也是一个迟到写入。

我对这个问题的判断是:真正适合改成只读的实体,本身就不应该有updatedAt。一个创建后永远不更新的日志流水,放一个updatedAt字段纯属自欺欺人。而一个经常要变状态的User实体,如果把它整个改成只读,那才是给自己挖坑。

所以重构方案是:createdAt进readonly实体,updatedAt留给可变实体。如果一个实体同时需要很强的不可变性和频繁更新,那说明这个设计本身还没有想清楚边界,先回头把职责拆开,再动手改代码。

4. 实操实录:把一个实体从getter/setter改造成只读实体

4.1 改造前:一个典型的AuditLog实体

我们继续用开头的AuditLog举例。实体本身是审计日志,写入后不会修改,属于第一类完全适合改造成只读的实体。原始代码有4个字段、7个方法,全是机械的getter/setter。

4.2 改造主键:从自增ID切到UUID

在改字段可见性之前,先把主键换掉。这里我用的是ramsey/uuid库,也可以用symfony/uid或者任何你习惯的UUID实现,关键是ID在应用层生成,不依赖数据库回填。self::class对应数据库字段长度36。

#[ORM\Entity] #[ORM\Table(name: 'audit_logs')] readonly class AuditLog { #[ORM\Id] #[ORM\Column(type: 'string', length: 36, unique: true)] public string $id; #[ORM\Column(length: 32)] public string $action; #[ORM\Column(type: 'json')] public array $context; #[ORM\Column(type: 'datetime_immutable')] public DateTimeImmutable $createdAt; public function __construct( string $action, array $context = [], ?DateTimeImmutable $createdAt = null, ) { $this->id = Uuid::v4()->toString(); $this->action = $action; $this->context = $context; $this->createdAt = $createdAt ?? new DateTimeImmutable(); } }

对比一下改造前后的代码量:原来7个方法现在全部消失,实体字段直接以public readonly形式暴露。读取日志动作时直接写$log->action,不再调用$log->getAction()

4.3 改造后:public readonly属性替代getter

有同事问,把字段设成public是不是就破坏了封装?我的看法是,public readonly和private getter在“外部可以读取”这个语义上是完全一致的,区别在于前者还明确表达了“外部不能写入”这层约束。过去我们写private+getter,只在语法层面防止了直接字段访问,但通过setter还是可以随便写;现在是彻底堵住了写入口,只留下一个构造函数。

这在审计日志这个场景非常合适,因为日志一旦入库就需要保持原样,任何后续修改都没有意义。如果未来真的需要修改,那也不该是修改这条日志,而是写一条新的日志。

4.4 过渡方案:普通实体里的半只读字段

当然,不是所有实体都适合一步到位改成readonly class。比如User,email和status都是经常要改的,整体只读不现实。我的做法是保留User作为普通实体,但把其中真正不可变的字段,比如注册时的id、创建时间,声明成public readonly:

class User { #[ORM\Id] #[ORM\Column(type: 'string', length: 36, unique: true)] public readonly string $id; #[ORM\Column(length: 64)] private string $username; public function __construct(string $username) { $this->id = Uuid::v4()->toString(); $this->username = $username; } public function getUsername(): string { return $this->username; } public function rename(string $username): void { $this->username = $username; } }

这样改造后,id不再需要getter,username则保留了行为方法而非裸setter。一直觉得setUsername太通用的,现在改成rename之后,语义反而清楚了。这个过渡方案能让你在一半实体上先见到效果,降低整体迁移的风险。

4.5 调用点批量调整:getXxx()方法怎么换

实体改完后,所有读取的地方都要跟着改。比如$log->getAction()要变成$log->action$log->getCreatedAt()->format(...)要变成$log->createdAt->format(...)。项目规模不大时可以靠IDE的重构功能和正则一批替换,但有几个问题要特别注意:

  • 如果方法名里有业务语义而不是简单的属性直读,比如isActive()hasPermission(),这些不是getter,不应该直接替换成属性访问;
  • 如果调用方原来通过getContext()拿到数组后转成了某种数据结构,要注意新代码里的类型一致性;
  • 替换完之后一定要让静态分析工具跑一遍,我当时是用PHPStan设了最高级别来扫,很多漏改的地方都是它抓出来的。

5. 踩着过的坑与排查方法

5.1 自增主键回填导致的“Cannot modify readonly property”

这个坑在3.2节已经提过,但值得再展开讲讲。如果你没切换主键就直接把实体改成readonly,最常见的报错长这样:

Cannot modify readonly property App\Entity\AuditLog::$id

这个错误通常不是发生在查询读取时,而是发生在插入新记录后的flush阶段。Doctrine拿到数据库生成的自增ID后,想把它同步回实体对象,结果发现对象已经初始化过了,于是直接抛出异常。排查的时候尤其迷惑,因为报错堆栈里看不到业务代码的痕迹。

我的建议是:在做只读化改造之前,先用脚本把涉及的自增主键全部切到UUID,并把数据库字段类型一起迁完,确认写入和读取都正常之后,再动实体字段的readonly声明。两步分开做,出问题的时候定位会快很多。

5.2 readonly class与懒加载代理不兼容

第二个大坑是懒加载。Doctrine在处理实体关联时默认会生成代理对象,代理继承实体类,并在访问属性时才触发真正的查询加载。但PHP的readonly class在使用上有限制,代理机制在属性初始化后无法再注入数据,这就导致只读实体和懒加载天然打架。

我遇到的典型场景是:Order实体上有一个ManyToOne指向User,User被改成了readonly class,结果一查Order就报错或proxy生成失败。解决方案是把这个关联改成EAGER加载,让User在查询Order时立即取出:

#[ORM\ManyToOne(targetEntity: User::class, fetch: "EAGER")] #[ORM\JoinColumn(nullable: false)] public User $user;

如果实体之间关联层次比较深,全是EAGER会导致查询join爆炸,再配合懒加载又会有N+1问题。所以更实际的做法是,只读实体尽量不持有需要懒加载的对象关联,宁可像前面说的那样在Repository里显式查询,也不要把整张关联网都挂在实体上。

5.3 不要在只读实体上使用refresh和merge

还有一个我踩过的小坑:EntityManager::refresh($entity)方法会强制从数据库重新取一次数据,覆盖当前对象的状态。在readonly实体上执行这个操作,本质上又是一个迟到写入,一样会触发PHP的Error。

反过来,如果业务里真的需要强制刷新最新数据,正确做法是先clear()清掉实体管理器,再重新查询一次。这样返回的是一个全新对象,所有属性从数据库row初始化,完全符合readonly的规则。这个区别在重构后的代码评审里我专门提醒了团队。

5.4 生命周期回调里不要试图更新时间戳

lifecycle callback里的prePersist、preUpdate事件在flush阶段触发,如果你尝试在这一步给readonly属性赋值,同样是“初始化后写入”,直接报错。更麻烦的是,这类报错发生在flush深处,业务代码里没有明显线索。

所以我前面才强调:有updatedAt需求的实体就别改成readonly。如果真的需要一个审计时间,我建议单独做成一条日志记录,而不是把更新时间塞进原有实体里。保持一个实体只做一种事情的边界,比省一张表要重要得多。

5.5 查询投影和只读实体是更好的组合

只读实体还有一个隐含收益:它天然适合做查询投影。以前用ResultSetMapping做自定义查询时,经常要写一个满屏getter/setter的DTO,现在完全可以直接用readonly class来承接结果集,PHP会自动做构造器参数匹配。

#[ORM\ColumnResult(name: 'action')] #[ORM\ColumnResult(name: 'created_at')] class AuditLogListDTO { public function __construct( public string $action, public DateTimeImmutable $createdAt, ) {} }

这让只读实体不止在领域层发光,在读模型和写模型分离的时候也更顺手。列表页展示、报表导出这类场景,少了整实体的重量,代码干净很多,也基本不会踩到懒加载的坑。

6. 迁移后的真实体感与建议

这次重构花了两周左右,不是一口气把所有实体全改掉,而是先挑了审计日志、流水、快照这类天然不可变的实体试点,跑了一个迭代,确认没有回归,再逐步扩大到其他实体的半只读改造。

改完之后最直观的变化是代码量缩水了很多,就拿AuditLog来说,从原来各种getter/setter堆叠的长类,变成一个不到30行、一眼能看完全部字段和构造函数的结构。对我来说,更重要的收获是,实体不再是一个谁都能随便写的公共数据袋子了,业务上该有边界的部分被语言特性强制立了起来。

如果你的团队也在考虑做类似的重构,我的建议是:

  • 不要追求所有实体都变成readonly,先把典型的日志、流水、价值对象类实体挑出来练手;
  • 动手前一定要先切UUID主键,这是最容易忽略也最伤筋动骨的准备工作;
  • 把实体里的一对多集合拆出去,不要让集合背负只读体的限制;
  • 静态分析工具一定要开,PHPStan或者Psalm在批量替换getter时能帮你兜住大部分低级错误。

这次重构的经验也让我彻底改变了写实体的习惯。以后再建新实体,我会先问一句:这个东西创建之

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询