[PHP-DEV] [RFC] Context Managers

On Tue, Apr 14, 2026, at 16:18, Rob Landers wrote:

On Tue, Nov 4, 2025, at 21:13, Larry Garfield wrote:

Arnaud and I would like to present another RFC for consideration: Context Managers.

https://wiki.php.net/rfc/context-managers

You’ll probably note that is very similar to the recent proposal from Tim and Seifeddine. Both proposals grew out of casual discussion several months ago; I don’t believe either team was aware that the other was also actively working on such a proposal, so we now have two. C’est la vie. :slight_smile:

Naturally, Arnaud and I feel that our approach is the better one. In particular, as Arnaud noted in an earlier reply, __destruct() is unreliable if timing matters. It also does not allow differentiating between a success or failure exit condition, which for many use cases is absolutely mandatory (as shown in the examples in the context manager RFC).

The Context Manager proposal is a near direct port of Python’s approach, which is generally very well thought-out. However, there are a few open questions as listed in the RFC that we are seeking feedback on.

Discuss. :slight_smile:


Larry Garfield
larry@garfieldtech.com

Hi Larry/Arnaud,

This is a pretty exciting thread and fascinating proposal. That being said, I have a couple of subtle questions that don’t seem to be answered in the (very long) thread or the RFC itself – If I missed it, please let me know:

  1. What happens if a Fiber is suspended in the using block and never resumed? When is the using block released to clean up the context?
  2. There’s still no mention of how this should affect debugging, will we see the “desugared” or “sugared” version? Is that even a concern for the RFC?
  3. I will say it is weird to have exitContext return an exception; but what happens if an exception is thrown during exitContext? Why not just have it return void and throw if you need to throw instead of having two paths to the same thing?
  4. Looking at the desugared form … I’m a bit confused: if exitContext is called during the finally path and returns an exception, it is just swallowed? But if it is thrown, it won’t be?
  5. That being said, I don’t think the RFC shares with us when we should return an exception vs. throw an exception.

— Rob

Maybe the desugared version should look more like this?

} catch (\Throwable $e) {
    try {
        $__mgr->exitContext($e);
    } catch (\Throwable $cleanupException) {
        throw new ContextManagerException(
            $cleanupException->getMessage(),
            previous: $e
        );
    }
    throw $e;
}

— Rob

On Tue, Apr 14, 2026, at 8:35 AM, Tim Düsterhus wrote:

Larry,

On 4/7/26 18:20, Larry Garfield wrote:

the continue question (which is now a secondary vote)

I'm really struggling with finding appropriate words in my reply here.

It is not at all clear to me how both Rowan's and my replies could lead
to a conclusion of "neither approach is obviously better and both have
downsides" and the addition of a secondary vote, particularly one with
voting options using a "suggestive question" wording.

Secondary votes are for multiple equally-valid options that *directly*
relate to the RFC in question, not to have a backdoor to ship changes to
PHP with less than a 2/3 majority. The RFC policy is quite clear on
that:

Combining multiple proposals into one RFC MUST NOT be used to turn a
primary vote into a secondary vote.

In this case this secondary vote would (potentially; depending on the
result) break the expectation that exchanging `break` with `continue`
and vice versa will continue (pun not intended) to target the same
control structure and add more special cases to the languages. This is a
semantic change that is unrelated to context managers, and thus would be
something requiring a 2/3 vote - or better: Something that should just
not happen.

I find it extremely disrepectful to use loaded language like "quirk" to
refer previously established design decision that you don't understand
the reasons for or that you disagree with.

I similarly find it extremely disrespectful to presume malicious intent where there is none. The "loaded language" you refer to is in reference to a statement by Nikita Popov in the thread that Rowan previously linked to. Quoting him again:

In PHP "switch" is considered a looping structure, for this reason

"break" and "continue" both apply to "switch", as aliases.

That would certainly qualify as a "quirk" in my book, because that's kinda weird and unlike any other language. Now, if that description is not accurate (or was at the time and no longer is), that's a different question. I have indeed not checked that part of the engine; if you would like to provide data to show Nikita's description was/is wrong, I will happily accept it.

As far as it being a "backdoor" change to another feature... once again, you seem to be assuming subterfuge or malicious intent without evidence. The secondary vote was, specifically, "here's a new feature, `using`, how should this existing feature, `continue`, interact with it?" That is directly related to the RFC in question; it would be a meaningless question to ask outside of an RFC introducing `using`.

You have made your opinion on this feature quite clear. Others disagree. Accusing us of trying to sneak around the rules because we don't agree with your argument is completely uncalled for. If that part of the RFC is enough for you to vote against the RFC over, that is certainly your right, and I've certainly voted against RFCs I otherwise supported myself due to smaller design decisions. But that is not grounds to make the baseless accusations you're making here.

