phasync
Context switching / Services that can be shared

Context switching

Services that can be shared

A service the whole worker uses is stateless after boot. Request data travels with the request, and per-request variants come from a factory.

A service is safe to share when no request can change it. Build it at boot, and from then on only read it.

Request data travels with the request#

Pass it as an argument, put it in the PSR-7 request's attributes, or keep it in a scoped object that you reach through Scope::getCurrent() (per-request state). Do not set it on the service.

Per-request variants come from a factory#

A service that is configured per request (a sender address, a tenant, a locale) hands out a new object that shares the expensive parts:

// Shared, but changed by every request that calls from()
final class Mailer
{
    public function __construct(private Transport $transport, private string $from = 'noreply@example.com') {}

    public function from(string $address): static
    {
        $this->from = $address;

        return $this;
    }
}

With two overlapping requests, both mails leave from Bob. Returning a new Mailer over the same transport gives each request its own:

    public function from(string $address): static
    {
        return new static($this->transport, $address);
    }
ann@example.com -> x@example.org | bob@example.com -> y@example.org

Other DI containers call this a service scope: shared parts underneath, a small object per request on top.

Toggles belong to the request#

A static that selects behaviour (the current locale, a test clock, a "debug" switch) is a toggle. Keep its default static and its value scoped:

final class CurrentLocale
{
    /** @var WeakMap<object, string> */
    private static WeakMap $current;

    public static function set(string $locale): void
    {
        self::$current ??= new WeakMap();
        self::$current[Scope::getCurrent()] = $locale;
    }

    public static function get(): string
    {
        self::$current ??= new WeakMap();

        return self::$current[Scope::getCurrent()] ?? 'en';
    }
}

A request that sets nb and waits sees nb; a request that never set one sees en.

Registration happens at boot#

Adding a route, a listener, a macro, a view composer or a service while handling a request changes the worker for everyone, and again for every request after it. Register in the code that runs once, when the worker starts: swerve.php or a provider's boot method.

Checklist#

For framework authors#

Designing a framework for concurrent requests puts these rules into a container.

Previous: Captive dependencies ยท Next: Caching: memoization