---
title: 'The Process Manager and the Web Server'
source: 'https://academia.sh/en/courses/php-production/process-manager-and-web-server'
course: 'Quality, Performance and Deployment in PHP'
language: en
updated: '2026-10-21T05:03:36+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Distinguish the concepts of web server, process manager and worker process'
    - 'Start the built-in web server with two workers and measure that a page returns the same status code in two profiles'
    - "Interpret that state kept in a worker process's memory is not carried across requests, while state written to a file is"
---

# The Process Manager and the Web Server

Measuring with the built-in web server who serves the requests and what worker processes do not share between requests: why state must be kept outside the process.

When a browser sends a request to a PHP application, two layers come between. The web server accepts the connection and reads the request. The process manager starts the worker processes that run the PHP code, watches them, and distributes requests among them. The manager can increase or decrease the number of workers and can put a new one in place of a worker that crashed. All of the details depend on the production setup; in this lesson, instead of that setup, a small model of the same job is measured: the built-in web server.

The built-in server runs only with `php -S` and is not designed for production; support for multiple worker processes is an experimental feature that came with PHP 7.4.0 and is not supported on Windows. In production, the web server and the process manager are set up with a separate configuration. The built-in server reduces this structure to a measurable scope: the `PHP_CLI_SERVER_WORKERS` environment variable sets the number of worker processes, and each worker is a separate process. The measuring question is the shared question of this course: does this behavior reach production? `side.php` is below verbatim and is only written to disk.

```php
<?php
// side.php — does this behavior reach production?
declare(strict_types=1);

// The two configuration files PHP ships (php.ini-development, php.ini-production) differ
// in seven directives; all seven are copied here verbatim. The last two production lines
// are comments in the shipped file: code cache on for the command line too, file checks off.
const PROFILES = [
    'development' => [
        'display_errors' => '1',
        'display_startup_errors' => '1',
        'error_reporting' => '32767',
        'mysqlnd.collect_memory_statistics' => '1',
        'zend.assertions' => '1',
        'zend.exception_ignore_args' => '0',
        'zend.exception_string_param_max_len' => '15',
    ],
    'production' => [
        'display_errors' => '0',
        'display_startup_errors' => '0',
        'error_reporting' => '24575',
        'mysqlnd.collect_memory_statistics' => '0',
        'zend.assertions' => '-1',
        'zend.exception_ignore_args' => '1',
        'zend.exception_string_param_max_len' => '0',
        'opcache.enable_cli' => '1',
        'opcache.validate_timestamps' => '0',
    ],
];

/** Runs the script in a separate process with no configuration file, only the profile's directives. */
function run(string $script, array $directives): bool {
    $command = ['php', '-n'];
    foreach ($directives as $name => $value) {
        array_push($command, '-d', "$name=$value");
    }
    $command[] = $script;
    $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes);
    $output = stream_get_contents($pipes[1]);
    fclose($pipes[1]);
    proc_close($process);
    $lines = array_values(array_filter(array_map('trim', explode("\n", $output))));
    return end($lines) === 'yes';
}

/** Derives the side from whether the behavior was observed in the two runs. */
function side(bool $inDevelopment, bool $inProduction): string {
    return match (true) {
        $inDevelopment && $inProduction => 'both sides',
        $inDevelopment => 'development only',
        $inProduction => 'production only',
        default => 'not in the run',
    };
}

function sideReport(string $name, string $script): string {
    $inDevelopment = run($script, PROFILES['development']);
    $inProduction = run($script, PROFILES['production']);
    $finalSide = side($inDevelopment, $inProduction);
    echo "$name → $finalSide\n    development ", $inDevelopment ? 'yes' : 'no',
        ' · production ', $inProduction ? 'yes' : 'no', "\n";
    return $finalSide;
}
```

First we write the script that starts the server in the two profiles. The server is always opened with `-d display_errors=0` and only on the `127.0.0.1` address. The profile directives are taken from the `PROFILES` table in `side.php`; the one deliberate difference is the `display_errors` directive: since this lesson measures the log, not the response body, it is given as `0` in both profiles, and the development value `1` in the table is not used here. Both profiles start with the same number of workers. The script writes two pages: `counter.php` increments an in-memory counter on every request and prints it, and `warning.php` produces a warning by reading an undefined variable. It waits with `curl` until the server is ready and stops it with `kill` at the end. The script also writes its results to the `server-result.txt` file.

