---
title: 'Form Handling'
source: 'https://academia.sh/en/courses/php-fundamentals/form-handling'
course: 'PHP Fundamentals'
language: en
updated: '2026-10-21T05:03:34+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Shows through which channel an invalid form value is heard by measuring it'
    - 'Observes the validation result and the output escaping by sending a POST form to the built-in server'
    - 'Separates a valid 0 from failure with a strict comparison'
---

# Form Handling

Validating strings that arrive from a form with filter_var: failure heard through the return value, converted to null, and a valid 0 confused with failure.

In the Superglobals lesson, request data always arrived as a string and a missing key was heard through the warning channel. In this lesson the key exists, but its content is not in the expected form: a field that should hold a number received `12a`. Does PHP say anything when it meets this value, and if it does, through which channel?

M14 covered the HTML structure of a form and how its fields are built; here only the server-side filtering of the submitted data is measured. PHP's `filter_var` function does the validation work: it takes a value and a filter constant, and if the filter accepts the value it returns the value in the appropriate type, otherwise it returns `false`. `htmlspecialchars` handles escaping in output; why escaping is needed and how the defenses against the underlying problem are classified is the subject of M25.

## An Invalid Value Is Heard Through the Return

The first measurement runs on the command line and tests the filter with strings supplied by hand.

```php
<?php
// filter.php — what filter_var returns for valid and invalid input
function show(mixed $value): string
{
    return match (true) {
        $value === null => 'null',
        is_bool($value) => $value ? 'true' : 'false',
        default => get_debug_type($value) . ' ' . $value,
    };
}

$measurements = [
    'valid number'          => filter_var('12', FILTER_VALIDATE_INT),
    'invalid form number'   => filter_var('12a', FILTER_VALIDATE_INT),
    'null_on_failure'       => filter_var('12a', FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE),
];

foreach ($measurements as $name => $result) {
    printf("%-24s%s\n", $name, show($result));
}
```

```
valid number            int 12
invalid form number     false
null_on_failure         null
```

For `12` the function returns `int 12`: the string has been converted to an integer. For `12a` it returns `false`. The function raised no warning, threw no exception, and execution went on; the problem is heard only by code that looks at the return value. This is the **return** channel of the course: the signal does not arrive by itself, and the caller has to ask for the value. **RR8.** None of the functions in this measurement produces an `E_WARNING`; that is not a setting but the defined behavior of `filter_var`, which is why no error handler was installed for the measurement.

The third row shows that the `FILTER_NULL_ON_FAILURE` flag turns failure into `null` instead of `false`. The channel does not change, it is still the return value; only the value itself differs. The meaning of the flag becomes clear in a later section: `false` is a valid result for the Boolean filter, and the flag exists to separate it from failure.

## Sending the Form to the Server

A real form submission is measured with the built-in server. The script reads two fields: `age` must be an integer, and `name` is user text that is written to the output after escaping.

```php
<?php
// form.php — validates the age sent by POST, writes the name escaped
$age = filter_var($_POST['age'] ?? '', FILTER_VALIDATE_INT);
$name = $_POST['name'] ?? '';

if ($age === false) {
    echo "invalid age\n";
} else {
    echo 'age=', $age, "\n";
}
echo 'name=', htmlspecialchars($name), "\n";
```

```bash
# server.sh — sends the form to the built-in server
php -S 127.0.0.1:8082 -d display_errors=0 >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8082/form.php && break; sleep 0.1; done
echo '--- valid'
curl -s -d 'age=30' --data-urlencode 'name=Ayse' -w 'status=%{http_code}\n' http://127.0.0.1:8082/form.php
echo '--- invalid age'
curl -s -d 'age=12a' --data-urlencode 'name=Ayse' -w 'status=%{http_code}\n' http://127.0.0.1:8082/form.php
echo '--- name with tags'
curl -s -d 'age=30' --data-urlencode 'name=<b>Ayse</b>' -w 'status=%{http_code}\n' http://127.0.0.1:8082/form.php
kill $pid
```

```
--- valid
age=30
name=Ayse
status=200
--- invalid age
invalid age
name=Ayse
status=200
--- name with tags
age=30
name=&lt;b&gt;Ayse&lt;/b&gt;
status=200
```

