On Tue, Apr 21, 2026, at 7:16 AM, Rowan Tommins [IMSoP] wrote:
> On 15 April 2026 15:52:17 BST, Larry Garfield <[email protected]> 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

Reply via email to