```bash
# server.sh — starts the built-in server with two workers in two profiles
cat > counter.php <<'EOF'
<?php
$GLOBALS['counter'] = ($GLOBALS['counter'] ?? 0) + 1;
echo $GLOBALS['counter'];
EOF
cat > warning.php <<'EOF'
<?php
echo $undefined;
echo 'done';
EOF

DEVELOPMENT="-d display_startup_errors=1 -d error_reporting=32767 -d mysqlnd.collect_memory_statistics=1 -d zend.assertions=1 -d zend.exception_ignore_args=0 -d zend.exception_string_param_max_len=15"
PRODUCTION="-d display_startup_errors=0 -d error_reporting=24575 -d mysqlnd.collect_memory_statistics=0 -d zend.assertions=-1 -d zend.exception_ignore_args=1 -d zend.exception_string_param_max_len=0 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0"
: > server-result.txt

for profile in development production; do
  if [ "$profile" = development ]; then directives=$DEVELOPMENT; else directives=$PRODUCTION; fi
  rm -f log-$profile.txt
  PHP_CLI_SERVER_WORKERS=2 php -n -d display_errors=0 -d log_errors=1 -d error_log=log-$profile.txt \
    $directives -S 127.0.0.1:8471 >/dev/null 2>&1 &
  pid=$!
  for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8471/counter.php && break; sleep 0.1; done
  first=$(curl -s http://127.0.0.1:8471/counter.php)
  second=$(curl -s http://127.0.0.1:8471/counter.php)
  third=$(curl -s http://127.0.0.1:8471/counter.php)
  status=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8471/warning.php)
  kill $pid; wait $pid 2>/dev/null
  warnings=$(grep -c Warning log-$profile.txt)
  echo "$profile $first $second $third $status $warnings" >> server-result.txt
  echo "$profile: counter $first $second $third · warning page $status · warnings in log $warnings"
done
```

```
development: counter 1 1 1 · warning page 200 · warnings in log 1
production: counter 1 1 1 · warning page 200 · warnings in log 1
```

Now we read the results of the two profiles and derive the side. There are three measuring questions: is the counter `1` on every request, is the warning page `200`, and did the warning reach the log? The script checks whether each is the same in the two profiles and names it with the `side()` function in `side.php`.

```php
<?php
// server-side.php — derives the side of the server measurements
declare(strict_types=1);
require __DIR__ . '/side.php';

$rows = array_map(
    fn(string $row): array => explode(' ', trim($row)),
    file('server-result.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES),
);
[$development, $production] = $rows;

$measurements = [
    'the counter is 1 on every request' => fn(array $r): bool => array_slice($r, 1, 3) === ['1', '1', '1'],
    'a warning page returns 200' => fn(array $r): bool => $r[4] === '200',
    'a warning reaches the log' => fn(array $r): bool => (int) $r[5] === 1,
];

foreach ($measurements as $name => $measurement) {
    echo $name, ' → ', side($measurement($development), $measurement($production)), "\n";
}
```

```
the counter is 1 on every request → both sides
a warning page returns 200 → both sides
a warning reaches the log → both sides
```

The counter came out `1` on every request in both profiles; because the two worker processes carry separate memory, the in-memory counter did not accumulate across requests. The warning page returned `200` in both profiles, and the warning was written to the log, not to the screen. This is why in production a page can look healthy even though it produces a warning: the status code does not report the warning, and the way to see it is the log. (**PO4.** Each profile restarted the server from scratch with its own settings; changing the settings of a running server was not measured.)

## State per Request

The previous measurement showed that an in-memory counter does not accumulate across requests. This section asks the same question for four different kinds of state. The places where state can live in PHP are known: a class's static property, a global variable, a `static` variable inside a function, and the side effect of a file being loaded with `include_once`. Each of the four places keeps a counter, and the page prints all four at once. Our expectation is that all come out `1`, because PHP builds the execution environment from scratch on every request. Three requests are sent and only the response of the third is printed: if there were accumulation, the numbers would show `3` there.