The valid submission printed `age=30`. In the invalid submission the script printed `invalid age`, yet the status code is still **200**: from the server's point of view the request was handled successfully, because the validation result is not an error for PHP but a decision the code makes for itself. Reporting a validation failure to the client depends on the code choosing a status code or a message; PHP does not do it on its own. **RR9.** The status code does not say that a `false` return occurred; seeing 200 does not mean the validation passed.

In the third submission the tag in the `name` field was written as `&lt;b&gt;Ayse&lt;/b&gt;` thanks to `htmlspecialchars`. Without escaping, the browser would interpret this text as markup. This function is silent on the return channel as well: no channel reports that the value contains a tag.

## Filter Options Narrow the Range

A type check is often not enough: someone who types `150` or `-5` into the age field has sent a valid integer but not a valid age. `filter_var` solves this with the `options` array in the third argument; the `min_range` and `max_range` keys set the accepted range. The filter also trims whitespace at the start and end of the value, so a value such as `' 30 '` from a form counts as valid.

```php
<?php
// range.php — range and whitespace behavior of the integer filter
$options = ['options' => ['min_range' => 0, 'max_range' => 120]];

$inputs = ['30', ' 30 ', '150', '-5', '3.0'];
foreach ($inputs as $input) {
    $result = filter_var($input, FILTER_VALIDATE_INT, $options);
    printf("%-8s%s\n", '[' . $input . ']', $result === false ? 'false' : 'int ' . $result);
}
```

```
[30]    int 30
[ 30 ]  int 30
[150]   false
[-5]    false
[3.0]   false
```

The values `150` and `-5`, which fall outside the range, follow the same path as `12a`: both return `false`. From the caller's side, three different problems, a format error, exceeding the upper bound and falling below the lower bound, are reduced to one value. To find out which one occurred, the code has to examine the input again; the return channel says only that a failure happened and does not carry the cause. `3.0` is also rejected, because the filter does not accept decimal notation as an integer. **RR10.** The trimming and decimal behavior shown here belongs to the PHP 8 family in which the measurement ran; the lesson does not trust default limits and passes the range on every call.

## The Boolean Filter Separates `false` from Failure

The real job of the `FILTER_NULL_ON_FAILURE` flag shows up in the Boolean filter. For `FILTER_VALIDATE_BOOLEAN`, `false` is a valid result: `"false"`, `"0"` and the empty string are accepted spellings of it. Without the flag, both a valid `false` and an unrecognized text return `false`, and the caller cannot tell them apart. With the flag, the unrecognized text becomes `null` and the distinction stays in the value itself.

```php
<?php
// boolean.php — separating a valid false from failure in the Boolean filter
function show(mixed $value): string
{
    return $value === null ? 'null' : ($value ? 'true' : 'false');
}

foreach (['true', 'on', 'false', '0', '', 'abc'] as $input) {
    $withoutFlag = filter_var($input, FILTER_VALIDATE_BOOLEAN);
    $withFlag = filter_var($input, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
    printf("%-8s withoutflag=%-6s withflag=%s\n", '[' . $input . ']', show($withoutFlag), show($withFlag));
}
```

```
[true]   withoutflag=true   withflag=true
[on]     withoutflag=true   withflag=true
[false]  withoutflag=false  withflag=false
[0]      withoutflag=false  withflag=false
[]       withoutflag=false  withflag=false
[abc]    withoutflag=false  withflag=null
```

In the column without the flag, `false`, `0`, the empty string and `abc` look the same; only `abc` is actually invalid. The flag returns `null` for `abc`, and the others remain a valid `false`. This is the claim that was mentioned in earlier sections but not measured: the flag does not move failure to a new channel, it opens a distinction between `false` and `null` within the existing return channel. **RR11.** To use this distinction, the caller has to test with `=== null`; the not operator still treats `null` and `false` the same.

## A Missing Field Looks Like an Invalid Value

One trap of validation is a field that was never sent. The earlier `form.php` turns a missing field into the empty string with `?? ''`, and the filter rejects the empty string; the result is `false`, as if `12a` had been sent. To tell them apart, the presence of the key is tested separately.

```php
<?php
// missing_field.php — a missing field and an invalid field land on the same false
$age = filter_var($_POST['age'] ?? '', FILTER_VALIDATE_INT);
$present = array_key_exists('age', $_POST) ? 'yes' : 'no';
echo 'filter=', $age === false ? 'false' : $age, ' field_present=', $present, "\n";
```

