Laravel in TrueAsync: A Practical Guide to State Isolation for Coroutine Concurrency

Laravel can run concurrently in TrueAsync. The real challenge is isolating request-scoped state such as request, session, and auth, so singletons, static caches, and shared objects do not leak data across requests. Based on observations from laravel-spawn, this article outlines a low-intrusion migration path. Keywords: Laravel coroutines, TrueAsync, state isolation.

The technical snapshot defines the runtime and adaptation scope

Parameter Value
Language PHP
Framework Laravel
Runtime TrueAsync
Concurrency Model Coroutines / single-process multi-request
Core Protocols HTTP, database I/O, Redis/external API I/O
Reference Project laravel-spawn
GitHub Stars Not provided in the source
Core Dependencies Scope Context, Coroutine Context, Facade, PDO Pool, Trait, Enum

The real problem for Laravel in a coroutine runtime is shared state

Laravel is built on a request-per-process model by default. In synchronous execution, process state is naturally cleaned up after the request ends, so runtime data stored in singletons, static properties, or object members usually does not cause visible issues.

Once you move into a coroutine runtime such as TrueAsync, a single process may handle hundreds of requests at the same time. State that used to be “process-private” degrades into “request-shared” state. At that point, request, session, auth, route, locale, defer queues, and transaction counters can all bleed across requests.

The high-risk state inventory looks like this

<?php
$risks = [
    'current_request',   // Current request object
    'current_session',   // Current session
    'current_auth_user', // Current authenticated user
    'current_route',     // Current route
    'current_locale',    // Current locale
    'db_transactions',   // Database transaction nesting level
    'facade_cache',      // Cache of resolved Facade instances
];

This code summarizes the high-risk areas where state leakage is most likely during Laravel coroutine adaptation.

Laravel coroutine adaptation usually follows only two main paths

The first path is request-scoped instantiation. In other words, recreate stateful services for every request so multiple requests never share the same AuthManager, SessionManager, or other mutable objects.

The advantage is strong compatibility with legacy code, because services can keep storing state in object properties. The downside is that cross-request caches become harder to reuse, while initialization cost and memory usage increase.

The difference between the two paths is easy to express

<?php
// Path 1: independent instance per request
$authA = new AuthManager(); // Request A
$authB = new AuthManager(); // Request B

// Path 2: shared service + request context
$sharedAuthProxy = new ScopedServiceProxy($resolver); // Shared proxy

This code shows the two design directions: directly isolating instances versus using a shared proxy that resolves the real instance on demand.

The second path is to share immutable logic and relocate mutable state. Services can remain singletons, but mutable data such as request, session, and auth must move into request context instead of staying on shared object properties.

TrueAsync provides two layers of context capabilities

Coroutine Context is coroutine-private storage. It is a good fit for transaction counters, temporary state, and connection-associated data. It solves data isolation across multiple coroutines within the same request.

Scope Context is a set of inheritable scoped contexts across coroutines. On the server side, you can create a global Server Scope and then derive an independent Request Scope for each request. All child coroutines inherit the data stored in that request scope.

A typical request-context pattern looks like this

<?php
enum ScopedService: string {
    case REQUEST = 'request';
    case SESSION = 'session';
    case AUTH = 'auth';
}

current_context()->set(ScopedService::REQUEST, $request); // Store the request object
current_context()->set(ScopedService::SESSION, $session); // Store the session object
current_context()->set(ScopedService::AUTH, $auth);       // Store the authentication service

This code shows a key rule: request-scoped state should be written explicitly into Scope Context instead of staying attached to shared singletons.

The value of laravel-spawn lies in its low-intrusion adaptation strategy

laravel-spawn does not rewrite Laravel. Instead, it combines several lightweight techniques: request-scoped instances for some services, singleton services with relocated state for others, proxies for Facades, and local Trait-based replacement for deeply embedded state.

The core benefit of this strategy is that it addresses leakage points one by one rather than rebuilding the framework as a whole. That makes the migration incremental and much more practical for existing business systems.

Enum keys are more robust than plain strings for context access

<?php
enum ScopedService: string {
    case REQUEST = 'request';
    case AUTH = 'auth';
}

$request = current_context()->find(ScopedService::REQUEST); // Read the current request
$auth = current_context()->find(ScopedService::AUTH);       // Read the current auth instance

The important part here is not the syntax. It is traceability: Enum keys make it easier for IDEs, PHPStan, and code audits to locate every context access point.

Facade caching is the most subtle entry point for cross-request state leakage

Laravel Facades store resolved service instances in a static cache. In the synchronous model, this is a normal optimization. In a coroutine runtime, however, if the AuthManager resolved by the first call to Auth::user() gets cached statically, later requests may reuse the wrong instance.

The issue is not static method calls themselves. The issue is statically caching the real object that carries request-specific state. The solution is therefore not to abolish Facades, but to make the Facade cache hold a stateless proxy instead.

