---
title: 'The PHP Execution Model'
source: 'https://academia.sh/en/courses/php-fundamentals/the-php-execution-model'
course: 'PHP Fundamentals'
language: en
updated: '2026-10-21T05:03:34+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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
<?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
<?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
<?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
<?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.

```bash
# 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
<?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
<?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 `static` start 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?
