Laravel 容器的单例与绑定作用域
解读
在国内一线/二线互联网公司的 PHP 面试中,面试官提出“单例与绑定作用域”通常不是单纯考察记忆 API,而是想确认候选人是否真正理解 IoC 容器在 Laravel 生命周期中的位置、是否能在高并发场景下避免“隐式状态污染”、是否具备定位“内存泄漏”与“重复实例化”问题的能力。回答时务必结合 FPM 进程模型、Laravel 请求生命周期、CLI 命令行模式差异,给出可落地的代码示例与排坑经验,才能拿到高分。
知识点
- 容器四要素:bind/singleton/instance/extend 的源码级区别
- 单例(shared=true)在 FPM 下“跨请求仍单例”的误区:仅保证“一次请求”内单例,请求结束进程仍存活,但 Laravel 会在
Kernel::terminate()阶段对scoped与singleton做forgetScopedInstances(),防止下次请求复用。 - 作用域分类:
singleton:进程级单例(默认)scoped:请求级单例(Laravel 7+ 引入)transient:每次解析都新实例(默认 bind)
- 绑定方式:
- 闭包绑定:
App::singleton(Interface::class, fn() => new Impl) - 上下文绑定:
$this->app->when(Controller::class)->needs(Interface::class)->give(Impl::class) - 标签与作用域联动:
$this->app->tag([Cache::class], 'cache.driver')
- 闭包绑定:
- 源码位置:
Illuminate\Container\Container::singleton()最终把shared属性置为 true;scopedSingleton()额外把scoped属性置为 true,并在flush()时清掉。 - 常见坑:
- 在 ServiceProvider 的
register()里使用了请求级参数(如Request::ip())导致 CLI 下报错; - 把含 PDO 连接的单例注册到
scoped,造成连接池频繁重建; - 单元测试中使用
RefreshDatabase时,忘记flush()单例,导致旧连接持有事务。
- 在 ServiceProvider 的
答案
“Laravel 容器通过 singleton 与 scoped 两种绑定作用域来解决‘实例复用范围’问题。
- singleton:在单次 FPM 进程生命周期内只实例化一次,适合无状态、可复用的服务,如配置仓库、转换器。
$this->app->singleton(PaymentGateway::class, function ($app) { return new AlipayGateway(config('pay.alipay')); }); - scoped:在‘一次请求’内单例,请求结束由框架自动销毁,适合持有请求级状态的对象,如当前登录用户装饰器、请求追踪 ID。
$this->app->scoped(CurrentUser::class, function ($app) { return new CurrentUser(auth()->user()); }); - 默认 bind:每次
app()->make()都返回新对象,适合轻量级、无共享需求的数据对象。 - 验证方式:
- 在控制器连续两次
resolve(PaymentGateway::class),singleton 返回同一对象 (spl_object_id相等);scoped 在同一请求内相等,新请求则不同。 - 使用
php artisan tinker+--repeat=2可快速验证进程级单例。
- 在控制器连续两次
- 线上排坑:
- 若发现内存持续增长,优先检查自定义 singleton 是否持有闭包引用或 Eloquent 大集合;
- 若出现‘旧请求数据残留’,先确认是否误把
scoped注册成了singleton; - 在 Octane/Swoole 环境下,需额外使用
octane()->tick()或Scope::flush()手动清理 scoped,防止协程间污染。
一句话总结:singleton 保进程,scoped 保请求,bind 保隔离;选错作用域,轻则性能掉底,重则数据串户。”
拓展思考
- 在 Laravel Octane 中,Swoole/RoadRunner 的常驻内存模式打破了“请求结束即销毁”的假设,scoped 单例会变成“Worker 级”单例。此时需要借助
Octane::flush()事件或自定义ScopedInstanceInterface来重置状态,否则会出现用户 A 的上下文泄漏给用户 B 的严重安全事故。 - 结合 PHP8 的
readonly属性与构造函数属性提升,可以写出“不可变 scoped 对象”,既享受请求级复用,又杜绝状态被篡改。 - 对于需要“集群级单例”的场景(如分布式锁、限流计数器),应把实例放到 Redis 或 Consul,而不是依赖容器作用域;容器只负责封装客户端,避免把“分布式一致性”误当成“进程单例”来实现。