Prior to your post, Arnaud and I had already discussed it further and decided to revert back to the original RFC design, with `continue` mirroring `switch`'s behavior (do the same as `break` but trigger a Warning), based on Rowan's feedback. I dislike the idea of a new feature that includes a Warning out of the gate (which is why it's been an open question of discussion), but it seems to be the least-bad option. I'll be updating the RFC accordingly shortly. (This will eliminate the secondary vote.)

--Larry Garfield

And since we are inside a closure, we can assign this to a variable too:

···

Le 29/03/2026 à 13:14, Tim Düsterhus a écrit :

// …

I’m sorry that I’m arriving super late in the process, but I’ve subscribed to the mailing list only recently.

To give here my 2cents:

I think this RFC addresses something good (scoped code in the middle of a bigger chunk of code), but does it maybe a bit too bloated-ly.

(As a side-note, I think the keyword should be replaced for “scoped” instead of “using”, because “using” seems to refer mostly to the starting point of the code chunk, instead of the actual code inside it.)

TL;DR:

If this RFC is replaced with a “simpler” implementation relying only on Closures, I think this brings these advantages:

  • The engine already has everything for it, no need for new internal classes. Maybe a new boolean in ReflectionFunction like “isScopedBlock” that would indicate the closure was created from a “scoped” keyword, but so far I see no real use-case for this (yet, maybe you think about one case, feel free to tell)
  • No keyword misunderstandings (continue, break…)
  • Returns are possible by design, as well as return types
  • The closure is auto-destructed after execution, therefore freeing (or stacking for gc) the memory, as well as decreasing the number of references for objects if any parent-scope-created object variable was injected in the closure (so basically, refcount will be N before executing, N+1 during scope execution, and N again after scope finished executing), unless the returned variable contains references to the internal scope (which isn’t a good idea, and defeats the purpose of scoped code anyway).
  • Native support for try/catch blocks
  • Since you can specify return types, you can also handle Generators from inside the scope (but will need to return it into a variable, of course, but it’s still not a good idea, and an external function would be more appropriate, closure or not)

// end of TL;DR, here comes the explanations:

Let me explain:

The RFC proposes new classes and a new keyword, this keyword implies the existence of a sub-context as a “starter” etc etc, well, you read it in the RFC already, hopefully.

Meanwhile, PHP has had something quite cool for “scoping” some code execution, and it is even able to somehow prepare this scope before it is executed.

Of course, I mean Closures.

When I see this:

using (new Manager()) {
print “Hello world\n”;
}

// Will compile into approximately the equivalent of this:

$__mgr = new Manager();
$__closed = false;
$__mgr->enterContext();

try {
print “Hello world\n”;
} catch (\Throwable $e) {
$__closed = true;
$__ret = $__mgr->exitContext($e);
if ($__ret instanceof Throwable) {
throw $e;
}
} finally {
if (!$__closed) {
$__mgr->exitContext();
}
unset($__closed);
unset($__mgr);
}

I can’t help but think “This is too much”.

The Manager class comes here with lots of additional code, and even the “ContextManager” attribute proposed later adds even more code to the execution.

I think the same example could be fixed with a closure like this:

using {
print “Hello “.$world.”!\n”;
}

// Will compile into approximately the equivalent of this:

(function () use ($world) { print “Hello “.$world.”!\n”; })();

The advantage of using closures is that the engine already has everything needed for all possible use-cases, and the key word would mostly be syntactic sugar to create a directly-executed closure.

In the different use-cases presented here, I can already imagine a lot of things that would be simplified.

Here are some examples:

scoped {
echo “Hello world!”;
}
// Compiles into:
(function () {
echo “Hello world!”;
})();

scoped {
echo “Hello “.$world.”!”;
}
// Compiles into:
(function () use ($world) {
echo “Hello “.$world.”!”;
})();

$result = scoped: bool {
if (my_condition($input)) {
// do something.
return false;
}
return true;
}
// Compiles into:
$result = (function () use ($world): bool {
if (my_condition($input)) {
// do something.
return false;
}
return true;
})();

$iterator = scoped: Generator {
$h = fopen($sourceFile, ‘rb+’);
$headers = fgetcsv($h);
while ($row = fgetcsv($h)) {
yield array_combine($headers, $row);
}
fclose($h);
}
// Compiles into:
$iterator = (function () use ($sourceFile): bool {
$h = fopen($sourceFile, ‘rb+’);
$headers = fgetcsv($h);
while ($row = fgetcsv($h)) {
yield array_combine($headers, $row);
}
fclose($h);
})();

It already fixes the Exception/Throwable issue, because it’s “just” a closure, therefore one can surround it with try/catch:

try {
scoped {
some_task_that_might_throw();
}
} catch (Throwable $e) {
// Your stuff
}
// Compiles into:
try {
(function () {
some_task_that_might_throw();
})();
} catch (Throwable $e) {
// Your stuff
}

Maybe it’s even possible to “sugar” it to this:

try scoped {
some_task_that_might_throw();
} catch (Throwable $e) {
// Your stuff
}

(that would be great, actually)

And by design, it also fixes the other keywords that might not be exactly “intuitive” like “continue” or “break”: if some code is scoped, it means it should not know about its parent execution context, therefore neither “continue” nor “break” would have any effect on the parent contexts. And since that code is written in a closure, writing “continue” or “break” will throw a fatal error “break|continue not in the ‘loop’ or ‘switch’ context”, because it is scoped. Sure, this removes the ability to interact with the parent context, but IMO this is for the best: I can’t imagine how strange the code using this syntax will become over time, so preventing misuse might help understanding better the real use-cases.

One possible remaining thing that should be documented (because it’s more obvious for closures, but maybe less with a keyword that wraps a closure), is about shadowing variables.

This implies something like this:

$world = ‘world’;
scoped {
if ($world) {
$world = ‘shadowed’;
}
echo “Hello “.$world.”!”; // “Hello shadowed!”
}
echo “Hello “.$world.”!”; // “Hello world!”

Rust does this already with scoping blocks (some docs here: https://doc.rust-lang.org/rust-by-example/variable_bindings/scope.html )

However, this does not shadow object variables, because they are always passed by reference:

$parameters = new stdClass();
$parameters->key = ‘world’;
scoped {
$parameters->key = ‘shadowed’;
echo “Hello “.$parameters->key.”!”; // “Hello shadowed!”
}
echo “Hello “.$parameters->key.”!”; // “Hello shadowed!” <== “$parameters” is an object, therefore its reference was updated inside the scope.

Note: there could be a built-in feature in the “scoped” keyword that does copy-on-write for objects, to make sure any object updated in the scope is not updated outside of it, but this implies a whole lot of problems I don’t think anyone wants to be involved with. Basically: objects cloning. That’s for the people that might comment about it, and I think this should not be implemented, and the original behavior of closures should be kept as-is. It leaves less work on the engine (and the core devs), and is less prone to misunderstandings.

This can also nest scoped calls (even though it’s ugly, it’s an example):

$text = “world”;
scoped {
$text = “first scope”;
echo “Hello “.$text.”!”; // “Hello first scope!”
scoped {
$text = “second scope”;
echo “Hello “.$text.”!”; // “Hello second scope!”
scoped {
echo “Hello last scope!”;
}
}
echo “Hello again, “.$text.”!”; // “Hello again, first scope!”
}
echo “Hello “.$text.”!”; // “Hello world!”

// Compiles to this:

$text = “world”;
(function () use ($text) {
$text = “first scope”;
echo “Hello “.$text.”!”; // “Hello first scope!”
(function () use ($text) {
$text = “second scope”;
echo “Hello “.$text.”!”; // “Hello second scope!”
(function () {
echo “Hello last scope!”;
})();
})();
echo “Hello again, “.$text.”!”; // “Hello again, first scope!”
})();
echo “Hello “.$text.”!”; // “Hello world!”

And I’m even thinking about further usages similar to “move” or “async” blocks in Rust, like “scoped foreach”, “scoped while” or things like that:

scoped do {
$var = 1;
// …
} while (some_check());
// Variable “$var” does not exist here
// It would compile to this:
do {
(function () use ($variable) {
$var = 1;
// …
})();
} while (some_check());

// Or even:

scoped do {
if (isset($item)) {
// Do something
} else {
// Maybe throw?
}
} while ($item = some_check());
// Variable “$item” does not exist here
// It would compile to this:
scoped do {
(function () use ($item) {
if (isset($item)) {
// Do something
} else {
// Maybe throw?
}
})();
unset($item);
} while ($item = some_check());
// Variable “$item” does not exist here anymore

I could go on even more, but I think that, overall, the engine already has scoping capabilities thanks to Closures, and it wouldn’t need too many new features in the core to implement this :slight_smile:

On Tue, Apr 14, 2026, at 9:27 AM, Rob Landers wrote:

On Tue, Apr 14, 2026, at 16:18, Rob Landers wrote:

On Tue, Nov 4, 2025, at 21:13, Larry Garfield wrote:

Arnaud and I would like to present another RFC for consideration: Context Managers.

PHP: rfc:context-managers

You'll probably note that is very similar to the recent proposal from Tim and Seifeddine. Both proposals grew out of casual discussion several months ago; I don't believe either team was aware that the other was also actively working on such a proposal, so we now have two. C'est la vie. :slight_smile:

Naturally, Arnaud and I feel that our approach is the better one. In particular, as Arnaud noted in an earlier reply, __destruct() is unreliable if timing matters. It also does not allow differentiating between a success or failure exit condition, which for many use cases is absolutely mandatory (as shown in the examples in the context manager RFC).

The Context Manager proposal is a near direct port of Python's approach, which is generally very well thought-out. However, there are a few open questions as listed in the RFC that we are seeking feedback on.

Discuss. :slight_smile:

--
  Larry Garfield
  larry@garfieldtech.com

Hi Larry/Arnaud,

This is a pretty exciting thread and fascinating proposal. That being said, I have a couple of subtle questions that don't seem to be answered in the (very long) thread or the RFC itself -- If I missed it, please let me know:

1. What happens if a Fiber is suspended in the using block and never resumed? When is the using block released to clean up the context?

Since it decomposes to a try-catch-finally, it will exit whenever the finally block would have run if you'd just typed out try-catch-finally yourself. Arnaud checked, and confirmed that when the fiber is destroyed the using block will exit in a success case (ie, exitContext(null)).

2. There's still no mention of how this should affect debugging, will we see the "desugared" or "sugared" version? Is that even a concern for the RFC?

Error messages would see the original code, so "error on line X" would be based on the original `using` block. That's the same as any other desugaring we already do. (PIpes, PFA, constructor promotion, etc.) Debuggers will see the materialized opcodes, again, the same other desugaring cases.

3. I will say it is weird to have exitContext return an exception; but what happens if an exception is thrown during exitContext? Why not just have it return void and throw if you need to throw instead of having two paths to the same thing?

There's a subtle but important difference here: An exception passed through exitContext() is the original exception from lower in the call stack, and its backtrace will be the original location of the error. An exception thrown from within exitContext() itself indicates a failure that the Context Manager is responsible for, usually an error in the exitContext() logic itself.

Technically a Context Manager can wrap-and-rethrow the exception if it wants, but then it is "claiming ownership" over it, just like in any other case of wrap-and-rethrow.

Our expectation is that 90% of the time, "let the exception propagate up unimpeded" is the desired behavior. This approach makes "return $e" the right thing to do almost-always, which is nice and simple to remember.

See the "return values and exception handling" section for a discussion of this in more detail. As I said in a previous reply, our constraints are different than Python's so we end up with a different solution. If you have a suggestion for an alternate approach to the problem, we're happy to listen.

4. Looking at the desugared form ... I'm a bit confused: if exitContext is called during the finally path and returns an exception, it is just swallowed? But if it is thrown, it won't be?

The finally path is only reached in case of a successful exit. Therefore there is no exception to pass in, and thus returning an exception is meaningless. If exitContext() throws a new exception of its own (which would indicate an error in its own logic), that will just bubble up past the `using` block entirely, which is what we want.

5. That being said, I don't think the RFC shares with us when we should return an exception vs. throw an exception.

See the "return values and exception handling" section. If something there isn't clear, let me know and I will try to clarify further.

— Rob

Maybe the desugared version should look more like this?

} catch (\Throwable $e) {
    try {
        $__mgr->exitContext($e);
    } catch (\Throwable $cleanupException) {
        throw new ContextManagerException(
            $cleanupException->getMessage(),
            previous: $e
        );
    }
    throw $e;
}

I'm not sure I see a reason to force any new exceptions to be only of the ContextManagerException type. If there's a TypeError inside exitContext() or something, I'd expect that to be propagated as a TypeError.

--Larry Garfield

On Tue, Apr 14, 2026, at 3:42 PM, Alex Rock wrote:

Le 29/03/2026 à 13:14, Tim Düsterhus a écrit :

// ...

I'm sorry that I'm arriving super late in the process, but I've
subscribed to the mailing list only recently.

To give here my 2cents:

I think this RFC addresses something good (scoped code in the middle of
a bigger chunk of code), but does it maybe a bit too bloated-ly.

Hi Alex.

I think you're missing the point of this construct. What you're describing is much closer to the recently-declined `let` scoping keyword (PHP: rfc:optin_block_scoping).

The entire point of context managers is to abstract away and make reusable the try-catch-finally logic. That logic needs to live somewhere so that it can be reused. That is what the ContextManager object is for. It's not a complication; it's the entire purpose of the RFC.

Since you just recently joined the list you would have missed the earlier discussion in this thread of closures. (See [RFC] Context Managers - Externals). In short, closures are what people use today for this use case, but they're a PITA because they don't support auto-capture (unless you're using the single-line version). That makes them less common, because they're a pain to use in this case. In addition, they do create a new scope, which... is not what we want in this case. Not creating a new scope is a feature, not a bug.

Also, this email serves as notice that I've updated the RFC again to make continue an alias of break, as it was originally.

--Larry Garfield

Hi

Am 2026-04-14 20:18, schrieb Larry Garfield:

I similarly find it extremely disrespectful to presume malicious intent where there is none.

To provide context as to what happened from my point of view: Both Rowan and I explained why "continue == break" is important for language consistency, with Rowan even doing the research in the list archives to provide the explanation that your email (php.internals: Re: [RFC] Context Managers) claimed was "lost to history". Neither of us received any further reply on-list, until the generic "we updated the RFC" roughly a week after. Looking at the changes in the RFC then reveals that the "historic" explanation had been taken out of context (see below) and a secondary vote with misleading voting options had been added.

The only possible explanations I could find for that outcome were "lack of diligence", "misunderstanding", and "intent". Given this RFC has two authors, I assumed that any changes to the RFC would have been cross-checked by both authors, which I would expect to catch accidental mistakes arising from a lack of diligence or a misunderstanding (e.g. due to language barriers), which then left "intent".

If this assumption of "intent" is incorrect and it's one of the other options instead, I apologize for drawing this incorrect conclusion. But I find it problematic that this kind of oversight makes it into 2-author RFC.

The "loaded language" you refer to is in reference to a statement by Nikita Popov in the thread that Rowan previously linked to. Quoting him again:

In PHP "switch" is considered a looping structure, for this reason

"break" and "continue" both apply to "switch", as aliases.

That is correct, but it is taken out of context. The rest of the paragraph that immediately follows that quote is explaining why "switch is considered a looping structure". Let me include it here:

[...] For PHP, these are reasonable semantics, as PHP supports multi-level breaks. It would be very questionable if "break N" and "continue N" could refer to different loop structures just because there is a "switch" involved somewhere.

And other emails in that discussion thread, including Rowan's own (which he linked in his previous reply), agreed with that. Given that the discussion back then resulted in a change to the language (even if not via a formal accepted RFC), I believe it is a reasonable assertion that "break == continue" is a intentional part of PHP's language design that cannot be disregarded in passing.

That would certainly qualify as a "quirk" in my book, because that's kinda weird and unlike any other language. Now, if that description is not accurate (or was at the time and no longer is), that's a different question. I have indeed not checked that part of the engine; if you would like to provide data to show Nikita's description was/is wrong, I will happily accept it.

I refer to Rowan's email here: php.internals: Re: [RFC] Context Managers. In any case "quirk" or similar (negatively) connotated words are inappropriate for use in an RFC, which is a document that should accurately and unambiguously describe a change that is proposed to be made to PHP (or the PHP project's governance in case of policy RFCs). Readers should be able to make their own judgement based on the stated facts and well-justified decisions. A little more "flowery" language to describe the high-level goals of the RFC can be okay - particularly in the "Introduction" and "Examples" sections, which are intended to showcase what the RFC will enable. But deep in the semantics section of an RFC precise language is important for readers to build an informed decision.

The secondary vote was, specifically, "here's a new feature, `using`, how should this existing feature, `continue`, interact with it?" That is directly related to the RFC in question; it would be a meaningless question to ask outside of an RFC introducing `using`.

I don't think it is reasonable to consider "continue" and "break" to be two separate features given how closely they are related, both in PHP and in other programming language. Both in PHP's implementation and in PHP's documentation. Tutorials introducing "break" are usually introducing "continue" at the same time. And following from that, the secondary vote would affect the existing design of an unrelated feature.

You have made your opinion on this feature quite clear.

I believe that sufficient references have been provided that allow me to reasonably say that this is not just an "opinion". For the related "should break target using() in the first place" question this is something one can have an "opinion" on - one that I personally disagree with. The difference is that neither choice there breaks long-standing user expectations with regard to the language's behavior, which makes them equally valid.

This will eliminate the secondary vote.

Thank you.

Best regards
Tim Düsterhus

On Wed, Apr 15, 2026, at 16:52, Larry Garfield wrote:

On Tue, Apr 14, 2026, at 9:27 AM, Rob Landers wrote:

On Tue, Apr 14, 2026, at 16:18, Rob Landers wrote:

On Tue, Nov 4, 2025, at 21:13, Larry Garfield wrote:

Arnaud and I would like to present another RFC for consideration: Context Managers.

https://wiki.php.net/rfc/context-managers

You’ll probably note that is very similar to the recent proposal from Tim and Seifeddine. Both proposals grew out of casual discussion several months ago; I don’t believe either team was aware that the other was also actively working on such a proposal, so we now have two. C’est la vie. :slight_smile:

Naturally, Arnaud and I feel that our approach is the better one. In particular, as Arnaud noted in an earlier reply, __destruct() is unreliable if timing matters. It also does not allow differentiating between a success or failure exit condition, which for many use cases is absolutely mandatory (as shown in the examples in the context manager RFC).

The Context Manager proposal is a near direct port of Python’s approach, which is generally very well thought-out. However, there are a few open questions as listed in the RFC that we are seeking feedback on.

Discuss. :slight_smile:


Larry Garfield
larry@garfieldtech.com

Hi Larry/Arnaud,

This is a pretty exciting thread and fascinating proposal. That being said, I have a couple of subtle questions that don’t seem to be answered in the (very long) thread or the RFC itself – If I missed it, please let me know:

  1. What happens if a Fiber is suspended in the using block and never resumed? When is the using block released to clean up the context?

Since it decomposes to a try-catch-finally, it will exit whenever the finally block would have run if you’d just typed out try-catch-finally yourself. Arnaud checked, and confirmed that when the fiber is destroyed the using block will exit in a success case (ie, exitContext(null)).

  1. There’s still no mention of how this should affect debugging, will we see the “desugared” or “sugared” version? Is that even a concern for the RFC?

Error messages would see the original code, so “error on line X” would be based on the original using block. That’s the same as any other desugaring we already do. (PIpes, PFA, constructor promotion, etc.) Debuggers will see the materialized opcodes, again, the same other desugaring cases.

  1. I will say it is weird to have exitContext return an exception; but what happens if an exception is thrown during exitContext? Why not just have it return void and throw if you need to throw instead of having two paths to the same thing?

There’s a subtle but important difference here: An exception passed through exitContext() is the original exception from lower in the call stack, and its backtrace will be the original location of the error. An exception thrown from within exitContext() itself indicates a failure that the Context Manager is responsible for, usually an error in the exitContext() logic itself.

Technically a Context Manager can wrap-and-rethrow the exception if it wants, but then it is “claiming ownership” over it, just like in any other case of wrap-and-rethrow.

Our expectation is that 90% of the time, “let the exception propagate up unimpeded” is the desired behavior. This approach makes “return $e” the right thing to do almost-always, which is nice and simple to remember.

See the “return values and exception handling” section for a discussion of this in more detail. As I said in a previous reply, our constraints are different than Python’s so we end up with a different solution. If you have a suggestion for an alternate approach to the problem, we’re happy to listen.

  1. Looking at the desugared form … I’m a bit confused: if exitContext is called during the finally path and returns an exception, it is just swallowed? But if it is thrown, it won’t be?

The finally path is only reached in case of a successful exit. Therefore there is no exception to pass in, and thus returning an exception is meaningless. If exitContext() throws a new exception of its own (which would indicate an error in its own logic), that will just bubble up past the using block entirely, which is what we want.

  1. That being said, I don’t think the RFC shares with us when we should return an exception vs. throw an exception.

See the “return values and exception handling” section. If something there isn’t clear, let me know and I will try to clarify further.

— Rob

Maybe the desugared version should look more like this?

} catch (\Throwable $e) {
try {
$__mgr->exitContext($e);
} catch (\Throwable $cleanupException) {
throw new ContextManagerException(
$cleanupException->getMessage(),
previous: $e
);
}
throw $e;
}

