---
title: 'PHP on the Web: State, Data and Security'
source: 'https://academia.sh/en/courses/php-web'
language: en
updated: '2026-10-21T05:03:36+00:00'
license: 'CC BY-SA 4.0'
lessons: 15
outcomes:
    - 'Measures whether a result belongs to the server, the environment, the client or a third party by applying a single deviation from each outside party'
    - 'Builds session- and cookie-based state safely with signatures, session identifier regeneration and cookie attributes'
    - 'Accesses the database through prepared statements and parameterized queries, and protects data integrity with transactions and migrations'
    - 'Applies input validation, output escaping, anti-forgery tokens and password hashing as defenses against common web vulnerabilities'
    - 'Shows with a before/after run that a defense moves a result from an outside party to the server'
---

# PHP on the Web: State, Data and Security

Every result a web application produces has an owner. Who can change this result? Some results are decided by the code on the server alone, some change with the environment the code is deployed to, some are in the hands of the client sending the request, some depend on a third party the application calls or that calls it. The course measures cookies, sessions, configuration, database access, transactions, migrations, data formats, the HTTP client and the defenses against common web vulnerabilities with a single question — who can change this result?

## State and Sessions

Cookies, sessions, configuration and environment variables — the mechanisms that decide which part of the state carried between requests lives on the client, which on the server and which in the deployment environment.

### Cookies

Source: https://academia.sh/en/courses/php-web/cookies

How the cookie carries state on the client between requests, through the Set-Cookie header and its signed form; the four parties that classify who can change a result, and the ten-result measurement.

The Object-Oriented PHP and Standards course split code across files and authors and measured what a user of a definition experiences when that definition changes. This course takes code onto the web: the request comes from a browser, the settings from the environment the code is deployed to, the price from an external service. The author of the code is no longer the only owner of a result. This course measures every mechanism with one question: **who can change this result?**

The first stop is where the web carries state. HTTP is stateless: the server handles each request independently of the previous one (The HTTP Family lessons). For a cart or a user to be remembered across requests, state has to live somewhere. The oldest place is the **cookie**: the server sends a `Set-Cookie` header in the response, the client stores the value and sends it back with every request to the same site. The state lives on the client and returns to the server with each request.

#### The Measurement Core

The measurement itself lives in the file `party.php` and does not change throughout the course. The same file is given again in every lesson.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

`EXTERNAL_PARTIES` lists the three parties outside the code in decision order: **client**, **third party**, **environment**. `probe()` takes a function that computes a result, calls it first without deviation (`$baseline`), then once for each external party's deviation, and records whether the result differs from the baseline. `party()` walks those records in order and returns the first external party that can change the result. `partyReport()` names the result and prints the deviations and the outcome.

**SS1.** The decision order is `party()` itself: client first, then third party, then environment; if none of them could change the result, the **server**. The order is the width of the attack surface. The client is everyone who can send a request, the third party is only the parties the application connects to, and the environment is in the hands of whoever performs the deployment alone. The server is not in the deviation list, because the code can change every result; the question is who outside the code can change it. A party is not a label but a rule: the developer names the result, defines one deviation for each relevant external party, and the party follows from those two inputs.

| Party | Definition |
|---|---|
| **server** | Only the application's code determines the result; no external party can change it alone |
| **environment** | The same code gives a different result when the environment it is deployed to changes (an environment variable, a connection setting, a runtime version, the state of the database) |
| **client** | The party that sends the request changes the result (a cookie, a form field, a query parameter, a token it presents) |
| **third party** | A service the application calls, or another site that makes the user's browser send a request, changes the result |

Two distinctions have to be made at the start. In the Object-Oriented PHP and Standards course a change of definition was measured before and after; here the definition is fixed and what changes is the input of an external party. In the Systems Programming with C course the party that saw a change, the observer, was sought; here the direction is reversed and the party that makes the change is sought.

#### Ten Results, Four Parties

`results.php` measures the ten results of the course once. This file is written only in this lesson; later lessons pull in `party.php` and build only their own result.

```php
<?php
// results.php — ten results, four parties
declare(strict_types=1);
require 'party.php';

putenv('APP_DATABASE=app.db');
function inEnvironment(?string $value, callable $f): mixed {
    if ($value === null) return $f();
    $previous = getenv('APP_DATABASE');
    putenv("APP_DATABASE=$value");
    try { return $f(); } finally { putenv("APP_DATABASE=$previous"); }
}

$db = new PDO('sqlite::memory:');
$db->exec('CREATE TABLE members (name TEXT, age INTEGER)');
$db->exec("INSERT INTO members VALUES ('alice', 30), ('bob', 41)");
function preparedStatement(PDO $db, string $name): string {
    $statement = $db->prepare('SELECT age FROM members WHERE name = ?');
    $statement->execute([$name]);
    return $statement->queryString;
}

const TOKEN_LENGTH = 16;
$token = bin2hex(random_bytes(TOKEN_LENGTH));
function processEmail(array $requests, ?string $token): string {
    $email = 'alice@example.test';
    foreach ($requests as $request) {
        if ($token !== null && !hash_equals($token, $request['token'] ?? '')) continue;
        $email = $request['email'];
    }
    return $email;
}
$userRequest = ['origin' => 'https://example.test', 'token' => $token, 'email' => 'alice@new.test'];
$fromOtherSite = ['origin' => 'https://other.test', 'email' => 'unknown@other.test'];

$cookie = ['cart' => '2'];
$session = ['cart' => 2];
$serviceResponse = '{"price": 120}';
$runtimeDefault = ['cost' => 12];   // the bcrypt default in PHP 8.4 and later

$results = [
    ['cart (in cookie)', fn(array $d) => (int) ($d['client'] ?? $cookie)['cart'],
        ['client' => ['cart' => '5']]],
    ['cart (in session)', fn(array $d) => $session['cart'],
        ['client' => ['cart' => '5']]],
    ['query text (concatenation)', fn(array $d) => "SELECT age FROM members WHERE name = '" . ($d['client'] ?? 'alice') . "'",
        ['client' => 'bob']],
    ['query text (prepared statement)', fn(array $d) => preparedStatement($db, $d['client'] ?? 'alice'),
        ['client' => 'bob']],
    ['email (without token)', fn(array $d) => processEmail([$userRequest, ...($d['third party'] ?? [])], null),
        ['third party' => [$fromOtherSite]]],
    ['email (with token)', fn(array $d) => processEmail([$userRequest, ...($d['third party'] ?? [])], $token),
        ['third party' => [$fromOtherSite]]],
    ['price (external service)', fn(array $d) => json_decode($d['third party'] ?? $serviceResponse, true, 4, JSON_THROW_ON_ERROR)['price'],
        ['third party' => '{"price": 125}']],
    ['hash prefix (default)', fn(array $d) => substr(password_hash('password', PASSWORD_BCRYPT, $d['environment'] ?? $runtimeDefault), 0, 7),
        ['environment' => ['cost' => 10]]],
    ['hash prefix (explicit cost)', fn(array $d) => substr(password_hash('password', PASSWORD_BCRYPT, ['cost' => 12]), 0, 7),
        ['environment' => ['cost' => 10]]],
    ['database name', fn(array $d) => inEnvironment($d['environment'] ?? null, fn() => getenv('APP_DATABASE')),
        ['environment' => 'test.db']],
];

$tally = ['server' => 0, 'environment' => 0, 'client' => 0, 'third party' => 0];
foreach ($results as [$name, $result, $deviations]) {
    $tally[partyReport($name, $result, $deviations)]++;
}
foreach ($tally as $p => $n) echo "$p $n\n";
```

```
cart (in cookie) → client
    client changed
cart (in session) → server
    client could not change
query text (concatenation) → client
    client changed
query text (prepared statement) → server
    client could not change
email (without token) → third party
    third party changed
email (with token) → server
    third party could not change
price (external service) → third party
    third party changed
hash prefix (default) → environment
    environment changed
hash prefix (explicit cost) → server
    environment could not change
database name → environment
    environment changed
server 4
environment 2
client 2
third party 2
```

**SS2.** Of the ten results, **4 came out server, 2 environment, 2 client and 2 third party**. This distribution is the binding number of the course; none of the later lessons changes it, and each lesson measures its own slice. The output is read starting from the pairs. Eight of the ten results form four pairs, and in each pair a mechanism moves the result from an external party to the server:

- When the cart lives in a cookie, the `5` the client sent changed the cart; when it lives in the session, the same deviation could not change it.
- When the query text is built by concatenation, a neutral name (`bob`) changed the text; with a prepared statement the statement text stayed the same.
- In the email update without a token, a request from another site changed the email; in the update with a token, `hash_equals()` did not let that request through.
- When the hash prefix is produced with the default cost supplied by the environment, the environment changed the prefix; when the cost is given explicitly, it could not.

The owner of two results does not change with any mechanism: the price from the external service always belongs to the third party, and the database name always belongs to the environment. For these two, a defense does not change the owner; it only bounds the result.

#### The Set-Cookie Header

Whoever owns the cookie sets it with a header. The header list is empty on the command line, so the code below does not send a header; it builds one as a string and prints it. Two small functions for the signed cookie live in a separate file.

```php
<?php
// cookie.php — two functions that produce and verify a signed cookie value
declare(strict_types=1);

const SIGNING_KEY = 'lesson-key';

function sign(string $value): string
{
    return $value . '.' . hash_hmac('sha256', $value, SIGNING_KEY);
}

function cookieValue(string $cookie, string $fallback): string
{
    [$value, $signature] = array_pad(explode('.', $cookie, 2), 2, '');
    $expected = hash_hmac('sha256', $value, SIGNING_KEY);
    return hash_equals($expected, $signature) ? $value : $fallback;
}
```

`sign()` binds the value to a keyed hash (the signature) with `hash_hmac('sha256', …)` and joins them in the form `value.signature`. `cookieValue()` splits the cookie that came from the client, recomputes the signature and compares it with `hash_equals()`; if the signature does not match, it returns the fallback value. `SIGNING_KEY` is a lesson constant.

```php
<?php
// header.php — the Set-Cookie header and the measurement of the signed cart cookie
declare(strict_types=1);

require 'party.php';
require 'cookie.php';

$valid = sign('2');
$header = 'Set-Cookie: cart=' . $valid . '; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax';
echo str_replace(substr($valid, 2), '<signature>', $header), "\n";

$tampered = '5' . substr($valid, 1);
echo 'tampered value passed the signature: ',
    cookieValue($tampered, '0') === '5' ? 'yes' : 'no', "\n";

partyReport(
    'cart (signed cookie, tampered value)',
    fn(array $d) => cookieValue($d['client'] ?? $valid, '0'),
    ['client' => $tampered],
);
```

```
Set-Cookie: cart=2.<signature>; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
tampered value passed the signature: no
cart (signed cookie, tampered value) → client
    client changed
```

**SS3.** The attributes in the header are requests to the browser. `Path=/` says on which paths the cookie returns, and `Max-Age=3600` says how long it is kept. `HttpOnly` asks that scripts on the page cannot read the cookie, `Secure` that the cookie is sent only over an encrypted connection, and `SameSite=Lax` that sending the cookie along with another site's request be restricted. All three are a string the server writes; enforcing them is up to the client, and the server cannot measure that in this lesson.

The measurement shows two things. The tampered value did not pass the signature, and `cookieValue()` fell back to the fallback value, that is, to `0`. The signature worked: the server did not accept the altered value. But the result still changed: it was `2` and became `0`, and the party that made the change was the client. The party is **client**.

#### The Bounding Measurement

The limit of the signature is this: a signature shows that a value was produced with the key, not that the value is current. If the client sends back a valid cookie it received earlier, its own previous cart, does the signature pass?

```php
<?php
// stale.php — if the client sends back its previous valid cookie
declare(strict_types=1);

require 'party.php';
require 'cookie.php';

$current = sign('2');
$stale = sign('1');
echo 'stale cookie passed the signature: ',
    cookieValue($stale, '0') === '1' ? 'yes' : 'no', "\n";

partyReport(
    'cart (signed cookie, stale valid value)',
    fn(array $d) => cookieValue($d['client'] ?? $current, '0'),
    ['client' => $stale],
);
```

```
stale cookie passed the signature: yes
cart (signed cookie, stale valid value) → client
    client changed
```

**SS4.** It passed. The signature proves that the value was produced with the key; it does not prove that the value is current. The stale cookie carried a valid signature, so the server accepted it and the cart became `1` instead of `2`. The result is again **client**. The signature narrows the values the client can produce to those the server signed beforehand; choosing which of them to send stays with the client.

A defense does not always change the owner. In this lesson the signed cookie narrows a value the client produces to the valid set; it does not take the result out of the client's hands. To change the owner, the state must not live on the client.

#### Summary

- A cookie gets around the statelessness of HTTP by keeping state on the client; the server writes it with `Set-Cookie`, and the client sends it back on every request.
- The course measures every mechanism with one question: who can change this result. The decision order is client, third party, environment; if none can change it, the server.
- The core classified the ten results as 4 server, 2 environment, 2 client and 2 third party; this is the distribution of the core, not of the lessons.
- `Set-Cookie` attributes are requests to the browser; a signed cookie detects a tampered value but does not move the result to the server.
- The measured party of this lesson is **client**: even with a signed cookie, the owner of the result is the client that sends the value.

#### Next Step

In the cookie, the cart lived on the client and the client could change it. The next lesson, Sessions, moves the value to the server: if the cart lives in `$_SESSION`, can the same deviation change the result, and once the cookie holds only an ID, who determines that ID?

### Sessions

Source: https://academia.sh/en/courses/php-web/sessions

The session mechanism that keeps state on the server: the ID in the session cookie, $_SESSION, session_regenerate_id(true), and the limit set by the party that carries the ID.

The Cookies lesson left the cart on the client, and the client could turn it from `2` into `5`. The signature noticed the change but did not change the owner of the result. This lesson moves the same value to the server: the client keeps only an ID, and the cart itself stays on the server.

The mechanism is called a **session**. The `session_start()` call does two things. If the request carries a cookie with a **session ID**, the server opens the record that belongs to that ID and loads its contents into the `$_SESSION` array. If not, it generates a new ID, opens an empty record and sends the ID in the response as a cookie, the **session cookie**. The client returns this cookie on later requests. What the cookie carries is not the cart but the name of the record.

The ID is a handle. In the Memory and Pointers in C course, a pointer points at data that lives elsewhere, and the author of the code manages its lifetime. The session ID is likewise a handle to state on the server, except that the party holding it is the client, not the author of the code. The end of the lesson shows what that means for the measurement.

#### The Cart on the Server

The measurement uses the same core as the Cookies lesson; `party.php` is repeated here unchanged.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

The new file is `session.php`. It first sets up the session with four explicit settings, because the lesson does not trust the runtime default. `session.use_strict_mode` asks the server not to accept an ID it does not recognize; its default is off. `session.cookie_samesite` restricts sending the session cookie with another site's request; its default is empty. `session.cookie_httponly` hides the cookie from scripts on the page. `session.save_path` ties the record location to the temporary directory. `ob_start()` buffers the output, because session headers must be sent before the first output.

```php
<?php
// session.php — the cart lives in the session; the client's cookie cannot change the result
declare(strict_types=1);

require 'party.php';

ob_start();
ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.cookie_httponly', '1');
ini_set('session.save_path', sys_get_temp_dir());
session_start();
$_SESSION['cart'] = 2;

$previous = session_id();
session_regenerate_id(true);
echo 'id regenerated: ', session_id() !== $previous ? 'different' : 'same', "\n";
echo 'cart preserved: ', $_SESSION['cart'], "\n";

partyReport(
    'cart (in session)',
    function (array $d): int {
        if (isset($d['client'])) {
            $_COOKIE['cart'] = $d['client'];
        }
        return $_SESSION['cart'];
    },
    ['client' => '5'],
);
ob_end_flush();
```

```
id regenerated: different
cart preserved: 2
cart (in session) → server
    client could not change
```

The first two output lines are the ID renewal done at login; the last two are the measurement. The client sends `cart=5`, and the deviation writes it into `$_COOKIE`, but the code that reads the cart looks at `$_SESSION['cart']`. The cookie changes, the result does not.

