Table of Contents

PHP RFC: Scope Functions

Introduction

This RFC proposes a variant of closures for the common case of a callback that runs inside its defining function.

Current PHP closures that are supposed to affect outside variables require explicit capturing:

function () use (&$x, &$y, &$z) {
    // ...
}

The list is verbose, potentially unfamiliar to readers due to references, and silently wrong when forgotten as adding a new variable to the body without updating use() just shadows the variable.

For use cases involving the array_* functions, transaction wrappers, async callbacks, sorting, and others, having closure automatically its parents’ scope makes these functions shorter and easier to read, write, and maintain.

$autoScoped = 1;
$closure = fn () {
    var_dump($autoScoped);
    $autoScoped = 2;
}
 
// int(1)
$closure();
 
// int(2)
var_dump($autoScoped);

Proposal

Introduce a new type of closure that shares variables with the enclosing scope.

Syntax

fn(parameter_list)[: return_type] { body }

Disambiguated from arrow functions by a { block vs the => expression. The fn keyword is reused to avoid introducing a new keyword. fn(...) { ... }.

Semantics

A scope function is a Closure whose variables are scoped to the parent function. All reads, writes, and newly introduced variables share the parent's scope:

function example() {
    $x = 1;
    (fn() {
        $x++;
        $new = 'hi';
    })();
    // int(2), string(2) "hi"
    var_dump($x, $new);
}

Beyond shared variables, a scope function behaves like any other closure:

Examples

Async callbacks

// Current approach:
// - `use` for each callback
// - Results come back as an indexed array that the caller has to unpack.
function findSharedLikes($baseUrl, $userIdOne, $userIdTwo, $token, $client) {
    $promises[] = \Amp\async(function () use ($baseUrl, $client, $userIdOne, $token) {
        $req = $client->request("$baseUrl/api/user/$userIdOne/likes", ["Authorization" => "Bearer $token"]);
        return $req->getBody()->buffer();
    });
    $promises[] = \Amp\async(function () use ($baseUrl, $client, $userIdTwo, $token) {
        $req = $client->request("$baseUrl/api/user/$userIdTwo/likes", ["Authorization" => "Bearer $token"]);
        return $req->getBody()->buffer();
    });
    $results = \Amp\await($promises);
    return array_intersect($results[0], $results[1]);
}
 
// With scope functions:
// - results are written into nicely named variables in the parent scope
// - return is easy to read
function findSharedLikes($baseUrl, $userIdOne, $userIdTwo, $token, $client) {
    $promises[] = \Amp\async(fn() {
        $req = $client->request("$baseUrl/api/user/$userIdOne/likes", ["Authorization" => "Bearer $token"]);
        $likesOne = $req->getBody()->buffer();
    });
    $promises[] = \Amp\async(fn() {
        $req = $client->request("$baseUrl/api/user/$userIdTwo/likes", ["Authorization" => "Bearer $token"]);
        $likesTwo = $req->getBody()->buffer();
    });
    \Amp\await($promises);
 
    return array_intersect($likesOne, $likesTwo);
}

Transaction wrapper

A simple callback-based DB transaction wrapper that can be used with exceptions or return values.

class DatabaseConnection
{
    const TRANSACTION_ABORT = 1;
 
    public function transaction(callable $callback): void {
        try {
            if ($callback() === self::TRANSACTION_ABORT) {
                $this->rollback();
                return;
            }
        } catch (\Throwable $e) {
            $this->rollback();
            throw $e;
        }
        $this->commit();
    }
}
 
$connection->transaction(fn () {
    // No need to capture $connection here or use $affectedRows by reference
    $affectedRows = $connection->query("UPDATE ...");
    if ($affectedRows === 0) {
        return DatabaseConnection::TRANSACTION_ABORT;
    }
    // ...
});
// $affectedRows is available after the transaction
log("Ran transaction, update affected $affectedRows rows");

Sorting with an external comparator

function sortByPriority(array $items, PriorityTable $priorities, Metrics $metrics): array {
    // Track sort performance
    $comparisons = 0;
 
    // Today: $priorities could be used "normally" and $comparisons must be used by reference
    usort($items, function ($a, $b) use ($priorities, &$comparisons) {
        $comparisons++;
        return $priorities->of($a) <=> $priorities->of($b);
    });
 
    // With scope functions: Variables are simply in scope
    usort($items, fn($a, $b) {
        $comparisons++;
        return $priorities->of($a) <=> $priorities->of($b);
    });
 
    $metrics->recordComparisons($comparisons);
    return $items;
}

Accumulation

Aggregation of numbers into a total and count to avoid floating points errors in averages.

function aggregate(array $numbers): array {
    $count = '0';
    $sum = '0';
 
    // Currently: Both accumulators must be listed in use() with &
    array_walk(function ($number) use (&$count, &$sum) {
        $count = bcadd($count, '1');
        $sum = bcadd($sum, $number);
    }, $numbers);
 
    // With scope functions: $count and $sum are just used.
    array_walk(fn($number) {
        $count = bcadd($count, '1')
        $sum = bcadd($sum, $number)
    )}, $numbers);
 
    return [$count, $sum];
}

Filtering with a side channel for errors

function validateList(Validator $validator, array $items): array {
    $errors = [];
 
    // Today: $validator (read) and $errors (write) both have to be listed.
    $valid = array_filter($items, function ($item) use ($validator, &$errors) {
        if (!$reason = $validator->isValid($item)) {
            $errors[] = [$item, $reason];
            return false;
        }
        return true;
    });
 
    // With scope functions: nothing to list, intent reads top to bottom.
    $valid = array_filter($items, fn($item) {
        if (!$reason = $validator->isValid($item)) {
            $errors[] = [$item, $reason];
            return false;
        }
        return true;
    });
 
    return [$valid, $errors];
}

Restrictions

A scope function cannot leave the lifetime of its defining function.

The following forms are therefore not useful and thus forbidden:

Fatal error: Scope functions cannot be static in %s on line %d
Parse error: syntax error, unexpected token "use", expecting "{" in %s on line %d
Fatal error: Uncaught Error: Cannot recursively call scope function in %s:%d
Fatal error: Uncaught Error: Scope function closure must not outlive the declaring scope in %s:%d
Fatal error: Uncaught Error: Cannot call scope function: defining scope has exited in %s:%d
  This avoids silently retargeting an old closure handle to a new instance.

Alternatives Considered

Letting an older closure handle transparently resolve to the current instance when the same scope function declaration is re-evaluated:

This was rejected because it makes closure identity unstable: A stored closure would silently change meaning after later execution of the parent frame.

Reflection

ReflectionFunctionAbstract::isScopeFunction(): bool

Returns true for scope function closures. Available on ReflectionFunction.

Backward Incompatible Changes

None. fn(...) { ... } is currently a parse error.

Open Issues

None.

Future Scope

Scoped functions can be inlined with their callers. For example, for array_map this is a trivial operation that will improve performance.

Proposed PHP Version

PHP 8.6.

Voting

Accept the scope functions feature as described? Yes/No. Requires 2/3 majority.

References