ThinkPHP 8.0 多租户 SaaS 骨架核心是请求入口即识别租户、数据库连接动态重建清池、模型层强制全局租户过滤,并确保缓存、Session、日志及 SDK 全链路按租户隔离,任一环节缺失均可能导致跨租户数据泄露。

ThinkPHP 8.0 搭建多租户 SaaS 骨架,核心不在堆功能,而在请求一进来就“认准租户”、数据一查就“自动带租户条件”、连接一建就“只连本租户库”。漏掉任一环节,都可能跨租户读写——哪怕只是漏了一个 count() 或一次 with() 关联查询。
子域名识别必须放在最前端中间件
租户识别不是配置项,是整个请求链路的起点。不能靠 session、URL 参数或 cookie,这些在队列、定时任务、CLI 命令里根本不存在,也容易被伪造。
- 在
app/middleware/CheckTenant.php中,优先读取$_SERVER['HTTP_X_FORWARDED_HOST'](反向代理场景),再 fallback 到$_SERVER['HTTP_HOST'] - 绝对不用
request()->domain(),它可能已被 Nginx rewrite 规则污染 - 用正则白名单提取子域名,例如:
/^([a-z0-9\-]+)\.example\.com$/,过滤www、api等保留字,拒绝下划线、大写字母等非法字符 - 将解析出的租户标识(如
tenant1)直接绑定到请求实例:$request->tenantId = $tenantId - 该中间件必须排在
app/middleware.php的第一位,早于路由、模型、日志等任何初始化操作
数据库连接必须动态重建并清空池
ThinkPHP 的 Db 连接是懒加载 + 连接池复用机制。只改配置不重建连接,旧连接仍连着默认库,极易导致写错库、查不到数据、甚至跨租户串查。
- 在
CheckTenant.php中,调用Db::setConnectConfig($config)替换全局 tenant 连接配置,其中database字段必须为完整库名(如db_tenant1) - 立即执行
Db::clearConnectionPool()清空连接池,防止复用旧连接 - 所有模型统一声明
protected $connection = 'tenant',禁止使用Db::table()绕过模型连接配置 - PDO 连接需同步更新
charset和prefix,否则表前缀错位会导致Table not found
模型层必须用全局作用域强制过滤
即使连对了库,User::find(1) 仍可能查出其他租户的数据——这不是 Bug,是 ThinkPHP 模型设计使然:它本身不感知租户上下文。
立即学习“PHP免费学习笔记(深入)”;
- 在基类模型(如
app\model\BaseModel)的boot()方法中注册全局作用域:static::addGlobalScope('tenant', new TenantScope()) -
TenantScope必须实现think\Model\ScopeInterface,其apply()方法中通过app('request')->tenantId获取可信租户 ID,再调用$builder->where('tenant_id', $tenantId) - 特别注意软删除叠加租户过滤时的逻辑组合:
delete_time IS NULL AND tenant_id = ?,避免count()返回 0 却实际有数据
缓存、Session、日志与 SDK 必须按租户隔离
租户上下文一旦确立,所有共享资源都要带上租户标识,否则日志混写、缓存污染、Session 覆盖等问题会悄无声息地发生。
- 缓存 key 前缀统一加
tenant_{$tenantId}_,推荐使用Cache::tag("tenant_{$tenantId}")隔离 - Session 名称动态生成,例如:
session_name('s_' . $tenantId),并在config/session.php中禁用自动启动,改由中间件控制 - 日志文件按租户分目录写入:
log_path . '/' . $tenantId . '/runtime/log/,或在日志处理器中注入租户标识 - 第三方 SDK 实例(如短信、支付)按租户缓存单例,避免配置混用;可封装为
TenantSdkFactory统一管理



