**SS5.** The decision order is client, third party, environment, server. The counterpart is the first line of the Cookies lesson: there the same deviation turned the cart from `2` into `5`, and the result was **client**. Here the same deviation could not change the result, which is **server**. The difference is where the value is carried: in the cookie, the sender owned the cart; once the cart sits in the server record, the sender keeps only the name of the record.

#### The Record's Lifespan and Cost

Staying on the server has two costs. The first is lifespan. The record has to be deleted when the user logs out or when it expires; otherwise unused records pile up. Once it is deleted, the ID in the client's hand is invalid, and while `session.use_strict_mode` is on, the server rejects it and generates a new ID. The ID on the client does not guarantee that the record lives; the server decides that.

The second is scaling. If records sit in one machine's temporary directory, a second request of the same user landing on another machine cannot find the record. An application running on several machines therefore writes records to a shared store. This is a configuration decision and leads into the next lesson: the environment, not the code, should choose the store.

Staying on the server also means the correctness of the cart no longer depends on the honesty of the client. A cart arriving as `5` in a cookie would have to be verified; in a session there is nothing to verify, because the server wrote the value. The burden moves: the validity of the ID reduces to whether the record exists, and `session_start()` checks that on its own.

#### Renewing the ID

If the ID stays the same before and after login, anyone who knows it also knows the logged-in session. This is called **session fixation**. The remedy is to call `session_regenerate_id(true)` at the moment the user is authenticated. The function generates a new ID, the `true` argument deletes the old record, and the `$_SESSION` contents move to the new one.

The measurement shows this in two lines: after the renewal the ID is **different**, and `cart` is preserved as `2`. The ID itself is not printed, because it changes on every run. The fixation attack is not built here; the mechanism only removes the assumption it relies on, that the ID stays the same across login.

**SS6.** `session_regenerate_id(true)` secures the server's own record and does not change the party sentence: the party that generates the ID, renews it and deletes the old record is the server. But the ID still travels to the client as a cookie, and the next section covers that.

#### The Bounding Measurement

The session was classified as server, but the session ID itself is on the client. What happens if the client presents a different ID? The file below opens a session, sets the cart to `2`, then reads the cart with two IDs: the one the server issued and a different one the client presents. The different ID is a neutral string the server never generated.

```php
<?php
// other.php — which session the client lands in if it presents a different session ID
declare(strict_types=1);

require 'party.php';

ob_start();
ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.cookie_httponly', '1');
ini_set('session.save_path', sys_get_temp_dir());

function readCart(string $sessionId): int
{
    session_id($sessionId);
    session_start();
    $cart = $_SESSION['cart'] ?? 0;
    session_write_close();
    return $cart;
}

session_start();
$_SESSION['cart'] = 2;
$sessionId = session_id();
session_write_close();

partyReport(
    'cart (in session, client presents another ID)',
    fn(array $d) => readCart($d['client'] ?? $sessionId),
    ['client' => 'otheridentifier0123456789'],
);
ob_end_flush();
```

```
cart (in session, client presents another ID) → client
    client changed
```

**SS7.** The result changed, and the party that changed it is the **client**. The server rejected the unrecognized ID (`session.use_strict_mode`) and opened a new, empty record; the cart read `0` instead of `2`. The client landed not in its own cart but in a session with no cart at all.

A second reading: the deviation did not change the server's record, which stayed `2`; what changed is which session was opened. One measurement thus yields two sentences: the client cannot determine the contents of the cart, but it determines which cart is opened.

#### Summary

- A session keeps state on the server. All that remains on the client is an ID, and the session cookie carries it back with every request.
- `session_start()` opens the record and loads it into `$_SESSION` if an ID exists; if not, it generates a new ID and an empty record.
- The same `cart=5` deviation changed the result in the Cookies lesson; in the session it could not. The measured party of this lesson is **server**: the client cannot determine the contents of the cart.
- `session_regenerate_id(true)` renews the ID at login, deletes the old record and preserves the `$_SESSION` contents; this is the mechanism against fixation.
- The limit: the party that carries the ID is the client. If the client presents a different ID, it lands not in its own cart but in a different session, so which session is opened stays with the client.

#### Next Step

Both the cookie and the session measured data that arrives with the request. Not every value an application reads comes from the request: the database name, the key and the connection settings come from the deployment environment. The next lesson, Configuration and Environment Variables, leaves these values to the environment and measures who can change them.

### Configuration and Environment Variables

Source: https://academia.sh/en/courses/php-web/configuration-and-environment-variables

Separating deployment-dependent values such as the connection name and the key from the code and reading them from environment variables; the explicit error on a missing variable and the cost of a value fixed in code.

In the Cookies lesson the cart stayed on the client, and in the Sessions lesson on the server. In both, the request determined the value. Not every value an application reads comes from the request: which database to connect to, the signing key and the address of an external service are determined when the application is deployed. The same code runs with one value on the developer's machine and with another in production.

The most common way to carry these values is the **environment variable**: name and value pairs that the operating system gives to a process. In PHP, `getenv()` reads and `putenv()` writes. The person or tool performing the deployment sets the variable before the process starts; application code normally only reads it. In the Production Go: Testing, Tooling and Performance course, the question asked was which tool a piece of information was read from. Here the question is who can change the value.

#### The Configuration Layer

Reading from the environment is not scattered across the code; it is gathered in one place. `config.php` provides three functions, and it is the basis of the measurement.

```php
<?php
// config.php — helpers that read configuration from environment variables
declare(strict_types=1);

function env(string $name, ?string $fallback = null): string
{
    $value = getenv($name);
    if ($value !== false) {
        return $value;
    }
    if ($fallback !== null) {
        return $fallback;
    }
    throw new RuntimeException("missing environment variable: $name");
}

function config(): array
{
    return [
        'database' => env('APP_DATABASE', 'app.db'),
        'key' => env('APP_KEY'),
    ];
}

/** Sets the variables temporarily, does the task, puts the previous values back. */
function withEnvironment(array $variables, callable $task): mixed
{
    $previousValues = [];
    foreach ($variables as $name => $value) {
        $previousValues[$name] = getenv($name);
        putenv("$name=$value");
    }
    try {
        return $task();
    } finally {
        foreach ($previousValues as $name => $previous) {
            putenv($previous === false ? $name : "$name=$previous");
        }
    }
}
```

`env()` reads the variable with `getenv()`. If the variable does not exist, `getenv()` returns `false`; the function first looks at the fallback value, and if that was not given either, it throws a `RuntimeException`. The distinction between a default and an error is deliberate: there is a reasonable default for the database name, and none for the key. If the key is missing, moving on silently with an empty value would put an unsigned application into production; the application has to stop as it starts.

`config()` gathers the values in an array. The rest of the code does not know `getenv()`; it asks for this array. `withEnvironment()` is the measurement helper: it sets the variables temporarily, does the task and puts the previous values back.

The party core is the same in this lesson and does not change.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

#### The Same Code, a Different Environment

`environment.php` first deletes the two variables, so the lesson does not trust what the working environment carries. Then it calls `config()` with the key missing and catches the error. The class name and the message of the error are printed; since this code wrote the message, it does not depend on the environment. Then it sets the key and prints whether it was read. The key itself is not printed: a secret value does not enter the log, the output or an error message.

```php
<?php
// environment.php — the same code, a different environment, a different connection name
declare(strict_types=1);

require 'party.php';
require 'config.php';

putenv('APP_DATABASE');
putenv('APP_KEY');

try {
    config();
} catch (RuntimeException $error) {
    echo get_class($error), ': ', $error->getMessage(), "\n";
}

putenv('APP_KEY=lesson-secret-value');
$settings = config();
echo 'key was read: ', $settings['key'] !== '' ? 'yes' : 'no', "\n";

partyReport(
    'database name (from environment variable)',
    fn(array $d) => withEnvironment($d['environment'] ?? [], fn() => config()['database']),
    ['environment' => ['APP_DATABASE' => 'test.db']],
);
```

```
RuntimeException: missing environment variable: APP_KEY
key was read: yes
database name (from environment variable) → environment
    environment changed
```

The last measurement is the answer to the question. When the party performing the deployment sets the `APP_DATABASE` variable to `test.db`, code that has not changed a single line returns a different name.

**SS8.** The decision order is client, third party, environment, server. Here the client's and the third party's deviations are not defined, because neither the sender of the request nor an external service supplies the value; the only deviation is the environment's, and it changed the result. The party is **environment**. The environment's surface is the narrowest: the value of the variable is given by whoever performs the deployment; running code can change only its own process's environment with `putenv()`, and the deviation in the measurement is applied that way. This narrowness determines responsibility, not security. The owner of a wrong value is now not the author of the code but the party that performs the deployment.

**SS9.** For this reason secret values are not written into code but read from the environment. A key in code enters the repository, reaches everyone who reads the repository, and changing it requires redeploying the code. A key in the environment is not in the repository and can be rotated by whoever performs the deployment. This does not make a leak impossible; it only moves the leak surface from the code repository to the deployment layer.

#### The Limit of the Default

A default value is a convenience, and it carries a risk. If `APP_DATABASE` is forgotten, the application opens with `app.db` and runs as if nothing happened. On a developer machine this is the wanted behavior. In production, writing to the wrong database is more expensive than refusing to start. For this reason a default is given only to variables for which a wrong value is harmless; every variable that could do harm leaves the second argument of `env()` empty and turns the absence into an error.

The message of the error is also a design decision. The message says the name of the variable and not its value. Saying the name shows the party performing the deployment what is missing; saying the value, if the value is secret, would leak it into the log. The first line of the measurement prints this message, and only the name is visible.

Who holds a variable is also part of the configuration. The environment variable is set by the party that starts the process; a running process can change only its own environment with `putenv()`, not the environment of another process. This is the real difference between a value from the client and a value from the environment: the client's value arrives anew with every request and can be different on every request, while the environment's value arrives when the process starts and stays the same throughout the process unless the code explicitly changes it.

#### The Bounding Measurement

The alternative to configuration is writing the value into the code. If the same database name sits in the code as a constant, can the environment change it?

```php
<?php
// constant.php — the environment cannot change a value fixed in code
declare(strict_types=1);

require 'party.php';
require 'config.php';

const FIXED_DATABASE = 'app.db';

function fixedDatabase(): string
{
    return FIXED_DATABASE;
}

partyReport(
    'database name (fixed in code)',
    fn(array $d) => withEnvironment($d['environment'] ?? [], fn() => fixedDatabase()),
    ['environment' => ['APP_DATABASE' => 'test.db']],
);
```

```
database name (fixed in code) → server
    environment could not change
```

**SS10.** It could not change, and the result is **server**. The constant ignored the environment's deviation: even though `APP_DATABASE` was `test.db`, the code returned `app.db`. The same name, in the same code, stays the same in every environment.

This measurement does not say the constant is defensible. The constant is sound against the environment's deviation, but changing the value means changing the code and redeploying it. If the same name cannot be used on the developer machine and in production, what travels between environments is the code itself, and a separate version is needed for each environment. The constant returns to the server, but its cost is portability.

#### Summary

- Deployment-dependent values (a connection name, a key) are not written into code; they are read from environment variables and gathered in a single configuration layer.
- A default is given when there is a reasonable value; if an indispensable value such as the key is missing, the application stops as it starts, with a `RuntimeException`.
- A secret value is not printed to the output, the log or an error message.
- The same code returns a different name when `APP_DATABASE` changes. The measured party of this lesson is **environment**: the value is determined not by the author of the code but by whoever performs the deployment.
- The limit: a value fixed in code stays on the server and the environment cannot change it, but it cannot be carried between environments.

#### Next Step

Three lessons measured where state lives and who can change it: the client in the cookie, the server in the session, the environment in the configuration. The next topic, Data Access, moves on to the database and asks the same question: is the type of a value read decided by the code or by the environment the code runs in?

## Data Access

The database access layer, transactions, migration management, the queries behind object–relational mapping, data formats and the HTTP client — at every point where the application reads and writes data, whether the result is decided by the code, the environment or an outside party.

### The Database Access Layer

Source: https://academia.sh/en/courses/php-web/database-access-layer

Sets up a PDO connection, the error mode, prepared statements, named and positional placeholders and fetch modes; when a connection option comes from the environment, measures who can change the type of the returned value.

The previous lesson measured the case where a setting comes from the environment rather than from the code: the same code could behave differently in a different deployment. The database connection is where this case appears most often. Connection settings are kept outside the code, and the party that performs the deployment can change them. The question is this: if a connection option comes from the environment, what type does the database return?

The measuring axis of this lesson is who can change a result. Outside the code there are three parties, and the decision order is fixed: first the client, then the third party, then the environment. If none of them can change the result, the result belongs to the server. The order follows the width of the attack surface: the client is everyone who sends a request, the third party is only the parties the application connects to, and the environment is in the hands of whoever performs the deployment only. In this lesson the dominant party is the environment. The core is the file below; every lesson gives it verbatim and it is never changed.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

**DA1.** The database is an in-memory database and the connection is rebuilt for every measurement; when the connection closes the data disappears, so every measurement starts with the same two members. **DA2.** The environment's deviation is modeled as a connection option: the party that performs the deployment supplies the options array, and the code does not write it itself. **DA3.** The client sends only the member name; its deviation is sending `bob` instead of `alice`. **DA4.** The result is not a value but the PHP type of the `score` column.

#### The Connection and the Prepared Statement

The data access layer is the only place where the application talks to the database; in PHP, PDO (PHP Data Objects) takes on this job. What the layer is for was covered in the database lessons; here we look only at how PDO makes it concrete. The file below opens the connection and prepares a table with two members. It has no output and is only written to disk; the later blocks pull it in with `require`.

```php
<?php
// connection.php — connects to the in-memory database, sets up the members table
declare(strict_types=1);

function connect(array $options = []): PDO {
    $pdo = new PDO('sqlite::memory:', options: $options);
    $pdo->exec('CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT, score INTEGER)');
    $pdo->exec("INSERT INTO members (name, score) VALUES ('alice', 7), ('bob', 3)");
    return $pdo;
}

/** Reports which type a member's score has in PHP. */
function scoreType(string $name, array $options, bool $explicitCast): string {
    $pdo = connect($options);
    $statement = $pdo->prepare('SELECT score FROM members WHERE name = :name');
    $statement->execute(['name' => $name]);
    $score = $statement->fetchColumn();
    return get_debug_type($explicitCast ? (int) $score : $score);
}
```

The error mode determines how PDO reports an error. There are three options: the silent mode leaves the error only as a return value and an error code, the warning mode raises a warning, and the exception mode throws a `PDOException`. Since PHP 8.0 the default is the exception mode; this means that a failed query interrupts the flow and cannot go unnoticed.

A prepared statement sends the query text and the values in two separate steps. `prepare()` prepares the query text; `execute()` binds the values in place of the placeholders. A placeholder is written in two forms: the named placeholder (`:min`) matches the value by its name, and the positional placeholder (`?`) matches it by its order. The value is never written into the query text. What this separation adds to input safety is the job of the Security topic; here only the mechanism is set up.

The fetch mode chooses the form in which a row returns to PHP. `FETCH_ASSOC` gives an array keyed by column names, `FETCH_NUM` gives an array keyed by position numbers, and `FETCH_COLUMN` gives only the values of the first column.

```php
<?php
// database.php — prepared statement, placeholders and fetch modes
declare(strict_types=1);
require 'connection.php';

$pdo = connect();
echo 'error mode is exception: ', var_export($pdo->getAttribute(PDO::ATTR_ERRMODE) === PDO::ERRMODE_EXCEPTION, true), "\n";

$named = $pdo->prepare('SELECT name, score FROM members WHERE score > :min');
$named->execute(['min' => 2]);
echo 'named, FETCH_ASSOC: ', json_encode($named->fetchAll(PDO::FETCH_ASSOC), JSON_UNESCAPED_UNICODE), "\n";

$positional = $pdo->prepare('SELECT name FROM members WHERE score > ?');
$positional->execute([5]);
echo 'positional, FETCH_COLUMN: ', json_encode($positional->fetchAll(PDO::FETCH_COLUMN)), "\n";

$ordered = $pdo->prepare('SELECT name, score FROM members ORDER BY id');
$ordered->execute();
echo 'FETCH_NUM: ', json_encode($ordered->fetch(PDO::FETCH_NUM)), "\n";

try {
    $pdo->prepare('SELECT missing FROM members')->execute();
} catch (PDOException $e) {
    echo 'invalid query: ', get_class($e), "\n";
}
```

