symfony 7.2 无开箱即用多租户支持,需手动注入租户上下文并动态切换 doctrine 连接:在请求早期识别租户,close() 后 createconnection() 重建连接,禁用静态配置;租户上下文通过可注入服务传递,缓存键和数据库策略(如 postgresql 行级策略)须包含租户标识,四者缺一不可。

如何让 Doctrine 连接按租户动态切换
Doctrine 默认只配置一个 default 连接,多租户场景下必须在运行时替换连接实例,不能靠配置文件静态定义。关键不是“改 config”,而是“绕过连接池复用”。
- 不要在
config/packages/doctrine.yaml里写死url或dbname,留空或设为占位符(如%env(resolve:DATABASE_URL)%) - 用
Doctrine\DBAL\Connection的close()+createConnection()手动重建连接,而不是调用setDatabase()—— MySQL 不支持运行时切换库名 - 租户识别必须在请求早期完成(如 Kernel::handle() 或自定义 RequestListener),晚于 Doctrine 初始化就来不及了
- 若用共享 Schema 模式,可跳过连接切换,但必须全局启用
@Filter并确保tenant_id字段存在于所有主表
为什么不能依赖 Symfony 的环境变量自动加载租户配置
Symfony 的 .env 在容器编译期加载,而租户是运行时动态决定的(比如通过子域名 acme.example.com 或请求头 X-Tenant-ID)。硬编码到 .env 会导致所有请求都走同一个租户配置。
-
$_SERVER['HTTP_HOST']或request->getHost()是最常用租户识别入口,但需提前解析出租户标识(如去掉.example.com) - 别把租户信息塞进
$_ENV—— 它是进程级全局变量,多请求并发时会互相污染 - 推荐用
ThreadLocal类似机制(PHP 中用static $tenant+ 请求生命周期管理),配合reset钩子清理 - 如果用 Swoole 或 RoadRunner,还要注意协程/Worker 级别的状态隔离,
static变量可能跨请求残留
租户上下文如何安全传递到 Repository 和 Service 层
不能靠参数层层透传(易漏、难测),也不能靠全局函数(破坏 DI 原则)。正确做法是封装一个可注入的上下文服务,并在构造时绑定当前租户。
- 定义
TenantContext接口,实现类用RequestStack获取当前租户 ID,避免直接依赖超全局变量 - Repository 构造函数接收
TenantContextInterface,并在findBy()等方法中自动添加WHERE tenant_id = ? - 禁止在 DQL 中手写
WHERE tenant_id = :tenant—— 必须统一走@Filter或 Query Builder 封装 - 缓存键必须包含租户标识,否则
cache:pool:doctrine会跨租户命中错误数据
共享 Schema 下如何防止租户间数据越界查询
仅靠应用层过滤不够可靠,一旦某处漏掉 tenant_id 条件,就会暴露其他租户数据。必须叠加数据库级防护。
- MySQL 8.0+ 可用
ROW POLICY(需企业版)或触发器拦截非法 INSERT/UPDATE - PostgreSQL 更成熟:用
CREATE POLICY+USING (tenant_id = current_setting('app.tenant_id')),再配SET app.tenant_id = 'xxx' - 即使用了策略,也要在 Doctrine EventListener(如
prePersist)里强制设置tenant_id字段,防止业务代码绕过 - 单元测试必须覆盖“无租户上下文时抛异常”的场景,否则 CI 环境容易漏掉越界逻辑











