In traditional PHP-FPM, the runtime follows a "share-nothing" model. Every HTTP request begins with a totally blank memory slate, executes the code required to output a response, and then self-destructs. When your Laravel runs inside PHP-FPM, state leakage between users is impossible due to memory wipe on each request.
However, modern high-performance Laravel applications usually run inside persistent workers. You can implement such a setup using Laravel Octane (via Swoole, RoadRunner, or FrankenPHP) or just run your queue workers with php artisan queue:work continuously. Such an application boots once into server RAM and serves tens or hundreds of requests in one PHP process.
The problem is that a long-living PHP process leaks memory between requests. The most dangerous example is that a Context object (see Laravel's Context facade) can leak between requests and expose User B the information that belongs to User A. In this article, I'll explain how exactly this can happen and how to avoid it.
We will see how Laravel manages Context hydration inside persistent workers and how to write our own hydration sanitizer to ensure there is no possible information leakage between requests.
The Danger: State Leakage in Persistent Runtimes
To see how context leakage happens, consider what occurs when a worker processes two requests sequentially within persistent memory:
// Request 1 arrives from an authenticated Admin (User #101, Tenant #5)
Context::add([
'tenant_id' => 5,
'user_id' => 101,
'role' => 'admin',
]);
// Request 1 finishes and sends a response. But the PHP process DOES NOT TERMINATE.
// Request 2 arrives from an unauthenticated Guest user.
// The guest user never sets 'tenant_id' or 'role'.
// If Context is not purged, Request 2 still reads:
$tenant = Context::get('tenant_id'); // Returns 5
$role = Context::get('role'); // Returns 'admin'
If your application scopes database queries or evaluates authorization rules based on context data (e.g., Context::get('tenant_id')), the guest user in Request 2 would be able to access or change the private records of Tenant #5 without ever having to log in. This is a major multi-tenant data leak.
How Laravel Handles Automatic Resetting
Laravel ships with first-party protections against state leakage for both Octane and Queue Workers.
1. Laravel Octane Request Flushers
In Laravel Octane, the application registers internal listeners that are executed between every single request loop. Specifically, Octane hooks into the RequestTerminated event and automatically calls Context::flush().
You can see this in the configuration inside of config/octane.php, and you'll notice that there is a list of flush events that Octane runs by default:
'flush' => [
// Standard flushers that wipe memory after every request
\Illuminate\Support\Facades\Context::class,
// ...
],
2. Queue Worker Job Lifecycle
When you run php artisan queue:work
Laravel spawns a continuously looping worker that pulls jobs from Redis or database tables. When a job is dispatched, Laravel serializes the current context store into the job's JSON payload. When the worker pulls that job off of the queue, it Runs:
- Fires JobProcessing: Laravel clears out the context from the last job, hydrating in the context that was saved in the payload of the incoming job.
- Runs $job->handle(): Logging, business logic access hydrated context.
- Fires JobProcessed or JobFailed: Laravel runs Context::flush() to clear memory before fetching the next job from the queue.
Where Context Leakage Still Happens (The Edge Cases)
While Laravel is handling the normal request/job boundaries, there are some patterns that have been seen that leads to leaks:
- Uncaught Exceptions in Workers: A job has thrown an exception before any of the normal queue cleanup listeners have had a chance to run, leaving keys stuck in static memory
- Asynchronous coroutines/sub-processes: a forked process or swoole co-routine running a job in parallel, in which the context was cloned but not isolated
- Singletons Caching Context: A singleton resolves Context::get() once, and stores a copy of the context in a private property, being unaware of later flushes
Pattern 1: The Singleton Stale-Cache Trap
This is the most common bug developers introduce when working with persistent workers. Consider this service:
// VULNERABLE PATTERN IN OCTANE / PERSISTENT WORKERS
namespace App\Services;
use Illuminate\Support\Facades\Context;
class AuditLogger
{
protected ?string $traceId;
public function __construct()
{
// Resolved ONCE when the singleton boots in persistent memory!
$this->traceId = Context::get('trace_id');
}
public function log(string $action): void
{
// Stale trace ID will be used for EVERY request processed by this worker!
logger()->info($action, ['trace' => $this->traceId]);
}
}
Because the service is bound as a singleton, its constructor will be called only once on the worker's start. Even though Laravel flushes Context between requests, $this->traceId in your singleton will keep the value from the first request for eternity.
The Fix: Do not cache context values in class properties. Always fetch them dynamically from a facade at run-time:
namespace App\Services;
use Illuminate\Support\Facades\Context;
class AuditLogger
{
public function log(string $action): void
{
// Always reads dynamically from the active request context
logger()->info($action, [
'trace' => Context::get('trace_id'),
]);
}
}
Pattern 2: Sanitizing Sensitive Context Hydration on Queues
When dispatching background jobs you might want to carry public metadata (like trace_id and locale), but you would want to make sure that authentication tokens or user credentials are not written to job payloads in Redis.
Laravel allow us to control which context keys should be serialized to queues by using Context::dehydrateUsing() and Context::hydrateUsing() inside your AppServiceProvider:
namespace App\Providers;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// List of keys allowed to cross the boundary into queue worker memory
$allowedQueueKeys = [
'trace_id',
'tenant_id',
'initiated_via',
];
// 1. Sanitize before serialization (Dehydration)
Context::dehydrateUsing(function ($repository) use ($allowedQueueKeys) {
// Strip out any keys not explicitly allowlisted
return collect($repository->all())
->only($allowedQueueKeys)
->all();
});
// 2. Validate integrity upon worker receipt (Hydration)
Context::hydrateUsing(function ($repository, $data) {
foreach ($data as $key => $value) {
// Ensure no unexpected object injections occurred during transit
if (is_scalar($value) || is_array($value)) {
$repository->add($key, $value);
}
}
});
}
}
With the solution in place, even if some developer calls Context::add('user_token', $bearerToken) by accident in a controller, the dehydrator will intercept the queue dispatch and remove the user_token before it gets serialized into the Redis queue.
Pattern 3: Enforcing Strict Boundary Isolation in Middleware
Do not rely only on framework termination hooks. To ensure there is no context leakage whatsoever, create an Explicit Boundary Middleware that will sanitize the context both before and after execution:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Symfony\Component\HttpFoundation\Response;
class EnforceCleanContextBoundary
{
public function handle(Request $request, Closure $next): Response
{
// 1. Defensively flush any dirty state left by an aborted prior request
Context::flush();
Context::flushHidden();
try {
$response = $next($request);
} finally {
// 2. Guarantee memory wipe even if an unhandled exception occurred
Context::flush();
Context::flushHidden();
}
return $response;
}
}
Wrapping execution in a try / finally block guarantees that even if a controller throws an unexpected 500 error, the worker's context memory is deleted / cleaned before the process handles its next request.
Testing for Context Leakage in Pest / PHPUnit
Because tests are normally run in isolation or reset after each test case, context leakage bugs are often hidden in the CI/CD pipeline. You can write an integration test that simulates the worker persistence by performing two operations in the same process:
namespace Tests\Feature;
use App\Jobs\ProcessReportJob;
use Illuminate\Support\Facades\Context;
use Tests\TestCase;
class ContextIsolationTest extends TestCase
{
public function test_context_does_not_leak_across_sequential_jobs(): void
{
// Simulate Job 1 running on worker
Context::add('tenant_id', 'tenant_alpha');
$job1 = new ProcessReportJob();
$job1->handle();
// Simulate framework queue flusher between jobs
Context::flush();
// Simulate Job 2 running immediately after on the same worker
$job2 = new ProcessReportJob();
$job2->handle();
// Verify that Job 2 did not inherit tenant_alpha
$this->assertFalse(Context::has('tenant_id'));
$this->assertNull(Context::get('tenant_id'));
}
}
Security Checklist for Persistent Workers
| Vulnerability Vector | Risk Level | Mitigation Strategy |
|---|---|---|
| Singleton Property Caching | Critical | Never store Context::get() in class properties. Read on demand. |
| Queue Payload Exposure | High | Use Context::dehydrateUsing() to allowlist non-sensitive keys. |
| Aborted Request Residue | High | Use try / finally middleware to enforce Context::flush(). |
| Hidden Context Leakage | Medium | Call Context::flushHidden() alongside standard flushes. |
Things to Remember
- 📌 Don’t forget to clear hidden context: Calling Context::flush() only clears public context keys. If your application makes use of Context::addHidden() for internal variables, you must remember to also call Context::flushHidden() to clear internal memory.
- ⚡ Don’t pass complex objects over queue: When using hydrateUsing(), make sure you are passing only simple values such as strings, integers, booleans, and arrays. You should not pass database connections, file resources, closures, or other complex objects.
- ⚠️ Review your custom package/service providers: If you have written your own service providers or custom Laravel packages which bind classes to the Laravel application container as singletons, make sure that they are not using Context::get() anywhere in their register or boot methods.
Conclusion
While persistent memory runtimes such as Laravel Octane or even just continuous queue workers provide enormous performance gains, you need to carefully watch your memory usage. By understanding the limitations of long-living worker processes, avoiding singletons property caching and sanitizing your queue serialization with hydration hooks you can keep context tracing in your application powerful while not sacrificing multi-tenant security.
Thank you for reading this article 😊
For any query, do not hesitate to comment 💬