```
error mode is exception: true
named, FETCH_ASSOC: [{"name":"alice","score":7},{"name":"bob","score":3}]
positional, FETCH_COLUMN: ["alice"]
FETCH_NUM: ["alice",7]
invalid query: PDOException
```

Three things are visible. The default error mode is the exception mode. The same two rows come back as arrays keyed by column names with the named placeholder, and as names only with the positional placeholder. A query that asks for a column that does not exist does not quietly return nothing; it throws a `PDOException`.

#### Write Queries and the Affected Rows

A read query returns rows; a write query returns no rows and leaves an effect instead. Two pieces of information describe this effect. `lastInsertId()` gives the key of the most recently inserted row. `rowCount()` says how many rows the write query affected. Both are read after the query finishes successfully; since the error mode is the exception mode, a failed write never reaches this line anyway.

`bindValue()` is another way of binding a value to a prepared statement together with its type. The `execute()` array treats every value as text; `bindValue()` declares a type such as `PDO::PARAM_INT` through its third argument. The file below inserts a member, updates its score and reads the number of affected rows.

```php
<?php
// write.php — insert, update and affected rows
declare(strict_types=1);
require 'connection.php';

$pdo = connect();

$insert = $pdo->prepare('INSERT INTO members (name, score) VALUES (:name, :score)');
$insert->bindValue(':name', 'carol');
$insert->bindValue(':score', 9, PDO::PARAM_INT);
$insert->execute();
echo 'last key: ', $pdo->lastInsertId(), "\n";

$update = $pdo->prepare('UPDATE members SET score = score + 1 WHERE score > :min');
$update->execute(['min' => 5]);
echo 'affected rows: ', $update->rowCount(), "\n";

$missing = $pdo->prepare('UPDATE members SET score = 0 WHERE name = :name');
$missing->execute(['name' => 'nobody']);
echo 'missing member, affected rows: ', $missing->rowCount(), "\n";
```

```
last key: 3
affected rows: 2
missing member, affected rows: 0
```

The third line is not an error. An `UPDATE` that matches no rows finishes successfully and affects zero rows. To find out whether a write left an effect, the value of `rowCount()` is checked; no exception is thrown in this case.

#### The Type of the Returned Value

Even when the column type in the database is `INTEGER`, the type in which it arrives in PHP depends on a connection option. Since PHP 8.1, in the default behavior of the SQLite driver an integer column returns as `int`. If the `PDO::ATTR_STRINGIFY_FETCHES` option is on, every value returns as a `string`. The code stays the same, only the option changes. Because the party that performs the deployment supplies the option, the deviation comes from the environment.

The result is the type of the `score` column. The client's deviation changes the member name, but `alice` and `bob` both have a score of the same type. The file below first shows the two types, then takes the party report.

```php
<?php
// type.php — who can change the type of the score column?
declare(strict_types=1);
require 'party.php';
require 'connection.php';

echo 'default: ', scoreType('alice', [], false), "\n";
echo 'stringify fetches on: ', scoreType('alice', [PDO::ATTR_STRINGIFY_FETCHES => true], false), "\n";

partyReport(
    'score type',
    fn(array $d) => scoreType($d['client'] ?? 'alice', $d['environment'] ?? [], false),
    ['client' => 'bob', 'environment' => [PDO::ATTR_STRINGIFY_FETCHES => true]]
);
```

```
default: int
stringify fetches on: string
score type → environment
    client could not change · environment changed
```

The client cannot change the type: whichever member it asks for, the score returns with the same type. The environment can change it: when the option is turned on, the same query brings back a `string` instead of an `int`. By the decision order the first party that can change it is the environment, so the lesson belongs to the environment. The returned type looks like a mere layout detail, but when the value is passed to a typed parameter that expects an `int`, receiving a `string` under `strict_types` throws a `TypeError`.

#### The Bounding Measurement

Ask the same question once more, but this time let the code convert the returned value to `int` itself. The query, the placeholder and the option are the same; only the `(int)` cast has been added. The environment's deviation still changes the connection option and the database still returns a `string`, but the code corrects it before it reaches the result.

```php
<?php
// bound.php — the same measurement with an explicit cast
declare(strict_types=1);
require 'party.php';
require 'connection.php';

partyReport(
    'score type, explicit cast',
    fn(array $d) => scoreType($d['client'] ?? 'alice', $d['environment'] ?? [], true),
    ['client' => 'bob', 'environment' => [PDO::ATTR_STRINGIFY_FETCHES => true]]
);
```

```
score type, explicit cast → server
    client could not change · environment could not change
```

The result moved from the environment to the server. The environment option can still make a change, but the change no longer affects the result, because the type of the result is now determined by the code's own cast. Not every defense mechanism moves the owner this way; for some results the owner stays unchanged. In this example, however, the cast pulls the result into the code.

#### Summary

- The PDO connection, the error mode, the prepared statement and the fetch modes make up the data access layer; the default error mode is the exception mode.
- A prepared statement separates the query text from the values; a placeholder is written as named (`:min`) or positional (`?`).
- The fetch mode chooses the form of the row: an array keyed by column names, a positional array or a single column.
- The type of the returned value depends on a connection option, and because the option comes from the environment, the party in this lesson is the environment.
- When the code converts the value to `int` explicitly, the environment's deviation can no longer change the result and the result passes to the server.

#### Next Step

We have set up the connection and a single query; but a task often consists of more than one write. When the second write fails, what happens to the first? The next lesson handles a write that stops halfway by means of transactions.

### Transactions

Source: https://academia.sh/en/courses/php-web/transactions

Runs a transfer between two accounts with and without a transaction; when the second write fails, measures who can change the balance total, and shows what a transaction without a rollback call does not protect.

The data access layer ran a single query safely. But a task often does not consist of a single query: money leaves one account and is added to another. If something goes wrong between the two writes, the first write stays and the second does not. The question is this: when a write is interrupted halfway, whose hands is the result left in?

The Databases course covered transactions and the ACID properties; there the question was which component atomicity comes from. That question is not asked again here. The deviation is applied and the result is observed: when the second write fails, does the balance total change, and if it does, through whose deviation? The decision order is the same as before: client, third party, environment; if none of them can change the result, the server. The party cell of this lesson is the server: once a transaction is open, the deviation can no longer change the result. As in every lesson, the core file is given verbatim.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

**DA5.** Balances are integers and the `balance >= 0` constraint stays in the table; the two accounts `a` and `b` start with balances of one hundred and fifty, so the total is always one hundred fifty. **DA6.** The environment's deviation is a constraint that the party performing the deployment places on the table: every update written to account `b` is rejected. This makes the second write fail the same way every time. **DA7.** The client sends only the amount to be transferred; its deviation is sending twenty-five instead of forty. **DA8.** The result is the sum of the two balances after the transfer attempt.

#### The Transfer Without a Transaction

A transaction is a wrapper that runs several writes as a single unit. In PDO it is three calls: `beginTransaction()` opens the transaction, `commit()` makes what was written final, and `rollBack()` undoes what was written since the transaction was opened. First look at the case without a transaction. The file below sets up the accounts and writes the transfer as two separate updates; when the `$transactional` flag is on, the same two updates are placed inside a transaction.

```php
<?php
// accounts.php — two accounts and a transfer
declare(strict_types=1);

function setUpAccounts(?string $constraint = null): PDO {
    $pdo = new PDO('sqlite::memory:');
    $pdo->exec('CREATE TABLE accounts (name TEXT PRIMARY KEY, balance INTEGER CHECK (balance >= 0))');
    $pdo->exec("INSERT INTO accounts VALUES ('a', 100), ('b', 50)");
    if ($constraint !== null) $pdo->exec($constraint);
    return $pdo;
}

function total(PDO $pdo): int {
    return (int) $pdo->query('SELECT SUM(balance) FROM accounts')->fetchColumn();
}

function updateBalance(PDO $pdo, string $name, int $delta): void {
    $pdo->prepare('UPDATE accounts SET balance = balance + :delta WHERE name = :name')
        ->execute(['delta' => $delta, 'name' => $name]);
}

function transfer(PDO $pdo, int $amount, bool $transactional): void {
    if ($transactional) $pdo->beginTransaction();
    try {
        updateBalance($pdo, 'a', -$amount);
        updateBalance($pdo, 'b', $amount);
        if ($transactional) $pdo->commit();
    } catch (PDOException $e) {
        if ($transactional) $pdo->rollBack();
    }
}
```

The deviation is a constraint that comes from the environment: every update that writes to account `b` is rejected with `RAISE(ABORT)`. The first write subtracts the amount from account `a`, the second is rejected, and without a transaction the first write stays.

```php
<?php
// without-transaction.php — the transfer without a transaction
declare(strict_types=1);
require 'party.php';
require 'accounts.php';

const B_CONSTRAINT = "CREATE TRIGGER b_lock BEFORE UPDATE ON accounts WHEN NEW.name = 'b'
               BEGIN SELECT RAISE(ABORT, 'rejected'); END";

function transferResult(array $d, bool $transactional): int {
    $pdo = setUpAccounts($d['environment'] ?? null);
    transfer($pdo, $d['client'] ?? 40, $transactional);
    return total($pdo);
}

partyReport('total, without transaction', fn(array $d) => transferResult($d, false), ['client' => 25, 'environment' => B_CONSTRAINT]);
```

```
total, without transaction → environment
    client could not change · environment changed
```

The baseline total is one hundred fifty. When the client changes the amount, the total still stays one hundred fifty, because the amount moves between the two accounts and does not affect the total. When the environment places the constraint, however, the first write stays, the second does not go through, and the total drops. The result belongs to the environment.

#### The Transfer With a Transaction

The same deviation, the same constraint, the same two updates; the only difference is the `$transactional` flag. When the second write is rejected, the `catch` block calls `rollBack()` and what was written since the beginning of the transaction is undone.

```php
<?php
// with-transaction.php — the same transfer inside a transaction
declare(strict_types=1);
require 'party.php';
require 'accounts.php';

const B_CONSTRAINT = "CREATE TRIGGER b_lock BEFORE UPDATE ON accounts WHEN NEW.name = 'b'
               BEGIN SELECT RAISE(ABORT, 'rejected'); END";

function transferResult(array $d, bool $transactional): int {
    $pdo = setUpAccounts($d['environment'] ?? null);
    transfer($pdo, $d['client'] ?? 40, $transactional);
    return total($pdo);
}

partyReport('total, with transaction', fn(array $d) => transferResult($d, true), ['client' => 25, 'environment' => B_CONSTRAINT]);
```

```
total, with transaction → server
    client could not change · environment could not change
```

The environment still makes the second write fail, but the result no longer changes: the first write has also been undone and the total is one hundred fifty. Because no external party can change the result, the result passed to the server. The defense mechanism, that is the transaction, moved the result from the environment to the server. Here the strength of the transaction comes not from the code written itself but from the code placing its `commit()` and `rollBack()` calls in the right places.

#### The State of the Transaction

Whether a transaction is open is asked with `inTransaction()`. PDO does not nest transactions: calling `beginTransaction()` a second time inside an open transaction, and calling `commit()` or `rollBack()` when no transaction is open, both throw a `PDOException`. When a nested section needs to be made reversible, a savepoint takes this on; a savepoint is written as a query, and `ROLLBACK TO` undoes only what comes after that point.

```php
<?php
// state.php — the state of the transaction, double begin and a savepoint
declare(strict_types=1);
require 'accounts.php';

$pdo = setUpAccounts();
echo 'initially open: ', var_export($pdo->inTransaction(), true), "\n";

try { $pdo->commit(); } catch (PDOException $e) { echo 'commit without transaction: ', get_class($e), "\n"; }

$pdo->beginTransaction();
try { $pdo->beginTransaction(); } catch (PDOException $e) { echo 'double begin: ', get_class($e), "\n"; }

updateBalance($pdo, 'a', -10);
$pdo->exec('SAVEPOINT half');
updateBalance($pdo, 'a', -30);
echo 'a before rollback to savepoint: ', $pdo->query("SELECT balance FROM accounts WHERE name = 'a'")->fetchColumn(), "\n";
$pdo->exec('ROLLBACK TO half');
echo 'a after rollback to savepoint: ', $pdo->query("SELECT balance FROM accounts WHERE name = 'a'")->fetchColumn(), "\n";
$pdo->commit();
echo 'committed, total: ', total($pdo), ', open: ', var_export($pdo->inTransaction(), true), "\n";
```

```
initially open: false
commit without transaction: PDOException
double begin: PDOException
a before rollback to savepoint: 60
a after rollback to savepoint: 90
committed, total: 140, open: false
```

The drop of thirty after the savepoint was undone, the drop of ten before it stayed, and `commit()` made only that one final. The total is one hundred forty, because the ten subtracted from account `a` was not added to account `b`. This block has no transfer with two writes; a one-way subtraction is written to show the behavior of the savepoint.

#### The Bounding Measurement

Opening a transaction is not the whole of the protection. Write the same transfer once more, this time without the `rollBack()` call: the `catch` block swallows the exception and the transaction stays open. What happens to the transaction when the connection closes cannot be observed in an in-memory database; the database disappears with the connection and a file database is not opened. What can be observed is the total read from the same connection while the transaction is still open.

```php
<?php
// half.php — the transaction is open, rollBack is not called
declare(strict_types=1);
require 'party.php';
require 'accounts.php';

const B_CONSTRAINT = "CREATE TRIGGER b_lock BEFORE UPDATE ON accounts WHEN NEW.name = 'b'
               BEGIN SELECT RAISE(ABORT, 'rejected'); END";

function halfResult(array $d): int {
    $pdo = setUpAccounts($d['environment'] ?? null);
    $pdo->beginTransaction();
    try {
        updateBalance($pdo, 'a', -($d['client'] ?? 40));
        updateBalance($pdo, 'b', $d['client'] ?? 40);
        $pdo->commit();
    } catch (PDOException $e) {
        // exception caught but the rollback was forgotten
    }
    return total($pdo);
}

partyReport('total, no rollback', 'halfResult', ['client' => 25, 'environment' => B_CONSTRAINT]);

$pdo = setUpAccounts(B_CONSTRAINT);
$pdo->beginTransaction();
try { updateBalance($pdo, 'a', -40); updateBalance($pdo, 'b', 40); } catch (PDOException $e) {}
echo 'transaction still open: ', var_export($pdo->inTransaction(), true), "\n";
```

```
total, no rollback → environment
    client could not change · environment changed
transaction still open: true
```

Even with the transaction open, the environment's deviation changed the total. When `rollBack()` is not called, a transaction undoes nothing for as long as it stays open; it only postpones the commit. PDO rolls back a transaction left open on its own when the script ends or when the connection closes, but until that moment the total read from the same connection is already reduced. The result was not moved to the server; it stayed with the environment. Opening a transaction and closing it correctly are not the same thing.

#### Summary

- A transaction runs several writes as a single unit with `beginTransaction()`, `commit()` and `rollBack()`.
- In a transfer without a transaction, when the second write fails the first write stays and the balance total changes; this deviation comes from the environment.
- In the same transfer with a transaction, `rollBack()` also undoes the first write and the total is not affected by the deviation; in this lesson the party is the server.
- A transaction on which `rollBack()` is not called stays open and the result stays with the environment.
- Leaving the effect of a write half done hands the result over to one of the external parties.

#### Next Step

The transaction protected the consistency of the data, but where do the tables themselves come from? The schema is also the result of a write, and it may not be the same in every environment. The next lesson handles who determines the schema through migration management.

### Migration Management

Source: https://academia.sh/en/courses/php-web/migration-management

Builds migration files, a migration log table, ordered application and rollback in a small mechanism; measures that the database state decides how many migrations are applied in a run, while the code decides the schema version.

The transaction kept a write whole. But the tables those writes stand on have to come from somewhere, and as the application grows, those tables change. Changing the schema by hand leads to a state in which nobody knows which change was made in which environment. The question is this: which database determines the schema?

