Table of Contents

PHP RFC: Context Managers

Introduction

Context Managers are a concept borrowed from Python. They provide a way to abstract out common control-flow and variable-lifetime-management patterns, leading to vastly simplified “business logic” code that can skip over a great deal of boilerplate. They place certain guards around a “context” (block), such that certain behaviors are guaranteed upon entering and leaving the block.

Common use cases include file management and database transactions, although there are many more.

As a canonical example, this code for robust file handling that includes all necessary error management:

$fp = fopen('file.txt', 'w');
if ($fp) {
    try {
        foreach ($someThing as $value) {
          fwrite($fp, serialize($value));
        }
    } catch (\Exception $e) {
        log('The file failed.');
    } finally {
      fclose($fp);
    }
}
unset($fp);

Can be simplified to this, using a context manager common to all similar cases:

with (file_for_write('file.txt') as $fp) {
    foreach ($someThing as $value) {
        fwrite($fp, serialize($value));
    }
}
// At this point, we're guaranteed that $fp has closed, whether there was an error or not.

The semantics of this proposal draw very heavily on Python.

Proposal

This RFC introduces a new keyword, with, and a corresponding interface, ContextManager. They work together to create a “context block” in which certain behaviors are guaranteed to happen at the start, and at the end.

The interface is defined as follows:

interface ContextManager
{
    public function enterContext(): mixed;
 
    public function exitContext(?\Throwable $e = null): ?bool;
}

The syntax for with is as follows:

with (EXPR [as VAR]) {
    BODY
}

Where:

The intent is to “factor out” the common setup and tear down processes from various use cases to the context manager object.

The EXPR could be new SomethingThatImplementsContextManager(), or a static factory method call on such a class, or a function that returns such an object, or even a variable created previously that points to such an object.

Behavior

At the start of the block, EXPR is validated to be a ContextManager instance. If not, a TypeError is thrown. Then enterContext() is called, which may take arbitrary setup steps and may return a value. If it does not return a value, null will be assumed. (This is true even if enterContext() is typed to return void.) If a context variable is specified, the return value will be assigned to that variable, otherwise it will be ignored. The statements within the block will execute normally.

If the end of the block is reached, this is considered a success case. First, the exitContext() method is called with no arguments. It may take arbitrary cleanup steps, and its return value if any is ignored. Then, if VAR was set, it will be explicitly unset(). If the context manager is not otherwise bound to a variable (as will typically be the case), it will go out of scope naturally at this point and be garbage collected like any other object.

If an exception is thrown in the course of the context block that propagates up to the context block, this is considered a failure case. First, the exitContext() method is called with the exception as its only parameter, which may take arbitrary cleanup steps. Then, if VAR was set, it will be explicitly unset(). If the context manager is not otherwise bound to a variable (as will typically be the case), it will go out of scope naturally at this point and be garbage collected like any other object. If exitContext() returned true, then no further action is taken. Otherwise, the exception will be rethrown without modification.

All exceptions or errors will be caught by the context manager. By default, the Throwable will be rethrown after the exitContext() method completes. To suppress the exception, exitContext() may return true. Returning false, null, or not returning at all (which is the same as null) will cause the Throwable to rethrow. Alternatively, the method may rethrow the same Throwable or a new Throwable (which should reference the original), in which case the original throw is also suppressed. Be aware that it should do so only after any appropriate local cleanup is done, such as closing files or IO connections.

There are three existing keywords that have special meaning within a with block (but not in functions called from the block).

break will jump to the end of the with block. This will be considered a success case as described above. If the break is within another control block, it will hit that first; use break 2 or similar, as in other cases of nested control structures. This behavior is consistent with switch statements.

continue will behave the same as break, but will trigger a warning. This is consistent with a switch statement, where continue within a case block triggers a warning. While not ideal, we felt it was best to keep the behavior consistent between with and switch rather than having two subtly different behaviors. Should continue in switch be cleaned up in the future (eg, to forbid it), the same cleanup should be done here. (continue within a foreach or other control statement is fine.)

return will return from the function in which the with block resides, bypassing the remaining statements. This will trigger a success case as defined above, which will happen before the function returns.

Importantly, the with block does not create a new scope the way a function or closure does. Any variables that exist before the with block are available within it, and any variables created within the with block will continue to exist after it, with the exception of the context variable.