I’m not sure I see a reason to force any new exceptions to be only of the ContextManagerException type. If there’s a TypeError inside exitContext() or something, I’d expect that to be propagated as a TypeError.

–Larry Garfield

Thanks Larry, this clears things up for me. The example I had in mind is distinguishing between errors in a transaction:

class DatabaseTransaction implements ContextManager
{
    public function __construct(private PDO $connection) {}

    public function enterContext(): PDO
    {
        $this->connection->beginTransaction();
        return $this->connection;
    }

    public function exitContext(?\Throwable $e = null): ?\Throwable
    {
        if ($e) {
            $this->connection->rollback(); // PDO throws: server has gone away
        } else {
            $this->connection->commit();
        }
        return $e;
    }
}

// Application code:
using ($db->transaction() => $conn) {
    $conn->execute('INSERT INTO orders ...'); // throws ValidationException
}

The application will see the PDOException about the server going away, but the original ValidationException that caused the rollback attempt is lost entirely. My point with my suggestion wasn’t to hide an exception behind a specific type, but to use exception chaining native to PHP to preserve both independent failures in a way that an application can understand what actually happened.

In other words, as a developer, all I’d see is a rollback failed due to a server disconnection. I’d be missing what caused the rollback in the first place.

I’d be fine if the desugared catch path simply attached the original exception as $previous on whatever escapes exitContext(), so the root cause isn’t lost. That’s a one-line change in the engine: just set the previous property at the bottom of the cleanup exception’s chain before rethrowing.