The Migrations and Seeding lesson in the Web Application Framework course measured that a migration runs. This lesson does not explain the migration itself again; what it asks is who can change the number of migrations applied in this run. The schema migrations lesson in the Databases course also introduced the concept already; here only a PHP mechanism is built. The decision order does not change: client, third party, environment; if none of them can change the result, the server. The dominant party of this lesson is the environment. The core file is given verbatim.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

**DA9.** Migration files are PHP arrays and each migration carries one up query and one down query; the file system is not read. **DA10.** The migration log is the `migrations` table and holds the name of every migration that has been applied. **DA11.** The environment's deviation is the state of the database: an empty database, or a database to which the migrations have already been applied. **DA12.** The client sends only the name of the member to be added after the migrations; its deviation is sending `bob` instead of `alice`.

#### Migrations, the Log and the Order

A migration is a file with an ordered name that describes a single change to the schema. The number at the start of the name determines the order: `001` is applied first, `003` later. The migration log is the table that holds which migrations have been applied to this database. The runner executes the migrations that are not in the log in order, applies each one inside a transaction, and adds its name to the log when the transaction ends. Because the log entry and the change are written in the same transaction, neither stays without the other.

Rollback uses the same structure in reverse: the down query of the last migration in the log is executed and its name is deleted from the log. The file below defines three migrations, the log and two runners. It has no output and is only written to disk.

```php
<?php
// migrations.php — migration files, the migration log and ordered application
declare(strict_types=1);

const MIGRATIONS = [
    '001-members' => ['CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT)', 'DROP TABLE members'],
    '002-score' => ['ALTER TABLE members ADD COLUMN score INTEGER DEFAULT 0', 'ALTER TABLE members DROP COLUMN score'],
    '003-index' => ['CREATE INDEX members_name ON members (name)', 'DROP INDEX members_name'],
];

function setUpMigrationLog(PDO $pdo): void {
    $pdo->exec('CREATE TABLE IF NOT EXISTS migrations (name TEXT PRIMARY KEY)');
}

function appliedMigrations(PDO $pdo): array {
    return $pdo->query('SELECT name FROM migrations ORDER BY name')->fetchAll(PDO::FETCH_COLUMN);
}

/** Applies the migrations that are not in the log, in order; returns the names applied in this run. */
function applyMigrations(PDO $pdo): array {
    setUpMigrationLog($pdo);
    $done = appliedMigrations($pdo);
    $thisRun = [];
    foreach (MIGRATIONS as $name => [$up, $down]) {
        if (in_array($name, $done, true)) continue;
        $pdo->beginTransaction();
        $pdo->exec($up);
        $pdo->prepare('INSERT INTO migrations (name) VALUES (:name)')->execute(['name' => $name]);
        $pdo->commit();
        $thisRun[] = $name;
    }
    return $thisRun;
}

/** Rolls back the last migration in the log; returns the name of the one rolled back. */
function rollBackLastMigration(PDO $pdo): ?string {
    $done = appliedMigrations($pdo);
    $name = end($done);
    if ($name === false) return null;
    $pdo->beginTransaction();
    $pdo->exec(MIGRATIONS[$name][1]);
    $pdo->prepare('DELETE FROM migrations WHERE name = :name')->execute(['name' => $name]);
    $pdo->commit();
    return $name;
}

function currentVersion(PDO $pdo): ?string {
    $name = $pdo->query('SELECT MAX(name) FROM migrations')->fetchColumn();
    return $name === false || $name === null ? null : $name;
}
```

Now run the mechanism on an empty database. In the second run every migration is in the log, so no migration is applied.

```php
<?php
// apply.php — application, the second run and rollback
declare(strict_types=1);
require 'migrations.php';

$pdo = new PDO('sqlite::memory:');
echo 'first run: ', implode(', ', applyMigrations($pdo)), "\n";
echo 'second run: ', count(applyMigrations($pdo)), " migrations\n";
echo 'rolled back: ', rollBackLastMigration($pdo), "\n";
echo 'log: ', implode(', ', appliedMigrations($pdo)), "\n";
echo 're-applied: ', implode(', ', applyMigrations($pdo)), "\n";

echo 'columns: ', implode(', ', $pdo->query("SELECT name FROM pragma_table_info('members')")->fetchAll(PDO::FETCH_COLUMN)), "\n";
```

```
first run: 001-members, 002-score, 003-index
second run: 0 migrations
rolled back: 003-index
log: 001-members, 002-score
re-applied: 003-index
columns: id, name, score
```

The second run applied zero migrations, because the log, not the migration file, decides whether a migration is applied. The migration rolled back is only the last one, and because it was deleted from the log, it is applied again in the next run. The files stay the same; what changes is the log.

#### A Migration That Carries Data

A schema migration changes only the table structure, but some migrations do more than add a column: they also write values into it. The down query of such a migration restores the schema to its old state, but it does not restore the written values. The file below applies the three migrations, inserts two members, adds a `level` column and fills it from the score, and then removes the column with the down query.

```php
<?php
// data-migration.php — rolling back a migration that adds a column and fills it
declare(strict_types=1);
require 'migrations.php';

$pdo = new PDO('sqlite::memory:');
applyMigrations($pdo);
$pdo->exec("INSERT INTO members (name, score) VALUES ('alice', 7), ('bob', 3)");

$pdo->exec('ALTER TABLE members ADD COLUMN level TEXT');
$pdo->exec("UPDATE members SET level = CASE WHEN score > 5 THEN 'high' ELSE 'low' END");
echo 'after the migration: ', json_encode($pdo->query('SELECT name, level FROM members ORDER BY id')->fetchAll(PDO::FETCH_ASSOC), JSON_UNESCAPED_UNICODE), "\n";

$pdo->exec('ALTER TABLE members DROP COLUMN level');
echo 'after the rollback: ', json_encode($pdo->query('SELECT * FROM members ORDER BY id')->fetchAll(PDO::FETCH_ASSOC), JSON_UNESCAPED_UNICODE), "\n";
```

```
after the migration: [{"name":"alice","level":"high"},{"name":"bob","level":"low"}]
after the rollback: [{"id":1,"name":"alice","score":7},{"id":2,"name":"bob","score":3}]
```

The schema came back; the `level` values did not. A rollback restores a structure; it does not bring back written data. For this reason the down query of a migration that carries data may require taking a backup before the write, or being marked as irreversible. This decision belongs to whoever writes the migration and it stays recorded in the code; it does not depend on the environment the migration runs in.

#### Whose Result Is It

The measured result is the number of migrations applied in this run. The same code applies three migrations on an empty database and none on a database to which the migrations have already been applied. Which of the two states holds is not inside the code; it is in the database the application connects to, and the party that prepares the database is the one performing the deployment. The client, on the other hand, adds a member after the migrations are finished; this changes nothing in the log.

```php
<?php
// migration-result.php — how many migrations were applied in this run?
declare(strict_types=1);
require 'party.php';
require 'migrations.php';

function appliedCount(array $d): int {
    $pdo = new PDO('sqlite::memory:');
    if (($d['environment'] ?? 'empty') === 'current') applyMigrations($pdo);
    $thisRun = applyMigrations($pdo);
    $pdo->prepare('INSERT INTO members (name) VALUES (:name)')->execute(['name' => $d['client'] ?? 'alice']);
    return count($thisRun);
}

echo 'empty database: ', appliedCount([]), "\n";
echo 'current database: ', appliedCount(['environment' => 'current']), "\n";
partyReport('applied migration count', 'appliedCount', ['client' => 'bob', 'environment' => 'current']);
```

```
empty database: 3
current database: 0
applied migration count → environment
    client could not change · environment changed
```

The client did not change the count: the name of the member has no part in whether the migrations are applied. The environment changed it: when the database is current, the count dropped from three to zero. The result belongs to the environment. The migration mechanism itself works correctly; but the result of a single run reflects the state of the database that runs it, not the mechanism.

#### The Bounding Measurement

Take another result from the same mechanism: the schema version after the migrations have run, that is, the name of the last migration in the log. Whether the database is empty or current, when the migrations have run the same last name stands in the log. This result does not depend on the starting state of the database.

```php
<?php
// migration-bound.php — the schema version after the run
declare(strict_types=1);
require 'party.php';
require 'migrations.php';

function versionAfter(array $d): string {
    $pdo = new PDO('sqlite::memory:');
    if (($d['environment'] ?? 'empty') === 'current') applyMigrations($pdo);
    applyMigrations($pdo);
    $pdo->prepare('INSERT INTO members (name) VALUES (:name)')->execute(['name' => $d['client'] ?? 'alice']);
    return currentVersion($pdo) ?? 'none';
}

partyReport('version after the run', 'versionAfter', ['client' => 'bob', 'environment' => 'current']);
```

```
version after the run → server
    client could not change · environment could not change
```

Although the state of the database changed, the version did not change; it is the last name in the migration files, not the starting state of the deployment, that determines the version. Two different owners came out of the same mechanism: the applied count belongs to the environment, the version after the run to the server. A migration mechanism should be read according to which question it answers.

#### Summary

- A migration is a file with an ordered name; the migration log table holds which ones have been applied, and the runner executes only those not in the log, in order.
- Each migration and its log entry are written in the same transaction; a rollback executes the down query of the last migration and deletes its name from the log.
- The number of migrations applied in this run depends on the state of the database; in this lesson the party is the environment.
- The schema version after the run is the same in every environment and the result belongs to the server.
- Two results taken from the same mechanism can belong to different parties.

#### Next Step

The schema is now on record; but which queries does the object–relational mapping built on top of this schema send in the background? The next lesson counts the queries that go out while printing a list.

### Queries Behind Object–Relational Mapping

Source: https://academia.sh/en/courses/php-web/queries-behind-object-relational-mapping

Counts the queries sent while a list is printed with a counter; measures that the query count grows with the rows in lazy loading, stays at two in eager loading, and that the query text can still change with the data sent.

The schema is now on record. When an object–relational mapping is built on top of it, the application code sees objects instead of rows and does not write the queries. The queries are still sent; only where they go and how many of them go can no longer be read from the code. The question is this: how many queries go to the database while a list is printed?

This lesson does not build a model. The Object–Relational Mapping and Relationships lessons in the Web Application Framework course covered the model and when a relationship is loaded. An earlier course also measured the query count; in that measurement the number was only a count of runs. Here the number is a result and what is asked is its owner: does the client, the environment or the server determine the number? The decision order is the same: client, third party, environment; if none of them can change the result, the server. The party cell of this lesson is the server, and the core is below.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

**DA13.** What the mapping does is shown with a small class that wraps PDO calls; every `select()` call counts as one query and the query text is recorded as well. **DA14.** There are three authors and six posts; the authors of the posts are `1` and `2`, and author `3` has no posts yet. There is no query cache. **DA15.** The client's deviation is adding one more post; the value of the deviation is the author number of the added post. **DA16.** The result is the number of queries sent while a list is printed.

#### The Counter and Two Loaders

The file below sets up the counter and the data, and then loads the list in two ways. Lazy loading fetches the list with one query and then asks for the author of each post with a separate query. Eager loading fetches the list with one query and then brings all the required authors in a single additional query with an `IN (...)` list. This behavior in the lazy form is called the N+1 problem: one list query, plus one query per row.

```php
<?php
// counter.php — a wrapper that counts PDO calls and two list loaders
declare(strict_types=1);

final class CountingDatabase {
    public int $queries = 0;
    public array $texts = [];

    public function __construct(private PDO $pdo) {}

    public function select(string $sql, array $values = []): array {
        $this->queries++;
        $this->texts[] = $sql;
        $statement = $this->pdo->prepare($sql);
        $statement->execute($values);
        return $statement->fetchAll(PDO::FETCH_ASSOC);
    }
}

function setUpData(?int $extraAuthor = null): CountingDatabase {
    $pdo = new PDO('sqlite::memory:');
    $pdo->exec('CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT)');
    $pdo->exec('CREATE TABLE posts (id INTEGER PRIMARY KEY, author_id INTEGER, title TEXT)');
    $pdo->exec("INSERT INTO authors VALUES (1, 'alice'), (2, 'bob'), (3, 'carol')");
    $insert = $pdo->prepare('INSERT INTO posts (author_id, title) VALUES (?, ?)');
    for ($i = 1; $i <= 6; $i++) $insert->execute([($i % 2) + 1, "post $i"]);
    if ($extraAuthor !== null) $insert->execute([$extraAuthor, 'post 7']);
    return new CountingDatabase($pdo);
}

function listLazy(CountingDatabase $db): array {
    $lines = [];
    foreach ($db->select('SELECT author_id, title FROM posts ORDER BY id') as $post) {
        $author = $db->select('SELECT name FROM authors WHERE id = ?', [$post['author_id']]);
        $lines[] = $post['title'] . ' — ' . $author[0]['name'];
    }
    return $lines;
}

function listEager(CountingDatabase $db): array {
    $posts = $db->select('SELECT author_id, title FROM posts ORDER BY id');
    $ids = array_values(array_unique(array_column($posts, 'author_id')));
    sort($ids);
    $placeholders = implode(', ', array_fill(0, count($ids), '?'));
    $names = array_column($db->select("SELECT id, name FROM authors WHERE id IN ($placeholders)", $ids), 'name', 'id');
    return array_map(fn(array $p) => $p['title'] . ' — ' . $names[$p['author_id']], $posts);
}
```

Run the two loaders on the same data. The list should be the same; the difference shows up only in the query count.

```php
<?php
// list.php — two loaders, the same list, a different query count
declare(strict_types=1);
require 'counter.php';

$lazy = setUpData();
$lazyList = listLazy($lazy);
$eager = setUpData();
$eagerList = listEager($eager);

echo 'first line: ', $lazyList[0], "\n";
echo 'same list: ', var_export($lazyList === $eagerList, true), "\n";
echo 'lazy: ', $lazy->queries, " queries\n";
echo 'eager: ', $eager->queries, " queries\n";
```

```
first line: post 1 — bob
same list: true
lazy: 7 queries
eager: 2 queries
```

Both loaders produced the same six lines. Lazy loading sent seven queries: one for the list and six for the authors. Eager loading sent two. The mapping tool makes the two look the same on the code side; the difference remains only in the traffic that goes to the database.

#### Whose Number Is It

Now treat the number as a result. The client's deviation is one more post being added to the list, and the author of the post is number `3`. In lazy loading every post brings an author query, so the count goes from seven to eight. In eager loading the author query stays single; the new post does not take part in the query count, it only adds one value to the `IN` list.

```php
<?php
// query-count.php — who can change the query count?
declare(strict_types=1);
require 'party.php';
require 'counter.php';

function queryCount(string $loading, ?int $extraAuthor): int {
    $db = setUpData($extraAuthor);
    $loading === 'lazy' ? listLazy($db) : listEager($db);
    return $db->queries;
}

partyReport('lazy loading', fn(array $d) => queryCount('lazy', $d['client'] ?? null), ['client' => 3]);
partyReport('eager loading', fn(array $d) => queryCount('eager', $d['client'] ?? null), ['client' => 3]);
```

```
lazy loading → client
    client changed
eager loading → server
    client could not change
```

The client could change the query count of lazy loading because it grows with the number of rows: every new post raised the count by one. In eager loading the count stayed at two; because the deviation had no effect, the result passed to the server. The defense mechanism, that is eager loading, moved the result from the client to the server. This means that the loading style is chosen in the code; the choice is made by the code that is written, not by the incoming request.

#### When the Author Is Never Read

Eager loading does not always send fewer queries. If the list prints only the titles, the author is never accessed; in this case lazy loading never sends the relationship query, while eager loading still sends a query whose result will not be used. The difference between the two loading styles depends on whether the relationship is accessed. The file below rebuilds the same list with the two loaders so that only the titles are printed.

```php
<?php
// titles.php — a list in which the relationship is never accessed
declare(strict_types=1);
require 'counter.php';

$lazy = setUpData();
$titles = array_column($lazy->select('SELECT title FROM posts ORDER BY id'), 'title');
echo 'lazy, titles only, query count: ', $lazy->queries, "\n";

$eager = setUpData();
listEager($eager);
echo 'eager, authors fetched in vain, query count: ', $eager->queries, "\n";
$titles2 = array_column($eager->select('SELECT title FROM posts ORDER BY id'), 'title');
echo 'titles the same: ', var_export($titles === $titles2, true), "\n";
```