```bash
# missing_field.sh — sends two POST requests, one with the field and one without
php -S 127.0.0.1:8082 -d display_errors=0 >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8082/missing_field.php && break; sleep 0.1; done
echo '--- age=12a'
curl -s -d 'age=12a' -w 'status=%{http_code}\n' http://127.0.0.1:8082/missing_field.php
echo '--- no field'
curl -s -d 'name=Ayse' -w 'status=%{http_code}\n' http://127.0.0.1:8082/missing_field.php
kill $pid
```

```
--- age=12a
filter=false field_present=yes
status=200
--- no field
filter=false field_present=no
status=200
```

The filter result is the same in both requests: `false`. Only `array_key_exists` shows the difference. In the Superglobals lesson the missing field was on the warning channel; `?? ''` pulled it to the silent channel, and validation finally lowered it to the return channel. Three channels are chained in a single line, and at no point does the result say which one is in effect. **RR12.** Code that does not know which channel was chosen meets "the field is missing" and "the field is malformed" with the same error message.

## Other Filters Use the Same Return Path

The integer and Boolean filters are not the only examples. The email and decimal number filters work the same way: they return the value for valid input and `false` for invalid input. For decimal numbers the separator is taken to be a period; the input of a user who types a comma is rejected.

```php
<?php
// others.php — return values of the email and decimal number filters
$measurements = [
    'email valid'     => filter_var('name@example.test', FILTER_VALIDATE_EMAIL),
    'email invalid'   => filter_var('name@', FILTER_VALIDATE_EMAIL),
    'decimal point'   => filter_var('1.5', FILTER_VALIDATE_FLOAT),
    'decimal comma'   => filter_var('1,5', FILTER_VALIDATE_FLOAT),
];

foreach ($measurements as $name => $result) {
    printf("%-18s%s\n", $name, $result === false ? 'false' : get_debug_type($result) . ' ' . $result);
}
```

```
email valid       string name@example.test
email invalid     false
decimal point     float 1.5
decimal comma     false
```

All four filters speak the same way: the signal is in the return value, and there is no detail. The rejection of `1,5` shows that the filter does not look at the locale; changing the separator is the caller's job. **RR13.** All the measurements of this lesson reach one conclusion: PHP does not report the invalidity of the input to the client, it gives the caller only a value, and what follows is the code's decision.

## The Bounding Measurement

The weak point of the return channel is how failure is tested. The shortest way to tell failure from `false` is to test the value with the not operator, but `0` is a valid integer.

```php
<?php
// zero.php — a valid 0 and a failure tested the same way
$measurements = [
    'zero'    => filter_var('0', FILTER_VALIDATE_INT),
    'invalid' => filter_var('0a', FILTER_VALIDATE_INT),
];

foreach ($measurements as $name => $result) {
    $short = !$result ? 'judged failed' : 'valid';
    $strict = $result === false ? 'failed' : 'valid';
    printf("%-10s%s: %-18s%s: %s\n", $name, 'not operator', $short, 'strict', $strict);
}
```

```
zero      not operator: judged failed     strict: valid
invalid   not operator: judged failed     strict: failed
```

For `0`, `filter_var` returns a valid `int 0`. When tested with the not operator, this value looks the same as `false`, and valid input is counted as failed. The strict comparison (`=== false`) separates the two. **RR14.** This is not a PHP quirk but the natural limit of the return channel: the channel fits both the result and the failure into a single value, and the caller makes the distinction. The `FILTER_NULL_ON_FAILURE` flag moves the distinction into the value itself, but then a valid `false` and `null` are what get separated for the Boolean filter; in every case, code that reads the signal has to know which value means what.

## Summary

- Form data arrives as `string` and `filter_var` tests it with a filter: a valid value comes back in the appropriate type, an invalid one comes back as `false`.
- The channel measured in this lesson is **return**: for the invalid `12a` there is neither a warning nor an exception; the problem is heard only by code that looks at the return value.
- A validation failure does not change the status code: even if the script prints `invalid age` by its own decision, the response stays 200.
- `htmlspecialchars` converts tags to `&lt;` and `&gt;`; the classification of escaping belongs to M25.
- A valid `0` looks the same as failure under the not operator; the strict comparison (`=== false`) separates the two.

## Next Step

In this lesson the request carried a single value, and a missing or malformed field was caught by a check. In a file upload, the request carries the file itself and a status code that accompanies it. The File Uploads lesson measures what `move_uploaded_file` returns when it is asked to move a file that was not uploaded, and what is left in `$_FILES` and `$_POST` when the server limit is exceeded.