```bash
# state.sh — what state is carried per request?
cat > loaded.php <<'EOF'
<?php
$GLOBALS['loaded'] = ($GLOBALS['loaded'] ?? 0) + 1;
EOF
cat > state.php <<'EOF'
<?php
final class Holder {
    public static int $n = 0;
}
function tick(): int {
    static $c = 0;
    return ++$c;
}
Holder::$n++;
$GLOBALS['g'] = ($GLOBALS['g'] ?? 0) + 1;
include_once __DIR__ . '/loaded.php';
echo 'static property ', Holder::$n, ' · global ', $GLOBALS['g'],
    ' · static inside function ', tick(), ' · include_once ', $GLOBALS['loaded'];
EOF

DEVELOPMENT="-d display_startup_errors=1 -d error_reporting=32767 -d mysqlnd.collect_memory_statistics=1 -d zend.assertions=1 -d zend.exception_ignore_args=0 -d zend.exception_string_param_max_len=15"
PRODUCTION="-d display_startup_errors=0 -d error_reporting=24575 -d mysqlnd.collect_memory_statistics=0 -d zend.assertions=-1 -d zend.exception_ignore_args=1 -d zend.exception_string_param_max_len=0 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0"

for profile in development production; do
  if [ "$profile" = development ]; then directives=$DEVELOPMENT; else directives=$PRODUCTION; fi
  PHP_CLI_SERVER_WORKERS=2 php -n -d display_errors=0 $directives -S 127.0.0.1:8471 >/dev/null 2>&1 &
  pid=$!
  for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8471/state.php && break; sleep 0.1; done
  curl -s -o /dev/null http://127.0.0.1:8471/state.php
  curl -s -o /dev/null http://127.0.0.1:8471/state.php
  last=$(curl -s http://127.0.0.1:8471/state.php)
  kill $pid; wait $pid 2>/dev/null
  echo "$profile: $last"
done
```

```
development: static property 1 · global 1 · static inside function 1 · include_once 1
production: static property 1 · global 1 · static inside function 1 · include_once 1
```

We read the arrangement the script sets up. `state.php` increments a counter in four separate places. The first is the static property of the `Holder` class; a static property belongs to the class, not to an object, and so one might think it "lives for the whole process". The second is the `g` key in the global array. The third is the `static $c` variable inside the `tick()` function; this variable keeps its value between function calls. The fourth is the `loaded.php` file: `include_once` loads a file once, and the code inside the file increments a global counter. The "once" in the name `include_once` means once within a request; our question is whether the file is loaded again in a new request.

Both lines of the output show all four numbers as `1`. This is the measured form of PHP's request model: every request is built from scratch as far as user code is concerned, even if the worker process stays the same. The static property is reset, because the class definition is created again with its values on every request. The global array is emptied when the request ends. The `static` variable inside the function starts from zero, because the function is defined anew on every request. `include_once` loads the file again on every request and increments the counter inside it from zero. All four are the result of the same rule, and that they are the same in the two profiles shows that the rule does not depend on the settings; this is why the side of the section is **both sides**.

This result has two aspects in real production. The positive aspect is that the dirt a request leaves behind does not spoil the next one: a request that leaks does not affect the variables of the next request. The model that gives this property cheaply takes its price from another place: every request builds the application from scratch. The web application framework reading its configuration, loading classes and opening connections is repeated on every request. The code cache removes only one part of this price, the work of translating files. The remaining part, building the objects, is the natural result of per-request state. Long-lived execution models keep state between requests to reduce this price, and in return they have to manage the risk of leaks themselves. Those models were not set up in this lesson; what was measured is only the classic model of one request per worker process.

## The Bounding Measurement

If state in memory is not carried across requests, would writing state to a file be a solution? The script below starts the page that keeps the counter in a file in the two profiles and prints the values returned by three requests.

