Laravel Octane gotchas: state that leaks between requests
Octane boots Laravel once and reuses it for every request. Learn where state survives, how to reset it, and how to test that one tenant's data never reaches another.

Under PHP-FPM, every request starts from nothing. Under Laravel Octane, the application boots once and then serves request after request from memory, so anything left on a long-lived object is still there when the next request arrives. In a multi-tenant app, that's how one hotel's tracking script can end up in the head of a page that isn't theirs.
In this post, we'll walk through the places request state hides in a Laravel app, fix each one, and finish with tests that catch a leak before it ships. The examples use a multi-tenant hotel platform, where any two requests in a row can belong to two different hotel websites.
Prerequisites
To follow along, you'll need:
- A Laravel app running on Octane with FrankenPHP or Swoole, or one you plan to move there
- Working knowledge of the service container, service providers and middleware
- Pest for the tests (PHPUnit works the same way)
How Octane keeps state alive
Octane runs your service providers' register and boot methods once, when a worker starts. For each request, it gives the app a fresh copy of the container. That copy is shallow: any object built before it, during boot, by a package, or through the warm list in config/octane.php, is the same object in every request.
So three kinds of state outlive a request:
- Static properties, which belong to the PHP process
- Objects built at boot, plus anything stored on them, such as the view factory's shared data
- Package and SDK state kept on either of the above
Octane resets the framework state it knows about, such as auth, session, config and Inertia's shared props. It can't know about yours. The Octane docs cover the classic cases of injecting the container or the request.
A singleton first resolved in the middle of a request is technically thrown away with that copy. Don't count on it. One resolution during boot, by a provider, a facade call or a package, makes it permanent, and queue workers don't copy the container at all. Treat every singleton as long-lived.
Step 1: Bind per-request services with scoped()
The container gives you two shared lifetimes. singleton() builds an object once and keeps it. scoped() builds it once per request or queue job, and Octane forgets it when the request ends. The container docs describe both.
Here's a service that memoizes the current hotel's room rates:
<?php
namespace App\Rates;
use App\Models\RoomRate;
use App\Tenancy\HotelContext;
use Illuminate\Support\Collection;
class RoomRates
{
/** @var Collection<int, RoomRate>|null */
private ?Collection $loaded = null;
/** @return Collection<int, RoomRate> */
public function all(): Collection
{
return $this->loaded ??= RoomRate::query()
->whereBelongsTo(app(HotelContext::class)->hotel)
->orderBy('nightly_price')
->get();
}
}
Bound with scoped(), $loaded is a harmless per-request memo. Bound with singleton(), it's a cache with no expiry: once that instance outlives a request, every hotel after the first sees the first hotel's rates. HotelContext holds the current hotel, and Step 2 shows how to set it.
// app/Providers/AppServiceProvider.php
public function register(): void
{
// One instance per worker: leaks between hotels
// $this->app->singleton(RoomRates::class);
// One instance per request or queue job
$this->app->scoped(RoomRates::class);
}
Use scoped() for anything that describes the current request, such as the locale, the authenticated client or the tenant. A memo is fine there, because it dies with the request.
Step 2: Model request context as a binding you set
The current tenant and locale are the values most likely to leak, because so much code reads them. A reliable pattern is to register the class once as scoped() with a closure that throws, then have middleware set the instance.
<?php
namespace App\Tenancy;
use App\Models\Hotel;
final class HotelContext
{
public function __construct(
public readonly Hotel $hotel,
public readonly string $locale,
) {}
}
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->scoped(
HotelContext::class,
fn () => throw new LogicException(
'No hotel context. Set one in middleware or a job.',
),
);
}
<?php
namespace App\Http\Middleware;
use App\Models\Hotel;
use App\Tenancy\HotelContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ResolveHotelFromHost
{
public function handle(Request $request, Closure $next): Response
{
$hotel = Hotel::query()
->where('domain', $request->getHost())
->firstOrFail();
app()->instance(
HotelContext::class,
new HotelContext($hotel, $hotel->default_locale),
);
return $next($request);
}
}
Because the class is registered as scoped, Laravel forgets the instance when the request or job ends, even though middleware set it with instance(). The throwing default means code that runs without a tenant fails loudly instead of quietly reusing the last one. The properties are readonly, so nothing downstream can swap the tenant halfway through a request.
Queue jobs and console commands don't run your middleware, so give them a helper on the same class:
// app/Tenancy/HotelContext.php
public static function runFor(Hotel $hotel, Closure $callback): mixed
{
app()->instance(
self::class,
new self($hotel, $hotel->default_locale),
);
try {
return $callback();
} finally {
app()->forgetInstance(self::class);
}
}
// app/Jobs/SendPreArrivalEmails.php
public function handle(): void
{
HotelContext::runFor($this->hotel, function () {
// Everything in here sees $this->hotel
});
}
If you nest these calls, restore the previous instance instead of forgetting it.
Step 3: Read scoped values at call time
A scoped binding only helps if the code that reads it resolves it again for each request. Inject it into the constructor of a long-lived object and you've pinned the first request's copy:
// Bound with singleton(): built once, pinned to the first hotel
class BookingLinks
{
public function __construct(
private HotelContext $context,
) {}
public function forRoom(string $roomCode): string
{
return $this->context->hotel->booking_url
.'?room='.urlencode($roomCode);
}
}
// Resolves the current hotel on every call
class BookingLinks
{
public function forRoom(string $roomCode): string
{
$hotel = app(HotelContext::class)->hotel;
return $hotel->booking_url.'?room='.urlencode($roomCode);
}
}
This bites hardest in libraries that build long-lived objects for you. An OAuth server, for example, creates its grants and token repositories once and reuses them for every request on the worker. If one of them takes your request context in its constructor, it keeps the first request's context for good, even though Octane throws a fresh one away after every request. Resolve the context in the method that needs it, and every call sees the current request.
Step 4: Reset View::share on every request
The view factory is built at boot and shared by every request, so View::share() writes to worker-wide state. A typical multi-tenant layout prints each hotel's title, favicon and custom head and body code, such as chat or booking widgets, from shared view data. If a later request renders that layout without sharing new values, it prints the previous hotel's.
Setting values isn't enough. You have to clear every key first, on every code path that renders the layout:
<?php
namespace App\View;
use App\Models\Hotel;
use Illuminate\Support\Facades\View;
final class HotelLayoutData
{
private const KEYS = [
'hotelName', 'iconUrl', 'headHtml', 'bodyHtml',
];
public static function clear(): void
{
foreach (self::KEYS as $key) {
View::share($key, null);
}
}
public static function share(Hotel $hotel): void
{
self::clear();
View::share([
'hotelName' => $hotel->name,
'iconUrl' => $hotel->favicon_url,
'headHtml' => $hotel->custom_head_html,
'bodyHtml' => $hotel->custom_body_html,
]);
}
}
Watch the error page. A request for an unknown host never gets through the tenant middleware, but the exception handler renders the same layout, so it must call the same clear(). With the key list in one class, no caller can miss a new key.
The same rule covers setters on other long-lived framework objects. If an exception handler changes a framework setting, such as Inertia's root view, change it back in finally:
final class PublicErrorPage
{
public static function render(
Request $request,
int $status,
): Response {
HotelLayoutData::clear();
Inertia::setRootView('public');
try {
return Inertia::render('Errors/Show', [
'status' => $status,
])->toResponse($request)->setStatusCode($status);
} finally {
Inertia::setRootView('app');
}
}
}
Step 5: Set package state on every request
Packages keep state too. With teams enabled, Spatie's laravel-permission stores the active team ID on its PermissionRegistrar, a singleton that can survive between requests. Every permission check resolves against that team.
The mistake is to set it only when there's something to set:
// Leaks: a request without a hotel keeps the last team ID
if ($hotel = $request->route('hotel')) {
setPermissionsTeamId($hotel->id);
}
// Safe: every request sets it, null included
setPermissionsTeamId($request->route('hotel')?->id);
Set it in middleware on every request. On token-authenticated routes, where you only know the team later, clear it to null before anything else runs. When code needs to act in another team for a moment, such as assigning a role, save the current team and restore it:
<?php
namespace App\Support;
use Closure;
final class TeamScope
{
public static function within(
int|string|null $teamId,
Closure $callback,
): mixed {
$previous = getPermissionsTeamId();
setPermissionsTeamId($teamId);
try {
return $callback();
} finally {
setPermissionsTeamId($previous);
}
}
}
// Usage
TeamScope::within(
$treetops->id,
fn () => $user->assignRole('front-desk'),
);
SDKs work the same way. An error tracker's "last event ID" belongs to the worker, not the request. If you show it to users as a support reference, take the ID from the capture call and store it on the request:
// A thin wrapper around your error tracker's SDK
interface ErrorTracker
{
public function capture(Throwable $e): ?string;
}
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->report(function (Throwable $e) {
$eventId = app(ErrorTracker::class)->capture($e);
request()->attributes->set('error_reference', $eventId);
});
})
Read it back from $request->attributes when you build the response, never from the SDK.
When is a singleton safe?
A singleton is safe when its data is immutable and the same for every request. Ask three questions:
- Does it hold anything that depends on the request, user, tenant or locale? Then it isn't safe.
- Can its data change while the worker runs? Then it needs a way to notice.
- Can a caller change its state? Then the next request sees that change.
A parsed file that ships with your code is a good singleton. A JSON schema or a list of country codes in your repository can't change while the worker runs, so parsing it once per worker saves that work on every request that needs it.
A cache of files that a command can rewrite at runtime is different. Say a scheduled command downloads exchange rates to a JSON file while workers are running. A singleton that memoizes that file has to check a version in the shared cache before it trusts its memory:
<?php
namespace App\Currency;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
final class ExchangeRates
{
/** @var array<string, float>|null */
private ?array $rates = null;
private mixed $version = null;
public function rate(string $currency): ?float
{
$version = Cache::get('exchange-rates:version');
if ($this->rates === null || $version !== $this->version) {
$this->rates = Storage::json('exchange-rates.json') ?? [];
$this->version = $version;
}
return $this->rates[$currency] ?? null;
}
}
The sync command writes a new value to exchange-rates:version right after it rewrites the file, and every worker drops its copy on its next lookup. One cache read per lookup costs far less than decoding the file every time.
Testing for leaks
Laravel's test client sends every request in a test through the same application instance. Like an Octane worker, it doesn't reset view data or package state between requests. So the simplest leak test is two requests in one test:
use App\Models\Hotel;
it('does not leak custom head code between hotels', function () {
$lagoon = Hotel::factory()->create([
'domain' => 'lagoon-retreat.test',
'custom_head_html' => '<script src="/chat.js"></script>',
]);
$treetops = Hotel::factory()->create([
'domain' => 'treetops-lodge.test',
'custom_head_html' => null,
]);
$this->get("http://{$lagoon->domain}/")
->assertOk()
->assertSee('/chat.js', escape: false);
$this->get("http://{$treetops->domain}/")
->assertOk()
->assertDontSee('/chat.js', escape: false);
});
The test client doesn't forget scoped instances between requests, though. To test a scoped binding, mark the request boundary yourself:
use App\Models\Hotel;
use App\Rates\RoomRates;
use App\Tenancy\HotelContext;
it('builds new room rates for each request', function () {
$first = app(RoomRates::class);
app()->forgetScopedInstances();
expect(app(RoomRates::class))->not->toBe($first);
});
it('forgets the hotel when the request ends', function () {
app()->instance(HotelContext::class, new HotelContext(
Hotel::factory()->create(),
'en',
));
app()->forgetScopedInstances();
expect(fn () => app(HotelContext::class))
->toThrow(LogicException::class);
});
The same boundary tests Step 3. Resolve your long-lived service, write to the context it sees, call forgetScopedInstances(), and assert that the service now sees a fresh context.
Static properties deserve a blanket rule. PHP doesn't allow readonly static properties, so every static property is mutable, and a reflection test can ban them from code that runs per request:
it('has no static properties in request code', function () {
$offenders = [];
foreach (classesUnder(app_path('Http')) as $class) {
$statics = (new ReflectionClass($class))
->getProperties(ReflectionProperty::IS_STATIC);
foreach ($statics as $property) {
if ($property->class === $class) {
$offenders[] = $class.'::$'.$property->getName();
}
}
}
expect($offenders)->toBeEmpty();
});
Here classesUnder() is a small helper that maps the PHP files in a folder to class names.
Gotchas
- Re-registering a scoped binding at runtime.
forgetScopedInstances()clears instances but keeps bindings. If a job callsapp()->scoped()with a closure that captures state, that closure serves the next job too. Register once in a provider, then only ever set instances. - Restoring "whatever was there". A save-and-restore helper puts back the value that was set when it started. If that was left over from an earlier request, you've restored the leak. Clear to a known value at the start of the request, then let helpers restore.
- Conditional resets. Any "set it if we have one" branch leaks into requests that don't have one: guests, unknown hosts, health checks. Reset unconditionally.
- Error paths. Exception handlers and fallback routes often skip your middleware. Make sure they reset shared state too.
Checklist
Before you ship to Octane, check that:
- Request, user, tenant and locale data lives in a scoped binding or on the request, never in a singleton or a static property
- Long-lived services resolve scoped values when they need them, not in their constructors
- Every
View::share()key a layout reads is reset before each render, including error pages - Package and SDK context is set on every request,
nullincluded - Temporary changes to shared state are undone in a
finallyblock - Singletons hold only immutable, request-independent data, or check a version before reusing a cache
- Each fix has a test with two requests, or a
forgetScopedInstances()boundary
Wrapping up
Octane is fast because it keeps your application in memory, and the price is that you decide what survives a request. Make request state scoped, reset shared state at the start of every request, and save singletons for data that never changes. HotelierKit runs on these rules to keep every hotel's website its own.