— Rob

Le 15/04/2026 à 17:10, Larry Garfield a écrit :

I think you're missing the point of this construct. What you're describing is much closer to the recently-declined `let` scoping keyword (PHP: rfc:optin_block_scoping).

Yep, I would have been much more okay with a revised version of the `let` scoping keyword, though the implementation also seems quite complex too.

The entire point of context managers is to abstract away and make reusable the try-catch-finally logic. That logic needs to live somewhere so that it can be reused. That is what the ContextManager object is for. It's not a complication; it's the entire purpose of the RFC.

Since you just recently joined the list you would have missed the earlier discussion in this thread of closures. (Seehttps://externals.io/message/129077).

I have read a few of the messages on externals.io, maybe not everything, but your answer indeed makes things a bit clearer. It mostly makes me think that this RFC is a bit complex and I don't see /many/ userland use-cases that would actually need this.

IMO, it tries to /make some safeguards/ implicit, like closing resources. I recognize that it's useful for this case, but does PHP really need a new feature this big "just" to close resources 10 milliseconds earlier in a process that takes 100ms anyway? The benefits would only go to projects with huge amount of concurrent calls, which are not all of them.

In short, closures are what people use today for this use case, but they're a PITA because they don't support auto-capture (unless you're using the single-line version). That makes them less common, because they're a pain to use in this case. In addition, they do create a new scope, which... is not what we want in this case. Not creating a new scope is a feature, not a bug.

No auto-capture in closures is IMO an actual *good* thing, because it avoids having issues with the current world of JS/TS where auto-capture is default, and it brings problems with references. My suggestion with the `scoped` keyword also carries auto-capture by design, which makes the "capturing system" (shadowing, references, etc.) less intuitive in the first place. It's "yet another thing to keep in mind" at first, but for legacy apps renovators like me, it's mostly "they haven't kept this in mind" debugging hell ^^', but considering there's always worse, it's negligible, especially if it's intended to be a new feature.

Still: creating a new scope makes things safer. It creates a new stack with its own pointers, and frees it at finish. They are a pain to use for now mostly because there's more code to write, it needs to be called (or auto-called with `()` after it), and capture must be explicit via `use` . The `scoped` keyword proposal automates all this with existing PHP features, while the ContextManager adds new features for that, and we don't know (yet) the overhead.

Sorry for being this picky :slight_smile:

On Wed, Apr 15, 2026, at 11:39 AM, Alex Rock wrote:

I have read a few of the messages on externals.io, maybe not
everything, but your answer indeed makes things a bit clearer. It
mostly makes me think that this RFC is a bit complex and I don't see
*many* userland use-cases that would actually need this.

The RFC lists several. It's also all over Python code and has been for years. Though to be sure, people writing `using` blocks will outnumber those writing custom context managers 100:1, I imagine. That's fine.

IMO, it tries to *make some safeguards* implicit, like closing
resources. I recognize that it's useful for this case, but does PHP
really need a new feature this big "just" to close resources 10
milliseconds earlier in a process that takes 100ms anyway? The benefits
would only go to projects with huge amount of concurrent calls, which
are not all of them.

"Big" seems like a fairly subjective word to use here. It's just an interface and some desugaring. Compared to many of the changes already added just in this release, this is a small impact RFC. :slight_smile:

And we know it's overhead: It's a couple of instructions that you would have written manually anyway, plus 2 method calls, which you may have had anyway. So, negligible in practice.

No auto-capture in closures is IMO an actual *good* thing, because it
avoids having issues with the current world of JS/TS where auto-capture
is default, and it brings problems with references.

Almost every language has closures with capture now. Only two require explicitly listing values to capture: PHP and C++. Everyone else figured out how to do it safely. The problem with JS is that it's by ref capture.

But that's off topic, as we're discussing a feature that doesn't impact closures at all. If you want to make a separate proposal that leverages closures, you're welcome to do so, but that's not what this RFC is about.

--Larry Garfield

Hi

Am 2026-04-15 16:52, schrieb Larry Garfield:

The finally path is only reached in case of a successful exit. Therefore there is no exception to pass in, and thus returning an exception is meaningless. […]

That's not at all how I understood the RFC text (and I admittedly didn't look at the desugaring):

If exitContext() returns a throwable (either a new one or the one it was passed), it will be rethrown.

I understood the “it” in “it will be rethrown” as “the returned Throwable is thrown”, not “the Throwable that was caught is thrown”. That is also what is mentioned in your email php.internals: Re: [RFC] Context Managers

or a throwable, which will then get thrown

and

If there's a reason to wrap and rethrow the exception, do that and return the new exception

And that is what the second half of my email php.internals: Re: [RFC] Context Managers is based on, particularly the joke in the footnote. Why has this misunderstanding not been pointed out back then?

------------

So based on the desugaring that Rob thankfully looked at, of all the information in a `?\Throwable` return type, only a single bit is used - and only in some cases. I don't see how we can meaningfully explain to users that throwing away all the other information is the expected behavior.

Best regards
Tim Düsterhus

On 15 April 2026 15:52:17 BST, Larry Garfield <larry@garfieldtech.com> wrote:

3. I will say it is weird to have exitContext return an exception; but what happens if an exception is thrown during exitContext? Why not just have it return void and throw if you need to throw instead of having two paths to the same thing?

There's a subtle but important difference here: An exception passed through exitContext() is the original exception from lower in the call stack, and its backtrace will be the original location of the error. An exception thrown from within exitContext() itself indicates a failure that the Context Manager is responsible for, usually an error in the exitContext() logic itself.

PHP collects traces when exceptions are constructed, not when they're thrown, so this is a distinction without a difference. From the outside, it's impossible to tell the difference between "return $e;" and "throw $e;".

That means you have the following options:

- throw the passed exception unchanged
- return the passed exception, which is equivalent to throwing it
- throw a new exception, or fail to catch an exception in the cleanup logic, which as Rob points out will hide the passed exception unless you remember to attach it as $previous
- return a new exception, which according to the current RFC text will be completely ignored (is "throw $e" supposed to say "throw $__ret"?)

It does seem like it would be more straightforward to have the return value be "void", and leave it to the implementation to throw or not.

In practice, as you say, "throw unchanged" will be common, but unless that's the behaviour of a *null* return (i.e. the default if not opted out), "throw $e;" seems the natural boilerplate for that.

Regards,

Rowan Tommins
[IMSoP]

On Tue, Apr 21, 2026, at 7:16 AM, Rowan Tommins [IMSoP] wrote:

On 15 April 2026 15:52:17 BST, Larry Garfield <larry@garfieldtech.com> wrote:

3. I will say it is weird to have exitContext return an exception; but what happens if an exception is thrown during exitContext? Why not just have it return void and throw if you need to throw instead of having two paths to the same thing?

There's a subtle but important difference here: An exception passed through exitContext() is the original exception from lower in the call stack, and its backtrace will be the original location of the error. An exception thrown from within exitContext() itself indicates a failure that the Context Manager is responsible for, usually an error in the exitContext() logic itself.

PHP collects traces when exceptions are constructed, not when they're
thrown, so this is a distinction without a difference. From the
outside, it's impossible to tell the difference between "return $e;"
and "throw $e;".

That means you have the following options:

- throw the passed exception unchanged
- return the passed exception, which is equivalent to throwing it
- throw a new exception, or fail to catch an exception in the cleanup
logic, which as Rob points out will hide the passed exception unless
you remember to attach it as $previous
- return a new exception, which according to the current RFC text will
be completely ignored (is "throw $e" supposed to say "throw $__ret"?)

It does seem like it would be more straightforward to have the return
value be "void", and leave it to the implementation to throw or not.

In practice, as you say, "throw unchanged" will be common, but unless
that's the behaviour of a *null* return (i.e. the default if not opted
out), "throw $e;" seems the natural boilerplate for that.

