- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: System Design Pocket Guide: Fundamentals
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You flip the switch to Octane. The first p95 graph drops in half. Then a customer support ticket lands: a user opened their invoice page and saw someone else's company name in the header. You can't reproduce it. Two days later, a different ticket: a stale feature flag served to 4% of traffic for an hour after a rollout.
Both bugs were already in the code. PHP-FPM was hiding them.
That is the whole story of moving a Laravel app to Octane. Long-lived workers expose failure modes the runtime was quietly cleaning up for you, request by request. The fix isn't "be careful with statics." It's an architecture where the question doesn't come up.
Why PHP-FPM lets you get away with murder
The PHP-FPM model is a master process that forks a pool of worker children. Each request gets bound to one worker for the duration of the request. When the response is sent, the worker tears down its userland state ($_SESSION, your container bindings, every static property, every singleton) and returns to the pool clean. The next request starts from autoload zero.
That is why a chunk of working PHP code looks like this:
class CurrentCompany
{
private static ?Company $instance = null;
public static function set(Company $c): void
{
self::$instance = $c;
}
public static function get(): ?Company
{
return self::$instance;
}
}
A middleware reads the subdomain, looks up the company, and stashes it on the static. Controllers and Blade views pull it back out. In FPM that works because the static dies with the worker at the end of the request. Nothing leaks because nothing survives.
Octane changes one thing and only one thing: the worker stays alive. The same PHP process handles request N+1 with request N's memory still loaded. Your container is the same container. Your singletons are the same singletons. Your statics still hold whatever the last request put there.
The Octane docs call this out directly. They warn about memory leaks and state bleeding, and the framework ships a flush() event to clear known offenders. That helps with Laravel's own bindings. It does not help with the static you wrote last quarter.
Bug 1: a static that thinks it is request-scoped
Here is the leak in a form that ships to production all the time.
namespace App\Http\Middleware;
use App\Domain\CurrentCompany;
use App\Models\Company;
use Closure;
use Illuminate\Http\Request;
class ResolveCompany
{
public function handle(Request $request, Closure $next)
{
$subdomain = explode('.', $request->getHost())[0];
$company = Company::where('slug', $subdomain)->firstOrFail();
CurrentCompany::set($company);
return $next($request);
}
}
namespace App\Http\Controllers;
use App\Domain\CurrentCompany;
class InvoiceController
{
public function show(string $id)
{
$company = CurrentCompany::get();
return view('invoice', [
'company' => $company,
'invoice' => $company->invoices()->findOrFail($id),
]);
}
}
Under FPM, the static is set, used, and discarded. Under Octane (Swoole or FrankenPHP both keep the worker alive), the static lives until the worker is recycled. A request comes in for acme.example.com, the middleware sets CurrentCompany. The next request, for globex.example.com, hits a route that does not pass through that middleware. A health check, or a webhook that bypasses the middleware entirely. The handler reads CurrentCompany::get() and gets Acme, not Globex.
Or worse: an exception in ResolveCompany short-circuits before set() runs, leaving the previous tenant in place. That is the support ticket.
You will not see this in unit tests, because PHPUnit gives every test a fresh process. You will not see it in feature tests, because Laravel's test bootstrap clears the container between tests. It only shows up in production, on traffic that interleaves tenants on the same worker.
The bandaid fix is to register a state cleaner:
use Laravel\Octane\Events\RequestTerminated;
Event::listen(RequestTerminated::class, function () {
CurrentCompany::set(null);
});
That works for this one static. It does not work for the next one you forget. The pattern of "remember to clear it" loses, every time, against the pattern of "there is nothing to clear."
Bug 2: a container binding that captures a request
The second leak is sneakier because it looks like clean Laravel code. A service provider binds a class as a singleton, and the constructor reads the request.
namespace App\Providers;
use App\Services\AuditLogger;
use Illuminate\Support\ServiceProvider;
class AuditServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(AuditLogger::class, function ($app) {
$request = $app->make('request');
return new AuditLogger(
actorId: $request->user()?->id,
requestId: $request->header('X-Request-ID'),
);
});
}
}
Under FPM, the singleton is built once per worker lifetime, which is once per request. The captured request is the current request. Fine.
Under Octane, the singleton is built once per worker lifetime, which spans hundreds or thousands of requests. The first request to resolve AuditLogger captures that request's user and request ID, and every subsequent request gets an AuditLogger reporting actions as if user #1 performed them. Audit log corruption, blamed on a user who logged out an hour ago.
Octane mitigates a chunk of this by flushing known bindings between requests, but custom singletons that closure-capture request state are on you. The framework cannot tell which of your bindings are safe to keep and which are a tenant ID waiting to bleed.
The pattern that creates this bug is shared. The class is doing two jobs: it is a logger (singletonable) and it is a holder of request context (not singletonable). The two jobs are entangled in the constructor.
What hexagonal changes, for free
The hexagonal layout that the Decoupled PHP book teaches has a specific shape:
- Domain — pure PHP types and behavior. No framework.
-
Use cases — application services that orchestrate one verb (
SubmitInvoice,ResolveCompanyForRequest). Stateless. Constructor takes ports; method takes a request DTO; returns a response DTO. -
Ports — interfaces the use case talks to (
CompanyRepository,Clock,AuditSink). - Adapters — Laravel controllers, Eloquent repositories, Guzzle clients. Live at the edges.
The discipline that protects against both bugs above is one rule: use cases hold no mutable state, and request-scoped data is passed in, never captured.
A use case looks like this:
namespace App\UseCase;
use App\Domain\Company;
use App\Domain\Invoice;
use App\Port\CompanyRepository;
use App\Port\InvoiceRepository;
use App\Port\AuditSink;
final readonly class ShowInvoice
{
public function __construct(
private CompanyRepository $companies,
private InvoiceRepository $invoices,
private AuditSink $audit,
) {}
public function handle(ShowInvoiceInput $in): ShowInvoiceOutput
{
$company = $this->companies->bySlug($in->companySlug);
$invoice = $this->invoices->forCompany(
$company->id,
$in->invoiceId,
);
$this->audit->record(
actorId: $in->actorId,
requestId: $in->requestId,
action: 'invoice.viewed',
subject: $invoice->id,
);
return new ShowInvoiceOutput($company, $invoice);
}
}
ShowInvoice is readonly. Its constructor receives ports (interfaces, not request data). The actor and request ID arrive as fields on ShowInvoiceInput, built fresh per request by the controller. There is no static to leak across requests because there is no static. There is no captured request inside a singleton because the request data flows through method arguments.
The controller (an inbound adapter) is the one place that touches the HTTP request:
namespace App\Http\Controllers;
use App\UseCase\ShowInvoice;
use App\UseCase\ShowInvoiceInput;
use Illuminate\Http\Request;
final class InvoiceController
{
public function __construct(private ShowInvoice $useCase) {}
public function show(Request $request, string $id)
{
$output = $this->useCase->handle(new ShowInvoiceInput(
companySlug: explode('.', $request->getHost())[0],
invoiceId: $id,
actorId: $request->user()?->id,
requestId: $request->header('X-Request-ID') ?? '',
));
return view('invoice', [
'company' => $output->company,
'invoice' => $output->invoice,
]);
}
}
The controller can be bound however you like in the container. Octane recycles it or keeps it; neither matters. It reads the request the framework just handed it, builds a fresh DTO, calls the use case, returns a view. No statics. No closure capture.
AuditSink is the audit-logger port. Its adapter is a LogAuditSink that wraps a PSR-3 logger. The logger has no request state in it. Actor and request ID arrive as method arguments on each record() call. The provider can register it as a singleton safely:
$this->app->singleton(AuditSink::class, LogAuditSink::class);
Because the implementation holds no per-request fields, "singleton" and "request-scoped" produce the same observable behavior. The bug from earlier cannot happen. There is no state to clear.
The rules, written down
Three rules cover every Octane state bug worth catching.
1. Use cases are readonly and hold only ports. No request data, no tenant ID, no current user, no clock value in the constructor. If the use case needs the actor's ID, it arrives as a field on the input DTO. The constructor takes interfaces; the method takes the request.
2. Adapters are stateless, or their state is wired per request by the framework. A Postgres repository holding a PDO is fine; the PDO is shared across requests and that is what you want. A repository that caches user-specific rows on $this is a bug waiting to fire. If you need per-request caching, attach it to the input DTO or to a RequestScope object that the controller constructs and discards.
3. Statics and globals are off-limits for request data. Treat static, $GLOBALS, and singletons that capture closures over $request as compile errors. A grep on a Laravel codebase for public static function set( or $this->app->singleton with a request closure inside will surface most of these before they ship.
A short PHPStan rule or a Rector custom rule can enforce the first two automatically. The third is a code-review item until you write the rule.
What you don't have to give up
Moving the code to this shape does not require deleting your framework code. The hexagonal split is additive: the controller and the service provider stay where they were. What changes is what lives inside them. The controller becomes a thin adapter that builds an input DTO. The provider binds use cases and port implementations. The model layer becomes one of several outbound adapters, behind a CompanyRepository interface.
You also do not need to convert the whole app at once. The strangler approach works: pick the route that misbehaved under Octane first, lift its logic into a use case, get that route safe, leave the rest of the app on its current shape. The audit logger above is a common starting point because it tends to be touched by every controller and exposes the bug the fastest.
The Octane docs are right that you have to be careful about state. Any architecture where "be careful about state" is a per-controller obligation has already lost. Hexagonal removes the obligation. The state lives at the edges, in DTOs and adapters the framework owns. The middle is pure.
The p95 graph still drops in half. The support ticket does not arrive.
If this was useful
The full pattern (stateless use cases, ports that name what the domain needs, adapters that own the framework noise) is the spine of Decoupled PHP. It walks the same shape from a single Laravel route up to a service with HTTP, CLI, and queue entry points, all sharing one use-case layer that does not know which one called it. The Octane migration becomes a side effect of getting the layering right.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now. Portuguese and Spanish coming soon.



Top comments (0)