Skip to main content

Runtime-neutral development standard

This document covers the rules that apply while you write code. For choosing a runtime and performing a switch or rollback, see FPM and Octane/RoadRunner runtimes.

Why this matters​

Platform Laravel code must keep the same security, permission and tenancy meaning under both PHP-FPM and Laravel Octane. If a project has to change its code at the moment it enables Octane, that is already too late.

How the two runtimes differ​

PHP-FPMOctane
Process lifetimeOne request β€” memory is discarded afterwardsA worker stays alive across thousands of requests
Global stateCannot survive into the next requestSurvives unless you clear it explicitly

What breaking the rules looks like​

Nothing happens under PHP-FPM. The process dies after every request, so incorrect code still looks correct. The problem appears the moment Octane is enabled.

What survivesConsequence
Authenticated user and guardThe next request is treated as the previous user
SaaS, tenant and organization context, RLS sessionAnother tenant's data is returned
Permission resolution contextA lower-level admin passes a higher-level gate
Locale, timezone, debug flagsWrong language, wrong times, or debug output for someone else

This is a security standard, not a performance one.

It costs nothing under FPM​

Code that follows this standard behaves and performs identically under FPM. Since nothing is lost, there is no reason to postpone it until Octane is adopted β€” postponing only means auditing everything written in the meantime.

Rules at a glance​

CategoryVerdictUse instead
scoped_bindingAllowedThe recommended pattern for request-scoped state
rls_session_readAllowedReading is safe
singleton_bindingConditionalOnly for boot-time immutable services; never hold request, user or tenant state in fields
static_local_cacheConditionalOnly memoize values that do not depend on the request
container_context_mutationConditionalRegister every new context key with the cleanup contract
rls_session_writeConditionalGuarantee a reset at the end of the request
global_locale_mutationConditionalGo through the standard middleware path
mutable_static_propertyForbiddenInstance properties with dependency injection
runtime_config_mutationForbiddenPass the value as an argument
request_container_captureForbiddenAccept it as a method argument
auth_state_mutationForbiddenStandard guards and middleware
global_timezone_mutationForbiddenStore in UTC and convert when rendering
provider_boot_captureForbiddenRegister in boot, resolve values when they are used

Representative cases​

A singleton that holds request state​

// Unsafe β€” the first request's tenant is pinned for the worker's lifetime
$this->app->singleton(ReportBuilder::class, function () {
return new ReportBuilder(tenant: current_tenant());
});

// Safe β€” pass state at call time
$this->app->singleton(ReportBuilder::class, fn () => new ReportBuilder());
// Usage: $builder->for(current_tenant())->build();

When a fresh instance per request is required, use a scoped binding.

Static caches inside functions​

// Unsafe β€” the first request's settings are pinned
function currentSaasSettings(): array {
static $cache = null;
return $cache ??= current_saas()->settings;
}

// Safe β€” only cache values independent of the caller
function supportedLocales(): array {
static $cache = null;
return $cache ??= array_keys(config('app.available_locales'));
}

There is a single test: would every user and tenant calling this on the same worker get the same value?

Mutating configuration at runtime​

// Unsafe β€” pollutes worker-wide configuration
config(['services.kakao.client_id' => $saas->kakao_client_id]);

// Safe β€” pass the value where it is needed
$client = new KakaoClient($saas->kakao_client_id);

Reading request state in a provider's boot​

boot() runs once per worker. Reading request state there pins that value for every later request.

// Unsafe β€” the first request's value is pinned
public function boot(): void {
View::share('tenant', current_tenant());
}

// Safe β€” resolve when the value is used
public function boot(): void {
View::composer('*', fn ($view) => $view->with('tenant', current_tenant()));
}

Timezones​

Never change the process-wide timezone. Store in UTC and convert when rendering.

// Unsafe
date_default_timezone_set($user->timezone);

// Safe
$when->timezone($user->display_timezone ?? config('app.display_timezone'));

The request boundary contract​

Most conditional rules reduce to "register it so it is cleared when the request ends". Core provides that contract.

ComponentRole
Runtime lifecycle coordinatorA single begin()/end() contract at request and job boundaries
HTTP boundary middlewareWeb request boundary
Queue boundary listenerQueue job boundary
Five resettersAuth and tenancy, RLS session, domain context, configuration state, container context

This contract is not Octane-specific. The HTTP boundary middleware and the queue boundary listener are registered under FPM as well, so anything registered with a resetter keeps the same meaning regardless of runtime.

Core also carries no dependency on the Octane package. That absence is enforced by a test rather than promised in prose, and the Octane coupling lives only inside the optional runtime plugin.

Registering your own context keys​

When a plugin or project binds request context into the container, register your key at boot instead of editing Core's default list. The container context resetter exposes that extension point, and because the resetter registry is a singleton, the instance you obtain at boot is the one that runs when the request ends.

Editing Core directly will conflict on the next update. Cache keys derived from the SaaS identifier β€” anything shaped like {fixed prefix}.{current SaaS id} β€” need no registration at all, since the resetter reconstructs and clears them automatically.

Exceptions​

There is no machine-readable allowlist. Exceptions are made visible rather than hidden.

  1. Leave a marker comment in the code.
// runtime-neutral: singleton_binding exception β€” boot-time immutable settings cache, holds no request state
  1. Record the file, category, reason and approval date in the standard's exception table.
  2. Code review flags violations without a marker, and judges whether the stated reason holds when one is present.

Verification​

The platform ships an audit script that detects the categories above. Its category names match the entries in this document, so findings map directly onto these rules. Code review applies the same criteria as explicit rules.