Laravel Octane Issues That Look Like Random Bugs but Aren’t
Introduction
You ship Octane to improve throughput, memory usage looks acceptable, benchmarks are great, and then production starts doing strange things.
A user sees another user’s locale. A feature flag appears stuck for a few requests. A service behaves correctly after deploy, then drifts into bad state hours later. Restarting Octane “fixes” it, which makes the whole thing feel random.
It usually is not random.
In traditional PHP-FPM setups, every request starts from a mostly clean process state. With Octane, your application lives inside long-running workers. That changes the runtime model in ways many Laravel codebases were never written to handle. The result is a class of bugs that look intermittent, environment-specific, or impossible to reproduce locally.
This is the real Octane learning curve: not raw performance tuning, but identifying assumptions in your app that only worked because PHP kept throwing the process away.
The Problem Breakdown
The core issue is simple: Octane workers are long-lived.
That means objects, static properties, singleton bindings, cached configuration, and in-memory service state can survive across requests handled by the same worker. If any of that state depends on request-specific data, authenticated users, headers, locale, tenant, or temporary flags, you have a leak.
Under PHP-FPM, this often goes unnoticed because each request gets a fresh process. A bad singleton that stores the current user might still appear to work, because the process dies immediately after the response. Under Octane, that same singleton can serve hundreds or thousands of requests before the worker is recycled.
What changes under Octane
The failure mode is not that Laravel becomes unreliable. The failure mode is that your code assumes request isolation where there is none.
Common examples:
- A singleton service stores request-derived values on a property
- A static variable caches tenant or locale information
- A service provider resolves something once at boot that should be resolved per request
- A mutable object is shared through the container and reused unexpectedly
- Global state is updated during one request and read in the next
Here is a classic example:
class PricingContext
{
public ?int $customerId = null;
public function setCustomerId(?int $customerId): void
{
$this->customerId = $customerId;
}
public function customerId(): ?int
{
return $this->customerId;
}
}
And the container binding:
$this->app->singleton(PricingContext::class, function () {
return new PricingContext();
});
Then somewhere in middleware:
public function handle($request, Closure $next)
{
app(PricingContext::class)->setCustomerId(optional($request->user())->id);
return $next($request);
}
This may work under PHP-FPM. Under Octane, that singleton can retain the previous request’s customer ID if code later forgets to reset it, or if another path never runs the initialization middleware you expected.
That is why these bugs feel random: they depend on worker reuse, request ordering, and which code paths ran previously in the same worker.
Why It Happens
The most common root cause is not Octane itself. It is hidden application design assumptions.
1. Treating singleton as “cheap to resolve” instead of “shared for worker lifetime”
A lot of Laravel apps use singleton() casually. In a request-per-process model, that rarely hurts. In Octane, a singleton is much closer to application memory than request memory.
If the object is mutable, you need to assume its state can survive into the next request.
2. Using static properties as informal caches
Static properties are especially dangerous because they bypass container lifecycle entirely.
class TenantResolver
{
protected static ?Tenant $tenant = null;
public static function set(Tenant $tenant): void
{
static::$tenant = $tenant;
}
public static function current(): ?Tenant
{
return static::$tenant;
}
}
This is effectively global memory inside the worker. If it is not explicitly reset, it will persist.
3. Doing request-specific initialization at boot time
Code in service providers, package boot methods, or application startup can become stale under Octane if it reads request-sensitive information too early.
If you resolve locale, tenant, auth-dependent config, or per-request headers during boot, you may be freezing the wrong value into a long-lived object graph.
4. Assuming middleware always initializes state
Many apps rely on middleware to populate context objects. That is fine until one route group skips the middleware, a job path uses the same service differently, or an exception path exits before cleanup.
With long-lived workers, “not initialized this request” can silently turn into “still initialized from a previous request.”
Diagnosis and Debugging
When Octane bugs show up, guessing is usually what wastes the most time. You need to prove whether state is crossing request boundaries.
Step 1: Look for worker-sensitive symptoms
These are strong signals:
- The bug disappears after
octane:reloador process restart - The same endpoint behaves differently depending on prior requests
- The issue is hard to reproduce locally under FPM but appears under load
- Wrong values appear in small bursts, then vanish
- Logs show stale user, locale, tenant, or feature values without corresponding request input
If restart temporarily fixes it, treat that as a state leak signal, not a deployment success.
Step 2: Log request identity and worker identity together
You want to know whether bad behavior clusters around a specific worker.
Log values like:
- process ID
- request ID
- authenticated user ID
- tenant ID
- locale
- any suspect singleton property
Example middleware:
public function handle($request, Closure $next)
{
logger()->info('request.start', [
'pid' => getmypid(),
'request_id' => (string) Str::uuid(),
'user_id' => optional($request->user())->id,
'tenant' => $request->header('X-Tenant'),
'locale' => app()->getLocale(),
]);
return $next($request);
}
If wrong values recur within the same PID, you are likely looking at worker-local state.
Step 3: Audit mutable singletons and static state
Search your codebase for these patterns:
singleton(static $protected static- context or manager classes with setters
- services holding request-specific properties
- service providers resolving services during boot
The question is not just “is this shared?” but “can this object mutate based on one request and affect another?”
Step 4: Force a reproduction with sequential contrasting requests
This is more useful than random load testing.
Send two requests through the same environment with intentionally different values:
- Request A: authenticated as user 1, locale
en, tenantalpha - Request B: authenticated as user 2, locale
fr, tenantbeta
Then inspect whether any service, output, or log line still references A during B.
This catches a surprising number of leaks quickly.
Step 5: Inspect lifecycle assumptions, not just bad lines of code
The actual bug may not be where the wrong value is read. Often the real problem is where a service was bound with the wrong lifecycle.
If a class stores per-request state, the fix is usually not “reset it more carefully.” The fix is “it should not be a singleton at all.”
Solutions
Prefer stateless services
The safest service under Octane is one that does not retain mutable request-specific state.
Instead of this:
class CurrencyFormatter
{
protected string $currency;
public function setCurrency(string $currency): void
{
$this->currency = $currency;
}
public function format(int $amount): string
{
return $this->currency.' '.number_format($amount / 100, 2);
}
}
Prefer this:
class CurrencyFormatter
{
public function format(int $amount, string $currency): string
{
return $currency.' '.number_format($amount / 100, 2);
}
}
Passing context explicitly is less elegant at first, but far more predictable in long-lived workers.
Use container lifecycles correctly
If a service must hold state, avoid singleton() unless that state is truly safe for the entire worker lifetime.
Use transient bindings when appropriate:
$this->app->bind(PricingContext::class, function () {
return new PricingContext();
});
Trade-off: you may create more objects per request. In practice, that cost is usually far smaller than the operational cost of state leaks.
Remove static request context
If you have static tenant, locale, or user context, replace it with request-scoped access patterns.
Bad:
TenantResolver::current();
Better:
class TenantResolver
{
public function fromRequest(Request $request): Tenant
{
// resolve from host, header, token, etc.
}
}
Or pass the resolved tenant into the services that need it.
Don’t resolve request-sensitive values during application boot
If a value depends on the current request, resolve it inside middleware, controllers, actions, or request-aware services at execution time.
Do not freeze it during startup and assume it will remain correct.
Be deliberate with caches
In-memory caches inside PHP objects are riskier under Octane than many teams expect.
If the cache depends on request input, it should usually be:
- local to the method call
- local to the request object
- stored in a proper external cache with explicit keys and TTLs
An object property cache inside a singleton is often where “random” Octane bugs are born.
Reset only when you truly must
You can add cleanup hooks and explicit resets, but treat that as a fallback, not the first design choice.
Reset-based designs are fragile because they rely on every entry path and failure path behaving perfectly. Stateless designs fail less often.
Common Mistakes
“Octane is caching something weird”
Sometimes it is. Most of the time, your app is retaining mutable memory inside the worker.
Blaming the server too early delays the real fix.
“We can fix it by reloading workers more often”
Worker recycling can reduce symptom duration, but it does not remove the bug. It just narrows the blast radius.
That may be a temporary mitigation, not a solution.
“Singleton is fine because Laravel resolves it once per request”
That assumption was effectively true enough under PHP-FPM. Under Octane, it is no longer safe.
Re-evaluate every singleton that stores mutable state.
“It only happens in production, so it must be concurrency”
Sometimes it is not concurrency at all. It is sequence.
A single worker handling two requests in the wrong order is enough to surface the issue.
Real-World Insight
One of the more subtle production cases I have seen involved tenant-specific configuration.
A SaaS app loaded branding settings from the database and stored them in a singleton BrandingManager. Middleware set the active tenant at the start of each web request. Most of the app worked, but a handful of routes skipped that middleware because they were mounted separately during a refactor.
Under PHP-FPM, those routes mostly behaved because no previous tenant state existed in the process. Under Octane, the worker often already had a tenant loaded from the prior request. That meant some users occasionally received another tenant’s logo, colors, and email sender name.
It looked like a cache corruption issue. It was not.
The fix was:
- remove mutable tenant state from the singleton
- resolve tenant explicitly from the current request
- pass tenant data into branding logic instead of storing it globally
- add request/tenant logging to confirm isolation
The bug disappeared, and just as importantly, the code became easier to reason about outside Octane too.
That is the useful side effect of fixing Octane bugs properly: you usually end up with cleaner service boundaries.
Conclusion
Octane does not create random bugs. It exposes state and lifecycle mistakes that request-per-process PHP used to hide.
If a bug disappears after restarting workers, assume leaked or stale state until proven otherwise. Audit mutable singletons, static properties, boot-time initialization, and any service that stores request-derived values.
The right mindset is simple: under Octane, shared memory is real. Design services to be stateless when possible, resolve request-specific context at request time, and use logging to prove where state is crossing boundaries.
Once you start debugging Octane issues through lifecycle and state instead of superstition, these “random” production bugs become much more predictable to diagnose and fix.
Member discussion