A catalogue endpoint returns the same 249-byte JSON response from two Laravel runtimes. One boots a fresh CLI process for every request. The other sends every request through one Octane RoadRunner worker.
In the LAR-05 fixture, their local median latencies at concurrency one are 43.2 ms and 1.3 ms. That is the tempting part of Octane.
Now send tenant A and then tenant B through the Octane worker. A mutable singleton returns tenant A twice. A singleton holding the configuration repository remembers tenant A twice. A singleton created with Laravel’s bootstrap request never sees either tenant.
The speedup and the defects have the same cause: the process kept something.
The benchmark must isolate what Octane removes
Octane boots an application once and keeps it in memory while the worker handles later requests. Laravel’s Octane documentation presents that lifecycle as the source of its performance and its request-state cautions. A useful comparison therefore separates repeated boot work from the work inside the endpoint.
The fixture runs Laravel 13.20.0, Octane 2.17.4, PHP 8.4.12, and RoadRunner 2025.1.15 on arm64 Linux. The container has two CPUs and 1 GiB of memory. Both paths execute the same route, build the same three catalogue rows, serialize the same response, and retain status, content type, bytes, and body hash.
The conventional control is a fresh CLI process per request. It is not PHP-FPM and says nothing about OPcache, a production process manager, or its scheduling cost. The Octane path is one real RoadRunner worker. It is not evidence about Swoole, Open Swoole, or FrankenPHP.
To make boot visible, the service provider performs 5,000 deterministic SHA-256 operations. This is synthetic work, outside the controller. It is not Brian’s application boot profile.
After declared warm-ups, the fixture keeps three trials at concurrency one and four:
| Runtime | Concurrency | Requests | p50 | p95 | Errors |
|---|---|---|---|---|---|
| Fresh CLI process | 1 | 60 | 43.2 ms | 48.0 ms | 0 |
| Fresh CLI process | 4 | 60 | 90.8 ms | 95.9 ms | 0 |
| Octane RoadRunner | 1 | 150 | 1.3 ms | 1.6 ms | 0 |
| Octane RoadRunner | 4 | 150 | 3.0 ms | 3.8 ms | 0 |
Those distributions show that this retained worker avoids work paid by this fresh-process control. They do not promise an application-wide percentage. The synthetic provider loop accounts for a varying share of fresh elapsed time, and the comparison excludes a production database, cache, network, traffic mix, and topology. Measure the intended workload before turning the table into a purchasing decision.
A singleton can outlive the fact it captured
Traditional isolated PHP request lifecycles make a useful class of mistake temporary. The process ends, so a request object, tenant value, or mutable field disappears with it. A retained worker makes the lifetime declared in the container matter literally.
The fixture sends two requests through process 160:
| Value | Tenant A request | Tenant B request |
|---|---|---|
| Current request | tenant A | tenant B |
| Captured configuration | tenant A | tenant A |
| Mutable singleton | tenant A | tenant A |
| Scoped binding | tenant A | tenant B |
| Lazy request lookup | tenant A | tenant B |
The captured Request case is stranger but equally useful. The singleton is
created from the request object available during worker bootstrap, so both
requests report missing. It did not retain the first real request; it retained
the wrong request lifetime altogether.
Laravel’s container documentation defines scoped bindings for
one request or job lifecycle and says that Octane flushes them when a new
lifecycle begins. The fixture’s scoped state follows tenant A and tenant B
correctly. A singleton which resolves the current request lazily also follows
both requests:
$this->app->scoped(
ScopedRequestState::class,
fn (Application $app) => new ScopedRequestState(
$app->make(Request::class),
),
);
$this->app->singleton(
LazyRequestState::class,
fn () => new LazyRequestState(fn () => app(Request::class)),
);These are not interchangeable recipes. A scoped object is recreated for the request lifecycle. A singleton with a resolver remains one object and delegates the lookup when used. Pick the lifetime which matches the object’s own mutable state and collaborators. Do not mechanically replace every singleton with one of the two.
Framework resets are also specific, not magical. The fixture changes the Laravel application locale to Finnish in one request. The next request begins in English, as the pinned Octane lifecycle restores it. The same result does not prove that an application’s tenant registry, a package static, or an SDK client resets. Each state owner needs its own evidence.
Recycling bounds a worker, not the defect
A static fixture appends one 64 KiB value per request. Across 40 requests, its reported allocated memory moves from 24 MiB to 26 MiB while the retained entry count reaches 40. A stable endpoint remains at 22 MiB across the same number of requests after warm-up.
The allocation is deliberately synthetic. PHP’s allocator reports memory in chunks, so the line does not rise by exactly 64 KiB on every request. The stable control matters because a process can retain a high-water allocation without continuing to grow.
RoadRunner’s max_jobs setting replaces the growing worker. It does not remove
the static array or prove that growth is a leak. The next worker merely starts
with an empty copy of the same defect.
Nor is max_jobs: 8 an exact counter for eight calls to /retain. RoadRunner
counts worker executions, including health traffic. The retained run observed
seven, eight, and eight /retain calls in completed generations before the
server reported that the execution limit had been reached. Treat recycling as
a process-lifecycle boundary, not an application-route metronome.
The same fixture kills a worker and triggers a one-second execution timeout. Each request returns 500, and RoadRunner starts a replacement with a new process identifier. Octane’s output exposes the stop reason in these cases but not the child exit code or signal, so the fixture records those fields as unobservable. An operational design must decide whether that signal is enough for diagnosis before relying on it.
Activating code does not update a retained worker
The fixture starts release A, changes the active release file to B, and asks the existing worker which release it serves. It still returns A. The process already booted its configuration.
Only an explicit octane:reload --server=roadrunner starts a healthy release B
worker. A 300 ms request already in flight finishes on A while the replacement
comes up on B. A deliberately invalid release fails its readiness check and B
continues serving. Reloading the restored A release completes the rollback.
That sequence keeps four events separate:
- make release B’s code active;
- ask the server to replace retained workers;
- prove each replacement is ready; and
- retire old workers without mixing release behavior.
The Laravel deployment documentation requires Octane workers to be reloaded after deployment. The fixture proves one local RoadRunner sequence, not a Kubernetes rollout, systemd unit, load-balancer drain, or production service objective. Whichever system owns the workers must also own readiness, draining, rollback, and alerts.
This is the retained-process version of caching the deployment rather than an assumption. Code activation and process reload are different changes. Combining them in one hopeful deployment command only makes the failure harder to locate.
Compatibility is an inventory, not a Laravel badge
The locked fixture contains 83 packages and two application providers. It runs focused contracts for its owned state controls, Octane’s RoadRunner lifecycle, and Laravel’s locale reset. Most transitive packages are only locked and installed. They are explicitly marked “not individually verified for long-lived state.”
That is not evidence that 77 packages are broken. It is evidence that installation is not a compatibility test.
For an intended application, inspect providers and packages for captured requests, configuration, locale, tenant, connections, static fields, globals, timers, and server-specific APIs. Then combine maintainer documentation and source inspection with a sequential-request contract which can expose the state. “Works with Laravel” answers a different question.
RoadRunner evidence must stay RoadRunner evidence. Features such as Swoole tables, ticks, task workers, and Octane’s Swoole cache are not generic properties of every Octane server. Server choice changes both capabilities and the failure modes the team must operate.
Adopt the lifecycle, not the benchmark result
Octane is credible when repeated boot is material in the real workload, the response remains equivalent, request and package state reset, memory remains bounded, and the chosen server’s replacement and deployment lifecycle is owned. A conventional isolated PHP runtime remains credible when boot is not a meaningful constraint, process isolation is valuable, packages remain unverified, or the gain does not pay for another operational contract.
The fixture cannot make that decision for Brian’s application. It has no
production workload, concurrency, service objective, memory limit, package and
extension inventory, process manager, topology, observability owner, or cost.
Its adoption record therefore says undecided instead of calculating an
Octane-readiness score.
Revisit an adoption when boot work, framework or Octane version, server, packages, extensions, request state, traffic, memory trend, deployment topology, service objective, or operating cost changes. Also revisit the conventional runtime: removing unnecessary providers, configuration work, queries, network calls, or serialization may reduce the measured constraint without retaining the application.
The reproduction keeps 420 catalogue observations, recycled worker generations,
state and memory controls, crash and timeout replacements, the A/B deployment
sequence, 83 locked packages, 20 rejected counterfactuals, 13 primary-source
responses, and a clean export of commit
e269e501c53e3651e3ffa6b42cad2b360e741a00.
Octane removes repeated boot by keeping the application alive. Decide whether to use it by proving what else stays alive with it.