Since this seems like a contentious area, let me go back to first principles and try to explore the problem space. (This is an essay; meaning I don't know where it's going to end up yet as I write this.)

## The problem space

A context manager may exit in one of two conditions: Success or Failure.

There are three types of code that a CM may want to run, but not always all of them:

* Code that happens on success.
* Code that happens on failure.
* Code that happens on both.

In the case of Success, there is no value to propagate.

In the case of Failure, there is a value (exception) to conditionally propagate, but the default case should be propagate.

The CM may have its own error, in which case it will want to propagate that error (as a throwable).

Would a CM ever want to wrap-and-rethrow a lower-level exception, rather than just passing it on itself? I suppose it's possible, though I'm not sure of a specific example off hand.

## Python's answer

The Python answer is a single `__exit__` method, which may be passed optional exception information. In Python, not returning anything is equivalent to returning null, which is falsy, so "return true to stop propagation or do nothing to continue propagation" is reasonable and ergonomic. That is not the case in PHP, however; a function with a typed return MUST have a `return` statement in it, and it's not immediately obvious to a user what true-vs-false will do. (Does true mean "yes propagate" or "yes suppress"?) It also makes the return value meaningless in the success case, which is not immediately obvious. So mimicking Pythong in this case is not a viable approach.

## Basic structure

