Laravel applications traditionally handle requests using a lifecycle in which the application is bootstrapped and request-specific state is managed for each request. Laravel Octane changes this execution model by keeping the application running in a long-lived process.
Octane uses application servers such as RoadRunner or Swoole to handle requests without bootstrapping the entire application from scratch for every request. This can improve performance by reducing repeated initialization and allowing resources to be reused.
However, a long-lived application introduces an important risk: data stored in memory can survive after a request finishes.
If an application stores the current user's ID, permissions, tenant information, or request-specific data in a persistent singleton or static property, that information may accidentally affect a later request. In a multi-user application, this can become a security issue.
This article explains why request isolation matters in Laravel Octane, how state can leak between requests, and how to structure services safely.
Understanding Laravel Octane's Long-Lived Process
In a traditional request lifecycle, developers may assume that objects created during application initialization will be discarded soon after a request finishes. With Octane, the application can remain in memory and serve many requests.
For example, consider a service that stores the current user's ID:
<?php
namespace App\Services;
class CurrentUserService
{
public ?int $userId = null;
public function setUserId(int $userId): void
{
$this->userId = $userId;
}
public function getUserId(): ?int
{
return $this->userId;
}
}
Now suppose the service is registered as a singleton:
<?php
namespace App\Providers;
use App\Services\CurrentUserService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(
CurrentUserService::class,
function () {
return new CurrentUserService();
}
);
}
}
A singleton is created once per application container instance and reused when requested again. In a long-lived Octane worker, that object may remain available across multiple requests.
If one request sets userId to 101 and another request does not update it correctly, the second request could read the previous value.
This example illustrates the underlying problem: the object contains mutable, request-specific state but has a lifetime that extends beyond the request.
A singleton is not inherently unsafe. Stateless services, such as a formatter or a service that only holds immutable configuration, can be suitable singletons. The risk arises when persistent objects retain data that should belong to an individual request.
How User Data Can Leak Between Requests
Consider an application that displays reports for different customers. A developer creates a service to store the current tenant ID:
<?php
namespace App\Services;
class TenantContext
{
public ?int $tenantId = null;
}
The service is registered as a singleton, and middleware assigns the tenant ID based on the authenticated user.
<?php
namespace App\Http\Middleware;
use App\Services\TenantContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SetTenantContext
{
public function handle(
Request $request,
Closure $next
): Response {
$tenant = $request->user()?->tenant;
if ($tenant) {
app(TenantContext::class)->tenantId = $tenant->id;
}
return $next($request);
}
}
This middleware assigns a tenant ID when the authenticated user has a tenant. However, it does not reset the existing value when a later request has no tenant.
If the same service instance is reused, the old tenant ID may remain in memory.
A downstream service that trusts this value could accidentally retrieve data belonging to the previous tenant.
The correct fix is not simply to assume that every request will overwrite the value. Request-specific state must have a clear lifetime, and authorization must not depend on stale values retained by a long-lived object.
Keep Request-Specific Data Out of Persistent Singletons
The safest general rule is to avoid storing mutable user or request information in long-lived services.
Instead of using a singleton to remember the current user, pass the required user or tenant explicitly to the method that needs it.
For example:
<?php
namespace App\Services;
use App\Models\Report;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
class ReportService
{
public function getReportsForUser(
User $user
): Collection {
return Report::query()
->where('tenant_id', $user->tenant_id)
->get();
}
}
A controller can pass the authenticated user directly:
<?php
namespace App\Http\Controllers;
use App\Services\ReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReportController extends Controller
{
public function index(
Request $request,
ReportService $reportService
): JsonResponse {
$user = $request->user();
abort_unless($user, 401);
$reports = $reportService->getReportsForUser(
$user
);
return response()->json($reports);
}
}
The service does not store the user in a property. It receives the user as a method argument and uses that user to construct the query.
This makes the dependency explicit and reduces the chance that one request can influence another through retained service state.
For multi-tenant applications, also verify that the user is authorized to access the requested tenant. Passing a user object is not a substitute for authorization checks, and filtering by tenant must be consistent across every relevant query.
Understanding Singleton, Scoped, and Transient Services
Laravel's service container supports different binding lifetimes. Choosing the right lifetime helps prevent unintended state sharing.
Binding type | Typical behavior | Appropriate use |
|---|
singleton()
| Reuses one instance from the container | Stateless services and reusable infrastructure |
scoped()
| Reuses an instance within a defined application lifecycle scope | State that should be refreshed between supported lifecycle boundaries |
bind()
| Creates a new instance when the container resolves the binding | Services that need a fresh instance on each resolution |
Example registrations:
<?php
use App\Services\ReportService;
use App\Services\TenantContext;
$this->app->singleton(ReportService::class);
$this->app->scoped(TenantContext::class);
$this->app->bind(SomeTemporaryService::class);
These examples illustrate the binding APIs, but they should not be interpreted as a guarantee that every kind of state is automatically reset in every environment. The behavior depends on how and when a service is resolved, the framework version, and the execution lifecycle.
In Octane applications, use Laravel's documented lifecycle-aware mechanisms for request-specific state. Prefer stateless services when possible, and verify the behavior of custom bindings under the actual Octane server and worker configuration.
A scoped binding can be appropriate when a service genuinely needs request-scoped state, but it should not replace careful lifecycle design.
Be Careful With Static Properties
Static properties can also retain values across requests because they belong to the class rather than an individual object.
Consider this example:
<?php
namespace App\Services;
class RequestCache
{
public static array $data = [];
}
A controller might add data to this property:
RequestCache::$data['user_id'] = $request->user()->id;
If the static array is not cleared, it may contain values from earlier requests. It can also accumulate entries over time, increasing memory usage.
Avoid storing authenticated user details, tenant IDs, permissions, or other request-specific information in static properties.
If temporary caching is needed, use a cache mechanism with an appropriate scope, key design, and expiration policy. Do not assume that a PHP static property behaves like a fresh variable for every incoming HTTP request.
Avoid Capturing Request Data in Long-Lived Callbacks
Another source of problems is a long-lived service or callback that captures request-specific information.
For example, a service might retain a closure containing a user object:
<?php
namespace App\Services;
use Closure;
class NotificationService
{
private ?Closure $callback = null;
public function setCallback(Closure $callback): void
{
$this->callback = $callback;
}
public function notify(): void
{
if ($this->callback) {
($this->callback)();
}
}
}
If this service is a singleton and the callback is set during one request, a later request might execute the previously stored callback.
Closures are not unsafe by themselves. The problem is retaining request-specific closures in a long-lived object without an appropriate cleanup strategy.
Prefer passing callbacks directly to the operation that needs them rather than storing them on persistent services.
For example:
<?php
namespace App\Services;
use Closure;
class NotificationService
{
public function notify(Closure $callback): void
{
$callback();
}
}
Here, the callback is used for the operation and is not retained as mutable service state.
Handle Caching Carefully in Multi-Tenant Applications
Caching is another area where request isolation matters.
Suppose a report is cached using only a report ID:
Cache::remember(
"report:{$reportId}",
300,
fn () => Report::findOrFail($reportId)
);
If report IDs are not globally unique across tenants, or if authorization and visibility differ by tenant, this cache key may be insufficient.
A safer key includes the relevant tenant identifier:
$tenantId = $request->user()->tenant_id;
$report = Cache::remember(
"tenant:{$tenantId}:report:{$reportId}",
300,
fn () => Report::query()
->where('tenant_id', $tenantId)
->findOrFail($reportId)
);
The query also restricts the record to the current tenant.
The exact cache-key design depends on the data model, authorization rules, and whether users within a tenant have different permissions. If the result varies by user, role, or permission set, those dimensions may also need to be considered.
Always authorize access before returning sensitive cached data. Cache isolation and authorization are related but separate responsibilities.
Resetting State and Managing Worker Memory
Sometimes an application genuinely needs a stateful service. In that case, define the lifetime explicitly and ensure that state is cleared at the correct boundary.
For example, a service that accumulates temporary request information should not retain that information indefinitely. Prefer a request-scoped design supported by the framework and verify that the service is recreated or reset as expected.
Avoid relying on ad hoc cleanup in a controller's final statement. Exceptions, early returns, and failed requests can bypass simplistic cleanup logic.
For resources such as database connections and other infrastructure clients, follow the lifecycle management guidance for the relevant library and Laravel Octane version. Do not indiscriminately close shared infrastructure resources after each request, because other requests may depend on them.
Octane also provides mechanisms for managing worker behavior and reloading workers. Worker recycling can help control memory growth, but it is not a substitute for fixing data leaks. A leaked tenant ID can cause a security incident long before a worker is recycled.
Testing for Cross-Request Data Leakage
A useful test sends multiple requests through the same running application process and verifies that request-specific information does not persist unexpectedly.
For example, test the service behavior directly:
<?php
use App\Services\ReportService;
use App\Models\User;
it('does not retain a user between calls', function () {
$service = app(ReportService::class);
$firstUser = User::factory()->create();
$secondUser = User::factory()->create();
$firstResult = $service->getReportsForUser(
$firstUser
);
$secondResult = $service->getReportsForUser(
$secondUser
);
// Assert that each result contains only records
// authorized for the corresponding user.
});
The assertions depend on the application's report model and tenant rules. The key is to verify that the second operation is independent of the first.
For stronger integration coverage, run tests against the same application instance and lifecycle used by Octane. Verify behavior across successive requests, including authenticated requests from different tenants.
Test cases should include:
A request with an authenticated user followed by an unauthenticated request.
Two requests from different tenants.
A request that throws an exception before normal cleanup.
Repeated requests that exercise caches and shared services.
Long-running workers that process many requests.
Standard feature tests are useful, but they may not reproduce every detail of a long-lived Octane worker. Where feasible, add integration tests using the actual Octane server configuration used in deployment.
Common Mistakes to Avoid
Mistake | Why it is risky | Better approach |
|---|
Storing the current user in a singleton property | The value may survive across requests | Pass the user explicitly to service methods |
Using static properties for tenant information | Static state can persist for the worker's lifetime | Use request-scoped state or method arguments |
Assuming every service is recreated per request | Octane keeps application processes alive | Review service lifetimes and lifecycle behavior |
Using incomplete cache keys | Results may be reused across tenants or users | Include all dimensions that affect visibility |
Treating worker recycling as a security fix | Leaks can occur before recycling | Remove the underlying shared-state bug |
Resetting shared clients after each request | Other requests may depend on the same client | Follow the resource's documented lifecycle |
Advantages and Disadvantages of Long-Lived Services
Long-lived services can improve performance by avoiding repeated initialization and allowing expensive resources to be reused. Stateless services are particularly suitable because they do not retain request-specific information.
However, long-lived processes require developers to think carefully about object lifetimes, mutable state, caching, and cleanup. A design that appears safe when every request starts with a fresh application may behave differently when services survive across requests.
The goal is not to avoid singletons entirely. It is to ensure that the lifetime of each object matches the lifetime of the data it owns.
Summary
Laravel Octane can improve application performance by keeping workers alive and reusing application resources. That execution model also means that mutable state may survive longer than developers expect.
Avoid storing current-user details, tenant IDs, permissions, or request-specific callbacks in persistent singletons and static properties. Pass request data explicitly to service methods, use appropriate container lifetimes, and design cache keys around the application's authorization boundaries.
Test consecutive requests from different users and tenants under a realistic Octane lifecycle. These practices help prevent cross-request data leakage while preserving the performance benefits of long-lived application workers.
Join the conversation! Your thoughts help the community grow.