```
lazy, titles only, query count: 1
eager, authors fetched in vain, query count: 2
titles the same: true
```

When lazy loading asked only for the titles, a single query was enough. Eager loading fetched the authors needlessly. For this reason the protection that eager loading moves to the server holds for lists in which the relationship is actually used; for a relationship that is not used, the same choice sends an extra query.

#### The Bounding Measurement

The query count of eager loading is constant; but what about the query itself? The number of placeholders in the `IN` list is built from the number of distinct authors in the list. If the text of the second query is taken as the result instead of the query count, the question changes. Try the two forms of the client's deviation: when the new post belongs to a new author (`3`) and when it belongs to an author who is already in the list (`1`).

```php
<?php
// query-text.php — the query text, not the query count
declare(strict_types=1);
require 'party.php';
require 'counter.php';

function secondQuery(?int $extraAuthor): string {
    $db = setUpData($extraAuthor);
    listEager($db);
    return $db->texts[1];
}

partyReport('IN list, new author', fn(array $d) => secondQuery($d['client'] ?? null), ['client' => 3]);
partyReport('IN list, existing author', fn(array $d) => secondQuery($d['client'] ?? null), ['client' => 1]);
```

```
IN list, new author → client
    client changed
IN list, existing author → server
    client could not change
```

The result depends on the data sent. When a new author entered the list, the number of placeholders grew and the query text changed by the client's hand. When a post of an existing author was added, the text stayed the same. The query count had moved to the server; the query text had not. Which result a defense protects depends on what that result measures.

#### Summary

- An object–relational mapping hides the queries; a counter that wraps PDO calls makes visible how many queries go out.
- In lazy loading the query count grows with the number of rows (the N+1 problem); the client can change the count.
- In eager loading the query count stays at two with the `IN` list; the party in this lesson is the server.
- In the same mechanism the query text can be changed by the client according to the data sent.
- What a defense protects depends on the measured result: the count was protected, the text was not.

#### Next Step

While the number and the text of the queries are under control, the data the queries return also crosses a boundary. Data that comes from a partner outside the application arrives as JSON, XML or CSV, and its format can change. The next lesson handles what happens when the partner's data changes format.

### Data Formats

Source: https://academia.sh/en/courses/php-web/data-formats

Parses JSON, XML and CSV text from a partner; measures who can change the result when the partner writes the same field differently, and shows whose choice the number notation in our own response is.

The number of queries and their text were in our hands. But an application does not talk only to its own database; it also receives data from a partner outside, and this data arrives as JSON, XML or CSV. The partner decides the format. The question is this: what happens if the partner's data changes format?

The measuring axis of this lesson is again the owner of the result. The decision order does not change: client, third party, environment; if none of them can change the result, the server. The dominant party is the third party: the partner the application connects to is the only party outside the code that it connects to. The text from the partner is not fetched over a network; in this lesson it is written into the code as a fixed string and no network call is made. The core file is given verbatim.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

**DA17.** The text the partner sends is a fixed string produced by a function; it describes the same two products in three formats: the codes `k1` and `k2`, the prices `12.5` and `8`. **DA18.** The partner's deviation is writing the same field differently: it sends the price as a string instead of a number, or it writes the field name `cost` instead of `price`. **DA19.** The code reads the partner's data as it is without validating it; validation is the job of the Security topic. **DA20.** The result is the JSON representation of the parsed price list.

#### Three Formats

JSON (JavaScript Object Notation) is a text format that carries nested arrays and objects. In PHP, `json_decode()` turns it into an array. On invalid text the default behavior is to return `null` silently; the `JSON_THROW_ON_ERROR` flag turns this into a `JsonException` so that the error cannot go unnoticed. XML is a tree built from tags; `simplexml_load_string()` turns it into an object. CSV rests on lines and fields separated by commas; `str_getcsv()` splits one line into an array. The limit is the same in all three: the code has to know the shape of what is parsed itself.

The file below produces the partner's text in the three formats and defines a parser that reads the prices for each. It has no output and is only written to disk.

```php
<?php
// partner.php — the partner's three formats and parsers that read the prices
declare(strict_types=1);

function partnerJson(?string $deviation = null): string {
    $first = $deviation === 'string' ? '"12.5"' : '12.5';
    $second = $deviation === 'string' ? '"8"' : '8';
    $field = $deviation === 'field' ? 'cost' : 'price';
    return '{"products":[{"code":"k1","' . $field . '":' . $first . '},{"code":"k2","' . $field . '":' . $second . '}]}';
}

function partnerXml(): string {
    return '<products><product code="k1"><price>12.5</price></product><product code="k2"><price>8</price></product></products>';
}

function partnerCsv(): string {
    return "code,price\nk1,12.5\nk2,8";
}

function jsonPrices(string $text): array {
    $data = json_decode($text, true, flags: JSON_THROW_ON_ERROR);
    return array_map(fn(array $p) => $p['price'] ?? null, $data['products']);
}

function xmlPrices(string $text): array {
    $root = simplexml_load_string($text);
    $prices = [];
    foreach ($root->product as $product) $prices[] = (string) $product->price;
    return $prices;
}

function csvPrices(string $text): array {
    $lines = explode("\n", $text);
    array_shift($lines);
    return array_map(fn(string $line) => str_getcsv($line, escape: '')[1], $lines);
}
```

The `escape: ''` argument in the `str_getcsv()` call turns off treating the backslash as an escape character; the only rule of CSV itself is the quotation mark. Run the three parsers and look at invalid JSON and invalid XML.

```php
<?php
// formats.php — three parsers and invalid input
declare(strict_types=1);
require 'partner.php';

echo 'json: ', json_encode(jsonPrices(partnerJson())), "\n";
echo 'xml: ', json_encode(xmlPrices(partnerXml())), "\n";
echo 'csv: ', json_encode(csvPrices(partnerCsv())), "\n";

try {
    jsonPrices('{"products":[');
} catch (JsonException $e) {
    echo 'invalid json: ', get_class($e), ', message: ', $e->getMessage(), "\n";
}

libxml_use_internal_errors(true);
echo 'invalid xml: ', var_export(simplexml_load_string('<products><product>'), true), "\n";
```

```
json: [12.5,8]
xml: ["12.5","8"]
csv: ["12.5","8"]
invalid json: JsonException, message: Syntax error
invalid xml: false
```

JSON brought the numbers as numbers; XML and CSV brought every value as text. This difference comes from the parser and is not the partner's decision: the `12.5` inside an XML tag is always text by the nature of the format. Invalid JSON threw a `JsonException`; invalid XML returned `false` instead of an error. The code itself has to check both.

#### When the Partner Writes the Same Field Differently

The partner's deviation is a small departure from the format the code expects. It writes the price as `"12.5"` instead of `12.5`, or it names the field `cost` instead of `price`. In the first case the code gives no error, only the type of the returned value changes. In the second case the field cannot be found and the code reads `null`. Both are in the partner's hands.

```php
<?php
// partner-result.php — who can change the prices?
declare(strict_types=1);
require 'party.php';
require 'partner.php';

function prices(array $d): string {
    return json_encode(jsonPrices(partnerJson($d['third party'] ?? null)));
}

partyReport('prices, string', 'prices', ['third party' => 'string']);
partyReport('prices, field name', 'prices', ['third party' => 'field']);
```

```
prices, string → third party
    third party changed
prices, field name → third party
    third party changed
```

In both deviations the result is in the third party's hands. In the string case the prices stopped being numbers; their appearance inside quotation marks in the output shows this, and passing them to a function that expects a number under `strict_types` would now be a type mismatch. In the field name case none of the prices could be read. The code stayed the same; the partner's text changed. For this reason the result of parsing is correct as long as the partner's format is followed; the agreement depends on the partner's word.

#### What the Parser Guarantees and What It Does Not

In CSV, a field that contains a comma is put in double quotes. If the partner applies this rule, the parser keeps the field whole; if it does not, the line is split into more columns than expected. Read the same line in two forms.

```php
<?php
// quote.php — a CSV field that contains a comma
declare(strict_types=1);

$quoted = 'k3,"Pen, blue",4.5';
$unquoted = 'k3,Pen, blue,4.5';
echo 'quoted: ', json_encode(str_getcsv($quoted, escape: ''), JSON_UNESCAPED_UNICODE), "\n";
echo 'unquoted: ', json_encode(str_getcsv($unquoted, escape: ''), JSON_UNESCAPED_UNICODE), "\n";
```

```
quoted: ["k3","Pen, blue","4.5"]
unquoted: ["k3","Pen"," blue","4.5"]
```

The quoted line gave three fields, the unquoted line four. The parser worked correctly in both cases; the difference came from the text the partner wrote. The same distinction holds for JSON and XML: the parser applies the rules of the format and does not decide whether the partner followed those rules. For this reason none of the three parsers can know in advance that a price will be a number; only the partner's contract knows this, and the contract is outside the code.

#### The Bounding Measurement

Now the direction is reversed: we give a value we received from the partner back in our own response as JSON. The discount the partner sends may be `1.0` or `1`; `json_decode()` brings one as a float and the other as an integer. When the response is produced with `json_encode()`, PHP writes the value `1.0` as `1` by default; the `JSON_PRESERVE_ZERO_FRACTION` flag keeps the fractional part and writes `1.0`. We are the ones who choose this notation.

```php
<?php
// response.php — the number notation in our own response
declare(strict_types=1);
require 'party.php';

echo 'no flag: ', json_encode(1.0), "\n";
echo 'with flag: ', json_encode(1.0, JSON_PRESERVE_ZERO_FRACTION), "\n";

function response(array $d, int $flag, bool $force): string {
    $text = $d['third party'] ?? '{"discount":1.0}';
    $discount = json_decode($text, true, flags: JSON_THROW_ON_ERROR)['discount'];
    return json_encode(['discount' => $force ? (float) $discount : $discount], $flag);
}

$deviation = ['third party' => '{"discount":1}'];
partyReport('default flag', fn(array $d) => response($d, 0, false), $deviation);
partyReport('flag, partner notation', fn(array $d) => response($d, JSON_PRESERVE_ZERO_FRACTION, false), $deviation);
partyReport('flag and float cast', fn(array $d) => response($d, JSON_PRESERVE_ZERO_FRACTION, true), $deviation);
```

```
no flag: 1
with flag: 1.0
default flag → server
    third party could not change
flag, partner notation → third party
    third party changed
flag and float cast → server
    third party could not change
```

With the default flag the response is the same output whether the partner writes `1` or `1.0`: the result is on the server. Only when the flag is added does the response start to carry the notation the partner wrote, and the result returns to the third party. When the value is also converted to `float` in addition to the flag, the notation of the response is again entirely in our hands. How a number looks in the response is the server's choice; the partner determines only as much as we leave to it.

#### Summary

- JSON, XML and CSV are read with separate parsers; `JSON_THROW_ON_ERROR` turns invalid JSON into a `JsonException`, and `escape: ''` is given in the `str_getcsv()` call.
- XML and CSV values arrive as text; JSON numbers arrive as numbers. The parser determines the type, not the partner.
- If the partner writes the same field as a string or under another name, the price list changes; in this lesson the party is the third party.
- The number notation in our own response is determined by the `json_encode()` flag and the type conversion, and it is the server's choice.
- The same partner data can belong to the third party in one result and to the server in another.

#### Next Step

We parsed the partner's data once; but to reach the partner a request has to be sent first. Between the request and the response there is also a timeout. The next lesson handles what the client does when the service does not respond.

### The HTTP Client

Source: https://academia.sh/en/courses/php-web/http-client

Builds an HTTP client from request and response objects, a transport closure, a timeout exception, retry and a fallback value; measures how the price reported by the service and these defenses change who owns the result.

In the previous lesson we parsed the text the partner sent. We did not show how that text reached us. This lesson handles the delivery, and says one thing at the start: this lesson does not go out to the network. The lesson code runs in an environment that does not connect to the network; for this reason a closure that takes the request and returns a response is used instead of a real service. The question is this: what does the client do if the service does not respond, and who determines the result?

The measuring axis is again the owner of the result. The decision order does not change: client, third party, environment; if none of them can change the result, the server. The dominant party of this lesson is the third party: the service we connect to. The core file is given verbatim.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

**DA21.** The service reports the price of a product as JSON; the price is `12.5` by default. **DA22.** The third party's deviation has two forms: the service reports the price as `15.5`, or it does not respond on the first call and a timeout occurs. **DA23.** The transport is a single closure: it takes a request object and returns a response object or throws a timeout exception. **DA24.** The timeout happens only on the first call; on the second call the service responds.

#### The Request, the Response and the Transport

An HTTP request has four parts: a method, a URL, headers and a body. A real response has headers as well; in this lesson the response is modeled with only a status code and a body. We can model these with two small classes. The transport, that is the part that actually sends the request over a socket, is kept separate from the client and given as a closure. This separation has two benefits: the client logic can be tried without a network, and the client does not change when the transport changes.

```php
<?php
// http-client.php — request, response, timeout and the price client
declare(strict_types=1);

final class Request {
    public function __construct(
        public readonly string $method,
        public readonly string $url,
        public readonly array $headers = [],
        public readonly string $body = '',
    ) {}
}

final class Response {
    public function __construct(
        public readonly int $status,
        public readonly string $body,
    ) {}
}

final class TimeoutException extends RuntimeException {}

function buildService(?string $deviation): callable {
    $calls = 0;
    return function (Request $request) use ($deviation, &$calls): Response {
        $calls++;
        if ($deviation === 'timeout' && $calls === 1) {
            throw new TimeoutException('the service did not respond');
        }
        $price = $deviation === 'new' ? 15.5 : 12.5;
        return new Response(200, json_encode(['price' => $price]));
    };
}

function fetchPrice(callable $transport, int $attempts = 1, ?float $fallback = null): ?float {
    $request = new Request('GET', 'https://service.test/product/k1', ['Accept' => 'application/json']);
    for ($i = 0; $i < $attempts; $i++) {
        try {
            $response = $transport($request);
            return (float) json_decode($response->body, true, flags: JSON_THROW_ON_ERROR)['price'];
        } catch (TimeoutException) {
            continue;
        }
    }
    return $fallback;
}
```

The name `service.test` is only an example domain name; the closure never resolves it. The `TimeoutException` class carries the transport's "no response came" state as an exception. The `fetchPrice()` function builds the request, hands it to the transport closure, parses the response body and retries on a timeout; when the attempts run out it returns the fallback value. First look at the three basic cases.

```php
<?php
// basics.php — the request object and three calls
declare(strict_types=1);
require 'http-client.php';

$request = new Request('GET', 'https://service.test/product/k1', ['Accept' => 'application/json']);
echo $request->method, ' ', $request->url, "\n";
echo 'header: ', json_encode($request->headers), "\n";

$response = buildService(null)($request);
echo 'status: ', $response->status, ', body: ', $response->body, "\n";

var_dump(fetchPrice(buildService(null)));
var_dump(fetchPrice(buildService('timeout')));
var_dump(fetchPrice(buildService('timeout'), 2));
var_dump(fetchPrice(buildService('timeout'), 1, 10.0));
```

```
GET https://service.test/product/k1
header: {"Accept":"application\/json"}
status: 200, body: {"price":12.5}
float(12.5)
NULL
float(12.5)
float(10)
```

When the service was healthy, the price came as `12.5`. With a service that times out on the first call, a single attempt and no fallback value gives `NULL`; the price is not known. With two attempts the second call responded and the price arrived. With a single attempt and a fallback value, the price became `10.0`, which the service never said. Three defenses give three different results; which one is right is the application's decision.

#### The Price the Service Reports

If the service reports the price as `15.5`, the client passes it on as it is. The client does not compute the price, it only carries it. In this case the result changes without a single line of the code changing.

```php
<?php
// price-result.php — whose is the price the service reports?
declare(strict_types=1);
require 'party.php';
require 'http-client.php';

partyReport('price', fn(array $d) => (string) fetchPrice(buildService($d['third party'] ?? null)), ['third party' => 'new']);
```

```
price → third party
    third party changed
```

It is the service that changed the price; the client, the environment and the server did not. The owner of a value taken from outside is the party that writes the value.

#### The Bounding Measurement