The context manager object itself is never exposed to the body of the with statement. If that is desireable for some reason, it may be instantiated as a normal variable prior to the with statement and then simply referenced. It will then be available within the with block like any other variable.

$mgr = new SomeManager();
 
with ($mgr as $var) {
  // Do stuff here, including referencing $mgr.
}

Alternatively, it is allowed for a context manager to return itself as the context variable. This is mainly useful for “resource replacement objects”, where the object itself is what you want to use, but you want it to self-clean as well.

Implementation

The with block is desugared at compile time into more traditional code. This minimizes the impact on the engine, opcache, and optimizer, as they will only see opcodes that already exist (plus some error handling, see below). The best way to understand how the with block works is to see its desugared form.

with (new Manager() as $var) {
    print "Hello world\n";
}
 
// Will compile into approximately the equivalent of this:
 
$__mgr = new Manager();
$__closed = false;
$var = $__mgr->enterContext();
 
try {
    print "Hello world\n";
} catch (\Throwable $e) {
    $__closed = true;
    $__ret = $__mgr->exitContext($e);
    if ($__ret !== true) {
        throw $e;
    }
} finally {
    if (!$__closed) {
        $__mgr->exitContext();
    }
    unset($var);
    unset($__closed);
    unset($__mgr);
}

The $__mgr, $__closed, and $__ret variables will not actually be available by that name. They're just a convenient way to describe what happens.

A break statement in the body of the with block will be transformed into a goto to jump to the end of the with. The transpilation step is smart enough to detect the level of the jump and whether it should be done. (Eg, a break inside with { foreach { } } should not be translated, but a break 2 would be, etc.)

At runtime, opcodes equivalent to the above code is what will execute. Note that exitContext() is only ever called once. Any cleanup common to both success and failure cases should be handled appropriately.

Most of the above is implemented by generating the equivalent AST and compiling it. One exception is that type validation of the result of EXPR is implemented by a new opcode, for simplicity, and to reduce the size of generated code.

Resources

Resources are an interesting case. They are one of the most common things we would want to ensure are closed at a certain point. Currently, it is difficult to ensure a resource is closed properly, as the value may be assigned to another variable, or captured in an a backtrace or logger, or various other code paths that prevent “variable out of scope so close” behavior from happening. Context managers provide a straightforward and effective solution for that.

However, resources are not objects, yet. Efforts to convert them are ongoing, but not yet complete. In particular, file handles are still resources, not objects, yet are one of the most common targets for a context manager.

For that reason, resource variables are special-cased to be automatically wrapped into a generic context manager. Fortunately, all resources can be closed with a consistent interface from C, making this possible. The wrapping manager is approximately equivalent to:

class ResourceContext implements ContextManager
{
    public function __construct(private $resource) {}
 
    public function enterContext(): mixed
    {
        return $this->resource;
    }
 
    public function exitContext(?\Throwable $e = null): ?bool
    {
        close($this->resource);
    }
}

Which enables:

// This code
with (fopen('foo.txt', 'r') as $fp) {
  fwrite($fp, 'bar');
}
 
// Will get translated into this:
with (new ResourceContext(fopen('foo.txt', 'r')) as $fp) {
  fwrite($fp, 'bar');
}

As a reminder, the context manager and context variable are typically separate values. So $fp here is the same resource value we're used to, and thus compatible with fwrite() et al.

Whenever resources are finally removed from the language, the file objects can implement ContextManager on their own and this special casing can be removed.

Naturally anyone can write their own context manager that wraps a resource and use that instead of the situation calls for it.

Examples

The biggest value of context managers is their simplification of business logic code. In the examples below, compare the with code shown with implementing the same logic manually in dozens of places around a code base.

Database transactions

It's very common to use a closure to wrap a block of code in a database transaction, forcing it to either commit or rollback. However, doing so frequently requires manually re-useing variables from the surrounding scope, which is frequently cumbersome. Context Managers offer a better alternative:

class DatabaseTransaction implements ContextManager
{
    public function __construct(
        private DatabaseConnection $connection,
    ) {}
 
    public function enterContext(): DatabaseConnection
    {
        return $this->connection;
    }
 
    public function exitContext(?\Throwable $e = null): ?bool
    {
        if ($e) {
            $this->connection->rollback();
        } else {
            $this->connection->commit();
        }
    }
}
 
class DatabaseConnection
{
    public function transaction(): DatabaseTransaction
    {
        return new DatabaseTransaction($this);
    }
}
 
