共享数据库+tenant_id字段+全局作用域即可稳跑两年;租户识别须在请求初通过子域名提取并校验存在性,用app()->instance()注入单例;模型层依赖BelongsToTenant Trait自动加全局作用域与填充tenant_id;文件、缓存、日志均需按tenant_id隔离;队列与命令行等非请求场景须显式传参重置上下文。

直接上结论:别一上来就选 hyn/multi-tenant 或 stancl/tenancy,90% 的新项目用共享数据库 + tenant_id 字段 + 全局作用域就能稳跑两年,代码可控、迁移不裂开、测试能覆盖。
中间件里怎么安全识别租户
租户识别必须在请求生命周期最开始做,且不能依赖 session 或 auth 用户——因为登录态可能跨租户残留,或管理员需要切换视角。核心是把租户 ID 存进 Laravel 的 request 生命周期上下文,而不是全局变量或静态属性。
- 推荐从子域名提取:
tenant1.yourapp.com→tenant1,比路径参数(/t/tenant1)更干净,也比请求头更可靠(避免前端漏传) - 务必校验租户存在性,不存在直接
abort(404),别 fallback 到默认租户——那是数据泄露的温床 - 用
app()->instance('currentTenant', $tenant)注入单例,后续所有地方通过app('currentTenant')?->id取值,不走 session、不查 auth - 每个请求结束前不用手动清理,Laravel 的 application instance 在 request 结束时自动释放
模型层怎么自动加 tenant_id 过滤
靠手写 where('tenant_id', ...) 一定会漏,尤其在关联查询、软删除、Eloquent Builder 链式调用里。必须用全局作用域(Global Scope)+ Trait 组合拳。
- 定义一个
BelongsToTenantTrait,在boot阶段注册全局作用域,只对当前请求上下文中的租户 ID 生效 - 作用域内读取
app('currentTenant')?->id,不是Auth::user()?->tenant_id——后者在命令行、队列、API 调用中根本不可用 - 写入时自动填充:
creating事件里检查$model->tenant_id是否为空,为空且上下文有租户 ID 才赋值 - 注意:软删除(
withTrashed())会绕过全局作用域,如需隔离已删除数据,得重写withTrashed()方法或加额外 scope
DB::connection('tenant') 切换连接到底要不要用
除非你明确需要物理级隔离(比如金融、医疗类 SaaS),否则别碰动态连接切换。它带来的运维成本远超收益。
- 每次
DB::reconnect('tenant')都是一次 TCP 握手,高并发下 DB 连接池利用率暴跌 -
.env无法配置多租户连接,必须运行时Config::set(),但这个操作在队列任务、Artisan 命令、单元测试里极易失效 - 迁移命令
php artisan migrate --database=tenant无法指定具体租户库,得自己写脚本循环执行,500 个租户就是 500 次迁移 - 备份恢复变成噩梦:你得分别 dump 500 个库,还不能搞混顺序;而共享库只要一次
mysqldump
文件存储和缓存怎么按租户隔离
数据库隔离了,但上传的头像、合同 PDF、Redis 缓存如果没隔离,照样串租户。
- 文件磁盘配置里用
tenant_id动态拼路径:'root' => storage_path('app/tenants/' . app('currentTenant')?->id) - 不要用
Cache::put('key', $value),改用带前缀的键:Cache::put('tenant_' . app('currentTenant')?->id . '_dashboard_stats', $data) - 队列任务里如果用到了租户上下文,必须显式传递
tenant_id字段,不能依赖闭包捕获 —— 闭包在反序列化时拿不到 request 上下文 - 日志也建议加租户标识,
Log::info('xxx', ['tenant_id' => app('currentTenant')?->id]),排查问题时省一半时间
真正容易被忽略的是「上下文生命周期」——app()->instance() 只在当前 request 有效,但它在 Artisan 命令、队列、WebSocket 连接里完全不存在。这些场景必须显式传参、重置上下文,否则隔离就形同虚设。


















