Re: [RFC] Context Managers
| From: | Larry Garfield | Date: | Wed, 22 Apr 2026 18:28:15 +0000 |
| Subject: | Re: [RFC] Context Managers | ||
| References: | 1 2 3 4 5 | Groups: | php.internals |
| Request: | Send a blank email to internals+get-130685@lists.php.net to get a copy of this message | ||
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