// Arbitrary place in code:
 
// Note that in this case the 'as' expression is omitted,
// as its return value is not needed.
with ($connection->transaction()) {
  $connection->insert(blah blah);
  // ...
}

Resource locking

As with database transactions, it's common to need to set aside a block of code that requires exclusive access to some resource, usually a file. Context managers make that easy. The example below wraps the core flock() function, but other implementations are possible.

class FileLock implements ContextManager
{
    private $handle;
    private bool $locked;
 
    public function __construct(
        private string $file,
        private bool $forWriting = true,
    ) {}
 
    public function enterContext(): mixed
    {
        $this->handle = fopen($this->file, $this->forWriting ? 'w' : 'r');
        $this->locked = flock($this->handle, $this->forWriting ? LOCK_EX : LOCK_SH);
        if (!$this->locked) {
          throw new \RuntimeException('Could not acquire lock.');
        }
        return $this->handle;
    }
 
    public function exitContext(?\Throwable $e = null): ?bool
    {
        if ($this->locked) {
            flock($this->handle, LOCK_UN);
        }
        fclose($this->handle);
    }
}
 
// This code wants exclusive write access to this file:
with (new FileLock('file.txt') as $fp) {
  fwrite($fp, 'important stuff');
}
 
// Whereas this code doesn't want to write to the file,
// just use it for synchronization purposes.
with (new FileLock('sentinel')) {
  // Do stuff that doesn't involve a file at all.
}

Structured asynchronous control

Context managers also provide a convenient way to enforce structured asynchronous behavior. Assume for a moment the existence of a lower-level async library that has a class named Scope, to which all coroutines are attached. Then the following code becomes possible:

class BlockingScope implements ContextManager
{
    private Scope $scope;
 
  public function enterContext()
  {
      return $this->scope = new Scope();
  }
 
  public function exitContext(?\Throwable $e = null): ?bool
  {
      if ($e) {
          foreach ($this->scope->routines as $r) {
              $r->cancel();
        }
      }
      else {
          foreach ($this->scope->routines as $r) {
              $r->wait();
          }
      }
  }
}
 
class CancellingScope implements ContextMananger
{
  public function enterContext()
  {
      return $this->scope = new Scope();
  }
 
  public function exitContext(?\Throwable $e = null): ?bool
  {
    foreach ($scope->routines as $r) {
      $r->cancel();
    }
  }
}
 
with (new BlockingScope() as $s) {
  $s->spawn(blah);
}
// The code will block when it reaches this point, and not continue until all coroutines have completed.
 
with (new CancellingScope() as $s) {
  $s->spawn(blah);
  $s->wait(5);
}
// Any coroutines still alive here gets canceled immediately.  The code blocks until all coroutines complete their cancellation process.

Naturally, various other behavior such as integrated timeouts would be possible as well. Such an API could be provided by core in the future, or in user space.

Temporarily setting global configuration

There are times when a segment of code needs to modify some global runtime setting temporarily, such as disabling an error handler for example. That process is often fraught and error prone. That could be greatly simplified by wrapping it up into a context manager. A simplified example is shown below.

class CustomErrorHandler implements ContextManager
{
    private $oldHandler;
 
    public function __construct(
        private $newHandler,
    ) {}
 
    public function enterContext(): void
    {
        // There's no meaningful context variable here, so don't return anything.
        $this->oldHandler = set_error_handler($this->newHandler);
    }
 
    public function exitContext(?\Throwable $e = null): ?bool
    {
        // The behavior is the same whether an exception was thrown or not.
        set_error_handler($this->oldHandler);
    }
}
 
// Disable all error handling and live dangerously.
// Note that as there is no context variable returned, using
// the 'as' clause is not allowed.
with (CustomErrorHandler(fn() => null)) {
    // Live dangerously here.
}
 
// We're guaranteed to have the previous error handler back by this point.

A similar class that allows changing an ini setting only within a given context is left as an exercise for the reader.

Backward Incompatible Changes

A new global namespace interface is introduced called ContextManager. It will conflict with any pre-existing symbols of that name. As the global namespace is generally considered reserved for PHP Internals, we do not anticipate any issues.

A new semi-reserved keyword is introduced called with. That means it will be disallowed for global constants or functions, but remain valid for existing methods or class constants.

Proposed PHP Version(s)

