DEV Community

Cover image for PHP Engineering: Variable Scope, References & Closures — What's Actually Happening Under the Hood
Al Amin
Al Amin

Posted on • Edited on

PHP Engineering: Variable Scope, References & Closures — What's Actually Happening Under the Hood

Most PHP tutorials explain what these features do. This article explains why they work the way they do, what PHP is actually doing at the engine level, and how misunderstanding them causes real production bugs.


1. Variable Scope: It's About Symbol Tables, Not Magic

PHP's scope model isn't arbitrary — it maps directly to how Zend Engine manages symbol tables. Every execution context (a function call, the global script) owns its own symbol table: a hash map of variable names to zval containers.

When a function is invoked, Zend allocates a new symbol table for that call frame. When the function returns, the table is destroyed. This is why local variables don't leak — it's not a language rule bolted on top, it's a consequence of the execution model.

function calculateArea(int $width, int $height): int {
    $area = $width * $height;
    return $area;
}
// $area doesn't "not exist yet" outside — it exists in a completely
// different symbol table that has already been freed.
Enter fullscreen mode Exit fullscreen mode

Global Scope and $GLOBALS

The global symbol table persists for the lifetime of the request. When you write global $site_name inside a function, you're not copying the variable — you're inserting a reference into the local symbol table that points to the entry in the global table.

$site_name = "MyApp";

function showSite(): void {
    global $site_name;
    // $site_name here is a reference alias to the global entry.
    // Mutating it mutates the global.
    $site_name = "Mutated"; // ← this changes the global
}

showSite();
echo $site_name; // "Mutated"
Enter fullscreen mode Exit fullscreen mode

This is the real reason global is dangerous — it's not just "messy", it's a hidden aliased reference that can mutate shared state from anywhere in the codebase. Pass dependencies explicitly instead.

Static Variables: Per-Function, Not Per-Instance

static variables live in the function's compiled op-array, not in the call frame's symbol table. This has two non-obvious consequences:

1. They're shared across all call sites of the same function — but not across different functions.

function counter(): int {
    static $count = 0;
    return ++$count;
}

counter(); // 1
counter(); // 2
counter(); // 3 — $count survives between calls
Enter fullscreen mode Exit fullscreen mode

2. In methods, they're per-class-definition, not per-object-instance.

class Logger {
    public function log(string $msg): void {
        static $callCount = 0;
        $callCount++;
        echo "[{$callCount}] {$msg}\n";
    }
}

$a = new Logger();
$b = new Logger();

$a->log("first");  // [1] first
$b->log("second"); // [2] second — same static, different instance
Enter fullscreen mode Exit fullscreen mode

If you expect per-instance state, use a property, not static. This distinction causes subtle bugs in long-running processes (e.g., PHP-FPM workers, ReactPHP, Swoole).


2. References: Symbol Table Aliasing

PHP is copy-on-write by default. When you assign $b = $a, both variables point to the same zval in memory, and a copy is only made when one of them is modified. This is an engine optimization — not references.

References are something different: $b =& $a makes both variables entries in the symbol table point to the same zval, and that zval is marked as a reference container. Now neither variable owns it exclusively — they're aliases.

$a = 10;
$b =& $a;

// Both $a and $b now point to the same zval.
// Changing one changes the value in that shared container.
$b = 20;
echo $a; // 20
Enter fullscreen mode Exit fullscreen mode

Pass-by-Reference in Function Signatures

The & in a function signature forces the argument to be passed as a reference, meaning the local parameter is an alias of the caller's variable:

function increment(&$value): void {
    $value++;
}

$x = 5;
increment($x);
echo $x; // 6 — the caller's variable was modified
Enter fullscreen mode Exit fullscreen mode