```bash
# bound.sh — if state is written to a file, is it carried across requests?
cat > file-backed.php <<'EOF'
<?php
$path = __DIR__ . '/counter.txt';
$n = (int) @file_get_contents($path) + 1;
file_put_contents($path, (string) $n);
echo $n;
EOF

DEVELOPMENT="-d display_startup_errors=1 -d error_reporting=32767 -d mysqlnd.collect_memory_statistics=1 -d zend.assertions=1 -d zend.exception_ignore_args=0 -d zend.exception_string_param_max_len=15"
PRODUCTION="-d display_startup_errors=0 -d error_reporting=24575 -d mysqlnd.collect_memory_statistics=0 -d zend.assertions=-1 -d zend.exception_ignore_args=1 -d zend.exception_string_param_max_len=0 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0"

for profile in development production; do
  if [ "$profile" = development ]; then directives=$DEVELOPMENT; else directives=$PRODUCTION; fi
  rm -f counter.txt
  PHP_CLI_SERVER_WORKERS=2 php -n -d display_errors=0 $directives -S 127.0.0.1:8472 >/dev/null 2>&1 &
  pid=$!
  for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8472/file-backed.php && break; sleep 0.1; done
  rm -f counter.txt
  first=$(curl -s http://127.0.0.1:8472/file-backed.php)
  second=$(curl -s http://127.0.0.1:8472/file-backed.php)
  third=$(curl -s http://127.0.0.1:8472/file-backed.php)
  kill $pid; wait $pid 2>/dev/null
  echo "$profile: file counter $first $second $third"
done
```

```
development: file counter 1 2 3
production: file counter 1 2 3
```

The counter in the file came out `1 2 3` in both profiles: the two workers saw the same file, and state was carried across requests. The in-memory counter had stayed `1 1 1`. The difference is not in the worker's memory but in where the state is kept. Variables tied to worker processes, static properties and cached objects are not reliably shared across requests; state that must be shared is kept outside the process, in a file, a database or a cache server. Here we used the file because it is the smallest external store for measurement; three sequential requests do not collide. For two workers writing at the same time, a file lock or a real store is needed, and this was not measured.

These measurements together shed light on the decision about the number of workers. Each worker process runs with its own request memory; two workers can serve two requests at the same time, but they do not see each other's memory. So increasing the number of worker processes increases the number of requests that can be served at the same time and also increases total memory use. Raising the number without knowing the typical memory amount of a worker process can lead to exceeding the machine's memory and slowing the system. Concurrency comes with a number, but memory sets the upper limit of the number. Writing "this much memory per worker, this many workers in total" in your deployment document instead of "two workers" makes the decision explainable afterwards.

(**PO6.** The server's profile directives are the same as the `PROFILES` table in `side.php`, except that `display_errors` is given as `0` in both profiles; the development value in the table is `1`. This lesson measured only the log and the status code, not the response body; the effect of the `display_errors` difference on the response was not measured and is covered in the next configuration tuning lesson.) The built-in server's worker processes were chosen for measurement. How the process manager determines the number of workers in production, after how many requests workers are restarted, and what happens when a worker crashes are configuration topics and are outside the scope of this measurement. The measurement showed that even with two workers the two profiles give the same state and that state is not carried across requests. These two pieces of information will be the ground when discussing configuration differences in the next lesson. In short: state lives outside the worker process, and the number of worker processes is therefore a deployment decision that weighs concurrency against memory.

## Summary

- The web server takes the connection; the process manager starts the worker processes that run the PHP code, watches them and distributes requests among them.
- The built-in web server runs only with `php -S` and is not for production; in this lesson it was used as a small model with two workers.
- The side of this lesson is **both sides**: the in-memory counter gave `1` on every request in both profiles, the warning page returned `200` in both profiles, and the warning reached the log.
- The status code does not report a warning; in production the way to see the warning is the log.
- State in memory is not carried across requests, state in a file is: state to be shared is kept outside the process.
- The static property, global variable, `static` variable inside a function and `include_once` counters all gave `1` on the third request; per-request state is the same in the two profiles (side: both sides).

## Next Step

The next lesson starts with the worker process settings: the memory limit, error reporting and code cache directives are given when the process starts. The last lesson writes how the two profiles diverge in seven directives and measures in which profile changing a directive at run time with `ini_set()` is accepted.
