skip to content

After moving a Laravel banking API to Octane, some responses show the previous customer's accounts; which Octane-specific state is carrying them over, and how do you fix it?

level: seniorimportance: must knowfreq 45%

answer

  1. base app cloned per request
  2. boot-resolved instances are shared
  3. memoized property on a singleton
  4. static properties are worker-global
  5. scoped, octane.flush or a listener

basics

~20 s

Octane clones one booted app per request, but instances resolved at boot or warmed, and static properties, are shared by every request on the worker. A singleton or static cache that memoizes the current customer serves them to the next request.

solid answer

~50 s

Each Octane worker boots one application and handles every request in a **clone** of it. The clone copies the container's instance list, so objects already resolved in the base app - during a provider's `boot()`, or listed in `octane.warm` - are the same objects in every request. If such a singleton memoizes `$this->customer` or `$this->accounts` on first use, the next request on that worker gets the previous customer's data; static properties and static caches are shared the same way. Instances first resolved during a request live in that request's sandbox and are discarded. Fix it by not storing per-request data on long-lived objects: pass the customer as a method argument or read `auth()->user()` on each call, bind the service with `scoped()` or add it to `octane.flush`, and reset any static cache in a `RequestReceived` listener.

code

php · 22 lines
php
<?php

namespace App\Services;

use App\Models\Customer;

class AccountContext
{
    // Leaks under Octane if this singleton is resolved at boot
    private ?Customer $customer = null;

    public function current(): Customer
    {
        return $this->customer ??= auth()->user();
    }

    // Safe: no state kept between calls
    public function accountsFor(Customer $customer)
    {
        return $customer->accounts()->get();
    }
}

go deeper

for a junior

Recall that under Octane, singletons resolved at boot and static properties outlive a request, so they must never hold the current user's data.

for a middle

Explain Octane's clone-per-request model, why boot-resolved and warmed instances are shared while request-resolved ones are not, and what Octane already resets.

for a senior

Show a methodical hunt - one worker, two users, statics and memoizing singletons - and fixes ranked from redesign to scoped bindings, flush and reset listeners.

for a principal

Treat cross-customer leakage as a security risk: review rules for statics, Octane-readiness checks for packages, and regression tests before any rollout.

## How the leak happens under Octane A banking API returns a customer's accounts. Under PHP-FPM it works: every request builds its own objects. After switching to **Laravel Octane**, a small share of responses show another customer's accounts. The cause is always some object that outlives a request. To find it, you need Octane's exact model: 1. Each worker boots **one base application**. 2. For every request, Octane's `Worker` does `clone $this->app` to create a **sandbox**, handles the request in it, calls `$sandbox->flush()`, and switches back to the base app. 3. Cloning the container copies its arrays of bindings and resolved **instances**. The array is new, but the objects in it are the **same objects**. So there are two kinds of long-lived objects: | Where the object came from | Lifetime under Octane | |---|---| | resolved in the base app - during a provider's `register()`/`boot()`, or pre-resolved from `octane.warm` | shared by every request on the worker | | first resolved during a request (in the sandbox) | discarded when the sandbox is flushed | | stored in a **static** property or a static variable | shared by every request in the process | ## The usual culprits - **Memoization on a boot-resolved singleton.** An `AccountContext` singleton is resolved in a provider's `boot()` (to register a macro or a listener). It has `private ?Customer $customer = null;` and a `current()` method that fills it on first call. The first request on the worker fills it; every later request gets that customer. - **Static caches.** `AccountRepository::$cache[$id] ??= ...` keyed by something not unique to the customer, or a static "current customer" holder set in middleware and never cleared. - **Closures registered at boot that captured a value** instead of fetching it when they run. - **Third-party packages** that keep state in singletons or statics and have no Octane reset. Octane already resets Laravel's own per-request state: authentication guards (`FlushAuthenticationState`), the session, the configuration clone, the request instance, queued cookies, the locale, and several first-party packages. Your own classes it cannot know about. ## Finding it - The wrong data follows a **worker**, not a customer or a time window; lowering `--workers` to 1 locally makes it reproducible with two logins in a row. - Search for `static` properties and for singletons with nullable "current" properties. - List what is resolved at boot: providers' `boot()` code and the `octane.warm` list. ## Fixing it, best first 1. **Keep per-request data off long-lived objects.** Pass the customer as a method argument, or read `auth()->user()` inside the method each time. Octane's docs recommend passing request data to methods at runtime. 2. **Give the service a per-request lifetime.** Bind it with `scoped()` (or mark the class `#[Scoped]`); Octane calls `forgetScopedInstances()` after each operation, so it is rebuilt next time. Alternatively add the binding to the `flush` array in `config/octane.php`, which Octane forgets from the base app after every request. 3. **Reset statics explicitly** in a listener registered under `RequestReceived` in `config/octane.php`, for code you cannot restructure. 4. **Do not rely on luck**: a singleton that happens to be resolved first inside a request is per-request today, but one `boot()` change or `octane.warm` entry makes it shared. `--max-requests` recycling does not fix this class of bug: the leak happens from the second request onward, long before any worker is recycled. ## A walk-through with one worker Start Octane locally with `--workers=1` so every request lands on the same worker: 1. Log in as customer A and call `GET /api/accounts`: A's accounts, correct. 2. Log in as customer B and call it again: if B sees A's accounts, the state is worker-level. 3. Restart the server and log in as B first: now B's data appears for A on the second call, confirming the first request "wins". 4. Remove suspects one at a time - comment out the memoization, clear the static - until step 2 is correct. Under several workers the same bug looks random, because it depends on which worker served whom first; that apparent randomness is itself a hint. ## Why it matters more in banking A data leak between customers is a confidentiality incident, not a glitch. Add a test that performs two requests as two users against the same application instance and asserts the second sees only its own data, and treat any `static` or memoizing singleton in the request path as a review item.

  • Why doesn't every singleton leak under Octane?
    Octane handles each request in a clone of the base application. A singleton first resolved during a request is stored in that clone's instance list and dropped when the clone is flushed. Only singletons already resolved in the base app - at boot or through `octane.warm` - and static state are shared across requests.
  • How would you write a regression test for this leak?
    Make two requests in one test as two different users, for example with `actingAs()` for customer A, then for customer B, and assert B's response contains only B's accounts. In a normal test the app persists across both calls within the test, so boot-resolved singletons and static caches carry over as they would in a worker.

saying these in an interview costs you the question

  • Every singleton binding leaks under Octane, so avoid singletons entirely
  • Octane resets static properties on your classes between requests
  • Lowering --max-requests to 100 fixes cross-customer leaks
  • auth()->user() itself leaks the previous user under Octane
  • The leak must be a race between concurrent requests in one worker