ScopedServiceProxy is the key adaptation layer

<?php
class ScopedServiceProxy {
    public function __construct(private readonly Closure $resolver) {}

    public function __call(string $method, array $args): mixed {
        return ($this->resolver)()->$method(...$args); // Resolve the real service from the current context on every call
    }
}

This code turns the Facade static cache from a cache of real instances into a cache of lazily resolved proxies.

AsyncApplication can replace services with proxies at the container layer

In async mode, the container can return ScopedServiceProxy for high-risk aliases such as auth and session. Business code still calls Auth::user(), but the underlying real service is resolved from the current Request Scope on each invocation.

This is a highly practical low-intrusion adaptation. The upper-layer API stays the same, the calling style stays the same, and the risk is concentrated and controlled inside the container and proxy layer.

The container replacement logic can be simplified as follows

<?php
public function offsetGet($key): mixed {
    if ($this->asyncMode && in_array($key, ['auth', 'session'], true)) {
        return new ScopedServiceProxy(fn() => $this->tryResolveScoped($key));
    }

    return parent::offsetGet($key);
}

This code shows that only high-risk Facade-backed services need proxying, which avoids indiscriminate rewrites of the global container.

Database transaction counters must move into coroutine context

The transactions counter on a database connection is another classic danger zone. Even if the PDO pool assigns a different physical connection to each coroutine, concurrent coroutines can still interfere as long as the Laravel Connection object is shared and $this->transactions remains on that shared object.

For that reason, transaction nesting level must be bound to Coroutine Context rather than Scope Context. The former follows the current coroutine and physical connection. The latter is meant for request-shared information. Their semantics are different and should not be mixed.

A Trait example for transaction isolation

<?php
trait CoroutineTransactions {
    private const CTX_TRANSACTIONS = 'db.transactions';

    public function transactionLevel(): int {
        return coroutine_context()->find(self::CTX_TRANSACTIONS) ?? 0; // Each coroutine reads its own transaction level
    }

    private function setTransactionLevel(int $level): void {
        coroutine_context()->set(self::CTX_TRANSACTIONS, $level, replace: true); // Write coroutine-private state
    }
}

This code moves the transaction counter from a shared object property into coroutine-private context.

A realistic Laravel coroutine migration should be incremental

The first step is to identify shared mutable state. Focus on writes to static properties, runtime fields inside singletons, Facade caches, and database connection state. The second step is to establish a Scope Context for each request and inject objects such as request, session, auth, and locale.

The third step is to handle high-risk Facades. The fourth step is to use Traits for local overrides where deep internal state cannot be replaced wholesale. The fifth step is to design concurrency tests that verify user state, Session state, transaction state, and nested coroutine context never leak across requests.

The main benefit of async execution is throughput rather than faster single-request execution

Coroutines do not make PHP code itself run faster. What they do is let other requests reuse idle time while one request is waiting on a database, Redis, an HTTP API, or file I/O. The payoff appears as fewer workers, lower memory usage, higher concurrency capacity, and better I/O utilization.

From an engineering perspective, whether Laravel is a good fit for coroutine execution does not depend on syntax. It depends on whether state can be split precisely, and whether the proxy and context mechanisms are stable enough.

This migration path proves Laravel coroutine support does not require a framework rewrite

Based on the adaptation practices observed in laravel-spawn, one conclusion is clear: making Laravel asynchronous in a coroutine runtime is fundamentally a state-isolation project, not a framework-rewrite project. As long as the runtime provides Scope, Coroutine Context, inheritance, and connection pools, practical adaptation is achievable.

As primitives such as request_context() continue to mature, the main obstacles to Laravel on async runtimes will shift away from the framework itself and toward tooling, conventions, and testing systems.

WeChat sharing prompt

AI Visual Insight: This GIF is a blog-platform sharing prompt. It shows a user interaction entry point rather than framework architecture, code execution flow, or system topology, so it does not provide meaningful information for technical design analysis.

FAQ structured answers

FAQ 1: What should I change first after migrating Laravel to TrueAsync?

Do not start with business logic. Start by locating all request-scoped mutable state, especially auth, session, request, route, locale, Facade caches, and transaction counters.

FAQ 2: Why can’t Facades directly reuse the original cached instances?

Because the static Facade cache survives across requests. In a coroutine model, that means a stateful service resolved for Request A may continue to be used by Request B, causing authenticated user or Session leakage.

FAQ 3: When should I use Scope Context versus Coroutine Context?

Use Scope Context for state shared within the request, such as request, session, and auth. Use Coroutine Context for coroutine-private state, such as transaction counters and temporary connection state. Do not blur the boundary between the two.

AI Readability Summary: This article uses laravel-spawn as a reference to break down Laravel’s main risks in a TrueAsync coroutine runtime, explain the two context models, and show how Facade proxying and transaction isolation can enable a low-intrusion, verifiable path to async coroutine execution.