====== PHP RFC: Scope Functions ====== * Version: 1.0 * Date: 2026-04-26 * Authors: Bob Weinand , Volker Dusch * Status: Under Discussion * Target: PHP 8.6 * Implementation: Draft PR at https://github.com/php/php-src/pull/21968 ===== 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 share 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: * **Return:** ''return'' returns from the scope function only, not from the parent. * **Exceptions:** Exceptions propagate normally to the caller of the scope function, then up the stack. * **Call stack:** Calls to a scope function appear as their own frame in ''debug_backtrace()'' and stack traces. * **''$this'':** Inside of methods, ''$this'' inside the closure is the same as outside of it. * **''extract(), compact(), $$var'':** All scope modifying functions and constructs to work and simply modify the parent's scope. ===== 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, updated $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 point errors in averages. function aggregate(array $numbers): array { $count = '0'; $sum = '0'; // Currently: Both accumulators must be listed in use() with & array_walk($numbers, function ($number) use (&$count, &$sum) { $count = bcadd($count, '1'); $sum = bcadd($sum, $number); }); // With scope functions: $count and $sum are just used. array_walk($numbers, fn($number) { $count = bcadd($count, '1'); $sum = bcadd($sum, $number); )}); 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 ===== ==== Closure operations ==== * **''static fn() {}'':** ''$this'' is already shared, a static scope function would be incoherent. Fatal error: Scope functions cannot be static in %s on line %d * **''fn() use () {}'':** Every variable is already shared. ''use()'' is redundant. Parse error: syntax error, unexpected token "use", expecting "{" in %s on line %d * **Recursion:** Recursive calls will raise an ''Error''. Fatal error: Uncaught Error: Cannot recursively call scope function in %s:%d * **Cloning:** ''clone $fn'' on a scope function raises an ''Error''. Two clones would share the same parent scope, interfering with each other's state. * **Rebinding:** Mutating the existing closure returns the same instance. ''Closure::bind()'' and ''Closure::bindTo()'' may rebind only the scope (class name). Passing anything but the current instance to ''$newThis'' raises an ''Error'': class Ancestor { function call() { return "parent"; } } class A extends Ancestor { function call() { return "child"; } function do() { $fn = fn() { print $this->call(); }; (Closure::bind($fn, $this, "Ancestor"))(); // Allowed, prints "parent" Closure::bind($fn, null); // Cannot rebind $this of a scope function } } ==== Lifetime ==== A scope function cannot leave the lifetime of its defining function as its variables live in the parent. A scope function may be invoked any number of times while that frame is alive. Once a functions exits, via ''return'', an exception, or through a generator/fiber being closed the closure is invalidated. Scope functions declared inside other scope functions share the outermost scope. All nested instances are invalidated together when the outermost ordinary function exits. This check happens at the outermost scope exit, and at call time. function issue() { $x = 1; return fn() { return $x; }; } // Error: Scope function closure must not outlive the declaring scope $f = issue(); Should the thrown error be caught and the stored scope Closure be accessed afterwards, the Closure will throw when called. The eager error is intended as a debugging aid. Every other operation on the Closure remains valid". * **Generators & Fibers:** ''yield'', ''yield from'', and ''Fiber::suspend()'' inside a scope function work normally. If the parent exits while the coroutine is suspended inside an active scope function call, the coroutine is unwound. === Suspended callers === When a parent exits while a Generator or Fiber is suspended inside an active scope function call, the coroutine must be cleaned up before the scope function frame can be destroyed. Generators and Fibers handle this differently. **Generators** are unwound as if they lost their last reference. The Generator is closed, and subsequent iteration behaves as for any other closed Generator. **Fibers** are forcibly unwound up to the point where the scope function exits, then re-suspended. The Fiber object stays alive. ''Fiber::suspend()'' is raises an ''Error'' if invoked from a ''finally'' block. A subsequent ''Fiber::resume()'' forcibly throws an ''Error'' into the Fiber just past the scope function call. === Includes and eval === A scope function at the top level of a script, ''include'', ''require'', or ''eval'' will be cleaned up at the end of that script. As included code shares the caller's symbol table, the closures variables would otherwise leak into the caller after the include returns, where any subsequent invocation would raise the "escape" error. An automatic cleanup, removing any variables holding scope functions, is done to avoid requiring users to do this ''unset()'' manually in every included file. // include.php // entry.php ==== Re-evaluation ==== The same scope function declaration (same source code location) invalidates the previous instance. Should it still be called, an error is raised. Any scope closure is only allocated a single slot, preventing recursion and double declaration. There are two choices: guaranteeing uniqueness (returning the active Closure again) or invalidating the previous Closure. We chose the second option to avoid the misconception that scope functions would capture the current variables. E.g. in foreach loops, it will always print the current active value and never the value at declaration time. $closures = []; for ($i = 0; $i < 3; $i++) { $closures[] = fn() { return $i; }; } var_dump($closures[2]()); // int(3) $closures[0](); // Error: Cannot call scope function: defining scope has exited ===== Alternatives Considered ===== Having older closure handles 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 ===== ==== Inlining ==== Given that a scope function shares the parent's scope, inlining a call into the caller is easy. ''array_map'', ''array_filter'', ''usort'', and others can be optimized at the compiler level to eliminate per-element call overhead when invoked with a scope function literal. ===== Proposed PHP Version ===== PHP 8.6. ===== Voting ===== Accept the scope functions feature as described? Yes/No. Requires 2/3 majority. ===== References ===== * [[https://wiki.php.net/rfc/arrow_functions_v2|Arrow Functions 2.0 RFC]] * [[https://wiki.php.net/rfc/closures|Original Closures RFC]] * [[https://wiki.php.net/rfc/closure-optimizations|Closure optimizations]] * [[https://wiki.php.net/rfc/auto-capture-closure|(Declined) Short Closures 2.0 RFC]]