In PHP, we could have the three different code paths in one, two, or three methods.

The three method approach would be something like:

public function contextSuccess() {
  // Do stuff only on a success case.
}

public function contextFail(Throwable $e): bool {
  // Do stuff only on a failure case, you must return a value.
}

public function contextExit() {
  // Do stuff in any case.
}

That approach has 2 problems: One, contextExit() must then be called either before or after the success/fail callback, always, which may not support the desired cleanup process. Two, if all three are on the CM interface then they all must be implemented, even if there's nothing for them to do. That's bad ergonomics. (Side note: interface-default-methods would help a ton here.)

The second point could be resolved by making them all magic methods rather than an interface, but that brings with it all the lack-of-introspection challenges of magic methods.

The two method approach would be:

public function contextSuccess() {
  // Do stuff only on a success case.
  // Do common stuff here.
}

public function contextFail(Throwable $e): bool {
  // Do stuff only on a failure case, you must return a value.
  // Do common stuff here, redundantly.
}

Or alternatively, call out to a separate common method from both. This would probably work, but again has two problems: One, it makes common actions harder to do, as it requires either redundancy or another method (which may not always be viable in context). Two, the same "must define both of them even if you don't care" problem exists. (Again, interface-default-methods would solve this.)

The single method approach is what the RFC currently proposes:

