ThinkPHP 8 多租户隔离需三步:租户识别在首中间件用子域名提取并绑定上下文;数据库连接动态重建并清空缓存;模型层通过全局作用域强制 tenant_id 过滤。

ThinkPHP 8 实现多租户授权隔离,核心不是加权限菜单或角色判断,而是从请求入口开始切断跨租户数据通路——识别必须早、连接必须换、查询必须拦,三者缺一不可。
租户识别必须在第一个中间件完成
不能依赖 session、cookie 或 URL 参数,这些在 CLI、队列、定时任务中完全失效,且易被伪造。正确做法是:
- 优先读 $_SERVER['HTTP_X_FORWARDED_HOST'](反向代理场景),再 fallback 到 $_SERVER['HTTP_HOST']
- 用正则提取子域名,例如:
/^([a-z0-9\-]+)\.example\.com$/i,捕获组 $matches[1] 即租户标识 - 严格清洗:去掉非法字符,限制长度(如 ≤64),拼出安全库名(如
db_tenant1) - 把租户 ID 绑定到可信上下文,推荐
think\facade\App::instance()->tenant = $tenantId - 该中间件(如
app/middleware/CheckTenant.php)必须排在app/middleware.php的第一位
数据库连接必须动态重建并清空缓存
ThinkPHP 的 Db 连接是懒加载 + 连接池复用,只改配置不清理,旧连接仍连着默认库,极易写错库或查不到数据。
- 在
CheckTenant.php中,根据子域名生成完整连接配置,database字段必须是具体库名(如db_tenant1),不能只传前缀 - 调用
Db::setConnectConfig('tenant', $config)注册连接 - 立即执行
Db::clearConnection('tenant'),确保后续首次调用重建连接 - 所有模型统一声明
protected $connection = 'tenant',禁止使用Db::table()绕过 - PDO 的
charset和prefix也需按租户单独设置,避免表前缀错位导致Table not found
模型层必须用全局作用域强制过滤 tenant_id
即使连对了库,User::find(1) 仍可能查出其他租户的数据——这是 ThinkPHP 模型的设计特性,不是 Bug。
立即学习“PHP免费学习笔记(深入)”;
- 在基类模型(如
app\model\BaseModel)的boot()方法中,显式注册:static::addGlobalScope('tenant', new TenantScope()) -
TenantScope必须实现ScopeInterface,apply()方法里调用$builder->where('tenant_id', $tenantId),其中$tenantId从App::instance()->tenant获取 - 聚合查询(
count())、关联查询(with())、软删除等场景,全局作用域默认不覆盖,需在TenantScope中手动兼容逻辑 - 需要跨租户操作时(如后台管理),用
withoutGlobalScope('tenant'),而不是useGlobalScope(false)
授权与资源隔离要同步落地
授权隔离不只是“查不到别人数据”,还包括缓存、Session、日志、Redis、静态资源等全部按租户打标。
- 缓存 key 加前缀,如
cache_tenant1:user:100;Session 配置session.name和session.path按租户区分 - 日志文件名或通道名包含租户标识,避免日志混杂;SDK 实例(如短信、支付)也需按租户初始化独立实例
- 静态资源路径(如上传目录)按租户分隔,例如
public/uploads/tenant1/ - 新增数据时,用模型事件
before_insert自动填充tenant_id字段,杜绝漏填 - 为
tenant_id字段建立联合索引(如(tenant_id, status)),兼顾查询性能与隔离强度



















