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-FPM | Octane | |
|---|---|---|
| Process lifetime | One request β memory is discarded afterwards | A worker stays alive across thousands of requests |
| Global state | Cannot survive into the next request | Survives 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 survives | Consequence |
|---|---|
| Authenticated user and guard | The next request is treated as the previous user |
| SaaS, tenant and organization context, RLS session | Another tenant's data is returned |
| Permission resolution context | A lower-level admin passes a higher-level gate |
| Locale, timezone, debug flags | Wrong 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β
| Category | Verdict | Use instead |
|---|---|---|
scoped_binding | Allowed | The recommended pattern for request-scoped state |
rls_session_read | Allowed | Reading is safe |
singleton_binding | Conditional | Only for boot-time immutable services; never hold request, user or tenant state in fields |
static_local_cache | Conditional | Only memoize values that do not depend on the request |
container_context_mutation | Conditional | Register every new context key with the cleanup contract |
rls_session_write | Conditional | Guarantee a reset at the end of the request |
global_locale_mutation | Conditional | Go through the standard middleware path |
mutable_static_property | Forbidden | Instance properties with dependency injection |
runtime_config_mutation | Forbidden | Pass the value as an argument |
request_container_capture | Forbidden | Accept it as a method argument |
auth_state_mutation | Forbidden | Standard guards and middleware |
global_timezone_mutation | Forbidden | Store in UTC and convert when rendering |
provider_boot_capture | Forbidden | Register 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.
| Component | Role |
|---|---|
| Runtime lifecycle coordinator | A single begin()/end() contract at request and job boundaries |
| HTTP boundary middleware | Web request boundary |
| Queue boundary listener | Queue job boundary |
| Five resetters | Auth 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.
- Leave a marker comment in the code.
// runtime-neutral: singleton_binding exception β boot-time immutable settings cache, holds no request state
- Record the file, category, reason and approval date in the standard's exception table.
- 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.
Relatedβ
- FPM and Octane/RoadRunner runtimes β choosing, switching and rolling back runtimes
- Core and plugin catalog