Re: [RFC] Scope
| From: | Larry Garfield | Date: | Thu, 29 Oct 2026 12:22:04 +0000 |
| Subject: | Re: [RFC] Scope | ||
| References: | 1 2 3 | Groups: | php.internals |
| Request: | Send a blank email to internals+get-132952@lists.php.net to get a copy of this message | ||
On Wed, Oct 28, 2026, at 7:09 PM, Frode Børli wrote:
> 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
I'd request at least one example somewhere that shows using the PFA form, if only to clarify
that it's legal and valid. (Whether it's optimally readable is a more subjective question
I'm fine not addressing here.)
>> "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.
That's valid. Please include that in the RFC.
>> 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".
Yeah, the terminology here is going to be tricky, but important to be extremely clear on.
"request" already means a half dozen things today depending on context, so maybe it's
best to avoid it where possible in the RFC?
>> 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.
Ah, so a rule of "scope is bound at stack-frame-creation-time" is a useful global rule to
state explicitly, then explain how it applies to various scenarios. That would help clarify the
logic, methinks.
>> 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.
I'm not clear how the parent scope could mutate values inside the new scope.
My concern is this sort of case:
function legacyLogic(string $message)
{
print $message . ', ' . $_GET['name'];
}
If that is run today, it works. (It's dumb, but it works.)
However, if we do this:
new Scope()->run(legacyLogic('Hello', ...));
Then inside the scope $_GET['name'] no longer exists, so poof, it no longer works. And in
a non-trivial scenario, legacyLogic() could be called 20 functions deep, so it's nearly
impossible to know that it exists. It means as the scope creator I need to always do something like
this, just in case:
$get = $_GET;
$post = $_POST;
$request = $_REQUEST;
$server = $_SERVER;
new Scope()->run(function () use($get, $post, $request, $server) {
$_GET = $get;
$_POST = $post;
$_REQUEST = $request;
$_SERVER = $server;
legacyLogic('hello');
});
Needing to write lots of boilerplate like that "just in case" is a red flag in my mind.
Moreover, if the secondary vote doesn't pass initially but is introduced in some later version,
it becomes a BC break for anything using a superglobal that happens to be called from within a
scope, something the function (by design) doesn't know.
Not cloning feels like a footgun. I would think initializing with a clone of the parent scope and
letting the scope setup code modify it if desired would be the safer option, and require less work.
>> 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.
That's a valid argument, but both are also frequently used for request-specific caches today.
(Global current user, or a static cache that derives from request-sensitive data, etc.) Those would
suddenly become buggy if there's multiple scopes happening and the code isn't prepared for
it.
Thought: Would a
scoped keyword on a static property or global (maybe just static
property) be useful, to easily separate that data by scope rather than needing to reengineer it to
use a WeakMap? Basically automating the WeakMap logic.
I suppose that wouldn't help with BC anymore than telling people to use getCurrent() would, but
it's something. BC would require doing it via an attribute, and I don't know if
there's appetite for that.
Something like:
class Foo {
#[Scoped]
private static $cache;
public function getFoos() {
return $this->cache ??= $this->computeFoosRequestSensitive();
}
}
Which simply automates the WeakMap logic that everyone would need to replicate anyway, while also
being backward compatible with existing code.
Other thought: We already have a magic syntax for "the current object scope": $this.
Would it make sense to have some other first-class syntax for Scope::getCurrent() that is more
compact and self-descriptive? $scope almost certainly wouldn't work for BC reasons, but maybe
a keyword?
>> 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:
I'm sold on the use case, and the more I think about it the more sold I am. It's more
that I'm torn on this point:
> 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.
which is a concern I completely share! I don't think $scope->attributes['good luck
keeping these straight'] is the right answer either. My concern is if everyone needs to write
their own WeakMap routines, it is very likely that at least a third of them will be subtly wrong in
some way. The #[Scoped] attribute above might go a long way to mitigating that concern, but it
requires some more consideration.
Also, if we just "magically" separate the superglobals or locale or timezone or whatever,
it's not obvious that something has been separated. As the list of things that are separated
grows with future RFCs, that could introduce subtle behavior breaks that only show up when code is
used in a scope, as in the $_GET example above.
> 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.
That's going to be fun to deal with... Are there good examples we can point to to help PHP
collectively wrap its head around that pattern faster?
> 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);
> }
This is the sort of code I fear everyone doing slightly differently and half of us getting it
slightly wrong.
If I understand correctly, it would also then be incumbent on the container to ensure that a service
that depends on a scoped service is itself scoped. If it doesn't, that sounds like Weird
Things(tm) could happen. (As in my database example below.)
> 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.
So the way to do it would be to have a non-scoped factory layer that implements the Connection
interface (and a second one that implements the EntityManager interface) and passes all requests on
to a scope-sensitive instance. And the factory would need to be externalized so that it could check
the current scope itself? Something like:
class ConnectionProxy implements ConnectionInterface {
private WeakMap $conns;
public function __construct(private Closure $open) {}
public function executeQuery(string $q, array $args) {
$this->getScopedConn()->executeQuery($q, $args);
}
private function getScopedConn() {
return $this->conns[Scope::getCurrent()] ??= ($this->open)();
}
}
$conn = new ConnectionProxy(function () {
$domain = $_SERVER['domain'];
// Logic to pick the DB here.
return DriveManager::getConnection(blah);
});
Give or take the pool management you mentioned before.
This is what I'm concerned about. That looks tricky to get right. Eg, it looks like I totally
forgot to manually initialize the WeakMap in the constructor. But I suspect this pattern will be
common, not just for databases but also for any user-specific objects that get created, like a
connection to someone's Mastodon account or whatever. So this is a pattern we need to work to
make as fool-proof as possible, because we're all fools. :-)
Continuing my brainstorming from above:
class ConnectionProxy implements ConnectionInterface {
#[Scoped]
private Connection $conn;
public function __construct(private ConnectionMaker $open) {}
public function executeQuery(string $q, array $args) {
($this->conn ??= $this->open())->executeQuery($q, $args);
}
}
class ConnectionMaker {
public function __invoke() {
$domain = $_SERVER['domain'];
// Logic to pick the DB here.
return DriveManager::getConnection(blah);
}
}
That's still not ideal, but has notably fewer moving parts to screw up.
Though thinking about it, passing a PSR-7 request object to the ConnectionMaker would make it not
scope-sensitive; It would *have* to work through the superglobals, or some other global call to get
a request object. I'm seeing turtles all the way down here, which has me nervous.
As I said, still in favor of the concept, but I want to make sure we go down these rabbit holes to
ensure the turtles don't get too deep. (Boy that's an abused metaphor...)
--Larry Garfield