用途
该技能面向 Symfony 项目的持久层,处理实体、关联、仓库与迁移的写法,目标是让这些代码“经得起真实数据的检验”。它被标注为 core 层:实体、仓库和经过审查的迁移不是可选项;SELECT NEW 投影属于按需使用的部分,当列表页水合的数据多于展示所需时再使用。
基本立场
所有规则出自一个决定:实体是“哑”的。实体保持 maker 生成形态——私有属性、getter/setter、映射属性,别无其它。文档同时说明该选择的后果:带有每个属性 setter 的实体本身不强制任何规则。保留 maker 形态是因为“一条无例外的规则”容易坚持,且 make:entity、Form、fixtures 和 EasyAdmin 都依赖它;代价是保证只能寄托于每次写入都经过服务。因此不在实体上写 publish()、rate()、archive() 等方法——在一个守卫某条路径的方法旁边放一个不守卫任何东西的 setter 只是装饰。业务规则放在服务里:实体是带类型的行,服务拥有行为,仓库拥有 SQL。
唯一的例外是绕过服务的管理后台界面。EasyAdmin 直接写入实体,因此那里的服务规则不适用;应把它视为可信界面,若某条局部规则在管理后台仍需成立,就把它作为 #[Assert] 约束复制到实体属性上,与服务、Input DTO 读取同一常量(如 Book::MAX_RATING)。
实体与类型选择
- 主键:自增整型标识符。明确拒绝 UUIDv7、ULID、内部 id 加公开 id 的方案,理由是它们解决本标准不存在的问题(离线 id 生成、隐藏行数),却带来索引体积、日志可读性和每次评审都要讨论的代价。
- 日期:使用 \DateTimeImmutable,绝不用 \DateTime。可变日期交给调用方后可能在实体不知情的情况下被修改,并在下次 flush 时被静默持久化,成为没有可见原因的 bug。应使用 Types::DATE_IMMUTABLE 或 Types::DATETIME_IMMUTABLE;普通 date/datetime 类型映射到 \DateTime。
- 枚举:对封闭值集使用带 enumType: 的原生枚举,使属性真正带类型,PHPStan 可以据此推理。
- 版本基准:文档声明以 Doctrine ORM 3.6、DBAL 4.4、doctrine-bundle 3.3 和 Symfony 8.1 验证;vendor/ 是比本文更权威的事实来源,旧文章中的若干 API 已不存在。
关联与性能陷阱
当反向侧确实会被使用(模板或服务真的会从 book 走到其 ratings)时使用双向关联,否则用单向;没人读的 mappedBy 只会让 Doctrine 白白管理一个集合。双向的代价是一个具体陷阱:在模板循环里用 count($book->getRatings())、$book->getRatings()->count() 或 book.ratings|length,若关联未映射 fetch: 'EXTRA_LAZY',PersistentCollection::count() 会初始化整个集合;在 50 本书的列表上就是 50 次额外查询并水合所有评分对象,只为显示一个数字。正确做法是在仓库里计数(countByBook 使用 ->select('COUNT(r.id)')),列表用一条分组查询返回每本书的计数。fetch: 'EXTRA_LAZY' 能让 count() 发出 SELECT COUNT(*) 而不水合,是在既有代码库上的合法修复,但不是这里的默认值,因为它让昂贵调用看起来免费,并在模板阅读者面前隐藏查询。
仓库
仓库是应用中唯一可出现 DQL、QueryBuilder 或原生 SQL 的地方;这不是风格偏好,而是让 deptrac 能强制层契约、让慢页面能通过 grep 一个目录被发现的前提。要点包括:正确导入 Doctrine\Persistence\ManagerRegistry 与 Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository(导入错误是最常见的 Doctrine 自动装配失败);仓库不声明 final,而服务、控制器、DTO、voter、handler 等都声明 final;添加 @extends ServiceEntityRepository<Review> 与 @return list<Review> 泛型注解,否则 find() 返回 object、getResult() 返回 mixed,PHPStan 最高级别会在每个调用点报错;方法按调用方意图命名(如 findLatestPublishedForBook),而非按机制命名;$this->_em 已随 ORM 3 移除,改用受保护的 $this->getEntityManager()。
按需投影
水合实体的列表页要付出 identity map、变更跟踪和每行所有列的代价,却只用四个字段。此时应投影,例如用 SELECT NEW 构造 Read DTO:
/** @return list<ShelfStatsRow> */
public function findShelfStats(): array
{
return $this->createQueryBuilder('b')
->select(sprintf(
'NEW %s(b.id, b.title, AVG(r.rating), COUNT(r.id), b.lastReadAt)',
ShelfStatsRow::class,
))
->leftJoin('b.reviews', 'r')
->groupBy('b.id')
->getQuery()
->getResult();
}
Read DTO 放在 src/Dto/Read/,保存查询所决定的原始值。服务负责 Read → Output 的规范化(四舍五入、ISO 日期、null 与 0),该规范化是业务规则,可脱离数据库做单元测试;若投影已与 API 契约一致,可直接投影为 Output DTO 并跳过该层。已验证的两点行为决定 DTO 的类型写法:带 enumType: 映射的列在构造函数中以枚举而非字符串到达;聚合列到达时是平台相关的 PHP 类型。
flush 的归属
由服务在每个用例中调用一次 flush;仓库只做 persist() 和 remove() 就停。示例中实体以 maker 形态构造(无构造函数参数),仓库 add() 只 persist,服务 flush 一次并随后发出通知。仓库若自己 flush,就把“工作单元是否结束”的决定权从唯一知道答案的对象手中拿走,并让“添加三本书再保存”变成三个事务;一个方法里 flush 两次通常意味着里面有两个用例。当多次 flush 确实必须同成功或同失败(批量导入、状态变更加账本写入)时,用 $entityManager->wrapInTransaction(callable $func);不作为默认,因为单次 flush() 本身已是原子的。
迁移
生成的迁移文件是草稿,永远不是交付物。生成器只比较两个 schema,不知道表中已有的行,因此属性重命名会输出 DROP COLUMN 加 ADD COLUMN——SQL 正确、CI 通过,却会在生产环境毁掉该列内容;修复是手工改成 ALTER TABLE … RENAME COLUMN …,只有人读文件才能发现。同样的问题也适用于在已有数据的表上新增 NOT NULL 列、会静默截断的类型变更,以及生成器机械写出的 down()。生产中绝不使用 doctrine:schema:update,因为它回答的是“让数据库匹配映射”,而非“如何从现有 schema 及其数据走到想要的 schema”。
排障对照
文档给出症状—原因表:改动未保存(无人调用 flush(),或服务在修改前 flush);列表页发出数百查询(循环中 count($x->getItems()) 或模板中遍历关联);无法自动装配 ManagerRegistry(导错了 ORM/DBAL 注册表);保存后 getId() 为 null(在 flush() 之前读取,标识符由插入分配);日期自行改变(用了 \DateTime 且被调用方修改);ManyToOne 上的 nullable 被忽略(nullable 参数属于 #[ORM\JoinColumn]);schema 不同步且无待执行迁移(改了映射未生成迁移,运行 doctrine:schema:validate);删除父项抛外键错误(缺少 cascade: ['remove'] 或 orphanRemoval,或拥有侧是另一边);被移除的子项又回来(未设 orphanRemoval,从集合移除只解除引用);部署后数据丢失(需要 RENAME 的地方上线了生成的 DROP/ADD)。php bin/console doctrine:schema:validate 用于校验映射是否仍与数据库一致,自 ORM 3 起还校验 PHP 属性类型与 Doctrine 类型是否匹配,应放入 CI。
参考文件
- references/entities-and-relations.md:添加字段或关联、选择 cascade/orphanRemoval/拥有侧、映射枚举或日期时阅读。
- references/repositories-and-queries.md:编写或修复仓库方法、投影、聚合、分页、PHPStan 注解时阅读。
- references/migrations.md:运行生成的迁移前、重命名或回填列、部署 schema 变更时阅读。
限制
资料未提供该技能的安装命令、收费与价格信息、具体平台支持范围或效果数据;相关内容暂未提供。