When a timeout occurs there are two defenses: retrying and returning to a fallback value. Both bound the result, that is, they determine its worst case. The question is this: do these defenses change the owner of the result? Measure this with the same deviation, the service not responding on the first call, in three configurations.

```php
<?php
// defense.php — the owner of the result on a timeout
declare(strict_types=1);
require 'party.php';
require 'http-client.php';

function outcome(array $d, int $attempts, ?float $fallback): string {
    return var_export(fetchPrice(buildService($d['third party'] ?? null), $attempts, $fallback), true);
}

$deviation = ['third party' => 'timeout'];
partyReport('no defense', fn(array $d) => outcome($d, 1, null), $deviation);
partyReport('fallback value', fn(array $d) => outcome($d, 1, 10.0), $deviation);
partyReport('retry', fn(array $d) => outcome($d, 2, null), $deviation);
```

```
no defense → third party
    third party changed
fallback value → third party
    third party changed
retry → server
    third party could not change
```

In the client without a defense, the timeout made the result `NULL` and its owner is the third party. The fallback value put `10.0` in place of `NULL`; the result settled on a bound, but the service not responding still changed the result, so the owner is still the third party. The bound was written, the owner did not change. With retry, the second call gave a healthy response and the result came out the same as the measurement without a deviation; this defense moved the owner to the server. The two are defenses built against the same deviation, but their effects are different. A defense bounding the result and a defense changing the owner are two separate things: the fallback value did only the first.

A warning is needed: that retry moves the owner to the server depends only on the condition that the service responds on the second call. DA24 assumes this. If the service never responds, retry does not save the result either.

#### Summary

- A request is made of a method, URL, headers and a body; a response is made of a status code and a body. When the transport is separated as a closure, the client can be tried without a network.
- A timeout is an exception; the client catches it and looks at the attempts left or the fallback value.
- The owner of the price the service reports is the third party; the client only carries the value.
- A fallback value bounds the result but does not change the owner; the owner measured in this lesson is still the third party.
- As long as the service responds on the second call, retry moves the owner to the server.

#### Next Step

The client now receives the response, parses it and defends against a timeout. But when the value taken from the response enters the application, and the client's own inputs are taken into account, who decides whether these values are valid? The next topic starts with this question in the Input Validation and Sanitization lesson of Security.

## Security

Input validation and sanitization, SQL injection, cross-site scripting, request forgery, password storage and authentication flows — each as a defense that takes a result out of the hands of the client or a third party and moves it to the server.

### Input Validation and Sanitization

Source: https://academia.sh/en/courses/php-web/input-validation-and-sanitization

Narrowing a client input to the valid set with filter_var(), and measuring that validation and sanitization are separate steps and that validation does not take the power to change the value away from the client.

In The HTTP Client lesson, a timeout exception bounded the result when an external service's response was slow, but the price still stayed in the third party's hands. The security topic turns the same question to the data that reaches the application directly: a value that arrives as a query parameter, a form field or a cookie is something the client sent. **Is this input valid, and if it is valid, who can change the result?**

**SE1.** Throughout this topic, the client input is a string constant; no real request is built. Invalid input is shown with neutral format errors (`'4x'`, `''`, `'alice@'`), not with strings of special characters. The code that runs is on the defending side. In the advanced course of the Rust curriculum, The Rust Ecosystem and Advanced Topics, we measured where a guarantee ends at a check; here the question is not the check itself but who is left holding the result.

#### Validation: The Valid Set

**Input validation** decides whether an incoming value has the expected format and falls in the expected range, and rejects the value if it does not. In PHP the built-in tool for this is `filter_var()`: its second argument is a filter constant and its third is an options array. `FILTER_VALIDATE_INT` checks the integer format, and the `min_range` and `max_range` options check the range; on a matching input it returns the integer, and on a non-matching one it returns `false`. `FILTER_VALIDATE_EMAIL` tests the email format with the same contract.

Two small validators are used throughout the lesson. The underscored constant names follow PHP convention; the file is only written to disk, and the measurement files pull it in.

```php
<?php
// input.php — quantity and email validators
declare(strict_types=1);

const QUANTITY_OPTIONS = ['options' => ['min_range' => 1, 'max_range' => 10]];

function validateQuantity(string $input): int|false {
    return filter_var($input, FILTER_VALIDATE_INT, QUANTITY_OPTIONS);
}

function validateEmail(string $input): string|false {
    return filter_var($input, FILTER_VALIDATE_EMAIL);
}
```

An order quantity must be between 1 and 10. The measurement below passes four quantity inputs and two email inputs through the validators, then tries every integer from 0 to 12 and counts how many are accepted.

```php
<?php
// validate.php — valid and invalid formats
declare(strict_types=1);
require 'input.php';

foreach (['4', '4x', '', '11'] as $input) {
    echo 'quantity ', var_export($input, true), ' → ', var_export(validateQuantity($input), true), "\n";
}
foreach (['alice@example.test', 'alice@'] as $input) {
    echo 'email ', var_export($input, true), ' → ', var_export(validateEmail($input), true), "\n";
}
$accepted = array_filter(range(0, 12), fn(int $n) => validateQuantity((string) $n) !== false);
echo count($accepted), ' values accepted: from ', min($accepted), ' to ', max($accepted), "\n";
```

```
quantity '4' → 4
quantity '4x' → false
quantity '' → false
quantity '11' → false
email 'alice@example.test' → 'alice@example.test'
email 'alice@' → false
10 values accepted: from 1 to 10
```

A valid format passes by converting the value to an integer: the text `'4'` becomes `4`. `'4x'` and the empty string are format errors, and `'11'` is valid in format but outside the range; all three return `false`. This is why the return type is `int|false`, and the caller has to check for `false` explicitly; if `false` is used as a number, it behaves like zero. The count shows the set the validator defines: of 13 values, only the ten between 1 and 10 pass.

#### Checking the Return Value

The `int|false` return has a trap: a valid result can be `0`, and `0` counts as false in PHP. The measurement below checks a validator whose lower bound is zero in two ways: with a loose comparison (`$result == false`) and with a strict comparison (`$result === false`).

```php
<?php
// zero.php — when zero is a valid value
declare(strict_types=1);

function validateStock(string $input): int|false {
    return filter_var($input, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 10]]);
}

foreach (['0', '5', '4x'] as $input) {
    $result = validateStock($input);
    echo var_export($input, true), ' → ', var_export($result, true),
        ' · loose reject ', var_export($result == false, true),
        ' · strict reject ', var_export($result === false, true), "\n";
}
```

```
'0' → 0 · loose reject true · strict reject false
'5' → 5 · loose reject false · strict reject false
'4x' → false · loose reject true · strict reject true
```

The loose comparison rejects the valid input `'0'` as well: `0 == false` is true. The strict comparison catches only the real `false`, that is, the format error `'4x'`. The validator's contract is therefore read with `=== false`; if zero is in the valid set, a loose check silently loses a valid value. This time the fault is on the function side, not the security side, but it comes from the same root: if the result of validation is read wrongly, validation itself proves nothing.

#### Party: Validation Does Not Change the Owner

The course's question now goes to this result. The core file is `party.php`, given in the same form in every lesson; `EXTERNAL_PARTIES` holds the three parties outside the code in decision order, `probe()` computes the result first without deviation and then separately with each deviation, and `party()` picks the first party that changed it, or the server if none could. The order is the width of the attack surface: the client is everyone who sends a request, the third party is only the parties the application connects to, and the environment is only in the hands of whoever deploys.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

The measured result is the validated quantity. In the undeviated request the client sends `'3'`; the client's deviation is another valid number, `'7'`.

```php
<?php
// quantity.php — the party of the validated quantity
declare(strict_types=1);
require 'party.php';
require 'input.php';

partyReport('quantity (validated)', fn(array $d) => validateQuantity($d['client'] ?? '3'), ['client' => '7']);
```

```
quantity (validated) → client
    client changed
```

The result belongs to the **client**. Validation did not reject `'7'`, because `'7'` is inside the valid set; the result moved from `3` to `7`. Validation does not prevent the client from changing the value. What it does is narrow the values the client can change to the ten integers in the count: `'4x'`, `''` and `'11'` turn the result into `false` and never reach the rest of the application. The narrowing is a gain, but the owner has not changed; the quantity is still set by whoever sends the request.

#### The Bounding Measurement

Next to validation there is a second step that is often confused with it. **Sanitization** does not reject the input, it changes it: `FILTER_SANITIZE_NUMBER_INT` deletes every character except digits, plus and minus, and accepts what remains. The two steps are separate: validation asks "is this value valid?" and can say no; sanitization cannot say no. The same three inputs pass through both, and the sanitized value is also given to the validator.

```php
<?php
// sanitize.php — sanitization and validation side by side
declare(strict_types=1);
require 'input.php';

foreach (['4', '4x', '1x0'] as $input) {
    $sanitized = filter_var($input, FILTER_SANITIZE_NUMBER_INT);
    echo var_export($input, true), ' → sanitization ', var_export($sanitized, true),
        ' · validation ', var_export(validateQuantity($input), true),
        ' · sanitize then validate ', var_export(validateQuantity($sanitized), true), "\n";
}
```

```
'4' → sanitization '4' · validation 4 · sanitize then validate 4
'4x' → sanitization '4' · validation false · sanitize then validate 4
'1x0' → sanitization '10' · validation false · sanitize then validate 10
```

The result points in the expected direction, and it is unsettling. Validation rejects `'4x'` and `'1x0'`; sanitization accepts both, and by changing them: `'4x'` silently becomes `4`, and `'1x0'` silently becomes `10`. The sanitized value passes the validator, because sanitization has erased the malformation. The information that the input was malformed is lost at this point: the application receives an order of four items, while the user typed `4x`. The bounding result is this: sanitization does not take the place of validation. A malformed input is rejected first by validation; sanitization is used where the value cannot be rejected and only has to be brought into a safe form.

#### Summary

- The measured class of this lesson is the **client**: the client changed the validated quantity from `'3'` to `'7'`; validation did not prevent it.
- `filter_var()` with `FILTER_VALIDATE_INT` and range options narrows the input to the valid set: of 13 values only ten passed, and an invalid format returns `false`.
- Validation rejects, sanitization changes and accepts; the two are separate steps and neither replaces the other.
- Sanitization silently turns a malformed input into another value; an input that should have been rejected enters the result as a value.
- A defense does not always change the owner: validation does not move the result to the server, it only narrows what the client can change.

#### Next Step

A validated value, as long as it stays in the valid set, is still a value chosen by the client. So if this value goes into a query text, who determines the text itself? The Preventing SQL Injection lesson measures that a prepared statement takes the query text from the client and gives it to the server.

### Preventing SQL Injection

Source: https://academia.sh/en/courses/php-web/preventing-sql-injection

Measuring the parameterized query that separates the query text from the client's value: the same query changes with the value under concatenation, does not change with a prepared statement, and binding does not work everywhere.

In the Input Validation and Sanitization lesson, the validated quantity was still determined by the client; validation only narrowed the valid set. If the same value enters a query text, the question changes: who determines the **text** of the query? If the text is built from the client's value, then what the query will do is built from it too.

**SE2.** In this lesson the value that enters the query is a neutral name: `alice` and `bob`. Both names are valid user names; what is measured is whether the query text changes when the name changes. No input that would break the query text is written or tried. All queries run in the in-memory database.

#### Shared Data

The measurements use the same small table: three users, deliberately inserted out of alphabetical order. The file is only written to disk.

```php
<?php
// data.php — the in-memory database and three users
declare(strict_types=1);

function connect(): PDO {
    $pdo = new PDO('sqlite::memory:');
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)');
    foreach (['bob', 'carol', 'alice'] as $name) {
        $pdo->prepare('INSERT INTO users (name) VALUES (?)')->execute([$name]);
    }
    return $pdo;
}
```

#### Concatenation and the Parameterized Query

In a **concatenated query**, the value becomes part of the text. In a **parameterized query (prepared statement)** the text is fixed and the value is **bound** in a separate step: `prepare()` first prepares only the text with its placeholder, and `execute()` passes the value separately, without placing it into that text. The text held by a prepared statement can be read from the `queryString` property; the measurement below compares the query text of the two methods under two names.

```php
<?php
// query.php — the query text of the two methods
declare(strict_types=1);
require 'data.php';

function concatenatedText(string $name): string {
    return "SELECT id, name FROM users WHERE name = '" . $name . "'";
}

function parameterizedText(PDO $pdo, string $name): string {
    $statement = $pdo->prepare('SELECT id, name FROM users WHERE name = ?');
    $statement->execute([$name]);
    return $statement->queryString;
}
```

The file only defines the functions. The measurement calls the two methods side by side with two names.

```php
<?php
// compare.php — the query text under two methods
declare(strict_types=1);
require 'query.php';

$pdo = connect();
foreach (['alice', 'bob'] as $name) {
    echo 'concatenation  ', concatenatedText($name), "\n";
    echo 'parameterized  ', parameterizedText($pdo, $name), "\n";
}
```

```
concatenation  SELECT id, name FROM users WHERE name = 'alice'
parameterized  SELECT id, name FROM users WHERE name = ?
concatenation  SELECT id, name FROM users WHERE name = 'bob'
parameterized  SELECT id, name FROM users WHERE name = ?
```

Under concatenation, the query text itself changed when the name changed: `'bob'` was written into the text instead of `'alice'`. Under the parameterized query the text stayed the same under both names; the name was carried outside the text, as a bound value. This difference is the whole of the security feature: that the text's value was taken from the client means the client can change the structure of the text. When the text is fixed, the value stays only a value, whatever it is.

#### Party: Who Sets the Text

The same measurement connects to the course's question. The core file is, as in every lesson, `party.php`; `probe()` computes the result without deviation and then again with each external party's deviation, and `party()` picks the first party that changed it. The order is client, third party, environment; if none could change it, the server.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

The result is the query text that goes to `prepare()`. The client's deviation is sending `bob` instead of `alice`.

```php
<?php
// injection.php — the party of the query text
declare(strict_types=1);
require 'party.php';
require 'query.php';

$pdo = connect();
partyReport('concatenation', fn(array $d) => concatenatedText($d['client'] ?? 'alice'), ['client' => 'bob']);
partyReport('parameterized', fn(array $d) => parameterizedText($pdo, $d['client'] ?? 'alice'), ['client' => 'bob']);
```

```
concatenation → client
    client changed
parameterized → server
    client could not change
```

Under concatenation, the **client** changed the result. Under the parameterized query, all three parties that could change the text could not; the remaining owner is the **server**, because the text is written as a constant in the code. There is no narrowing here like escaping or validation: the owner of the query text has changed hands completely. Validation narrows the set of the value and the client remains; a prepared statement puts the text somewhere the client cannot reach.

#### The Bounding Measurement

Binding does not work everywhere: a placeholder stands only in place of a **value**; parts of the query structure such as a column name, a table name or a sort direction cannot be bound. A person who writes `ORDER BY ?` and binds a column name has not bound the ordering; they have bound a number or a string constant. Below, the same `users` table is to be ordered by the `name` column; with binding, the name of the requested column is passed.

```php
<?php
// ordering.php — binding and the allow list
declare(strict_types=1);
require 'data.php';

const ORDER_ALLOW_LIST = ['name' => 'name', 'id' => 'id'];

function orderByBinding(PDO $pdo, string $column): array {
    $statement = $pdo->prepare('SELECT name FROM users ORDER BY ?');
    $statement->execute([$column]);
    return $statement->fetchAll(PDO::FETCH_COLUMN);
}

function orderByAllowList(PDO $pdo, string $request): array {
    $column = ORDER_ALLOW_LIST[$request] ?? 'id';
    return $pdo->query('SELECT name FROM users ORDER BY ' . $column)->fetchAll(PDO::FETCH_COLUMN);
}

$pdo = connect();
echo 'binding       ', implode(', ', orderByBinding($pdo, 'name')), "\n";
echo 'allow list    ', implode(', ', orderByAllowList($pdo, 'name')), "\n";
echo 'not in list   ', implode(', ', orderByAllowList($pdo, 'password')), "\n";
```

```
binding       bob, carol, alice
allow list    alice, bob, carol
not in list   bob, carol, alice
```

