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