public function contextExit(?Throwable $e) {
  // Do whatever you want, in whatever order, and if ($e === null) to differentiate success/failure.
}

This approach resolves both issues of the previous models, but creates one more: the exit condition (return, throw, etc.) from this method is quite complex:

- In a Success case, there is no exit condition (return and continue)
- In a Failure case, there is a binary exit condition (propagate or suppress)
- In the case contextExit() itself has a failure, it would need to throw its own exception, which implies suppressing the original.

I do believe the single-method approach is the least-bad, if we can resolve the exit condition question.

## Ways of handling a single method's returns

Possible ways to do so off the top of my head, in no particular order:

- Return True to suppress, return False to propagate, return Null on success, throw on CM error. (This is what earlier versions of the RFC had.)

Pro: Clear delineation for each pathway.
Con: Needlessly complex in practice and not self-documenting. Doesn't differentiate between CM exception and underlying exception.

public function exitContext(?Throwable $e): ?bool {
  if ($e) { 
    // Error cleanup
  } else {
    // Success cleanup
  }

  // Oops, something went wrong.
  throw CMException();

  // Common cleanup

  return $e === null;
}

- Same as previous, but use enums.

Pro: more self-documenting.
Con: Still needlessly complex, now more verbose, too! Doesn't differentiate between CM exceptions and underlying exceptions. Returning one of the Failure case values on a Success case would, uh, just ignore it? That's not great.

public function exitContext(?Throwable $e): ?bool {
  if ($e) { 
    // Error cleanup
  } else {
    // Success cleanup
  }

  // Oops, something went wrong.
  throw CMException();

  // Common cleanup

  if ($e) { 
   if (something) {
      return CMResult::Propagate;
    }
    return CMResult::Suppress;
  } else {
    return CMResult::Success;
  }
}

- Return an exception to cause it to throw, or null to not throw. This folds the return value into a single line in most cases. (This is what the RFC says right now.)

Pro: Ergonomically very convenient.
Con: `return $e` and `throw $e` become effectively the same thing, so it's not clear when you'd use one or the other. Doesn't differentiate between CM exception and underlying exception.

public function exitContext(?Throwable $e): ?Throwable {
  if ($e) { 
    // Error cleanup
  } else {
    // Success cleanup
  }

  // Oops, something went wrong.
  throw CMException();

  // Common cleanup

   return $e;
}

- As Rowan suggested, void return, only propagate on throw.

Pro: Folds different pathways together in a natural way.
Con: The most common case (propagate exception) is the one that requires additional work, not the rare case (not propagating), so I can see it being very common for people to forget to rethrow. Doesn't differentiate between CM exception and underlying exception.

public function exitContext(?Throwable $e): void {
  if ($e) { 
    // Error cleanup
    throw $e;
  } else {
    // Success cleanup
  }

  // Oops, something went wrong.
  throw CMException();

  // Common cleanup
}

- Follow event-dispatcher patterns, like PSR-14, and call a built-in method to prevent propagation.

Pro: In the typical case where you want to allow propagation, there's literally nothing to do. That makes the common case very ergonomic.
Con: This would necessitate either a ContextManager base class instead of interface, or some black magic where adding the interface magically adds this method. (Side note: interface-default-methods would probably help here.)

public function exitContext(?Throwable $e): void {
  if ($e) { 
    // Error cleanup
    throw $e;
  } else {
    // Success cleanup
  }

  // Oops, something went wrong.
  throw CMException();

  // Common cleanup

  // $e will get propagated unless this is called.
  $this->stopPropagation();
}

- Totally wild thought: throw a special "don't throw anything else" exception, which gets special handling.

Pro: In the typical case where you want to allow propagation, there's literally nothing to do. That makes the common case very ergonomic.
Con: Throwing an exception to prevent an exception from being thrown is just... weird.

public function exitContext(?Throwable $e): void {
  if ($e) { 
    // Error cleanup
  } else {
    // Success cleanup
  }

  // Oops, something went wrong.
  throw CMException();

  // Common cleanup
  
  // If this line is missing, $e gets rethrown.
  throw new StopPropagationException();
}

None of these allow differentiating at runtime between a CM error and an underlying error. In most it could be differentiated in static analysis, but not at runtime. Whether or not that is a problem is, I think, an open question.

The Python PEP for context managers suggests that if one cares, it's possible to avoid `using` and call it manually, allowing for that differentiation. If we use the `return $e` approach, a manual/higher-order CM could make the differentiation by calling the CM methods itself, rather than relying on `using`.

try {
  $cm = new SomeCM();
  $cv = $cm->enterContext();
  
  // Do code here.
  
  $e = $cm->exitContext($e);
  if ($e !== null) {
      // body failed, exit success
  }
} catch (\Throwable $e) {
  // body failed, exit failed too
}

However, the whole point of `using` blocks is to not need to do that, so if it's a common need, that would be highly sub-optimal. It also wouldn't be available in the other approaches.

## Conclusion