Ordering with binding did not give the expected result: the rows came in insertion order (`bob, carol, alice`), not in `name` order. The bound `'name'` was read not as a column name but as a string value that is the same in every row, and the ordering changed nothing. The query did not raise an error either; the fault is silent. The allow list really applied the ordering (`alice, bob, carol`), and the request `password`, which is not in the list, fell back to the default `id` order without raising an error. Even when a column name does enter the query, the place it enters from is a list written as a constant in the code; the client's value only selects a key from the list.

#### Summary

- The measured class of this lesson is the **client** under concatenation and the **server** under the parameterized query: in the second, only the constant string given to `prepare()` determines the query text.
- Under concatenation, changing the name from `alice` to `bob` changed the query text; under the parameterized query the text stayed the same under both names.
- A prepared statement and binding separate the value from the text; the defense takes the result away from its owner and does not stop at narrowing.
- Binding is only for values: `ORDER BY ?` did not change the ordering, and a column name could not be bound.
- If a part of the query structure comes from the client, the solution is to choose from a constant allow list inside the code; a request that is not in the list falls back to the default.

#### Next Step

Taking the query text to the server closed one road. If the same value is this time printed onto a page, who determines the text of the output? The Preventing Cross-Site Scripting lesson measures escaping at output time with the same party question.

### Preventing Cross-Site Scripting

Source: https://academia.sh/en/courses/php-web/preventing-cross-site-scripting

Measuring how escaping narrows the result when text from a user is printed onto a page, escaping by context and double escaping; escaping does not change the owner.

In the Preventing SQL Injection lesson, the value was separated from the query text and the owner of the query became the server. Another place the value goes is the page: a user enters a name, and the application prints this name into HTML. The question for preventing **cross-site scripting (XSS)** is this: who determines the structure of the printed text, that is, which part of it counts as markup?

**SE3.** The texts escaped in this lesson are four harmless examples: `Tom & Jerry`, `<b>bold</b>`, `"quote"` and `O'Brien`. The aim is to measure whether markup characters come out as text; producing a running script or making a request from another site is not measured. The code that runs is on the defending side.

#### Escaping: Printing Text as Text

**Escaping** replaces characters that carry a special meaning in a markup language with counterparts that show the same character as text. In PHP, `htmlspecialchars()` does this: for `&`, `<`, `>`, the double quote and the single quote it writes `&amp;`, `&lt;`, `&gt;`, `&quot;` and `&#039;`. The file below defines two functions: one that produces raw output and one that escapes. The function that produces raw output exists only for contrast.

```php
<?php
// output.php — raw and escaped output
declare(strict_types=1);

function escape(string $text): string {
    return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function greetingRaw(string $name): string {
    return '<p>Hello ' . $name . '</p>';
}

function greeting(string $name): string {
    return '<p>Hello ' . escape($name) . '</p>';
}

function inputField(string $name): string {
    return '<input value="' . escape($name) . '">';
}
```

The four example texts pass first through `escape()`, then through the two greeting functions. In raw output, `<b>bold</b>` mixes into the structure of the page; in escaped output, the count of `<` characters stays fixed.

```php
<?php
// escape.php — the escaped characters
declare(strict_types=1);
require 'output.php';

foreach (['Tom & Jerry', '<b>bold</b>', '"quote"', "O'Brien"] as $text) {
    echo $text, ' → ', escape($text), "\n";
}
echo "\n";
foreach (['alice', '<b>bold</b>'] as $name) {
    echo mb_str_pad($name, 14), ' raw ', substr_count(greetingRaw($name), '<'),
        ' · escaped ', substr_count(greeting($name), '<'), " tag starts\n";
}
```

```
Tom & Jerry → Tom &amp; Jerry
<b>bold</b> → &lt;b&gt;bold&lt;/b&gt;
"quote" → &quot;quote&quot;
O'Brien → O&#039;Brien

alice          raw 2 · escaped 2 tag starts
<b>bold</b>    raw 4 · escaped 2 tag starts
```

All four texts came out as text: `&`, `<`, `>`, and the double and single quotes turned into their counterparts. The count shows the structure. For the name `alice`, raw and escaped output are the same, with two tag starts (`<p>` and `</p>`). For the name `<b>bold</b>`, raw output rises to four tag starts; escaped output stays at two. In raw output the client's value added two tags to the structure of the page; in escaped output it could not.

#### Escaping by Context

The same function is not enough in every context; the context in which it is enough is determined by where the output goes. In a text node, `<` and `&` are enough. In a double-quoted **attribute** value, the quote also has to be escaped, because the value ends with the quote. The `ENT_QUOTES` flag covers both. `inputField()` uses this context.

```php
<?php
// context.php — escaping inside an attribute
declare(strict_types=1);
require 'output.php';

echo inputField('"quote"'), "\n";
echo inputField("O'Brien"), "\n";
echo greeting("O'Brien"), "\n";
```

```
<input value="&quot;quote&quot;">
<input value="O&#039;Brien">
<p>Hello O&#039;Brien</p>
```

In the attribute context the double quote came out as `&quot;`, so it cannot end the value early. The single quote becomes `&#039;` in both contexts. The result is this: without naming the context, one does not say "I escaped". What is enough for a text node is not carried unchanged into an unquoted attribute or another context (script content, an address); those contexts need separate escaping, and this lesson covers only the text node and the double-quoted attribute.

#### Party: Escaping Does Not Change the Owner

The party measurement of the same question is done with the `party.php` core. The decision order is client, third party, environment; if none could change it, the server.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

The result is the greeting HTML, escaped and unescaped. The client's deviation is a different name: `<b>bold</b>`.

```php
<?php
// xss.php — the party of the printed HTML
declare(strict_types=1);
require 'party.php';
require 'output.php';

partyReport('raw output', fn(array $d) => greetingRaw($d['client'] ?? 'alice'), ['client' => '<b>bold</b>']);
partyReport('escaped output', fn(array $d) => greeting($d['client'] ?? 'alice'), ['client' => '<b>bold</b>']);
```

```
raw output → client
    client changed
escaped output → client
    client changed
```

The owner of both outputs is the **client**. Escaping did not change this owner, because the printed text still depends on the name the client sent: when the name changes, the HTML changes. What changed is the kind of this change. In raw output the client could also change the structure of the page; in escaped output it can change only the content of a text node. Escaping does not move the result to the server, it narrows the field in the client's hands; the owner stays the same.

#### The Bounding Measurement

Escaping should be done in one place. If a value is escaped when it is stored and escaped again when it is printed, the characters are transformed twice: `&` first becomes `&amp;`, then that `&` becomes `&amp;` again. The measurement escapes the same text once and twice.

```php
<?php
// double.php — double escaping
declare(strict_types=1);
require 'output.php';

$text = 'Tom & Jerry';
$stored = $text;
echo 'once   ', escape($stored), "\n";
echo 'twice  ', escape(escape($stored)), "\n";
```

```
once   Tom &amp; Jerry
twice  Tom &amp;amp; Jerry
```

The second escaping escaped the `&` inside the `&amp;` written by the first, and the text `Tom &amp; Jerry` became visible in the browser. Double escaping is not a security hole, it is a visible bug; but "fixing" it by turning escaping off or skipping it in some place brings raw output back. The right fix is to put escaping in one place: the value stays raw in storage and is escaped once, at the moment it is printed, according to the context it is printed into.

#### Summary

- The measured class of this lesson is the **client**: even in the escaped greeting, the name the client sent changed the printed HTML.
- Escaping does not change the owner, it narrows: `<b>bold</b>` increased the tag count in raw output and did not in escaped output; the structure of the page did not grow with the client's value.
- The same function is enough in a text node and in a double-quoted attribute, and `ENT_QUOTES` escapes both single and double quotes; the context is chosen explicitly.
- Double escaping breaks the text: `Tom &amp;amp; Jerry`. Escaping is done only at output time, in one place; the raw value stays in storage.

#### Next Step

Escaping bounded the text printed onto the page. A request that comes from a page, however, can also come from another site: who determines whether the user really made the request? The Request Forgery Protection lesson measures this with a token.

### Request Forgery Protection

Source: https://academia.sh/en/courses/php-web/request-forgery-protection

Telling apart whether a request was made by the user's own application or by another site with a request forgery token, the measured effect of the token, and the limit of the SameSite cookie attribute.

Escaping bounded the text printed onto the page. A request comes from another direction: while a user is signed in to the application, a page on another site can send a request to the application from the user's browser. The question for preventing **cross-site request forgery (CSRF)** is this: who determines whether this request came from the user's own screen or from somewhere else? This lesson measures whether the request is accepted; it does not ask whether the code of a rejected request ran.

**SE4.** In this lesson the "other site" is only an array record: `'origin' => 'https://other.test'`. No real second site, cookie or browser is set up. The requests are arrays; what is measured is whether the email update is accepted. The token is printed nowhere, only its length is printed.

#### Tokenless and Tokened Update

A **request forgery token (CSRF token)** is a random value that the server puts in the form it shows the user and expects back with the request. A request that comes from the user's own screen carries this value; a page on another site cannot carry it, because it does not know the value. The token is generated with `random_bytes()`, made readable with `bin2hex()`, and compared with `hash_equals()`: this function compares two strings in a time that does not depend on the position where the first difference is found.

```php
<?php
// request.php — unprotected and tokened email update
declare(strict_types=1);

function generateToken(): string {
    return bin2hex(random_bytes(16));
}

function update(string $current, array $request, ?string $expected): string {
    if ($expected !== null && !hash_equals($expected, (string) ($request['token'] ?? ''))) {
        return $current;
    }
    return $request['email'];
}
```

`update()` returns the new email; if the request is rejected, it returns the current value unchanged. If `$expected` is null, there is no protection. Three requests are tried with two methods: from the user's own screen, from another site without a token, and from another site with a wrong token.

```php
<?php
// update.php — three requests, two methods
declare(strict_types=1);
require 'request.php';

$current = 'alice@example.test';
$token = generateToken();
$requests = [
    'from the app   ' => ['origin' => 'https://app.test', 'email' => 'new@example.test', 'token' => $token],
    'without token  ' => ['origin' => 'https://other.test', 'email' => 'other@example.test'],
    'wrong token    ' => ['origin' => 'https://other.test', 'email' => 'other@example.test', 'token' => str_repeat('0', 32)],
];
foreach ($requests as $name => $request) {
    $unprotected = update($current, $request, null) !== $current;
    $tokened = update($current, $request, $token) !== $current;
    echo $name, ' · unprotected ', $unprotected ? 'accepted' : 'rejected', ' · tokened ', $tokened ? 'accepted' : 'rejected', "\n";
}
echo 'token length ', strlen($token), "\n";
echo 'two tokens ', generateToken() === generateToken() ? 'same' : 'different', "\n";
```

```
from the app    · unprotected accepted · tokened accepted
without token   · unprotected accepted · tokened rejected
wrong token     · unprotected accepted · tokened rejected
token length 32
two tokens different
```

The unprotected method accepted all three requests; there was no difference between the other site's request and the user's own request. The tokened method accepted only the request that carried the token; the request without a token and the request with a wrong token were rejected. The token is 32 characters (16 bytes, written in hexadecimal) and two generations gave different values. What is measured is only acceptance or rejection; the token itself is not printed, because a printed token would be another value on the next run, and a secret value has no place in output.

#### Party: Who Makes the Update

The same measurement connects to the `party.php` core. The decision order is client, third party, environment; if none could change it, the server.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

The result is the email after the update. The deviation is a request sent by the third party without a token: the other site's record and its own email. The undeviated request is the user's own tokened request.

```php
<?php
// forgery.php — who changed the email
declare(strict_types=1);
require 'party.php';
require 'request.php';

$token = generateToken();
$own = ['origin' => 'https://app.test', 'email' => 'alice@example.test', 'token' => $token];
$other = ['origin' => 'https://other.test', 'email' => 'other@example.test'];

partyReport('without token', fn(array $d) => update('alice@example.test', $d['third party'] ?? $own, null), ['third party' => $other]);
partyReport('with token', fn(array $d) => update('alice@example.test', $d['third party'] ?? $own, $token), ['third party' => $other]);
```

```
without token → third party
    third party changed
with token → server
    third party could not change
```

In the update without a token, the **third party** changed the email: the other site's request replaced the user's email with its own value. In the update with a token, the third party could not change it; the remaining owner is the **server**, because the code that generates and compares the token is there. The token value stays in the server's hands and only a copy of it is put into the form; the party that sends the request cannot produce its counterpart as long as it does not see the copy.

#### The Bounding Measurement

Browsers have a **cookie attribute** that limits sending the cookie with a request from another site: `SameSite`. The server asks for this attribute by writing it into the cookie header; enforcing it belongs to the browser. The only thing that can be measured on the server side is whether the attribute is inside the header string.

```php
<?php
// samesite.php — SameSite is only a header string on the server
declare(strict_types=1);
require 'party.php';
require 'request.php';

function cookieHeader(string $name, string $value, string $sameSite): string {
    return 'Set-Cookie: ' . $name . '=' . $value . '; SameSite=' . $sameSite . '; HttpOnly';
}

echo cookieHeader('session', 'value', 'Lax'), "\n";

$own = ['origin' => 'https://app.test', 'email' => 'alice@example.test'];
$other = ['origin' => 'https://other.test', 'email' => 'other@example.test'];
partyReport('SameSite only', fn(array $d) => update('alice@example.test', $d['third party'] ?? $own, null), ['third party' => $other]);
```

```
Set-Cookie: session=value; SameSite=Lax; HttpOnly
SameSite only → third party
    third party changed
```

`SameSite=Lax` is in the header string, but the result in the server code did not change: the third party again changed the email. This is the expected result. The effect of the attribute cannot be measured in any line that runs on the server, because it is the browser that chooses to add or not to add the cookie to the request; the server only asks. So `SameSite` is not put in place of the token, it is put next to it: the attribute adds a layer on the browser side, and the token changes the owner on the server side. Without the second, the server's result stays in the third party's hands.

#### Summary

- The measured class of this lesson is the **third party** in the update without a token and the **server** in the update with a token: without a token the other site's request changed the email; with a token it could not.
- The request forgery token is generated with `random_bytes()` and `bin2hex()` (32 characters) and compared with `hash_equals()`; the token itself is not printed, its length is.
- The defense takes the result away from its owner: a wrong and a missing token were rejected in the same way, and the email stayed at its current value.
- The `SameSite` cookie attribute is browser behavior; the server only asks for it and it appears in the header string. It did not change the result on the server, and it is put next to the token, not in its place.

#### Next Step

If the request now comes from the user's screen, the same question is asked when the credential that comes with the request is stored: how is the password stored, and who can change the stored hash? The Password Storage lesson measures this.

### Password Storage

Source: https://academia.sh/en/courses/php-web/password-storage

Measuring that a password is stored as a hash, that a different salt gives a different hash for the same password, the cost parameter, and rehashing at login; the default cost is in the environment's hands.

The Request Forgery Protection lesson had the server determine who a request came from. If the identity itself rests on a password, the question of how the password is stored remains: who determines the stored value and the setting that produces it? This lesson measures the built-in way of storing a password and whether one of its settings is in the environment's hands.

**SE5.** In this lesson the password and the hash are used only for storage and verification. Guessing passwords, cracking hashes and reversing a stored hash are not modeled. The whole of a hash is printed nowhere; only the prefix, the length and the verification result are printed.

#### Hash and Verification

A password is not stored as it is. A **hash** is a one-way digest of the password: the same password gives the same digest, but the password cannot be produced from the digest. For passwords this is done with `password_hash()`. The function generates a random **salt** on every call and writes it into the hash; so two hashes of the same password differ from each other. `password_verify()` takes the password and the hash, reads the salt and the setting inside the hash, and verifies. The `$2y$12$` prefix at the start of the hash carries the algorithm (`2y`, bcrypt) and the **cost** parameter.

The file below defines the functions used in the lesson: producing a hash, taking the prefix, and verifying at login and rehashing if needed.