Use this sparingly. It creates invisible action-at-a-distance: the caller has no syntactic indication the function will mutate their variable (unlike, say, Rust's &mut). Prefer returning a new value.

Legitimate use cases for pass-by-reference:

  • Large data structures where copying is genuinely expensive and profiling confirms it
  • Functions in the style of preg_match() that need to return multiple outputs
  • Implementing swap or sort algorithms on arrays

The foreach Reference Trap (A Real Bug)

This is one of the most common PHP bugs in production codebases:

$items = [1, 2, 3];

foreach ($items as &$item) {
    $item *= 2;
}
// After the loop, $item is STILL an alias for $items[2].

foreach ($items as $item) {
    // On the last iteration of this loop, $item = $items[2].
    // But $item IS $items[2] (still aliased).
    // So the last element overwrites itself with the second-to-last element's value.
    echo $item . "\n";
}

// $items is now [2, 4, 4], not [2, 4, 6]
Enter fullscreen mode Exit fullscreen mode

The fix:

foreach ($items as &$item) {
    $item *= 2;
}
unset($item); // Break the reference. Always.
Enter fullscreen mode Exit fullscreen mode

Make this a hard rule in your codebase: every foreach with & must be followed by unset($loopVar).


3. Closures: First-Class Functions with Captured Scope

A Closure in PHP is not just syntactic sugar for an anonymous function — it's an object that implements the Closure class. It has methods (bind, bindTo, call, fromCallable) and can be inspected, serialized (with some caveats), and passed as a value.

$greet = function(string $name): string {
    return "Hello, {$name}";
};

// $greet is a Closure object
var_dump($greet instanceof Closure); // bool(true)
Enter fullscreen mode Exit fullscreen mode

Why Closures Don't Capture Outer Variables by Default

Unlike JavaScript, PHP closures are lexically isolated by default. This is a deliberate design decision — implicit capture leads to subtle bugs when closures outlive their original context. PHP forces you to be explicit.

$multiplier = 3;

$fn = function(int $n): int {
    return $n * $multiplier; // Fatal error: Undefined variable $multiplier
};
Enter fullscreen mode Exit fullscreen mode

use: Explicit Lexical Capture

The use clause captures variables at the moment the closure is defined, not when it's called:

$multiplier = 3;

$fn = function(int $n) use ($multiplier): int {
    return $n * $multiplier;
};

$multiplier = 99; // Too late — closure already captured 3

echo $fn(5); // 15, not 495
Enter fullscreen mode Exit fullscreen mode

This is value capture (copy of the zval). The closure holds its own copy of $multiplier.

Capture by Reference

Use use (&$var) when the closure needs to observe or mutate the external variable after the closure is defined:

$count = 0;

$increment = function() use (&$count): void {
    $count++;
};

$increment();
$increment();

echo $count; // 2 — the original variable was mutated
Enter fullscreen mode Exit fullscreen mode

This is how you implement accumulators, memoization caches, and counters without global state or class properties.

When to use value vs. reference capture:

Intent Capture mode
Snapshot a value at definition time use ($var)
React to changes after definition use (&$var)
Accumulate state across calls use (&$var)
Freeze config/context use ($var)

4. Lexical Scoping: Where Code Is Written, Not Where It Runs

This is the rule that catches engineers off-guard when they first encounter it:

$message = "outer";

$fn = function() use ($message): void {
    echo $message;
};

function runner(callable $cb): void {
    $message = "inner"; // This has zero effect on $fn
    $cb();
}

runner($fn); // Output: "outer"
Enter fullscreen mode Exit fullscreen mode

The closure captured $message from the scope where it was created — the global scope at definition time. The runner function's local $message is completely invisible to it. This is lexical (static) scoping as opposed to dynamic scoping (where the call site's variables would be used instead).

PHP, like most modern languages, uses lexical scoping. This makes code easier to reason about: you can always determine what a closure accesses by reading the surrounding code at definition time.


5. Engineering Patterns Using These Primitives

Memoization

Cache expensive computation results without a class or global state:

function memoize(callable $fn): Closure {
    $cache = [];

    return function() use ($fn, &$cache) {
        $args = func_get_args();
        $key = serialize($args);

        if (!array_key_exists($key, $cache)) {
            $cache[$key] = $fn(...$args);
        }

        return $cache[$key];
    };
}

