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.
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"
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
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
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
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
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]
The fix:
foreach ($items as &$item) {
$item *= 2;
}
unset($item); // Break the reference. Always.
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)
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
};
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
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
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"
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
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
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"
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));
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)
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_filteruse an arrow function, that saves you from needinguse.