Lesson 01 / 14
The PHP Execution Model
Measuring which of the silent, return, warning and halts channels a problem is heard through in PHP; execution starting and ending per request, and an uncaught error cutting only that execution.
Published Updated
In this lesson
- Classifies a problem as heard through the silent, return, warning or halts channel by measuring it
- Reads the core measurement files and interprets the distribution of the fourteen observations
- Demonstrates with the built-in server that execution starts and ends per request
- Verifies with the status code that an uncaught error cuts only that request
Contents
The C and C++ curriculum (M11) closed with one distinction: there, most problems were caught at compile time, the rest fell into undefined behavior, and the language said nothing. This course does not measure the compile stage in PHP. A file is read, interpreted and run as it executes, so almost every problem is heard inside execution. Even so, the same problem is not heard the same way everywhere.
This course asks a single question: which channel is this problem heard through? A missing key, a mismatched type or a file that cannot be found can each report itself in one of four different ways. This lesson first names those four ways and counts fourteen observations with a measurement core; then it measures where execution itself begins and ends, using the built-in server. The prerequisite Programming Fundamentals course (M01/K02) established the concepts of variable, type, loop and function; they are not explained again here.
Four Channels and the Measurement Core
The course uses four channels, and their names do not change from here on. Silent: a result is produced and there is no signal; the problem is visible only by looking at the result. Return: a function returns false or null, and hearing it is left to the caller. Warning: an error level (E_WARNING, E_DEPRECATED) is reported and execution continues. Halts: a Throwable is thrown or execution ends; if it is not caught, that execution is cut off right there.
This distinction does not overlap with the “who gives the guarantee” question measured in M08/K03: that course asked whose promise a behavior was, while here the measurement is of a signal, not a promise. It also does not overlap with the “where does the assurance end” question measured in M10/K05: that course looked for the limit of the compiler’s checking, while here compilation is not measured and the course separates how problems appear during execution.
A rule decides the channel, not a label. The channel.php file below turns that rule into code: listen() first asks whether anything was thrown, then whether a warning was reported, then whether the result is false or null; if none of these apply, the problem is silent. Warnings are caught with set_error_handler and only the level name is kept, because the message text can change between versions and carries a real file path.
<?php // channel.php — helpers that classify which channel a problem is heard through // Level names of caught warnings collect here; the message text is not kept. $GLOBALS['heard_warnings'] = []; set_error_handler(function (int $level): bool { $names = [E_WARNING => 'E_WARNING', E_NOTICE => 'E_NOTICE', E_DEPRECATED => 'E_DEPRECATED']; $GLOBALS['heard_warnings'][] = $names[$level] ?? 'E_' . $level; return true; }); // Turns a value into a short text together with its type. function show(mixed $value): string { return match (true) { $value === null => 'null', is_bool($value) => $value ? 'true' : 'false', is_array($value) => 'array(' . count($value) . ')', default => get_debug_type($value) . ' ' . $value, }; } // Runs the job and asks, in order, which channel the problem is heard through: // first a throw, then a warning, then a false/null return; if none of them, silent. function listen(callable $job): array { $GLOBALS['heard_warnings'] = []; try { $result = $job(); } catch (Throwable $error) { return ['halts', get_class($error)]; } if ($GLOBALS['heard_warnings']) { $levels = implode(',', array_unique($GLOBALS['heard_warnings'])); return ['warning', $levels . ', result ' . show($result)]; } if ($result === false || $result === null) { return ['return', 'result ' . show($result)]; } return ['silent', 'result ' . show($result)]; } // Runs the code in a separate PHP execution; exit code 255 means execution halted. function run_separately(string $code): array { exec('php -d display_errors=0 -r ' . escapeshellarg($code) . ' 2>&1', $lines, $exit); $channel = $exit === 255 ? 'halts' : 'silent'; return [$channel, 'lines out ' . count($lines) . ', exit ' . $exit]; }
In the two observations that run in a separate execution, the measure is the exit code: 255 shows that execution halted. The second file, heard.php, gathers the fourteen observations into one table. Each observation belongs to one of the later lessons; here only the channel they are heard through is examined.
<?php // heard.php — fourteen observations: which channel is this problem heard through require 'channel.php'; function add_two(int $a, int $b): int { return $a + $b; } $observations = [ 'halted execution' => fn() => run_separately('echo "before\n"; throw new RuntimeException("stop");'), 'syntax error' => fn() => run_separately('echo "before\n"; $x = ;'), 'integer overflow' => fn() => listen(fn() => PHP_INT_MAX + 1), 'leading-numeric string' => fn() => listen(fn() => '5 apples' + 1), 'unmatched match' => fn() => listen(fn() => match (3) { 1 => 'one', 2 => 'two' }), 'extra argument' => fn() => listen(fn() => add_two(1, 2, 3)), 'key normalization' => fn() => listen(fn() => ['1' => 'a', 1 => 'b', true => 'c']), 'missing inner key' => fn() => listen(fn() => [['name' => 'Ada']][0]['surname']), 'element not found' => fn() => listen(fn() => array_search('apple', ['pear', 'quince'])), 'malformed json' => fn() => listen(fn() => json_decode('{broken')), 'missing query variable' => fn() => listen(fn() => $_GET['page']), 'invalid form number' => fn() => listen(fn() => filter_var('12a', FILTER_VALIDATE_INT)), 'non-uploaded file' => fn() => listen(fn() => move_uploaded_file('channel.php', 'target.php')), 'missing include file' => fn() => listen(fn() => include 'missing.php'), ]; $counts = ['silent' => 0, 'return' => 0, 'warning' => 0, 'halts' => 0]; printf("%-24s%-10s%s\n", 'observation', 'channel', 'evidence'); foreach ($observations as $name => $observe) { [$channel, $evidence] = $observe(); $counts[$channel]++; printf("%-24s%-10s%s\n", $name, $channel, $evidence); } echo "\n"; foreach ($counts as $channel => $n) { printf("%-10s%d\n", $channel, $n); } echo 'total observations: ', array_sum($counts), "\n";
observation channel evidence halted execution halts lines out 1, exit 255 syntax error halts lines out 0, exit 255 integer overflow silent result float 9.2233720368548E+18 leading-numeric string warning E_WARNING, result int 6 unmatched match halts UnhandledMatchError extra argument silent result int 3 key normalization silent result array(1) missing inner key warning E_WARNING, result null element not found return result false malformed json return result null missing query variable warning E_WARNING, result null invalid form number return result false non-uploaded file return result false missing include file warning E_WARNING, result false silent 3 return 4 warning 4 halts 3 total observations: 14
Of the fourteen observations, 3 were heard through the silent channel, 4 through return, 4 through warning, and 3 halted execution. LE1. This table is the result of an execution run in the sandbox without a loaded php.ini; the measurement depends on no ini value, and when a setting is needed it is passed with the -d flag. The rows of the same table stay the same on another installation, because what is printed is not the message text but the name of the level constant, the class name of the exception and the exit code.
Three things stand out in the table. In the silent channel the result is not wrong, only different: when the integer limit was exceeded, the type became float without a signal. The return channel leaves the signal to the caller: array_search returned false when it found nothing, and if no one asks, no one hears it. The warning channel announces but does not halt: the missing include file returned false and execution continued. Order matters too: a missing file pulled in with require first reports a warning and then throws Error; because the channel that cuts execution is the strongest channel, this mechanism falls in the halts channel.
Execution Starting and Ending per Request
The channel this lesson measures is halts, and it comes down to one question: where does execution end? The built-in server (php -S) runs the file from the beginning for every request. Nothing is carried over from the previous request’s variables, from static values, or from unfinished work of an earlier request. Where the lifetime of values ends was measured as a separate axis in M10/K02; that question is not asked here, and only the boundaries of one execution are drawn.
The first file increments a global variable and a static variable inside a function. The second divides by an integer and throws an uncaught error if the divisor is zero. Both files are given as files only, without output blocks; the script that follows measures them on the server.
<?php // counter.php — a global variable and a static restart on every request $visit = 0; function next_count(): int { static $counter = 0; return ++$counter; } $visit++; echo 'visit=', $visit, ' c=', next_count(), "\n";
<?php // division.php — if the divisor is zero, an uncaught error; the request ends with 500 $n = (int) ($_GET['n'] ?? 0); $remainder = 100 % $n; echo 'remainder=', $remainder, "\n";
The server starts with -d display_errors=0, so the error text does not mix into the response body and only the status code is measured. The script requests the counter file three times, and the division file first with n=0, then n=2, then n=0 again. The last line runs the same division file on the command line and prints the exit code.
# request.sh — execution per request: counter, division and the same file on the command line php -S 127.0.0.1:8081 -d display_errors=0 >/dev/null 2>&1 & pid=$! for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8081/counter.php && break; sleep 0.1; done for i in 1 2 3; do curl -s http://127.0.0.1:8081/counter.php; done curl -s -w 'status=%{http_code}\n' 'http://127.0.0.1:8081/division.php?n=0' curl -s -w 'status=%{http_code}\n' 'http://127.0.0.1:8081/division.php?n=2' curl -s -w 'status=%{http_code}\n' 'http://127.0.0.1:8081/division.php?n=0' kill $pid php -d display_errors=0 division.php; echo "exit=$?"
visit=1 c=1 visit=1 c=1 visit=1 c=1 status=500 remainder=0 status=200 status=500 exit=255
LE2. In three separate requests the global variable and the static variable both gave 1 every time: even if the server keeps its own process open, every request runs the file from the beginning, and the state of the previous request does not pass to the next. The server’s process model does not determine this result; what was measured is the behavior of the built-in server. How another installation achieves per-request execution is the subject of M16/K01.
The first n=0 request gave 500, because 100 % 0 throws a DivisionByZeroError and nothing catches it; since it was thrown before any output was printed, the server sets the status code to 500. The n=2 request right after it gave remainder=0 and 200. The error was inside a separate execution and left no trace for the next one.
Silencing Does Not Change the Channel
Code that does not want to hear a warning knows two ways: the @ operator and error_reporting(0). Neither removes the problem; they only affect where the report goes. If an error handler is registered, the warning still reaches it. The file below puts three reads side by side: unsilenced, with @, and after error_reporting(0). Each read reads an undefined array key.
<?php // silence.php — @ and error_reporting(0) do not remove the warning, nor change whether it reaches the handler $reports = 0; set_error_handler(function (int $level) use (&$reports): bool { $reports++; return true; }); $items = []; $a = $items['missing']; echo 'unsilenced: reports: ', $reports, ', value: ', var_export($a, true), "\n"; $b = @$items['missing']; echo 'with @: reports: ', $reports, ', value: ', var_export($b, true), "\n"; error_reporting(0); $c = $items['missing']; echo 'error_reporting(0): reports: ', $reports, ', value: ', var_export($c, true), "\n";
unsilenced: reports: 1, value: NULL with @: reports: 2, value: NULL error_reporting(0): reports: 3, value: NULL
LE3. All three reads gave the same value, NULL: the problem was the same in all three cases, and only the visibility of the report changed. The counter is the number of reports that reached the error handler. Silencing does not move a problem in the warning channel to the silent channel; it only prevents it from being written to the screen or the log. Code that does not hear the problem still has NULL in hand, and the code cannot know whether this value means false.
Exit and the Shutdown Function
exit ends execution on that line and the following lines do not run; this is in the halts channel. But execution does not go completely quiet: a function registered with register_shutdown_function() runs even after exit is called. The file below measures the order.
<?php // exit.php — exit cuts execution, the shutdown function still runs register_shutdown_function(function () { echo "shutdown\n"; }); echo "a\n"; exit(3); echo "b\n";
a shutdown
LE4. The output was a and shutdown; b was never printed. Execution was cut, but the registered function ran before the request closed. This shows that even when an uncaught error or exit cuts a request, a window is left in which cleanup code can run. The same window also opens for code that cannot see why execution was cut: the shutdown function runs without knowing the cause of the problem.
The Bounding Measurement
The scope of the halts channel is one execution, not the whole program. LE5. On the built-in server a request is one execution: an uncaught error cuts only that request and the server stays up, and the next request starts clean. On the command line, execution is the whole program: the same division.php file gave exit=255 there and nothing was left to run afterward. The channel is the same; the scope differs.
So the word “halts” must be read with two qualifications. The first is that halts holds inside the boundary of an execution: how the program is run determines where that boundary lies. The second is that the status code depends on where the error is thrown relative to the output; in this lesson the error was thrown before anything was printed and 500 came back. The Syntax and Tags lesson measures what this order changes.
A boundary can also be drawn inside an execution: a Throwable caught with try and catch stops being halts and passes into the control of whoever caught it. listen() in the core does this; even so, the evidence for two of the three halts rows in the table is still the exit code, because those two observations run in a separate execution.
Summary
- In PHP a problem is heard through one of the silent, return, warning or halts channels; the channel is decided not by a label but by the order in which
listen()asks. - Of the fourteen observations, 3 were heard through the silent channel, 4 through return, 4 through warning, and 3 halted execution.
- This lesson’s channel is halts: an uncaught error cuts execution right there.
- On the built-in server every request runs the file from the beginning; the global variable and
staticstart over on every request. - Halts cuts only that execution: on the server the next request starts clean, while on the command line execution is the whole program.
Next Step
It was measured that an execution can be cut by an error, but two separate forms of cutting sit side by side in the table: halted execution printed one line and exited with 255, while syntax error printed no line and exited with 255. The Syntax and Tags lesson measures this difference: when does a file never start running at all?
To keep your progress and take notes, Log in
My notes
Log in to take notes.