I think a key question to answer here is: Do we care to differentiate between CM errors and underlying errors? If not being able to do so is a non-issue, or small enough that we don't care, then we have more options. I'm not sure which of the last 3 I like most/dislike least: "always rethrow", "call method to suppress", "throw special to suppress". They all have pros and cons.

If we do care, then we may need to adapt the materialized code and expand the syntax in some way to allow for it. I'm not sure yet what that would look like.

I will stop here, however, and ask for input from the audience. (Not just the regulars in this thread of late, but all of you reading this.) Including if you have an alternate approach to the three listed above that would have notably fewer cons.

--Larry Garfield

Hi

Am 2026-04-22 20:28, schrieb Larry Garfield:

## Ways of handling a single method's returns

Possible ways to do so off the top of my head, in no particular order:

I don't currently have the time to digest this email in detail, but I wanted to mention an alternative you didn't before I forget: Making the exception an in-out parameter. That would be functionally similar to a return value, but more strongly default to “don't make a change”, because “doing nothing” will just work.

i.e.

     public function exitContext(?\Throwable &$e): void {
         // Assign null to suppress.
         $e = null;
     }

Best regards
Tim Düsterhus

On Wed, Apr 22, 2026, at 20:28, Larry Garfield wrote:

I think a key question to answer here is: Do we care to differentiate between CM errors and underlying errors?

I don’t think we need to differentiate at the CM level. Suppression is a policy decision that belongs to the caller, not the context manager. The CM’s job is cleanup: rollback the transaction, close the file, release the lock, etc. Whether the exception continues propagating after that is the caller’s call, and try using already provides exactly that:

try using ($db->transaction() => $conn) {
    $conn->execute('INSERT INTO orders ...');
} catch (ValidationException $e) {
    // Caller chooses to suppress this here
}

That makes exitContext() simple: return void, do your cleanup, and get out. If cleanup fails, it throws naturally, and the desugared form can chain the original exception as $previous so the root cause isn’t lost. If the caller wants to suppress or differentiate, they already have try using for exactly that.

It’s worth noting that every example in the RFC (database transactions, file locks, error handler swaps, async scopes) does cleanup and propagates. None of them actually need the power to suppress. If a context manager wants to give callers a clean exception hierarchy to catch against, it can wrap underlying exceptions in their own types during cleanup. That’s just normal exception design, no special syntax required.

— Rob

On Wed, Apr 29, 2026, at 9:30 AM, Rob Landers wrote:

On Wed, Apr 22, 2026, at 20:28, Larry Garfield wrote:

I think a key question to answer here is: Do we care to differentiate between CM errors and underlying errors?

I don't think we need to differentiate at the CM level. Suppression is
a policy decision that belongs to the caller, not the context manager.
The CM's job is cleanup: rollback the transaction, close the file,
release the lock, etc. Whether the exception continues propagating
after that is the caller's call, and `try using` already provides
exactly that:

try using ($db->transaction() => $conn) {
    $conn->execute('INSERT INTO orders ...');
} catch (ValidationException $e) {
    // Caller chooses to suppress this here
}

That makes `exitContext()` simple: return void, do your cleanup, and
get out. If cleanup fails, it throws naturally, and the desugared form
can chain the original exception as `$previous` so the root cause isn't
lost. If the caller wants to suppress or differentiate, they already
have `try using` for exactly that.

It's worth noting that every example in the RFC (database transactions,
file locks, error handler swaps, async scopes) does cleanup and
propagates. None of them actually need the power to suppress. If a
context manager wants to give callers a clean exception hierarchy to
catch against, it can wrap underlying exceptions in their own types
during cleanup. That's just normal exception design, no special syntax
required.

— Rob

Just to make sure I'm following you, you're arguing that a CM should not have any way at all to suppress an exception? I don't think I'd agree with that, personally. Even if it's a rare case, I do believe it's a feature that should remain.

--Larry Garfield

On Wed, Apr 29, 2026, at 17:04, Larry Garfield wrote:

On Wed, Apr 29, 2026, at 9:30 AM, Rob Landers wrote:

On Wed, Apr 22, 2026, at 20:28, Larry Garfield wrote:

I think a key question to answer here is: Do we care to differentiate between CM errors and underlying errors?

I don’t think we need to differentiate at the CM level. Suppression is
a policy decision that belongs to the caller, not the context manager.
The CM’s job is cleanup: rollback the transaction, close the file,
release the lock, etc. Whether the exception continues propagating
after that is the caller’s call, and try using already provides
exactly that:

try using ($db->transaction() => $conn) {
$conn->execute(‘INSERT INTO orders …’);
} catch (ValidationException $e) {
// Caller chooses to suppress this here
}

That makes exitContext() simple: return void, do your cleanup, and
get out. If cleanup fails, it throws naturally, and the desugared form
can chain the original exception as $previous so the root cause isn’t
lost. If the caller wants to suppress or differentiate, they already
have try using for exactly that.

It’s worth noting that every example in the RFC (database transactions,
file locks, error handler swaps, async scopes) does cleanup and
propagates. None of them actually need the power to suppress. If a
context manager wants to give callers a clean exception hierarchy to
catch against, it can wrap underlying exceptions in their own types
during cleanup. That’s just normal exception design, no special syntax
required.

— Rob

Just to make sure I’m following you, you’re arguing that a CM should not have any way at all to suppress an exception? I don’t think I’d agree with that, personally. Even if it’s a rare case, I do believe it’s a feature that should remain.

I’d argue CMs are mechanisms, not policies. They encode setup and teardown. Suppression is an error-handling policy decision that should be visible at the call site, not mixed with the concerns of setting up and tearing down resources.

If CM’s could suppress arbitrary exceptions, from a developer stepping over the code, they’d see the code randomly appear to jump out of the using block after a suppressed exception … at seemingly arbitrary points. There would be no way to trust what you were reading without having the CM’s code in front of you as well.

— Rob

Hi

Am 2026-04-30 09:42, schrieb Rob Landers:

I'd argue CMs are mechanisms, not policies. They encode setup and teardown. Suppression is an error-handling policy decision that should be visible at the call site, not mixed with the concerns of setting up and tearing down resources.

I agree here and noted something similar in the “RAII vs Context Manager” thread: php.internals: Re: Examples comparing Block Scoped RAII and Context Managers (last paragraph).

Best regards
Tim Düsterhus