结论:在 symfony 项目中落地 cqrs 和事件溯源,应使用 ecotone 框架而非手写总线或硬套 symfony/event-dispatcher 与 doctrine orm;前者提供开箱即用的命令路由、事件持久化、版本控制与快照管理,后者无法保障事件只追加、顺序重放与状态一致性。

直接说结论:在 Symfony 项目里落地 CQRS 和事件溯源,不推荐手写命令总线、事件分发器和聚合根重建逻辑。官方 symfony/event-dispatcher 只负责事件广播,不解决事件持久化、顺序保证、重放一致性等核心问题;硬套 Doctrine ORM 做事件存储会破坏只追加语义,也绕不开事务边界与快照管理的坑。
为什么不能直接用 symfony/event-dispatcher 实现事件溯源
它只是个轻量级事件广播器,不是事件存储基础设施:
-
EventDispatcher::dispatch()发完即忘,事件不落盘,断电就丢,无法支撑状态重建 - 没有内置的事件序列号(
version)、流 ID(stream_name)或幂等键控制,重放时容易漏事件或重复应用 - 监听器执行顺序依赖注册顺序,但事件溯源要求严格按时间戳/版本号重放,否则状态错乱
- 它不提供
loadStream($aggregateId)或appendEvents($stream, $events)这类仓储契约,得自己补全存储层
用 Ecotone 框架替代手写总线的关键动作
Ecotone 是目前 PHP 生态中唯一把 CQRS + 事件溯源当一等公民设计的框架,它把“命令路由”“事件发布”“聚合快照”“重放控制”全封装进注解和配置里:
- 定义命令处理器只需加
@CommandHandler注解,Ecotone 自动绑定到总线,不用手动注册服务 - 聚合根继承
AggregateRoot后,调用$this->recordThat(new OrderPlaced(...))即生成待存事件,框架自动收集并交由EventStore持久化 - 事件存储默认支持 MySQL、PostgreSQL、MongoDB,且强制使用
event_id+version+playhead三元组保证重放顺序 - 快照策略可配:比如每 100 个事件自动保存一次当前聚合状态,避免冷启动时全量重放
安装只要一行:composer require ecotone/ecotone,再配合 Ecotone\Modelling\Annotation\Aggregate 和 @AggregateRepository 就能跑通完整链路。
Doctrine 里存事件必须绕开 ORM 的三个陷阱
如果坚持用 Doctrine 管理事件表(比如 event_store),以下三点不处理,上线后必出数据不一致:
- 不要用
EntityManager::persist()存事件实体——它会触发级联更新、生命周期回调,破坏事件不可变性;改用Connection::insert()直写,字段包括id、aggregate_id、event_type、payload、version、created_at - 写事件和更新聚合版本号(如
order_version字段)必须在同一个数据库事务里提交,否则出现“事件写了但聚合没升版”,下次重放会跳过该事件 -
payload字段必须用 JSON 类型(MySQL 5.7+)或 TEXT,禁止序列化 PHP 对象——不同部署环境的类路径、版本可能不一致,反序列化直接失败
重放聚合状态时最容易被忽略的版本校验
很多人以为从数据库查出所有事件然后 foreach apply 就完事了,但漏掉了最关键的一环:聚合根内部的 $version 必须严格等于事件流的最新 version。否则会出现两种灾难:
- 版本落后:聚合当前是 v5,但只重放到 v3 的事件,导致状态缺失
- 版本超前:聚合当前是 v5,却强行重放 v6 事件(比如因并发写入导致事件乱序入库),
apply()方法可能抛异常或静默失败
正确做法是在聚合仓储的 load($id) 方法里,先查最新 version,再按 WHERE aggregate_id = ? AND version 拉取事件,最后比对 <code>count($events) === $latestVersion。这个校验点不在框架文档显眼位置,但生产环境出问题基本都栽在这儿。











