rfc:optin_block_scoping

PHP RFC: let construct (Block Scoping)

Introduction

This RFC proposes the introduction of the let() construct, a new language construct for managing lifetimes of variables. It allows developers to define a block or a statement in which one or more variables are in scope. Upon statement completion, these variables are automatically and reliably set back to their original value - or unset if previously undefined.

This construct addresses several recurring challenges in PHP. Often variables are only meant to be used in part of a function, for example after being checked by an if() statement or within the body of a loop. Accidentally reusing (temporary) variables after their intended purpose is a frequent source of subtle bugs, such as the classic foreach-by-reference issue, where the loop variable is reused and still attached to the last array element. Furthermore developers often need to ensure that resources like file handles are properly disposed of, especially when gracefully handling exceptions for fallible operations. Manually managing this with verbose try-finally blocks or error-prone unset() calls clutters code and reduces clarity.

The let() construct provides a single, elegant solution to these common problems. By having an explicit construct to making scoping intent clear to human readers and static analysis tools alike, PHP can offer a safer and more readable way to write robust, maintainable code.

<?php
 
let ($user = $repository->find(1)) if ($user !== null) {
    printf("Hello %s!\n", $user->name);
}
 
// $user is now unset and accidentally using it can be reported by
// both PHP and static analysis tools.
assert(!isset($user));

Proposal

We propose a new let() construct. It takes a list of variable declarations, ensures they are defined, executes the associated statement list (block) and reliably sets the variables back to their original value (unsetting them if they were undefined) afterwards, no matter how the block is left. This guarantees that variables are unset immediately upon exiting the scope, rather than waiting for the function to end.

If a stored array or object is not referenced elsewhere (i.e. the reassignment causes the reference count to drop to zero), it will trigger the existing destruction logic, including calling the destructors of all objects that will (recursively) be freed.

This feature provides immense value, particularly for modern applications built on long-lived servers, where disciplined and immediate resource cleanup is not just a best practice, but a necessity for stability and performance.

By desugaring to a try-finally block internally, it provides a zero-cost abstraction that is both safe and highly ergonomic, eliminating the need for verbose manual cleanup.

For example, the following code:

let ($a = $b, $c) /* <statement> */

Is semantically equivalent to:

try {
    if (array_key_exists('a', get_defined_vars())) {
        $a_original = $a; // If the variable is undefined, no backup is made without emitting a warning.
        unset($a); // Break references.
    }
    $a = $b;
    if (array_key_exists('c', get_defined_vars())) {
        $c_original = $c;
        unset($c);
    } else {
        $c = null; // If $c was not declared, it's initialized to null.
    }
    /* <statement> */
} finally {
    if (array_key_exists('c_original', get_defined_vars())) {
        $c = $c_original; // If a backup was made, it is restored.
    } else {
        unset($c); // Otherwise the variable is unset.
    }
    if (array_key_exists('a_original', get_defined_vars())) {
        $a = $a_original;
    } else {
        unset($a);
    }
}

The full syntax is defined as follows:

<scope> ::= "let" "(" <variable_declarations> ")" <statement>
<variable_declarations> ::= <variable_declaration> { "," <variable_declarations> }
<variable_declaration> ::= (<variable> "=" <expression>) | <variable>

Constraints

  • Neither global nor static variables may be defined within the statement list.
  • static variables may not be be a scoped variable.
  • goto into the statement list is disallowed.

Examples

Simple example:

<?php
 
$a = 'original a';
$b = 'original b';
$e = 'original e';
let ($a = 'updated a', $b, $c) {
    var_dump($a); // string(9) "updated a"
    var_dump($b); // string(10) "original b"
    var_dump($c); // NULL
    $b = 'updated b';
    $d = 'unaffected d';
    $e = 'updated e';
}
 
// $a and $b are reset to their original value.
var_dump($a); // string(10) "original a"
var_dump($b); // string(10) "original b"
 
// $c is unset.
// Warning: Undefined variable $c in example.php on line 21
var_dump($c);
 
// $d is unaffected.
var_dump($d); // 'unaffected d'
 
// $e remains updated.
var_dump($e); // string(9) "updated e"