$expensiveSquare = memoize(fn(int $n): int => $n * $n);

$expensiveSquare(4); // computed
$expensiveSquare(4); // cache hit
Enter fullscreen mode Exit fullscreen mode

Note the dual capture: $fn by value (the function doesn't change) and $cache by reference (it grows with each new result).

Currying / Partial Application

function multiply(int $factor): Closure {
    return fn(int $n): int => $n * $factor;
}

$double = multiply(2);
$triple = multiply(3);

echo $double(5); // 10
echo $triple(5); // 15
Enter fullscreen mode Exit fullscreen mode

Each returned closure captures a different $factor value — they're independent function objects even though they come from the same factory function.

Dynamic Pipeline

function pipeline(mixed $value, callable ...$fns): mixed {
    return array_reduce(
        $fns,
        fn($carry, $fn) => $fn($carry),
        $value
    );
}

$result = pipeline(
    "  Hello World  ",
    'trim',
    'strtolower',
    fn(string $s): string => str_replace(' ', '-', $s),
);

echo $result; // "hello-world"
Enter fullscreen mode Exit fullscreen mode

Filtering with Captured Context

The canonical use case — avoid hardcoding filter thresholds:

function priceFilter(int $max): Closure {
    return fn(array $product): bool => $product['price'] <= $max;
}

$products = [
    ['name' => 'Laptop',   'price' => 1200],
    ['name' => 'Mouse',    'price' => 25],
    ['name' => 'Monitor',  'price' => 350],
    ['name' => 'Keyboard', 'price' => 75],
];

$budget = array_filter($products, priceFilter(100));
$midrange = array_filter($products, priceFilter(400));
Enter fullscreen mode Exit fullscreen mode

6. Common Mistakes and How to Avoid Them

Mutating a global via global $var
Pass the value as a parameter instead. If you need a mutable shared state, inject a stateful object.

Forgetting unset() after a reference foreach
Adopt a linter rule (e.g., PHPStan or PHP_CodeSniffer) that flags reference-based foreach loops without a subsequent unset.

Assuming static is per-instance in a class
It isn't. Use object properties for per-instance state.

Capturing a large array by value in a closure
This copies the array at capture time. If you're passing closures across the codebase and the captured array is large, capture by reference or encapsulate in an object.

Expecting dynamic scoping behaviour
When a closure "can't see" a variable, the answer is always use — not rearranging function call order.


Summary

Concept What's actually happening Key rule
Local scope New symbol table per call frame Variables die when the frame does
global $x Reference alias into the global symbol table Mutations are global — avoid
static $x Stored in op-array, not the call frame Shared across all calls, not instances
$b =& $a Both symbols alias the same zval Mutations via either name affect the same value
&$param Caller's variable aliased into function scope Invisible mutation — use sparingly
foreach (&$v) Loop var stays aliased after loop ends Always unset($v) after
Closure A first-class object implementing Closure Can be bound, passed, stored
use ($var) Value captured at definition time Snapshot — immune to later changes
use (&$var) Reference captured at definition time Live wire — mutations propagate
Lexical scoping Closure sees its creation scope, not call scope Read the use clause to know what it captures

Top comments (1)

Collapse
 
xwero profile image
david duymelinck

Using static variables in a function that output the variable are problematic. A function call deeply hidden in the code could make expected output somewhere else unreliable.

A better option is to add the initial/previous value, and the logic creates a new value based on that input.

The reference variable in a loop is not more dangerous that the by value variable in a loop. Both live as long as the function it is in runs.
That is why it is a best practice to make the loop variables as specific to the context of the loop as possible. This makes it less likely a variable further in the function receives an unexpected value.

For the array_filter use an arrow function, that saves you from needing use.

$affordableProducts = array_filter($products, fn ($product) => $product['price'] <= $maxPrice);
Enter fullscreen mode Exit fullscreen mode