On Wed, Oct 28, 2026, at 21:58, Larry Garfield wrote:
> do we still want to have `run(callable, ...$args)`? `run(Closure $c)` would
> work just as well
My preferred way to write it is an arrow function, which shows exactly what
runs inside the scope:
$scope->run(fn() => doAThing(1, 2, 4));
But run(doAThing(...), 1, 2, 4) is consistent with the other core APIs:
Fiber::start(), call_user_func(), register_shutdown_function() and many
more take the same "$fn, ...$args" pair. Moving away from that pattern
seems worthy of a separate RFC, and I don't think the Scope RFC should be
the first to break ranks.
It also combines with PFA better than run(Closure $c) would:
function func(int $a, int $b): void {}
// run(Closure $c):
$scope->run(fn() => func(1, 2)); // works
$scope->run(func(1, 2, ...)); // works
$scope->run(func(1, ...)); // fails: $b is missing
// run(callable $c, mixed ...$args):
$scope->run(func(...), 1, 2); // works
$scope->run(func(1, ?), 2); // works
$scope->run(fn() => func(1, 2)); // works
Every form that works with a closure-only run() works with this one too,
and a partial application with open parameters only works with this one.
The examples will avoid the PFA form, though, because it reads like a call,
and the difference between running code in the scope and running it before
the scope is entered is three dots:
$scope->run(doAThing(1, 2, 4, ...)); // doAThing runs in $scope
$scope->run(doAThing(1, 2, 4)); // doAThing runs in the caller's scope;
// run() gets whatever it returned
> The docblock on Scope::bind() is horribly confusing and clumsy.
Agreed, I'll rewrite it. And yes, it is the Closure::bind() idea: it wraps
a closure in an explicit scope, and returns a new closure that always runs
in that scope, whoever calls it. That is sometimes useful, for example when
the closure needs dependency injection from the scope it came from rather
than from the scope it ends up being called in.
> "A scope keeps no link to the scope it was run from." So that fully
> precludes getParentScope() and such, yes? What is the reason?
It's simple to add in userspace with a WeakMap<Scope, Scope>, for code that
wants a hierarchy. In core it would carry a risk of memory leaks: an app
designed to create each new scope from the previous one, millions of times,
would keep the whole chain alive.
> I think you mean "PHP process" here
Under the CLI the root scope is the process, yes. Under php-fpm it is the
request, not the process, which handles many requests. I'll spell out both
cases instead of saying "request".
> Am I correct in summarizing it as "a closure inherits the scope of its
> callsite, not creation site"?
Yes. Closures don't have a scope; stack frames do. A closure is just code:
while it runs, some scope is current, and later the same closure can run
while another scope is current. What matters is the scope that called it,
since the scope never changes while a function's instructions are running.
Fibers and generators look different, but follow the same rule:
- A fiber gets a scope because it will get a stack frame as soon as
$fiber->start() is called. It is bound to the scope that created it, so
it starts in that scope, whoever starts or resumes it.
- A generator function is just a function and isn't bound to a scope. But
a Generator is, in a sense, a stack frame: calling the function creates
it, so the Generator object (not the generator function) is bound to the
scope it was created in.
I'll add examples for each.
> For secondary vote A (the superglobals), the example is very unclear.
> [...] Forget to do so and poof
$request and $handler are the server's own; I'll make that explicit. The
copying is not something applications do. It is done once, by whatever
creates the request scope: Swerve, FrankenPHP, RoadRunner. That is the same
place that fills the superglobals today.
A new scope starting with empty superglobals is deliberate. Scopes are a
new feature, and it seems safer to start the scope's globals empty and
require frameworks to populate them, than to start them as clones of the
parent's. Clones would let a parent scope mutate values inside its child
scopes through object properties, and every framework would have to clear
them out first.
> The first mention of $GLOBALS is in the superglobal secondary vote
> section. [...] I am undecided if I agree globals should not follow the
> scope.
I'll move that up. Globals and static properties stay shared because
applications use them for caches, registries and singletons. Warming those
once and keeping them across requests is the reason to run in worker mode
at all. If they followed the scope, every request would start cold.
> However, I am still uneasy about how narrow it is. [...] I also fear that
> the result is we'll have a lot of subtly-different and incompatible ways
> of handling that
That is what we have today, and it's why I wrote this. I spent two years
trying to do it in userspace with phasync. phasync attaches a context to
each fiber and that context follows every child fiber. It almost works,
but not quite:
1. A generator passed from one request to another, concurrent request can
produce one value in one fiber and the next in another. A fiber can't
serve as identity from userspace unless developers pass an
identification token as an argument everywhere.
2. Amp, React and phasync can each provide some identity tying code to a
request/response pair. But then every third-party library has to pick
one, React or Revolt or something else, integrate with it, or add an
adapter layer. That is the incompatibility you describe, and it exists
precisely because core has no such identity.
3. In core it is nearly free. It is fast in ext-scope and slow in
userspace.
4. It makes superglobals less global, which has been one of the main
criticisms of PHP for more than a decade.
5. Scope could store values, but then it could quickly become a kind of
service locator and encourage poor design. A WeakMap keyed on Scope
avoids naming conventions for keys, and it limits who can reach
something like a database connection: only the code holding the map.
6. There will be bugs, but not necessarily subtle ones. When one process
handles 5000 concurrent requests, you can't hand out 5000 PDO
connections; you need a limit on the total and a pool. Scope doesn't
cause that class of bug. High concurrency does, and the community will
have to learn to work with it, as Java, C#, Node and Python did.
> But the RFC examples are all doing a pseudo-global service locator, not
> something I am able or willing to do. [...] So what would it look like in
> a real dependency injection situation?
That's a fair objection, and the examples are to blame: I wrote them to
show the mechanism in as few lines as possible, and they ended up looking
like a service locator. I'm not proposing one, and I'll replace them with
a container example.
Scope supports the service locator pattern and dependency injection alike;
it doesn't care which one a framework chooses. I think the confusion comes
from where the scope lives. Functions and closures are not bound to a
scope, and neither is the DI container: it isn't bound to the root scope
just because it was built there. When the container is invoked, the call
goes on the stack with the current scope, so the container sees the scope
it was called from.
That lets a container offer what .NET calls a scoped lifetime: a service
that is neither a singleton shared by all requests nor a transient, but
one instance per request, shared by everything inside that request. The
container keeps those instances in a WeakMap keyed on the Scope, which
users never see.
This is how other languages do it. The runtime gives each unit of work an
identity that follows it across async boundaries, and the container, not
application code, uses it:
- .NET: AsyncLocal<T> flows with the ExecutionContext across awaits.
Microsoft.Extensions.DependencyInjection has ServiceLifetime.Scoped,
ASP.NET Core creates one IServiceScope per request, and the container
can refuse a scoped service injected into a singleton (ValidateScopes).
- Java: Spring's @RequestScope and CDI's @RequestScoped. A singleton gets
a proxy and calls it normally, and the proxy finds the current request's
instance through a ThreadLocal (RequestContextHolder in Spring).
ScopedValue (JEP 506) is the newer primitive for this, and works with
virtual threads.
- Node: AsyncLocalStorage. NestJS has request-scoped providers
(Scope.REQUEST), awilix has createScope(). TC39's AsyncContext proposal
would put the same thing in JavaScript itself.
- Python: contextvars (PEP 567), which asyncio copies into every task.
Async frameworks and DI libraries keep request-scoped state in it.
- Go passes context.Context explicitly as the first argument everywhere.
That is the alternative, and it is the "identification token as an
argument everywhere" PHP code would need without this RFC.
In none of these do classes look services up through the request
identity: they still get their dependencies through the constructor. PHP
has no such identity today, and that is all the primary vote adds.
This is a complete PSR-11 container with the three lifetimes:
final class Container implements Psr\Container\ContainerInterface
{
private array $shared = [];
private WeakMap $scoped; // Scope => that scope's services
/** @param array<string, array{Closure(self): mixed,
'singleton'|'scoped'|'transient'}> $services */
public function __construct(private array $services)
{
$this->scoped = new WeakMap();
}
public function get(string $id): mixed
{
[$make, $lifetime] = $this->services[$id] ?? throw new
NotFound($id);
if ('scoped' === $lifetime) {
$mine = $this->scoped[Scope::getCurrent()] ??= new
ArrayObject();
return $mine[$id] ??= $make($this);
}
return 'singleton' === $lifetime ? $this->shared[$id] ??=
$make($this) : $make($this);
}
public function has(string $id): bool
{
return isset($this->services[$id]);
}
}
A singleton is built once per worker, a scoped service once per request,
and the request's services are freed with its scope, without the container
ever hearing that the request ended.
For your case, your services stay singletons and are built once per
worker. Only the connection is resolved per request, which is what
Spring's scoped proxy does:
final class TenantConnection
{
private WeakMap $byScope;
public function __construct(private Closure $open)
{
$this->byScope = new WeakMap();
}
public function pdo(): PDO
{
return $this->byScope[Scope::getCurrent()] ??= ($this->open)();
}
}
$open looks at the current request's host (from $_SERVER['HTTP_HOST']
under vote A, or the framework's request object) and connects to that
customer's database. Every service downstream depends on TenantConnection,
not on a PDO, so none of them is recreated. That is also the one rule, as
in .NET: a singleton must not keep a scoped service it got in its
constructor.
Doctrine is the one place to be careful: the EntityManager holds request
state (the identity map and the unit of work), so it has to be scoped too,
or entities leak between customers. The expensive parts, metadata, proxies
and configuration, stay shared.
If you want to see it in practice, this is a complete app for Swerve
(https://github.com/phasync/swerve), using ext-scope's superglobals:
<?php // swerve.php
while (Swerve\Swerve::accept(null, function () {
$before = $_GET['name'] ?? '-';
usleep(500_000); // other requests run in this worker meanwhile
$after = $_GET['name'] ?? '-';
echo "before=$before after=$after\n";
})) {
}
$ composer require phasync/swerve
$ vendor/bin/swerve --workers=1 swerve.php
(Swerve loads the extension phasync bundles, which includes ext-scope.)
With one worker, 20 concurrent requests with ?name=req1 to ?name=req20 all
finished in 0.54 seconds, so they really overlapped. Each one printed its
own name before and after the sleep. No request object and no container:
just $_GET, which every request sees as its own.
Frode