Laravel Singletons Can Be Dangerous in Long Living Processes
Singletons are a great feature of Laravel's service container but users of the framework must be aware that context aware singletons can lead to unexpected behaviour in an application when using long living processes.
Vigilant relies heavily on long-living processes, it uses Octane for the handling of HTTP requests and Horizon for handling jobs. Vigilant is an application with a lot of background tasks. It is designed to have multiple tenants in one database so it is crucial that data is handled securely and that tenants do not see each other's data. Vigilant makes use of scopes to do this. These scopes all talk to a single singleton, the TeamService
which looks something like this:
class TeamService
{
protected ?Team $team = null;
public function fromAuth(): void
{
// Retrieve logged in user and set team
}
public function setTeamById(int $teamId): void
{
// Find team by id and set
}
public function team(): ?Team
{
if ($this->team === null) {
$this->fromAuth();
}
return $this->team;
}
}
This service class is then bound as a singleton: $this->app->singleton(TeamService::class);
A middleware will set the team for the entire application:
class TeamMiddleware
{
public function __construct(protected TeamService $teamService) {}
public function handle(Request $request, Closure $next): Response
{
$this->teamService->fromAuth();
return $next($request);
}
}
The scopes can then get an instance of the TeamService
and add the where query to filter data.
In a traditional PHP application, the entire app is built and destroyed on each request. That means that the singleton only lives for that request.
But how do long-living processes handle this?
This is where singletons become tricky. The singleton will get created when the process starts and will stay there until it is destroyed. For singletons that do not store any data, this is fine but when a singleton stores data like our TeamService
it will cause issues.
Let's take a Laravel job as an example, we have a job that sets the current team. When that job is finished the team will still be set and any next jobs that run will use the team that was set from the first job.
This is very dangerous as it means that historical jobs have an impact on current jobs, which is something we do not want and do not expect.
If a new job does not overwrite the team and it tries to retrieve something it will potentially get data it does not have access to. Or it will not have data when you expect it to have data.
The correct way of handling singletons in a long-lived application
Fortunately, Laravel has a built-in method for this called scoped
. Instead of using singleton
to bind the class we can use scoped
: $this->app->scoped(TeamService::class);
While the name isn't directly clear on what it does we can find this in the documentation:
The
scoped
method binds a class or interface into the container that should only be resolved one time within a given Laravel request/job lifecycle. While this method is similar to thesingleton
method, instances registered using thescoped
method will be flushed whenever the Laravel application starts a new "lifecycle", such as when a Laravel Octane worker processes a new request or when a Laravel queue worker processes a new job.
Let's dive into the framework to see how it works.
First of all, it will register your class as a singleton and store it in an array of scopedInstances
.
public function scoped($abstract, $concrete = null)
{
$this->scopedInstances[] = $abstract;
$this->singleton($abstract, $concrete);
}
When we look at the queue worker we can see that in the endless while
loop it will reset all scopes before processing each job:
while (true) {
// ...
// Reset scopes
if (isset($this->resetScope)) {
($this->resetScope)();
}
// Handle job
$job = $this->getNextJob(
$this->manager->connection($connectionName), $queue
);
// ...
}
The resetScope
is a callable on the worker that gets set in the queue service provider which calls the forgetScopedInstances
method on the app container. This method simply loops through the scoped instances and unsets them:
public function forgetScopedInstances()
{
foreach ($this->scopedInstances as $scoped) {
unset($this->instances[$scoped]);
}
}
Conclusion
Singletons are great to prevent setting up classes multiple times but if your singletons store any kind of data you should use a scoped singleton to make sure that the data does not persist between requests or jobs.
This feature was added in Laravel 8 in #37521.