PHP 8.6.

RFC Impact

To the Ecosystem

New syntax is introduced, which means SA tools will need to be updated and PHP FIG will likely need to issue a PER-CS update.

As a generic “setup and teardown” abstraction, future PHP APIs may be designed with this process in mind. That would allow avoiding extra syntax in favor of simply providing context managers for specific use cases. (The async example above is a key use case.)

To Existing Extensions

None.

To SAPIs

None.

Open Issues

Expression with

Should with be a statement, or an expression? Python's version is a statement, but there are potential advantages to an expression. In particular, it would allow it to be used in an expression context, such as match().

continue behavior

The behavior of continue described above is not ideal, but it is at least consistent with switch. The alternative would be to disallow continue within with entirely right now. If switch is ever cleaned up, it would end up that way. If not, it would mean an inconsistency.

The challenge with switch is that removing support for continue could break existing code in exciting ways, as it would change all of the continue X statements present. It's therefore unclear if it will ever be feasible to address.

We are open to either approach for with, depending on the consensus.

Keyword

At this time, we believe making with only a semi-reserved keyword will be sufficient to avoid conflict with existing code, and will therefore allow direct parallelism with Python, making it easier to learn. However, if further research suggests it is problematic the authors are open to using a different keyword with the same semantics as shown here.

Multiple managers in one block

If a given block requires multiple context variables with their own lifecycle control, with blocks can be easily nested:

with (new Foo() as $foo) {
    with (new Bar() as $bar) {
        // Code that uses $foo and $bar here.
    }
}

However, it would not be difficult to allow flattening that into a single block if desired:

with (new Foo() as $foo, new Bar() as $bar) {
    // Code that uses $foo and $bar here.
}

At this time we have not implemented that syntax, but are open to doing so if the consensus is that it would be useful.

Future Scope

Generator decorator managers

In Python, context managers are implemented under the hood using generators. That allows using stand-alone functions and an annotation to write new context managers, rather than using an object.

We have verified that it is possible to write a generator-based wrapper context manager, which would then use a callable that contains its own try-catch blocks. That is, the following would be possible in user-space:

function opening($filename) {
    return GeneratorDecorator::fromFactory(function () use ($filename) {
        $f = fopen($filename, "r");
        if (!$f) {
            throw new Exception("fopen($filename) failed");
        }
        try {
            yield $f;
        } finally {
            fclose($f);
        }
    });
}
 
with (opening(__FILE__) as $f) {
    var_dump($f);
}

Where GeneratorDecorator is a standard boilerplate class (code available). It would therefore be possible in the future to allow declaring a generator function/method to be a context manager, using an attribute to auto-wrap it. Something like:

#[ContextManager]
function opening($filename) {
    $f = fopen($filename, "r");
    if (!$f) {
        throw new Exception("fopen($filename) failed");
    }
    try {
        yield $f;
    } finally {
        fclose($f);
    }
}

At this time, we don't feel it necessary to go this far. The object version gets the job done adequately. However, once we have more experience “in the wild” it's possible that such an approach would be a useful addition/shorthand. Such an addition would be straightforward to do, once its value was demonstrated.

Voting Choices

Pick a title that reflects the concrete choice people will vote on.

Please consult the php/policies repository for the current voting guidelines.

Implement context managers as outlined in the RFC?
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

Implementation

After the RFC is implemented, this section should contain:

  1. the version(s) it was merged into
  2. a link to the git commit(s)
  3. a link to the PHP manual entry for the feature

References

Links to external references, discussions, or RFCs.

Rejected Features

Destructor-based logic

Arguably, it would have been simpler to support any object as a context manager, and rely on its constructor as the “enter” operation and its destructor as the “exit” operation. However, that carries with it a number of problems:

  1. It would not allow distinguishing between the context manager and context variable. This is useful in many cases.
  2. Destructors do not take an argument, and therefore cannot differentiate between success and failure termination. That would make many cases, such as database transactions, impossible to implement.
  3. Destructors are not called reliably. If the context manager object has been saved elsewhere (either outside the with block or inside it), then its refcount would not reach 0 at the end of the with block and the destructor won't be called until some unknown time later.
  4. Destructors are not always called immediately when the refcount hits zero. They are called some time after that when the garbage collector decides to. “Some time” is not a reliable or deterministic enough for this use case.

For that reason, we did not pursue a destructor-based design.

Changelog