ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Symfony 6.4 + Doctrine ORM 3.x 迁移实战:从XML到Attribute完整指南

Symfony 6.4 + Doctrine ORM 3.x 迁移实战:从XML到Attribute完整指南 前阵子我把一个跑了快五年的 Symfony 项目从 5.4 升到了 6.4底层的 Doctrine ORM 也从 2.x 翻到了 3.x。项目里有 40 多个实体、上百张表、将近八年的生产数据牵一发动全身。团队里有人说“Doctrine 3 是一次重写实体层等于要全重写”也有人说换到 Attribute 映射会踩无数坑。我用两周时间把这条路完整走了一遍把遇到过的问题、逐行改过的配置、分批迁移的手法都记了下来。如果你正在做 Symfony 6.4 迁移或者正准备把 Doctrine ORM 升到 3.x这篇文章就是我踩出来的地图——从 composer 预演到 schema 同步从 XML 映射报错到大数据量迁移每一步都有可以直接抄的作业。1. 迁移前的地图先把版本和依赖钉死1.1 为什么最终选了 Symfony 6.4 Doctrine 3.x很多人一听到“迁移”就想着一步到位上 Symfony 7 或者最新稳定版但实际做技术选型时稳定和可维护才是第一优先。Symfony 6.4 是一个 LTS 版本PHP 版本要求 8.1 以上安全维护周期足够长有充足的时间把项目逐步消化掉。换句话说它不是最新版本却是当前时间点最适合老项目升级的“最佳落点”。Doctrine ORM 3.x 同样要求 PHP 8.1 以上与 Symfony 6.4 的 PHP 版本要求完全齐平不用为兼容老语法做额外的妥协。我之所以坚持升到 Doctrine 3.x不只是为了“追新”。2.x 虽然成熟但很多 API 已经进入废弃阶段比如 EntityManager 的 create 之类的方法、老式的代理生成方式以及 DBAL 2 的 fetchAll 风格接口。继续停留在 2.x意味着后续依赖链上任何一个小升级都可能踩中废弃警告排查成本会越来越高。从 3.x 开始Doctrine 把映射元数据、代理模式、类型系统全部重新梳理了一遍代码路径更干净也给了我们换用 Attribute 映射的契机。升级之前我也犹豫过要不要保留 XML 映射毕竟改动最小。等我把 3.x 的文档翻完才发现XML 本身没有被放弃但 Attribute 是官方推荐的新写法IDE 支持比 XML 好太多。最终我选择了“保留 XML 过渡 逐步切 Attribute”的两步走方案先让项目在 6.4 上跑通再分批把实体映射改掉。1.2 动手前必须摸清的版本基线很多升级失败不是改代码改出问题而是没在动手前把版本基线理清。以下是我自己在 composer 动手前会逐项确认的东西PHP 实际版本。Symfony 6.4 需要 PHP 8.1我建议直接上 PHP 8.2 或 8.3因为有些 Doctrine 关联功能在 8.1 上表现略保守生产环境尽量留一点余量。当前 composer.json 里所有 symfony/* 组件的版本特别是 symfony/doctrine-bridge、doctrine/doctrine-bundle、doctrine/orm 三个关键包。数据库驱动是否完整比如 pdo_mysql、pdo_pgsql 对应 PHP 扩展是否启用。是否使用 APCu、Redis 做元数据缓存缓存组件的版本是否兼容。是否有历史遗留的数据库迁移脚本迁移脚本里是否用了 DBAL 2 独有的 API。我习惯在项目根目录直接跑一下环境检查php -v php -m | grep pdo composer show | grep -E symfony|doctrine真实经验是很多项目在 CI 环境和本地环境的 PHP 版本不一致composer 本地一跑没问题部署到服务器就报“Doctrine ORM needs PHP 8.1 or later”。这就是版本基线没钉死的典型表现。升级之前我建议先在测试环境把 PHP 版本统一到 8.2 或 8.3再动 composer 依赖能少踩一半的坑。1.3 先跑一次“预演”再动真格我在升级时没有直接改 composer.json 里的版本号而是先用 composer 的预演机制查清冲突。这一步能避免很多“升级到一半卡死”的尴尬。做法很简单用 why-not 命令反查依赖冲突composer why-not doctrine/orm 3.0 composer why-not symfony/framework-bundle 6.4如果某个包阻止了升级它会明确告诉你谁依赖了旧版本。比如我项目里当时有个老旧的第三方 bundle 锁死了 symfony/doctrine-bridge 5.4必须先处理那个 bundleORM 才能往上走。再配合 dry-run 参数把整包升级的影响范围先模拟一遍composer update --dry-run --profile -Wdry-run 不会真正改动 vendor只是列出将要升级的包和版本号。这一步让我提前看到了要动几十个依赖包也让团队在正式升级前有了心理准备。查看输出时重点看有没有“removed”或者“downgraded”的字样如果出现反向降级说明某个约束写得太死需要先放开。2. 配置文件迁移doctrine.yaml 的重构2.1 DBAL 连接配置的新旧对照升级之后第一个要改的是 config/packages/doctrine.yaml。旧的 DBAL 2 配置在新版里有些键已经不再推荐有些必须显式补齐。老项目里常见的配置是这样doctrine: dbal: driver: pdo_mysql host: %env(DATABASE_HOST)% port: %env(DATABASE_PORT)% dbname: %env(DATABASE_NAME)% user: %env(DATABASE_USER)% password: %env(DATABASE_PASSWORD)%到了 DBAL 3 Symfony 6.4推荐直接使用 url 统一管理同时显式声明 server_version。改完是这个样子doctrine: dbal: url: %env(DATABASE_URL)% server_version: 8.0 charset: utf8mb4server_version 这一项特别容易被忽略。DBAL 3 连接数据库后很多 schema 相关的判断依赖这个值如果不写Doctrine 可能需要做一次额外的版本探测。测试环境下因为网络延迟不明显生产环境压力一大连接耗时和误判就会暴露出来。我项目里用的是 MySQL 8.0所以 server_version 写 8.0。MySQL 5.7 的老库写成 5.7PostgreSQL 就写对应的版本号。根据我自己的经验如果你继续用旧的 host/dbname/user 拆分写法也不是完全不能跑但这种写法在 connection parameters 处理上不如 url 简洁而且一旦需要调整连接参数比如加 ssl-mode就得改多个地方。2.2 ORM 层配置的变化doctrine.yaml 的 orm 段是升级中变化最大的一块。升级到 Doctrine 3 后最明显的新配置项是 enable_lazy_ghost_objects。doctrine: orm: auto_generate_proxy_classes: true enable_lazy_ghost_objects: true report_fields_where_declared: true validate_schema: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware简单解释这几个配置的作用enable_lazy_ghost_objectsDoctrine 3 默认使用 Lazy Ghost 代理模式而不是 2.x 时代的传统代理。关联对象的初始化更轻量查询性能在复杂实体关系下会有可感知的提升。report_fields_where_declared让 ORM 只关心你在映射里明确声明的字段。之前有些项目习惯在实体里写一堆辅助属性但不做映射这个配置能避免 ORM 对这些字段做多余的“猜测”。validate_schema建议开启它让 Symfony 在启动阶段或执行 schema 校验时报出明显错误而不是把问题埋到查询时才爆发。naming_strategy 我沿用 underscore_number_aware如果老项目里数据库列名已经用了下划线风格不要轻易改这个策略否则所有字段映射都会错位。我遇到过一个特别典型的“坑”只改了 ORM 版本没开 enable_lazy_ghost_objects升级后某几个实体关联一起查就会偶发“Cannot initialize lazy collection”的错误。后来把这一项打开并清空缓存问题就不再出现。原因很复杂简单说就是旧代理类和新版 ORM 的初始化方式不兼容而 Lazy Ghost 模式才是 3.x 真正支持的方式。2.3 映射文件的路径与命名空间方案如果项目暂时还保留 XML 映射doctrine.yaml 里的 mappings 段要仔细核对。我当初迁移时报的“读取实体类的 xml 错误”根源就在这里。一个能正常工作的配置长这样doctrine: orm: mappings: App: is_bundle: false dir: %kernel.project_dir%/config/doctrine prefix: App\Entity alias: App这里有三个关键点dir 指向 XML 文件所在的真实目录prefix 是实体类的命名空间前缀alias 是别称。升级过程中最常见的错误是实体类在 src/Entity 下但 XML 文件放在 config/doctrine 下配置里的 dir 却写成了 src/Entity/xml 之类的错误路径。Doctrine 找不到 XML 时不会直接告诉你“路径不对”而是抛出一个让人摸不着头脑的 XML 解析错误或者干脆忽略 XML 配置。另一个高发问题是对不上命名空间XML 里写的实体名是 App\Entity\Article但 prefix 写成了 App\Domain\Entity两边对不上系统同样会报元数据异常。如果你打算彻底切换到 Attribute 映射mappings 可以简化为doctrine: orm: mappings: App: is_bundle: false dir: %kernel.project_dir%/src/Entity prefix: App\Entitydir 指向实体类目录不再需要单独的 XML 文件。切换前一定要确认所有实体类都已经加了 Attribute 映射否则该实体就变成“裸奔”只能靠默认命名规则猜列名数据错乱风险极高。3. 实体层改造映射从 XML 到 Attribute3.1 为什么优先换成 Attribute实体映射有三种主流方式XML、Annotation、Attribute。Doctrine 3 虽然还支持 XML但官方文档已经把 Attribute 列为推荐方案。我自己换完之后最大的感受是三个字少跳转。用 XML 时实体类的属性和数据库列的对应关系散落在另一个文件里改字段类型要来回切文件一旦 XML 路径配置错误错误信息还绕来绕去。用 Attribute 后字段类型、长度、关联关系直接写在属性上面IDE 能跳转代码评审也直观很多。这里多说一句Annotation 方式在 PHP 8 之后已经算是过渡形态Doctrine 3 对它的支持也不是长线方向。老项目如果还在用 ORM\Column 这样的注释Migration 时早晚要处理。既然要升级不如一步到位换成原生 Attribute。3.2 实体映射的实际改写示范以最常见的 Article 实体为例用 XML 映射时大概是这样的entity nameApp\Entity\Article tablearticle id nameid typeinteger columnid generator strategyAUTO/ /id field nametitle typestring columntitle length255/ field namecontent typetext columncontent/ field namecreatedAt typedatetime_immutable columncreated_at/ /entity改成 Attribute 后实体类的头部和属性直接书写映射信息#[ORM\Entity] #[ORM\Table(name: article)] class Article { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: Types::INTEGER)] private int $id; #[ORM\Column(type: Types::STRING, length: 255)] private string $title; #[ORM\Column(type: Types::TEXT)] private string $content; #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private \DateTimeImmutable $createdAt; }注意一个细节Type 常量必须 use Doctrine\DBAL\Types\Types。这是 DBAL 3 的推荐做法直接用字符串类型名也能跑但是容易拼错。比如 datetime_immutable 写成了 datetime语义就完全变了。我改造时没有手动一个个复制而是分了两步先用正则批量把 XML 里常见的 field 定义转成 Attribute再逐个人工核对关联关系和特殊类型。全自动转换工具生成代码会有遗漏比如联合主键、唯一约束、自定义索引这些AI 和脚本都不一定能生成对必须人工兜底。3.3 关联关系与类型系统的坑关联关系是实体层迁移中风险最高的部分。因为字段类型改错了最多报类型错误关联关系改错了会出现重复数据、外键约束失败、级联删除失效等问题而且通常在生产环境才会暴露。常见的多对一关系XML 写法是many-to-one fieldauthor target-entityApp\Entity\User join-column nameauthor_id referenced-column-nameid/ /many-to-oneAttribute 写法是#[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(name: author_id, referencedColumnName: id)] private User $author;这里我吃过一次亏。升级时忘了写 JoinColumn 的 referencedColumnNameDoctrine 默认假设关联的是对方实体的主键 id。大多数情况没问题但如果对方表的主键不叫 id或者关联的是唯一业务编号报错会非常隐晦错误信息只提示 join column 找不到。关系上的 orphanRemoval 行为也有变化。旧版里orphanRemovaltrue会在集合从关联中移除时直接删除子记录新版对生命周期回调的触发时机更严格如果代码里在 preUpdate 阶段做了一些集合操作可能触发额外查询。我建议升级后重点做一遍“增删改查 级联删除”的回归测试一定不要只跑查询。类型系统方面最需要注意的是 json_array 类型。DBAL 2 里的 json_array 在 DBAL 3 中已经改名为 json如果 XML 文件里还写着 json_array升级后会直接报“Unrecognized Doctrine type”。我项目里就有两个字段用了 json_array迁移时全部改成 json赋值和读取逻辑不需要动类型定义改掉即可。自定义类型也一样。DBAL 3 对自定义类型的 API 做了收紧老的 getSqlDeclaration 相关写法要改成静态方法风格。如果在升级过程中发现自定义类型无法注册多半是类型类没有适配新的 Type 接口。我的建议是能删则删能用 PHP 8.1 原生 enum 解决的就不要自定义类型。我项目里原本有一个用户状态的小整型自定义类型后来直接改成了原生枚举代码反而更清晰。4. 实操过程与数据迁移可以直接抄的步骤4.1 能一次性备份就别心存侥幸在动任何命令之前先备份。这是所有数据库操作里最朴素也最重要的一句话。我当时的备份方案是先让 DBA 打了一份全量逻辑备份然后用云平台的快照功能对生产库做了一次快照。逻辑备份用于跨环境恢复快照用于快速回滚。两条路同时走哪一个都不能少。代码层面同样要打 tag。迁移前把当前可运行的代码 commit 打上 tag比如 release-5.4-before-upgrade一旦升级过程不可控可以直接切回这个版本。实际操作中我见过太多人只备份数据库不备份代码状态结果数据能回滚代码已经改得面目全非。4.2 schema 同步先看 SQL 再执行依赖升级完成、配置改好之后下一步是同步数据库 schema。我强烈建议不要直接跑 force 强制同步先用 dump-sql 看清楚 Doctrine 打算执行哪些语句php bin/console doctrine:schema:update --dump-sql对照输出检查几个重点是否有 DROP 操作、是否有字段类型被重建、是否有外键约束被删除。如果出现不必要的 DROP通常是映射配置有误比如字段名拼写不一致导致 Doctrine 认为旧列不存在这时候千万不要盲目执行。确认没问题后再执行php bin/console doctrine:schema:update --force团队如果已经使用了 Doctrine Migrations 插件更规范的做法是生成迁移文件php bin/console doctrine:migrations:diff php bin/console doctrine:migrations:migrate --dry-run php bin/console doctrine:migrations:migrate迁移文件的好处是可以进入版本管理多人协作时不会互相踩 schema。我这次项目使用 Migrations对比老项目里手工执行 SQL 的方式幸福感提升明显。唯一的建议是生成 diff 后一定要人工 review 迁移文件自动 diff 偶尔会把索引名、外键名改得面目全非。4.3 大批量数据迁移脚本的写法如果只是升级代码数据迁移一般不做大规模的数据搬动。但我这次升级顺便统一了时间字段的类型把部分 datetime 改成了 datetime_immutable还清理了一些历史脏数据所以就涉及了数据迁移。大批量数据迁移最大的坑是内存和锁。几百万行的表一条 SQL 直接 UPDATE 或者一次性 SELECT 到内存必然把 PHP 内存吃掉还会锁住线上表。我采用的分批处理思路很简单按主键范围分段操作每次处理 500 行左右。use Doctrine\DBAL\Connection; use Doctrine\DBAL\Types\Types; public function migrate(Connection $connection): void { $lastId 0; $batchSize 500; while (true) { $rows $connection-fetchAllAssociative( SELECT id, old_time FROM legacy_table WHERE id :lastId ORDER BY id LIMIT :limit, [lastId $lastId, limit $batchSize], [lastId Types::INTEGER, limit Types::INTEGER] ); if (count($rows) 0) { break; } foreach ($rows as $row) { $newTime fixLegacyTime($row[old_time]); $connection-update( legacy_table, [new_time $newTime], [id $row[id]], [new_time Types::DATETIME_IMMUTABLE, id Types::INTEGER] ); $lastId $row[id]; } // 每处理完一批让连接休息一下 usleep(200000); } }代码里要注意Doctrine DBAL 3 的 fetchAllAssociative 替代了旧版 fetchAll这是升级中很典型的 API 变化。update 方法可以配合 Types 常量强制类型转换避免字符串时间被 MySQL 隐式转换出偏差。分批操作之后一定要重新统计表数据量对比迁移前后的总数。我习惯在执行前后各跑一次SELECT COUNT(*), COUNT(new_time) FROM legacy_table;如果迁移前总行数 迁移后总行数且新字段的非空数量等于旧字段的非空数量才算基本通过。数据完整性不能只靠“没有报错”来判定。4.4 回滚与灰度方案升级过程再顺利也要准备 Plan B。我的回滚方案分两层应用层回滚和数据库层回滚。应用层最简单把代码 tag 切回升级前然后强制刷新缓存git checkout release-5.4-before-upgrade php bin/console cache:clear --envprod数据库层回滚要看情况。如果只做了 schema 同步可以用 migration 的 rollback 命令回退最近一个版本php bin/console doctrine:migrations:migrate prev如果已经执行过数据迁移比如把 datetime 字段整体转了一遍那么回滚不是一句命令能解决的。所以生产环境上我建议先灰度一部分只读实例把读取流量切过去跑一段时间确认查询没异常再处理写入。真到了要回滚那一步优先依赖数据库快照比任何反向脚本都可靠。5. 常见问题与排查技巧那些能让你晕头转向的报错5.1 “读取实体类的 xml 错误”到底是什么问题这是我在升级过程中遇到的最棘手的一个报错也是网上搜索热度很高的一个词。当时升级完配置一执行 schema 相关命令就报类似“Failed to read XML mapping file”的信息实体类好像被读取了但又拿不到任何有效映射。排查下来其实同时存在两个问题而且都是很典型的“迁移病”。第一个问题XML 映射文件头部的 schema 版本太老。老文件里通常是这样doctrine-mapping xmlnshttp://doctrine-project.org/schemas/orm/doctrine-mapping xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsdDoctrine 3 对 XML XSD 做了调整3.x 的映射文件建议使用带版本号的 schemaLocation。如果不更新解析器很可能会在校验阶段失败报出含义不明的 XML 错误。解决办法是把 schemaLocation 更新为新版地址并核对所有 XML 文件均引用同一版本不要让新旧文件混用。第二个问题更隐蔽。doctrine.yaml 里 mappings 的 dir 指向了旧的映射目录而升级时我已经把部分实体改成了 Attribute剩下几个没改的实体仍然依赖 XML。结果 Doctrine 处于“一半靠 XML一半靠 Attribute”的混乱状态。有些实体类同时存在 XML 文件和 Attribute 声明时两边冲突报的错就是 XML 读取失败。这种情况没有捷径只能把实体改造进度彻底钉清楚。我的做法是列了一张实体清单标记每个实体当前用的是 XML 还是 Attribute每改完一个就删一个 XML 文件确保同一个实体绝不允许有两种映射来源。强烈建议你也用这种“一实体一来源”的方式管理能省去大量排查时间。5.2 迁移后必跑的体检命令升级完成不等于迁移完成。我会在迁移后固定跑一组“体检命令”任何一条失败都不过关php bin/console doctrine:schema:validate php bin/console doctrine:ensure-production-settings php bin/console cache:clear --envprod php bin/console doctrine:migrations:statusdoctrine:schema:validate 会同时检查映射正确性和数据库 schema 一致性。ensure-production-settings 会检查代理生成、元数据缓存是否适合生产模式如果配置不当会直接给出 warning。有些错误只有在清理缓存后才暴露。因为 PHP 的 OPcache 和 Symfony 的容器缓存可能还残留旧类我的经验是升级过程每完成一个阶段就 clear 一次缓存不要等到全部改完再来否则错误会被缓存掩盖排查难度成倍增加。5.3 问题速查表我把升级过程中遇到过的问题整理成一张速查表方便你到时候对照症状可能原因处理方式读取实体类的 xml 错误XML 映射 schema 版本过旧升级 XSD 版本统一所有映射文件 schemaLocation同一个实体报多处映射冲突同时存在 XML 和 Attribute 映射坚持“一个实体只保留一种映射来源”执行 schema 命令报未注册类型自定义类型未适配 DBAL 3更新类型类接口或改用原生枚举json_array 字段无法读取DBAL 3 已弃用 json_array将类型名改为 json关联加载报 Lazy 集合错误未开启 lazy ghost 配置启用 enable_lazy_ghost_objects 并清缓存数据库列被误判为不存在的字段命名策略不一致或映射字段名拼错对照数据库真实列名核对映射不要全信自动生成大批量迁移时内存耗尽一次性加载过多数据按主键分批处理每次 500 行左右升级后首次请求极慢代理类和元数据缓存未生成预生成代理类并预热缓存5.4 一个小技巧临时把错误层打开遇到无法理解的报错时我习惯先把错误显示级别调到最敏感。在 .env 里临时把 APP_DEBUG 设为 true然后重新执行出错的命令能看到完整的堆栈。调试完再改回 false。这一步听起来毫无技术含量但解决了我至少 30% 的疑难杂症。因为很多 Symfony 升级后的报错真正原因并不在表面那一行而在更早的元数据解析阶段只有堆栈才能暴露真实触发点。另外一个容易被忽略的点升级后服务器上的 OPcache 必须清空。很多人本地跑得好好的上生产就报各种离奇错误查半天最后发现是 OPcache 还在用老 PHP 类文件。清 OPcache 的方式可以根据服务器情况选最省事的是重启 PHP-FPM。这个动作小但救过我好几次。我个人在整个迁移过程里最大的感受是“升级不等于重写”。Doctrine 3 的变化确实大但它带来的性能提升和 API 清爽度也是实实在在的。渐进式迁移、分批验证、随时可回滚这三条原则任何一条都比硬往新版本上冲重要。如果让我再给一个建议就是每次只改一个层次先升依赖、再改配置、最后动实体映射不要同时攻多个层次否则出错后你会连问题出在哪一层都搞不清楚。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进