Lesson 12 / 14
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.
Published Updated
In this lesson
- 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
Contents
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 // 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 // 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";
# 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=<b>Ayse</b> 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 <b>Ayse</b> 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 // 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 // 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 // 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";
# 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 // others.php — return values of the email and decimal number filters $measurements = [ 'email valid' => filter_var('[email protected]', 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 [email protected] 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 // 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
stringandfilter_vartests it with a filter: a valid value comes back in the appropriate type, an invalid one comes back asfalse. - The channel measured in this lesson is return: for the invalid
12athere 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 ageby its own decision, the response stays 200. htmlspecialcharsconverts tags to<and>; the classification of escaping belongs to M25.- A valid
0looks 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.