Example showing an edge case (solving the foreach reference bug):

<?php
 
$array = [1, 2, 3];
 
let ($value) foreach ($array as &$value) {
    $value *= 2;
}
// $value is unset here, breaking the dangerous lingering reference to the last element.
 
// This loop no longer accidentally modifies $array[2].
foreach ([99] as $value) {}
 
var_dump($array);
// Correctly outputs: array(3) { [0]=> int(2) [1]=> int(4) [2]=> int(6) }

Example showing the combination of let and if():

<?php
 
final class User {
    public function __construct(public string $name) { }
}
 
function getUser(int $id): ?User {
    if ($id === 1) {
        return new User('John Doe');
    }
 
    return null;
}
 
$userId = random_int(1, 2);
let ($user = getUser($userId)) if ($user !== null) {
    var_dump($user->name);
}
 
// $user will no longer be in scope, preventing accidental use outside
// of the if() checking whether or not the User could be found.

Example showing reliable resource management:

<?php
 
// Using the https://github.com/azjezz/psl library for an object-oriented file API.
use Psl\File;
use Psl\File\LockType;
 
function process_file(string $path): string {
    try {
        // Both $file and $lock are guaranteed to be cleaned up after the block.
        let (
            $file = File\open_read_only($path),
            $lock = $file->lock(LockType::Shared),
        ) {
            // The lock ensures that the entire file contents is
            // read in an atomic fashion and are internally consistent,
            // but no longer needs to be held once we obtained the
            // entire contents and begin to process them.
            $content = $file->readAll();
        }
 
        // The file lock is released here, immediately after it is no longer needed,
        // which is crucial in high-concurrency environments.
    } catch (Exception $e) {
        // The file lock will also be released before handling the Exception.
 
        sleep(10); // Simulate expensive exception handling.
 
        throw new Exception('Processing failed', previous: $e);
    }
 
    sleep(10); // Simulate expensive processing
 
    return $content;
}

Example showing error situations:

<?php
 
function foo($a) {
    let ($a) {
        global $a; // Fatal error: Declaring global variable in use construct is disallowed
        var_dump($a);
    }
}
 
function bar($a) {
    static $a;
    let ($a) { // Fatal error: Cannot block scope static variable $a
        var_dump($a);
    }
}

Backward Incompatible Changes

let will become a semi-reserved keyword and will no longer be available as a function name. This affects 4 usages within the top 18,975 composer packages containing a total of 626,044 PHP files.

Proposed PHP Version(s)

Next PHP 8.x

RFC Impact

To the Ecosystem

  • IDEs, LSPs, and Static Analyzers: Will need updates to understand the new scoping rules. This will enable them to provide correct autocompletion and error analysis, correctly identifying when a variable is out of scope.
  • Auto-Formatters and Linters: Will require updates to support the new syntax.

To Existing Extensions

None.

To SAPIs

None.

Open Issues

None.

Future Scope

This RFC lays the foundation for explicit resource management in PHP. Future proposals could build upon it

  • Adding foreach ($foo as let $bar) syntactic sugar for let ($bar) foreach ($foo as $bar)
  • Adding a attribute / marker interface to automatically wrap resource objects into a WeakReference when capturing a backtrace.
  • Throwing errors when marked resource objects have RC > 1 at the end of the block.
  • Introducing a Disposable interface (similar to C#'s IDisposable) to allow objects to define custom, explicit cleanup logic that is automatically called by use.
  • Extending the let construct to support other resource management patterns.

Voting Choices

Primary Vote requiring a 2/3 majority to accept the RFC:

Add the let construct for block-scoping?
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

Patches and Tests

A proof-of-concept implementation is available at: https://github.com/php/php-src/compare/master...TimWolla:php-src:block-scope

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

Rejected Features

None.

Changelog

  • 2025-11-18: Use let() as the keyword.
  • 2025-11-12: Add list of constraints
  • 2025-11-12: Clarify that references are broken upon entering the scope.
  • 2025-11-05: The original values will now be restored instead of unconditionally unsetting everything.
rfc/optin_block_scoping.txt · Last modified: by timwolla