filter_var_array() checks a whole form against one definition per field, giving null for missing fields and false for failures. FILTER_REQUIRE_SCALAR fails body[]=x and FILTER_REQUIRE_ARRAY fails tags=x, so later code never meets an unexpected type.
<?php
declare(strict_types=1);
const BOOKS = [101 => 'Learning PHP 8.5', 102 => 'MySQL in Practice'];
const TAGS = ['clear', 'practical', 'dated'];
function e(string $v): string { return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE); }
function validateReview(array $in): array
{
$v = filter_var_array($in, [
'book' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1]],
'rating' => ['filter' => FILTER_VALIDATE_INT,
'options' => ['min_range' => 1, 'max_range' => 5]],
'email' => FILTER_VALIDATE_EMAIL,
'tags' => ['filter' => FILTER_DEFAULT, 'flags' => FILTER_REQUIRE_ARRAY],
'body' => ['filter' => FILTER_DEFAULT, 'flags' => FILTER_REQUIRE_SCALAR],
]);
$v['tags'] = array_values(array_intersect($v['tags'] ?: [], TAGS));
$v['body'] = trim((string) $v['body']); // null (missing) and false (array) give ''
return [$v, array_filter([
'book' => isset(BOOKS[(int) $v['book']]) ? null : 'Choose a book.',
'rating' => is_int($v['rating']) ? null : 'Pick a rating from 1 to 5.',
'email' => is_string($v['email']) ? null : 'Enter a valid email.',
'tags' => count($v['tags']) <= 2 ? null : 'Pick at most two tags.',
'body' => mb_strlen($v['body']) >= 20 ? null : 'Write at least 20 characters.',
])];
}Filters settle types; the rules after them are the shop's. array_filter() drops the null entries, leaving one message per failed field, keyed for the template. A curl 3,008 POST with rating=9, email=ann@example, all three tags and a short body got HTTP/1.1 422 Unknown Status Code (the built-in server has no reason phrase for 422) and this HTML, every value back, escaped:
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Add a review</title>
<style>body{font:15px sans-serif}label{display:block;margin:8px 0}b{color:#b00020}</style>
</head><body><h2>Add a review</h2>
<form method="post" action="/review.php">
<label>Book <select name="book">
<option value="101" selected>Learning PHP 8.5</option>
<option value="102">MySQL in Practice</option>
</select></label>
<label>Rating (1-5) <input name="rating" value="9"> <b>Pick a rating from 1 to 5.</b></label>
<label>Email <input name="email" value="ann@example"> <b>Enter a valid email.</b></label>
<label>Tags <input type="checkbox" name="tags[]" value="clear" checked>
clear <input type="checkbox" name="tags[]" value="practical" checked>
practical <input type="checkbox" name="tags[]" value="dated" checked>
dated <b>Pick at most two tags.</b></label>
<label>Review <b>Write at least 20 characters.</b><br>
<textarea name="body" rows="3" cols="40">Too "short" <b></textarea></label>
<button>Post review</button></form></body></html>
Sending body[]=x&book[]=1 also got a 422, without a single warning. Back the rules with database constraints (MySQL); Laravel 2,157 's validator (Laravel) scales the pattern up.