PHP RFC: Context Managers
- Version: 0.9
- Date: 2025-10-15
- Author: Larry Garfield (larry@garfieldtech.com), Arnaud Le Blanc (arnaud.lb@gmail.com)
- Status: Draft
- Implementation: Pending
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 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, although the implementation is notably different.
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(?\Exception $e): void; }
The syntax for with
is as follows:
with (EXPR [as VAR]) { BODY }
Where:
- EXPR is any arbitrary expression that evaluates to an instance of
ContextManager
. - VAR is variable name that will be assigned the return value of
enterContext()
. It may also be omitted if that value is not useful for a particular use case. - BODY is any arbitrary collection of PHP statements.
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, or if it returns null, then an Error will be thrown if there is an as VAR
portion. For any other return value, the return value will be assigned to the as
variable and 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. 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.
All exceptions will be caught by the context manager. If it is desireable to propagate the exception beyond its context, the exitContext()
method may rethrow the same exception or a new exception (which should reference the original). 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).
continue
will jump to the end of the with
block, skipping over any remaining statements. This will trigger a success case as defined above.
break
will throw a ContextAborted
exception. This will trigger a failure case as defined above.
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. }
Additionally, 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. 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: try { $__mgr = new Manager(); $__closed = false; $var = $__mgr->enterContext(); print "Hello world\n"; } catch (\Exception $e) { $__closed = true; $__mgr->exitContext($e); } finally { if (!$__closed) { $__mgr->exitContext(); } unset($var); unset($__closed); unset($__mgr); }
The $mgr
variables will not actually be available by that name. They're just a convenient way to describe what happens.
and
$closed
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.
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. However, they 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 auto-box into a generic handler. Fortunately, all resources can be closed with a consistent interface from C, making this possible. The autoboxing manager is approximately equivalent to:
class ResourceContext implements ContextManager { public function __construct(private $resource) {} public function enterContext(): mixed { return $this->resource; } public function exitContext(?\Exception $e): void { 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'); }
Whenever resources are finally removed from the language, the file objects can implement ContextManager
on their own and this special casing can be removed.
Examples
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-use
ing 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(?\Exception $e): void { if ($e) { $this->connection->rollback(); // Optionally rethrow the exception, or an exception that wraps it. } 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, ) {} 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(?\Exception $e): void { 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(?\Exception $e = null) { 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 __exit(?\Exception $e = null) { 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(?\Exception $e): void { // The behavior is the same whether an exception was thrown or not. set_error_handler($this->oldHandler); // Optionally rethrow the exception if desired. throw $e; } } // 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 keyword is introduced called with
. It will conflict with any user-defined constant of the same name.
For adding new functions, classes or keywords, here are some possibilities to measure potential impact:
- A script from nikic that checks the top 1000 Composer packages https://gist.github.com/nikic/a2bfa3e2f604f66115c3e4b8963a6c72
- GitHub regexp search
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
Should with
be a statement, or an expression? Python's version is a statement, but there are potential advantages to an expression.
Do we want to allow a shortcut for “nested” with
statements, the way Python does?
Future Scope
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.
Implementation
After the RFC is implemented, this section should contain:
- the version(s) it was merged into
- a link to the git commit(s)
- 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:
- It would not allow distinguishing between the context manager and context variable. This is useful in many cases.
- 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.
- 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 thewith
block and the destructor won't be called until some unknown time later. - 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
If there are major changes to the initial proposal, please include a short summary with a date or a link to the mailing list announcement here, as not everyone has access to the wikis' version history.