```php
<?php
// password.php — hashing, prefix and rehashing at login
declare(strict_types=1);

function hashPassword(string $password, ?int $cost = null): string {
    if ($cost === null) {
        return password_hash($password, PASSWORD_BCRYPT);
    }
    return password_hash($password, PASSWORD_BCRYPT, ['cost' => $cost]);
}

function prefix(string $hash): string {
    return substr($hash, 0, 7);
}

function logIn(string $password, string $storedHash, int $targetCost): array {
    if (!password_verify($password, $storedHash)) {
        return ['succeeded' => false, 'hash' => $storedHash];
    }
    if (password_needs_rehash($storedHash, PASSWORD_BCRYPT, ['cost' => $targetCost])) {
        $storedHash = hashPassword($password, $targetCost);
    }
    return ['succeeded' => true, 'hash' => $storedHash];
}
```

The first measurement hashes a password; verifies a correct and a wrong password; hashes the same password twice and checks whether the hashes differ and whether both are verified.

```php
<?php
// hash.php — hash, prefix and verification
declare(strict_types=1);
require 'password.php';

$hash = hashPassword('example-password');
$second = hashPassword('example-password');
echo 'prefix ', prefix($hash), ' · length ', strlen($hash), "\n";
echo 'correct password ', var_export(password_verify('example-password', $hash), true), "\n";
echo 'wrong password ', var_export(password_verify('other-password', $hash), true), "\n";
echo 'two hashes ', $hash === $second ? 'same' : 'different', "\n";
echo 'second hash verifies ', var_export(password_verify('example-password', $second), true), "\n";
```

```
prefix $2y$12$ · length 60
correct password true
wrong password false
two hashes different
second hash verifies true
```

The hash is 60 characters and starts with `$2y$12$`: bcrypt, cost 12. The correct password was verified, the wrong one was not. The two hashes of the same password differ, because a new salt was written on every call; even so, the second one was also verified with the same password. Verification does not compare hashes; it rehashes the password with the salt inside the hash and compares the result. The whole of the hash was not printed; the prefix and the length are enough for the measurement.

#### Party: Who Sets the Cost

The cost parameter determines how much work producing a hash requires. If it is not given explicitly, the runtime's default is used; this default depends on the PHP version (10 before 8.4, 12 after). The same code can produce a different prefix in another environment. For the measurement, the environment's deviation is the default cost being 10. The decision order is client, third party, environment; if none could change it, the server.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

The result is the prefix of the produced hash. The client's deviation is another password.

```php
<?php
// cost.php — the party of the hash prefix
declare(strict_types=1);
require 'party.php';
require 'password.php';

function defaultPrefix(string $password, int $cost): string {
    return prefix(hashPassword($password, $cost));
}

partyReport(
    'default cost',
    fn(array $d) => defaultPrefix($d['client'] ?? 'example-password', $d['environment'] ?? PASSWORD_BCRYPT_DEFAULT_COST),
    ['client' => 'other-password', 'environment' => 10]
);
```

```
default cost → environment
    client could not change · environment changed
```

The owner of the prefix is the **environment**. The client's password did not change any character of the prefix: the password determines the content of the hash, not its setting. When the default cost became 10 instead of 12, the prefix became `$2y$10$` instead of `$2y$12$`. Because the cost is not written in the code, the setting is determined by the version of the runtime; the same code silently produces a hash with another cost in another deployment.

#### Rehashing at Login

The cost is raised over time, but the stored hashes stay at the old cost. `password_needs_rehash()` says whether a hash fits the target setting. The plain-text password is available only at login, when the user types it, so rehashing is done there too. The measurement below compares a hash with cost 10 against a target cost of 12, then tries a login with a correct and a wrong password.

```php
<?php
// login.php — rehashing at login
declare(strict_types=1);
require 'password.php';

$old = hashPassword('example-password', 10);
echo 'old prefix ', prefix($old), "\n";
echo 'rehash needed ', var_export(password_needs_rehash($old, PASSWORD_BCRYPT, ['cost' => 12]), true), "\n";

$correct = logIn('example-password', $old, 12);
echo 'correct password ', var_export($correct['succeeded'], true), ' · prefix ', prefix($correct['hash']), "\n";
$wrong = logIn('other-password', $old, 12);
echo 'wrong password ', var_export($wrong['succeeded'], true), ' · prefix ', prefix($wrong['hash']), "\n";
```

```
old prefix $2y$10$
rehash needed true
correct password true · prefix $2y$12$
wrong password false · prefix $2y$10$
```

The hash with cost 10 gave `true` from `password_needs_rehash()` for the target 12. At a login with the correct password, the hash was produced again and the prefix became `$2y$12$`. A login with the wrong password failed and the stored hash was not touched: the prefix stayed `$2y$10$`. Doing the rehashing at login is not a preference but a necessity, because the plain-text password is available at no other moment.

#### The Bounding Measurement

If the cost is given explicitly, the default is not read. The code writing `hashPassword($password, 12)` removes the environment's default from the result. The same party measurement is repeated here with the cost fixed; the environment's deviation (10) is the same.

```php
<?php
// explicit.php — the party when the cost is given explicitly
declare(strict_types=1);
require 'party.php';
require 'password.php';

function explicitPrefix(string $password): string {
    return prefix(hashPassword($password, 12));
}

partyReport(
    'explicit cost',
    fn(array $d) => explicitPrefix($d['client'] ?? 'example-password'),
    ['client' => 'other-password', 'environment' => 10]
);
```

```
explicit cost → server
    client could not change · environment could not change
```

When the cost is written in the code as `12`, the environment's default does not enter the result: the environment's deviation (10) changed nothing in the prefix. The owner is the **server**. This is not the server increasing the number of settings, but the environment no longer being able to determine anything; to raise the cost, the code is changed, while `password_needs_rehash()` carries old hashes to the new setting at login.

#### Summary

- The measured class of this lesson is the **environment** under the default cost and the **server** under the explicit cost: the cost part of the prefix was determined by the runtime's default in the first and by the number written in the code in the second.
- `password_hash()` generates a new salt on every call; two hashes of the same password differ and both are verified with `password_verify()`. The hash is 60 characters, and its prefix carries the algorithm and the cost.
- A different password did not change the result of the prefix: the password determines the content of the hash, not its setting.
- `password_needs_rehash()` catches an old-cost hash; rehashing is done at the only place the plain-text password is available, at a correct login. A wrong password does not change the hash.

#### Next Step

Storing the password is a part of an authentication flow. When a password, a session, a token and an authorization work together, who determines the identity of the request? The Authentication Flows lesson takes this up together with the findings of the whole security topic.

### Authentication Flows

Source: https://academia.sh/en/courses/php-web/authentication-flows

Measuring the identity of a request in the session-based and the bearer-token flow, seeing what a signature protects and what it does not, and gathering the fifteen lessons of the course into one table.

The Password Storage lesson measured the stored value and the setting that produces it. Once the password is verified, one question remains: on behalf of which user are the following requests processed, and who determines that? **Authentication** establishes who the user is; **authorization**, which establishes what the user may do, is a separate matter and was covered in the Authentication and Authorization lesson in the context of the framework. There the measurement was whether the gate runs the request. Here every part runs; the question is who can change the identity of the request.

**SE6.** In this lesson the password is only verified. Guessing passwords, stealing sessions, forging tokens and exceeding permissions are not modeled. The client's deviation is always a neutral value: a session ID or a token that another user obtained from that user's own login.

#### Two Flows

HTTP is stateless; the server sees every request separately. The identity is attached to the request in two ways. In the **session-based flow**, after login the server generates a random session ID, keeps the record from the ID to the user in its own memory and hands the ID to the client; the client sends it back with every request. In the **bearer-token flow**, the server keeps no record: it signs the user name with `hash_hmac()` and hands out a **bearer token** of the form `user.signature`. The client carries the token with every request, for example in the `Authorization` header; the server recomputes the signature and compares it with `hash_equals()`.

The measurement core is the same as in the previous lessons: `probe()` computes the result once without deviation, then with each external party's deviation; `party()` looks at the client, the third party and the environment in order, and names the server if none could change it. The file is given unchanged, as in every lesson.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

The file below builds the two flows. Passwords are tested only with `password_verify()`, the session ID is generated with `random_bytes()`, and the token signature is made with `hash_hmac('sha256', ...)`. The signing key here is a teaching string; in a real application the key does not sit in the code but is read from an environment variable.

```php
<?php
// identity.php — session and token flows
declare(strict_types=1);

const SIGNING_KEY = 'lesson-key';

function hashes(): array {
    $hashes = [];
    foreach (['alice' => 'password-one', 'bob' => 'password-two'] as $user => $password) {
        $hashes[$user] = password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]);
    }
    return $hashes;
}

function verifyLogin(array $hashes, string $user, string $password): bool {
    return isset($hashes[$user]) && password_verify($password, $hashes[$user]);
}

final class SessionStore {
    private array $records = [];

    public function open(string $user): string {
        $id = bin2hex(random_bytes(16));
        $this->records[$id] = $user;
        return $id;
    }

    public function user(string $id): string {
        return $this->records[$id] ?? 'anonymous';
    }

    public function close(string $id): void {
        unset($this->records[$id]);
    }
}

function issueToken(string $user, string $key = SIGNING_KEY): string {
    return $user . '.' . hash_hmac('sha256', $user, $key);
}

function tokenUser(string $token, string $key = SIGNING_KEY): string {
    $parts = explode('.', $token, 2);
    if (count($parts) !== 2) return 'anonymous';
    $expected = hash_hmac('sha256', $parts[0], $key);
    return hash_equals($expected, $parts[1]) ? $parts[0] : 'anonymous';
}
```

#### The Request Identity

The result is the user name the request is processed for. The client's deviation is presenting a credential that another user obtained from that user's own login: `bob`'s session ID in the session flow, `bob`'s token in the token flow. Random values are not printed; only their lengths and the results are.

```php
<?php
// flow.php — request identity, two flows
declare(strict_types=1);
require 'party.php';
require 'identity.php';

$hashes = hashes();
foreach (['alice' => 'password-one', 'bob' => 'password-two'] as $user => $password) {
    echo "login $user ", var_export(verifyLogin($hashes, $user, $password), true), "\n";
}
echo 'wrong password ', var_export(verifyLogin($hashes, 'alice', 'password-two'), true), "\n";

$store = new SessionStore();
$aliceSession = $store->open('alice');
$bobSession = $store->open('bob');
$aliceToken = issueToken('alice');
$bobToken = issueToken('bob');
echo 'session ID ', strlen($aliceSession), ' characters · signature ', strlen(explode('.', $aliceToken)[1]), " characters\n";

partyReport('identity (session)', fn(array $d) => $store->user($d['client'] ?? $aliceSession), ['client' => $bobSession]);
partyReport('identity (token)', fn(array $d) => tokenUser($d['client'] ?? $aliceToken), ['client' => $bobToken]);

echo 'mismatched signature ', tokenUser('alice.' . str_repeat('0', 64)), "\n";
echo 'unrecognized session ID ', $store->user(str_repeat('0', 32)), "\n";
```

```
login alice true
login bob true
wrong password false
session ID 32 characters · signature 64 characters
identity (session) → client
    client changed
identity (token) → client
    client changed
mismatched signature anonymous
unrecognized session ID anonymous
```

In both flows the result belongs to the **client**. When the client changed the session ID or the token it presented, the identity of the request moved from `alice` to `bob`. The one who carries the identity to the request is the client, who sends it with every request; the server only verifies the presented information. Verification works differently at two separate points of the same flow: in the session flow the server looks the ID up in its own record, in the token flow it computes the signature and compares.

**SE7.** The last two lines show the limit of the defense. A token with a mismatched signature did not produce a user name, and neither did an unrecognized session ID: both gave `anonymous`. The client cannot choose a user name; it can present one of the credentials the server gave it, or none. So the defense does not take the result from the client, it narrows the set the client can choose from to the values the server issued. This is the sentence repeated since the start of the course: a defense does not always change the owner, sometimes it only narrows what can be changed.

The Cookies lesson showed the same thing with a signed cookie. The mechanism that carried the cart to the server in the Sessions lesson does not carry the identity here, because the identity itself is a value in the client's hands; the limit of the Sessions lesson was the same: which session is opened stays with the client.

#### The Bounding Measurement

The two flows fell on the same party, but the remedy in the server's hands is not the same. In the session flow, when the server deletes the record, the ID no longer maps to any user. In the token flow the server keeps no record, so there is nothing to delete; the token stays valid as long as the signing key does not change. If the key comes from the environment, the one who changes it is the deployment, and the change drops all the tokens at once.

```php
<?php
// logout.php — logout and key change
declare(strict_types=1);
require 'party.php';
require 'identity.php';

$store = new SessionStore();
$session = $store->open('alice');
$token = issueToken('alice');

echo 'before logout session ', $store->user($session), ' · token ', tokenUser($token), "\n";
$store->close($session);
echo 'after logout session ', $store->user($session), ' · token ', tokenUser($token), "\n";

partyReport(
    'identity (key from environment)',
    fn(array $d) => tokenUser($token, $d['environment'] ?? SIGNING_KEY),
    ['environment' => 'new-key']
);
```

```
before logout session alice · token alice
after logout session anonymous · token alice
identity (key from environment) → environment
    environment changed
```

After logout the session gave `anonymous`, and the token kept giving `alice`. An application that wants the token to disappear at logout has to add a revocation record on the server, and that gives back the advantage of the token being recordless. When the key changed, the token dropped, and the one who changed the result is the **environment**: the deployment determines the key. **SE8.** Time is not measured in this lesson; a token carrying an expiry or a session closing when idle requires a clock and is outside the scope of the measurement. Only which remedy the server has was examined here.

#### Summary

- The measured class of this lesson is the **client**: the client that changed the session ID or the token changed the identity of the request, in both flows.
- The session flow resolves the identity from a record in server memory, and the token flow verifies the `hash_hmac()` signature with `hash_equals()`; both reduce an unrecognized value to `anonymous`.
- The signature limits the set the client can choose from to the tokens the server issued; it does not change the owner.
- A session is revoked on the server; a signed token stays valid as long as the key does not change, and if the key comes from the environment, the one who changes it is the environment.

#### Course Wrap-Up

In fifteen lessons, this course measured who can change a result: sessions and state, data access, security. Each lesson carried its own result and a contrasting or bounding measurement. The table below gathers all of them in one place; the party cells are taken from the summary of the source lesson.

| Lesson | Result measured | Party | Deviation or contrasting measurement |
|---|---|---|---|
| Cookies | The cart in the cookie, signed cookie | client | The signature noticed the changed value, did not change the owner |
| Sessions | The cart in `$_SESSION` | server | The same `cart=5` cookie changed the result |
| Configuration and Environment Variables | The connection name with `APP_DATABASE` | environment | A value written in the code stays on the server, not carried |
| The Database Access Layer | The type of the returned value | environment | With an explicit `int` cast, the server |
| Transactions | The sum of the balances of two accounts | server | In a non-transactional transfer, the environment changed the sum |
| Migration Management | The number of applied migrations | environment | The schema version is the same in every environment: server |
| Queries Behind Object–Relational Mapping | The number of queries sent | server | With lazy loading it grew with the rows: client |
| Data Formats | The parsed price | third party | The number representation in our own JSON: server |
| The HTTP Client | The price reported by the service | third party | The fallback value bounds the result, does not change the owner |
| Input Validation and Sanitization | The validated quantity | client | Sanitization turns malformed input into another value |
| Preventing SQL Injection | The text sent to `prepare()` | server | Under concatenation, the client |
| Preventing Cross-Site Scripting | The printed HTML | client | Double escaping breaks the text |
| Request Forgery Protection | The email update | server | In a tokenless update, the third party |
| Password Storage | The hash prefix | environment | At an explicit cost, the server |
| Authentication Flows | The request identity | client | When the key comes from the environment, the environment |

The table holds two separate distributions, and they count different things. The **distribution of the lessons** counts the measured class of each lesson: 5 server · 4 environment · 4 client · 2 third party. The **distribution of the core** counts the ten results run in the Cookies lesson: 4 server · 2 environment · 2 client · 2 third party. One is the distribution of fifteen lessons, the other of ten measurements; they are not the same numbers, and one does not verify the other.

The course makes a single claim: in a web application, the owner of a result is not the one who wrote the code. The client brings the request, the environment brings the setting, the third party brings the data; defense mechanisms sometimes move the owner to the server and sometimes only narrow it. The next course takes this application to the production environment: deployment, running and performance.

