kernel.response重复触发是FrankenPHP的HTTP/2 Server Push机制所致,非代码bug;应通过$event->getRequest()->isMainRequest()在监听器开头做幂等校验,或在Caddyfile中配置php { push false }禁用推送。

kernel.response 重复触发是 FrankenPHP 的 HTTP/2 Server Push 行为,不是代码 bug
FrankenPHP 在响应中遇到 <link rel="preload"> 或模块化脚本时,会主动发起子请求预取资源。这些子请求仍走完整 Symfony 内核流程,导致 kernel.response 监听器被调用多次——日志里看到同一 request_id 出现两段执行记录,$event->getResponse()->isSent() 在第二次进入时仍为 false,但实际响应早已发出。
这不是事件系统失灵,而是 FrankenPHP 的请求生命周期设计使然:主请求 + 若干子请求共享会话与路由上下文,却各自 dispatch 完整事件链。
- 不能依赖
$event->getResponse()->isSent()判断,子请求的 Response 对象尚未 send,但你不该处理它 - 不能用静态变量或全局标记做幂等控制,FrankenPHP 多 worker 场景下不安全
- 必须基于请求上下文识别“谁才是真正的主请求”
监听器里唯一可靠的防御方式:用 isMainRequest() 做前置守门
Symfony 5.4+ 提供了明确的 isMainRequest() 方法,FrankenPHP 的子请求返回 false,主请求才返回 true。这是目前最轻量、最稳定、不依赖外部配置的判断依据。
在监听器开头加这一行就足够:
立即学习“PHP免费学习笔记(深入)”;
if (!$event->getRequest()->isMainRequest()) {
return;
}
若需兼容旧版 Symfony(
-
$event->getRequest()->headers->get('Sec-Purpose') !== 'prefetch'可排除部分 prefetch 类型子请求 - 但该头非强制存在,且无法覆盖所有 push 场景,
isMainRequest()仍是首选
Caddyfile 层禁用 Server Push 才能根治源头
监听器里的判断是“兜底”,真正想一劳永逸,得关掉触发子请求的机制。FrankenPHP 的 push 行为由 Caddy 控制,不是 Symfony 配置能干预的。
在 Caddyfile 的 php 指令块中显式关闭:
php {
push false
}
或者在 frankenphp.yaml 中设:
server_push: false
验证是否生效:
- 用
curl -I http://localhost查看响应头,不应再出现Link: <> ; rel=preload - 禁用后,内联资源需前端自行加载(如
import('./chunk.js')),不再由服务器自动 push
Mercure 推送重复的关键点:主题 URL 必须字节级一致
FrankenPHP 内置 Mercure Hub 时,重复消息常源于订阅与发布两端的主题 URL 不完全匹配。HTTP 协议对路径末尾斜杠、协议、域名大小写极度敏感。
例如以下两个 URL 被视为不同 topic:
https://example.com/notificationshttps://example.com/notifications/
发布端用前者,前端订阅后者,就会导致消息“发出去了却没人收到”,进而可能引发重试逻辑或前端误判为丢失,手动补发造成重复。
务必做到:
- 前后端硬编码的 topic 字符串完全一致(建议统一定义为常量)
- 避免动态拼接时引入空格、换行或编码差异(如
rawurlencode()后未统一解码) - JWT payload 中
mercure.subscribe和mercure.publish数组里的 URL 也必须严格一致
复杂点不在逻辑,而在 URL 字符串的肉眼比对——多一个斜杠、少一个协议,Mercure 就当它是另一个世界。



















