Глава 28 · Практика
Формы: безопасный приём, ошибки полей и повторная отправка
От сырых полей до проверенной заявки: тип, длина, бизнес-правила, CSRF, полномочия и PRG.
Форма поиска: два файла и один путь данных
Посетителю нужна инструкция к дрели. Он введёт «дрель», нажмёт «Найти» и увидит подтверждение «Вы ищете: дрель». Сейчас мы учимся доставлять текст с экрана на сервер; поиск по каталогу станет следующим изменением требования.
Форма — HTML-участок с полями и кнопкой. Она отправляет значения браузером. PHP не наблюдает набор букв в открытом поле; он получает только новый запрос после отправки. Нужны два файла из одного ZIP: form.html показывает поле, example.php получает запрос. Разместите их рядом.
- form.html: name="query", значение «дрель»
- submit → GET /example.php?query=…
- PHP: $_GET['query'] содержит строку «дрель»
- htmlspecialchars готовит текст для HTML
- В ответе: «Вы ищете: дрель»
Полный файл form.html
<!doctype html>
<html lang="ru"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Поиск инструкции</title>
<h1>Найти инструкцию</h1>
<form action="example.php" method="get">
<label for="query">Название инструмента</label>
<input id="query" name="query" value="дрель">
<button type="submit">Найти</button>
</form></html>
| Запись | Действие |
|---|---|
<form action="example.php" method="get"> | Открывает форму; адрес получателя относительно form.html — example.php, способ отправки — GET. |
<label for="query"> и id="query" | Связывают видимую подпись с полем. Нажатие подписи ставит курсор; средство чтения экрана произносит назначение. |
name="query" | Имя отправляемой пары. Именно query станет ключом $_GET; id эту работу не выполняет. |
value="дрель" | Начальное значение поля. Отправляется текущее значение после правок человека. |
type="submit" | Кнопка просит браузер отправить подходящие поля этой формы. |
</form> | Закрывает форму; поля снаружи без явной связи form не относятся к ней. |
Пара вида query=дрель попадёт после вопросительного знака URL. Кириллица будет закодирована для адреса; PHP декодирует её при разборе. GET удобен для поиска: адрес можно сохранить. Пароль или изменение заказа для такого адреса не подходят. Поле без name, отключённое disabled поле и неотмеченный checkbox обычно не отправляются; readonly поле отправляется.
Обработчик: прочитать одно значение и безопасно показать его
$_GET — подготовленный PHP массив входных параметров. Квадратные скобки с 'query' выбирают значение по имени. ?? '' даёт пустую строку при отсутствующем ключе. Адрес можно изменить вручную, поэтому содержимому нельзя доверять.
Новые операции перед кодом: is_string($query) спрашивает, строка ли это; ! означает «не». if (...) { ... } выполняет блок при истинном условии. strlen считает байты. http_response_code(422) задаёт статус ошибки, exit выводит сообщение и прекращает этот запрос. header задаёт заголовок до тела ответа.
htmlspecialchars возвращает текст с заменой специальных для HTML символов. Первый аргумент — ввод, ENT_QUOTES | ENT_SUBSTITUTE включает обработку обоих видов кавычек и замену повреждённой кодировки, третий — UTF-8. Поэтому введённое <b>дрель</b> отображается буквами, а не становится HTML-разметкой. Функция не проверяет бизнес-смысл запроса.
<?php
$query = $_GET['query'] ?? '';
if (!is_string($query)) {
http_response_code(422);
exit('Введите одно название инструмента.');
}
if (strlen($query) > 200) {
http_response_code(422);
exit('Название слишком длинное: максимум 200 байтов UTF-8.');
}
header('Content-Type: text/html; charset=utf-8');
echo '<h1>Результат поиска</h1>';
echo '<p>Вы ищете: ', htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</p>';
echo '<p><a href="form.html">Изменить запрос</a></p>';
Весь ZIP содержит example.php и form.html. В терминале из этой папки: php -S 127.0.0.1:8080. В браузере: http://127.0.0.1:8080/form.html. Нажмите «Найти»: статус 200, на странице «Результат поиска» и «Вы ищете: дрель». PHP CLI не заполнит $_GET так, как браузер. Этот пример проверен отдельно через HTTP; обычная проверка манифеста подтверждает синтаксис.
| Опыт | Ожидаемое поведение |
|---|---|
?query=0 | Показывает «Вы ищете: 0»; ноль не потерян из-за empty. |
?query= или без параметра | Показывает пустую строку поиска: это выбранная политика демонстрации. Можно позже добавить сообщение «Введите название». |
?query[]=дрель | 422 и «Введите одно название инструмента.»; вызов htmlspecialchars с массивом не происходит. |
Текст <b>дрель</b> | На экране видны угловые скобки, исходный HTML ответа содержит <b>. |
| 201 ASCII-буква | 422 и сообщение о 200 байтах. Это байты, а не универсальный лимит букв. |
Теперь поиск можно связать с каталогом: проверенная строка пойдёт в запрос базы через параметр PDO. Не склеивайте её с SQL и не считайте HTML-экранирование защитой SQL.
POST, флажок и массив тем: какие ключи реально придут
Появилось требование: выбрать инструкции и указать адрес для ответа. POST помещает поля формы в тело запроса. Адрес example.php?source=guide по-прежнему содержит отдельный параметр URL. В одном запросе получится $_GET['source'] и несколько ключей $_POST. POST сам не шифрует данные: на публичном сайте нужен HTTPS.
В первом опыте ниже форма только показывает выбранное человеку. Она не отправляет письмо и не подписывает его на рассылку; для изменения состояния дальше добавим сессию, CSRF и хранилище. Введите свой учебный адрес, снимите или поставьте флажки и сравните ответ.
Полный файл form.html
<!doctype html>
<html lang="ru"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Выбор инструкций</title><h1>Получить список инструкций</h1>
<form action="example.php?source=guide" method="post">
<p><label for="email">Адрес для ответа</label>
<input id="email" name="email" type="email" value="anna@example.test"></p>
<p><label><input name="subscribe" type="checkbox" value="yes"> Получать новости</label></p>
<fieldset><legend>Темы инструкций</legend>
<label><input name="topics[]" type="checkbox" value="drill" checked> Дрель</label>
<label><input name="topics[]" type="checkbox" value="saw" checked> Пила</label>
</fieldset>
<button type="submit">Показать выбор</button>
</form></html>
| Поле браузера | PHP после отправки | Почему именно так |
|---|---|---|
name="email", введено anna@example.test | $_POST['email'] = строка anna@example.test | name задаёт ключ, текущее содержимое — значение. |
| subscribe отмечен, value="yes" | $_POST['subscribe'] = строка yes | Это строка value, а не PHP true. При снятом флажке ключ отсутствует. |
Два name="topics[]" | $_POST['topics'] = ["drill", "saw"] | Скобки в имени просят PHP собрать список. При отсутствии обоих флажков отсутствует и ключ. |
?source=guide в action | $_GET['source'] = guide | Параметр URL сохраняет свой источник при POST. |
| input type=email | Всё ещё строка или поддельный массив | Проверка браузера помогает человеку, но сервер повторяет проверку. |
Перед обработчиком разберём повторяющиеся действия. in_array($value, $allowed, true) ищет значение в разрешённом списке с проверкой типа. is_array проверяет список, count ограничивает число элементов. foreach ($topics as $topic) по очереди кладёт каждый выбранный код в $topic. isset($names[$topic]) проверяет наличие перевода кода в название. array_unique убирает дубли после проверки всех значений. Тернарная запись условие ? одно : другое выбирает текст «да» или «нет».
<?php
header('Content-Type: text/plain; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit("Откройте form.html и отправьте форму.\n");
}
$email = $_POST['email'] ?? null;
$subscribe = $_POST['subscribe'] ?? 'no';
$topics = $_POST['topics'] ?? [];
if (!is_string($email) || strlen($email) > 254
|| filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
http_response_code(422);
exit("Укажите один корректный адрес для ответа.\n");
}
if (!in_array($subscribe, ['yes', 'no'], true) || !is_array($topics) || count($topics) > 2) {
http_response_code(422);
exit("Проверьте флажки формы.\n");
}
$names = ['drill' => 'Дрель', 'saw' => 'Пила'];
foreach ($topics as $topic) {
if (!is_string($topic) || !isset($names[$topic])) {
http_response_code(422);
exit("Выберите тему из списка.\n");
}
}
echo 'Адрес для ответа: ', $email, "\n";
echo 'Новости: ', $subscribe === 'yes' ? 'да' : 'нет', "\n";
echo "Выбранные инструкции:\n";
foreach (array_unique($topics) as $topic) {
echo '- ', $names[$topic], "\n";
}
Распакуйте пример отдельно от поиска и запустите тем же php -S 127.0.0.1:8080. Отправьте исходную form.html: ответ text/plain содержит «Адрес для ответа: anna@example.test», «Новости: нет», затем «Выбранные инструкции:», «- Дрель», «- Пила». Поставьте subscribe — «Новости: да». Снятие обеих тем даёт пустой список по договору этого опыта.
Для отладки выберите запрос в Network → Payload/Form Data: сравните отправленные имена с PHP-ключами. Если данных нет, сначала проверьте name, action, method и факт отправки нужной формы. Значение в консоли JavaScript ещё не является телом запроса — это реальная проблема из прочитанного обсуждения Stack Overflow. Не выводите публично весь $_SERVER или персональные данные ради диагностики.
Отсутствует, пусто, ноль, массив: четыре разных состояния
Система записи на консультацию может принимать «0» как отсутствие дополнительных участников. Если использовать empty, этот допустимый ноль исчезнет вместе с пустой строкой. Разделим тип и значение явно. Ниже входные массивы заданы кодом для повторяемого CLI-опыта; настоящий браузер создаёт их тем же способом, показанным выше.
<?php
declare(strict_types=1);
$requests = [[], ['guests' => ''], ['guests' => '0'], ['guests' => ['0']]];
foreach ($requests as $input) {
$guests = $input['guests'] ?? null;
if ($guests === null) {
echo "Укажите число дополнительных участников.\n";
} elseif (!is_string($guests)) {
echo "Нужно одно число, а не список.\n";
} elseif ($guests === '') {
echo "Поле оставлено пустым.\n";
} elseif ($guests === '0') {
echo "Запись для одного человека, без гостей.\n";
}
}
Ожидаемый результат
Укажите число дополнительных участников.
Поле оставлено пустым.
Запись для одного человека, без гостей.
Нужно одно число, а не список.
foreach берёт по одному снимку запроса; === сравнивает значение и тип. Это ещё не полный числовой валидатор: он демонстрирует четыре состояния. Для произвольного количества дальше проверяют формат и диапазон, а не приводят любой ввод к int.
Обработчик формы получает строки и массивы, но не надёжный тип int из type=number. Прямое (int) "2cats" скрывает ошибку ввода. Для поля количества используйте проверку целого с диапазоном, сравнивая результат строго с false. Для JSON собственное null отличается от отсутствия; там полезен array_key_exists. В обычном POST null служит здесь нашей меткой отсутствия.
Если POST неожиданно пуст целиком, проверьте ещё Content-Type: JSON читают из php://input, а не из $_POST. При превышении post_max_size PHP может оставить пустыми одновременно $_POST и $_FILES. Лимиты запроса согласуют с веб-сервером; отсутствие полей нельзя автоматически считать сознательным выбором человека.
От показа данных к настоящей заявке: договор и порядок работы
Теперь человек просит мастерскую починить инструмент. После успешного ввода он увидит «Заявка сохранена. Мы ответим на указанный адрес». В закрытом каталоге появится JSON с именем, адресом и текстом. Это локальное хранилище учебной мастерской: письмо ещё не отправляется. При ошибке данные не сохраняются, понятное сообщение показывается у поля, допустимые значения остаются для исправления.
Прежде чем собирать проект, задаём договор: имя — одна непустая строка до 80 байтов UTF-8, адрес — одна строка до 254 байтов и синтаксически допустимый email, сообщение — непустая строка до 2000 байтов. trim убирает внешние пробелы только у этих полей; для пароля такое правило нельзя переносить автоматически. Адрес anna@example.test намеренно учебный. Проверка формата не доказывает существование ящика.
Большой обработчик легче понять по обязанностям файлов. example.php принимает запрос и строит HTML; lib/validate.php возвращает очищенные значения и ошибки; lib/store.php записывает успешную заявку. public/index.php — короткая точка входа. Каталог storage лежит рядом с public, поэтому при правильно заданном document root его нельзя скачать по URL.
- GET /index.php: сервер выдаёт форму и случайный csrf
- POST: name=Анна, email=anna@example.test, message=Заменить патрон
- Проверка токена → validateRequest возвращает values и errors
- errors пуст: saveRequest создаёт storage/<случайный id>.json
- 303 /index.php → GET показывает подтверждение
- errors непуст: 422 и прежние безопасные значения у полей
| Новая запись | Что происходит на наших данных |
|---|---|
function validateRequest(array $input): array | Объявляем функцию: передадим $_POST как массив $input, вернётся массив с двумя ключами values и errors. |
return ["values" => $values, "errors" => $errors] | Возвращаем результат в точку вызова. Внешний код читает $checked["values"] и $checked["errors"]. |
foreach (["name" => 80, ...] as $key => $maxBytes) | Сначала key=name и maxBytes=80; затем email/254, message/2000. Одно правило формы применяется к трём полям. |
continue | Переходит к следующему полю после ошибки; trim не получает массив. |
preg_match("//u", $raw) | Пустой шаблон с u здесь проверяет корректность UTF-8; это не проверка имени или email. |
session_start и $_SESSION | Сессия хранит токен между GET и POST на сервере; браузер приносит cookie с идентификатором. |
bin2hex(random_bytes(32)) | Получаем 32 случайных байта и переводим в 64 шестнадцатеричных символа для скрытого поля. |
hash_equals | Сравниваем сохранённый и присланный токен после проверки строкового типа; при несовпадении запись запрещена. |
try / catch (Throwable $error) | Пробуем сохранить. Исключение переключает управление на сообщение об отказе; страницу успеха не показываем. |
<?= h($values[$key]) ?> | Сокращение echo; h — наша небольшая обёртка htmlspecialchars. В хранилище остаётся обычный текст, HTML-кодирование применяется при выводе. |
Если объявления функций и циклы ещё незнакомы, разберите вызов и возврат значения и условия и повторение. Но договор текущего проекта дан здесь: можно проследить каждую строку данных от поля до результата.
Сначала отдельно получить значения и понятные ошибки
Полный файл lib/validate.php
<?php
function validateRequest(array $input): array
{
$errors = [];
$values = ['name' => '', 'email' => '', 'message' => ''];
foreach (['name' => 80, 'email' => 254, 'message' => 2000] as $key => $maxBytes) {
$raw = $input[$key] ?? null;
if (!is_string($raw)) {
$errors[$key] = 'Заполните поле одной строкой.';
continue;
}
if (preg_match('//u', $raw) !== 1 || strlen($raw) > $maxBytes) {
$errors[$key] = 'Текст слишком длинный или содержит неверную кодировку.';
continue;
}
$values[$key] = trim($raw);
if ($values[$key] === '') {
$errors[$key] = 'Заполните это поле.';
}
}
if (!isset($errors['email'])
&& filter_var($values['email'], FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Укажите адрес вида anna@example.test.';
}
return ['values' => $values, 'errors' => $errors];
}
<?php
declare(strict_types=1);
require __DIR__ . '/lib/validate.php';
$checked = validateRequest(['name' => ' Анна ', 'email' => 'anna@example.test', 'message' => 'Заменить патрон']);
echo 'Имя для сохранения: ', $checked['values']['name'], "\n";
echo 'Работа: ', $checked['values']['message'], "\n";
$bad = validateRequest(['name' => ['Анна'], 'email' => 'не адрес', 'message' => '']);
foreach ($bad['errors'] as $field => $text) {
echo $field, ': ', $text, "\n";
}
Ожидаемый результат
Имя для сохранения: Анна
Работа: Заменить патрон
name: Заполните поле одной строкой.
message: Заполните это поле.
email: Укажите адрес вида anna@example.test.
Скачайте вместе example.php и lib/validate.php; выполните php example.php. require подключает объявления из файла относительно __DIR__. Ни одна заявка здесь ещё не пишется на диск: проверяем договор до подключения HTTP и хранилища.
Массив ошибок связан с конкретными полями, а не смешан с HTML. Массив допустимых значений хранится отдельно: опасный массив name[] не возвращается в input, слишком длинный текст не отражается целиком. Синтаксически неверный, но короткий email остаётся видимым для исправления. Значения экранируются в шаблоне.
Затем сохранить проверенную заявку в закрытый файл
saveRequest получает только values после успешной проверки и фиксированный каталог. Она создаёт случайное имя; имя посетителя никогда не становится путём. JSON — текстовый формат с именованными полями. Для Анны содержимое файла будет именно таким, независимо от случайного имени:
{"name":"Анна","email":"anna@example.test","message":"Заменить патрон"} — та же структура; наш JSON_PRETTY_PRINT запишет её с отступами и переводом строки.
Перед листингом: fopen с режимом xb создаёт новый двоичный файл и отказывается, если имя занято. fwrite возвращает число записанных байтов; сравниваем с strlen JSON. fflush передаёт буфер потока на следующий уровень ввода-вывода, но не обещает устойчивость к потере питания. fclose освобождает дескриптор. При частичной записи catch закрывает и удаляет только что созданный неполный файл. Ошибка передаётся наверх оператором throw; отправленный текст не теряется из формы.
Полный файл lib/store.php
<?php
function saveRequest(array $values, string $directory): string
{
$id = bin2hex(random_bytes(16));
$json = json_encode($values, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n";
$path = $directory . '/' . $id . '.json';
// x создаёт новый файл; существующий файл никогда не перезаписывается.
$handle = fopen($path, 'xb');
if ($handle === false) {
throw new RuntimeException('request_open_failed');
}
try {
$written = fwrite($handle, $json);
if ($written !== strlen($json) || !fflush($handle)) {
throw new RuntimeException('request_write_failed');
}
} catch (Throwable $error) {
fclose($handle);
unlink($path);
throw $error;
}
fclose($handle);
return $id;
}
Полная практика записи/чтения текста и CSV — в файловой главе. Здесь хранилище является последней операцией после проверки; сообщение «сохранено» не предшествует проверке записи.
Собрать и запустить форму мастерской целиком
Распакуйте ZIP без переименования: example.php находится в корне, внутри есть public/index.php, lib/validate.php, lib/store.php и storage/.keep. Запустите из корня: php -d display_errors=0 -d log_errors=1 -S 127.0.0.1:8080 -t public. Откройте http://127.0.0.1:8080/index.php. Для этого примера нужны PHP с JSON и сессиями и возможность записи в storage. Публичный корень — только public; запуск без -t public откроет лишние файлы.
В public/index.php parse_url с PHP_URL_PATH выделяет путь из адреса запроса. Разрешены только / и /index.php, остальные получают 404. Это нужно ещё и потому, что встроенный PHP-сервер может передавать неизвестные пути в index.php. dirname(__DIR__) поднимается из public в корень проекта; require подключает наш example.php.
В начале example.php ещё ничего не напечатано: можно начать сессию и отправить заголовки. ??= задаёт токен, только если его ещё нет. При POST сравниваем токен, получаем checked, сохраняем и посылаем Location со статусом 303. exit сразу прекращает код после перенаправления. GET забирает одноразовое notice и показывает подтверждение.
В HTML альтернативная запись if (...): ... endif; и foreach (...): ... endforeach; выполняет те же условия и циклы, что фигурные скобки. Общий список ошибок получает фокус; ссылки ведут к полям, aria-describedby связывает поле с текстом ошибки. Серверная проверка сработает и при обходе HTML required.
<?php
session_start(['use_strict_mode' => true, 'cookie_httponly' => true, 'cookie_samesite' => 'Lax']);
require __DIR__ . '/lib/validate.php';
require __DIR__ . '/lib/store.php';
function h(string $text): string {
return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$values = ['name' => '', 'email' => '', 'message' => ''];
$errors = [];
$notice = $_SESSION['notice'] ?? '';
unset($_SESSION['notice']);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['csrf'] ?? null;
if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
http_response_code(403);
$errors['form'] = 'Форма устарела. Обновите страницу и отправьте её снова.';
} else {
$checked = validateRequest($_POST);
$values = $checked['values'];
$errors = $checked['errors'];
if ($errors !== []) {
http_response_code(422);
} else {
try {
saveRequest($values, __DIR__ . '/storage');
$_SESSION['notice'] = 'Заявка сохранена. Мы ответим на указанный адрес.';
$_SESSION['csrf'] = bin2hex(random_bytes(32));
header('Location: /index.php', true, 303);
exit;
} catch (Throwable $error) {
error_log('Request storage failed: ' . get_class($error));
http_response_code(503);
$errors['form'] = 'Сейчас не удалось сохранить заявку. Ваш текст остался в форме; попробуйте позже.';
}
}
}
} elseif ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
header('Allow: GET, POST');
exit('Допустимы GET и POST.');
}
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
?>
<!doctype html>
<html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Заявка в мастерскую</title>
<style>body{font:18px/1.5 system-ui;max-width:42rem;margin:2rem auto;padding:0 1rem}label{display:block}input,textarea,button{font:inherit;box-sizing:border-box;max-width:100%;padding:.5rem}input,textarea{width:100%}p{overflow-wrap:anywhere}.error{color:#9b1c1c}</style>
</head><body><h1>Заявка в мастерскую</h1>
<?php if ($notice !== ''): ?><p role="status"><?= h($notice) ?></p><?php endif; ?>
<?php if ($errors !== []): ?>
<div role="alert" id="errors" tabindex="-1"><h2>Проверьте заявку</h2><ul>
<?php foreach ($errors as $key => $text): ?>
<li><a href="#<?= $key === 'form' ? 'request-form' : h($key) ?>"><?= h($text) ?></a></li>
<?php endforeach; ?></ul></div>
<?php endif; ?>
<form id="request-form" action="/index.php" method="post">
<input type="hidden" name="csrf" value="<?= h($_SESSION['csrf']) ?>">
<?php foreach (['name' => 'Ваше имя', 'email' => 'Адрес для ответа', 'message' => 'Что нужно сделать?'] as $key => $label): ?>
<p><label for="<?= h($key) ?>"><?= h($label) ?></label>
<?php if ($key === 'message'): ?>
<textarea id="message" name="message" rows="5" required aria-describedby="message-error"><?= h($values[$key]) ?></textarea>
<?php else: ?>
<input id="<?= h($key) ?>" name="<?= h($key) ?>" type="<?= $key === 'email' ? 'email' : 'text' ?>" value="<?= h($values[$key]) ?>" required aria-describedby="<?= h($key) ?>-error">
<?php endif; ?>
<span class="error" id="<?= h($key) ?>-error"><?= h($errors[$key] ?? '') ?></span></p>
<?php endforeach; ?>
<p>Пишите коротко: имя до 80, адрес до 254, сообщение до 2000 байтов UTF-8. Кириллическая буква обычно занимает два байта.</p>
<button type="submit">Сохранить заявку</button>
</form>
<?php if ($errors !== []): ?><script>document.getElementById('errors').focus();</script><?php endif; ?>
</body></html>
Запуск описан перед кодом. Введите Анна, anna@example.test, «Заменить патрон». POST вернёт 303, следующий GET — 200 и подтверждение. В storage появится один .json с этими тремя значениями; файл недоступен по /storage/... при -t public. Обновление страницы подтверждения — GET, оно не создаёт ещё одну заявку.
Полный файл public/index.php
<?php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path !== '/' && $path !== '/index.php') {
http_response_code(404);
exit('Страница не найдена.');
}
require dirname(__DIR__) . '/example.php';
| Изменение опыта | Что проверить |
|---|---|
| Неверный email или пустое сообщение | 422, текст у поля и в сводке; допустимое имя/сообщение сохранены в форме; файла нет. |
Поддельное name[]=Анна | 422, поле имени получает ошибку; TypeError не возникает. |
Текст <script>alert(1)</script> и неверный email | Текст виден в textarea буквами; скрипт не исполняется. |
| Удалить csrf из запроса или изменить один символ | 403; заявка не записана; предложено обновить форму. |
| Остановить сервер, временно переименовать storage, запустить и отправить годную форму | 503; «Сейчас не удалось сохранить заявку…», поля остаются. Верните storage после опыта. |
| После успеха обновить страницу | GET 200 без второго файла; токен после успеха уже заменён. |
Это законченная локальная заявка. Для публичного приёма до выпуска понадобятся HTTPS и cookie_secure=true, согласованные лимиты веб-сервера/PHP, ограничение частоты и процесс обработки заявок. Здесь CSRF не является защитой от автоматического спама и не заменяет аутентификацию или проверку права изменять чужую заявку.
303 улучшает поведение браузера после успеха. Повторный параллельный запрос или потерянный ответ всё ещё требуют договорённости об идемпотентности и уникальном ключе операции в хранилище; одна смена токена не является универсальной гарантией «ровно один раз». Следующий шаг: добавить необязательный телефон с собственной политикой отсутствия/пустоты и перенести сохранение в PDO.
Проверяем заявку слоями
POST — входные данные, которым ещё нельзя доверять
Покупатель оставляет заявку. В базу должна попасть проверенная заявка, а ошибки нужно вернуть к полям.
- Поля формы → POST
name=Анна, email=... Пришедшее значение может оказаться массивом вместо строки.
- Проверка запроса
PHP проверяет метод, размер, CSRF и необходимые права. Наличие $_POST не заменяет эти проверки.
- Проверка каждого поля
Тип, длина, обязательность и правила заявки. Обрезать пробелы недостаточно для проверки email.
- Выбрать результат
Сначала завершаем проверки. Затем выбираем одну из двух веток.
- Есть ошибки
Вернуть ошибки полей; при повторном HTML-показе экранировать введённые значения.
- Всё проверено
Сохранить через PDO с параметрами; после успеха ответить 303 на страницу результата.
Ветка ошибки не выполняет INSERT. CSRF, проверка полей и право выполнить действие — разные проверки.
Развитие собранной заявки: ниже те же проверки применяются к заказам. Вы уже видели HTML-поля, строковые значения, карту ошибок, сессию и запись. Теперь обсуждаем дополнительные бизнес-правила и ограничения.
Форма доставки спрашивает email, количество и согласие с условиями. HTML required, type=email и min полезны человеку, но их легко обойти. Сервер обязан повторить существенные проверки. Хороший обработчик различает повреждённый запрос, неверное значение, запрет операции и сбой хранилища. Это позволяет вернуть человеку понятную ошибку поля, не подменяя все неудачи сообщением «что-то пошло не так».
Сначала проверяют метод и ожидаемый Content-Type, затем ограничивают размер запроса на веб-сервере и в PHP. Потом извлекают поля с проверкой формы данных: строка это или массив. После этого выполняют допустимую нормализацию, синтаксическую проверку и бизнес-правила. Для email можно убрать внешние пробелы; пароль автоматически обрезать нельзя, иначе введённый секрет незаметно изменится. Цена товара, скидка и user_id берутся из доверенного серверного состояния, а не из скрытых полей.
Проверка CSRF подтверждает связь запроса с сессией; проверка авторизации подтверждает право редактировать именно этот заказ. Это разные барьеры. Для публичной контактной формы всё равно нужны ограничение частоты, лимиты длины и контроль злоупотреблений. CAPTCHA не заменяет ни одну из этих проверок. Важные правила должны жить в сервисе, чтобы их не обошёл другой контроллер или CLI-импорт.
- Метод и лимиты
- Тип каждого поля
- Нормализация
- CSRF и полномочия
- Формат и бизнес-правила
- Запись в транзакции
- 303 → результат
Строка, пустое поле и массив — разные случаи
Развитие собранной заявки: ниже те же проверки применяются к заказам. Вы уже видели HTML-поля, строковые значения, карту ошибок, сессию и запись. Теперь обсуждаем дополнительные бизнес-правила и ограничения.
<?php
declare(strict_types=1);
function validateOrder(array $input): array {
$errors = [];
$email = $input['email'] ?? null;
$quantity = $input['quantity'] ?? null;
if (!is_string($email) || strlen($email) > 254
|| filter_var(trim($email), FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Укажите корректный email';
}
if (!is_string($quantity)
|| !preg_match('/\A[1-9][0-9]{0,2}\z/D', $quantity)
|| (int) $quantity > 100) {
$errors['quantity'] = 'Количество: от 1 до 100';
}
return $errors;
}
foreach ([['email'=>'a@example.com','quantity'=>'2'],
['email'=>['a@example.com'],'quantity'=>'2cats'],
['email'=>'a@example.com','quantity'=>'0']] as $input) {
echo implode(',', array_keys(validateOrder($input))) ?: 'ok';
echo "
";
}
Ожидаемый результат
ok
email,quantity
quantity
Массив вместо email отклоняется до trim. Строка 2cats не превращается в 2. Ограничение 100 — правило этого магазина, а не PHP. FILTER_VALIDATE_EMAIL проверяет синтаксис, но не владение почтой; владение подтверждают отдельной ссылкой.
В больших формах возвращайте карту ошибок и очищенные значения отдельно. Не складывайте ошибки в строку с HTML. Возле поля используйте aria-describedby, список ошибок в начале формы и фокус на первом проблемном поле. При повторном показе сохраняйте безопасные значения, но не пароль, файл или секретный код. Значения экранируются в момент помещения в HTML, а не перед записью в базу: иначе при следующем выводе появляются двойные сущности.
Если поле необязательное, заранее решите разницу между отсутствием, пустой строкой и null. Для частичного обновления профиля отсутствие телефона часто означает «не менять», а пустая строка — «очистить». Оператор ?? объединяет отсутствие и null, поэтому для такого API может потребоваться array_key_exists. Излишне удобное автоматическое приведение типов на границе затрудняет диагностику.
CSRF проверяется, а не просто присутствует
Развитие собранной заявки: ниже те же проверки применяются к заказам. Вы уже видели HTML-поля, строковые значения, карту ошибок, сессию и запись. Теперь обсуждаем дополнительные бизнес-правила и ограничения.
<?php
declare(strict_types=1);
function validCsrf(mixed $submitted, mixed $stored): bool {
return is_string($submitted) && is_string($stored)
&& strlen($stored) === 64 && strlen($submitted) === 64
&& hash_equals($stored, $submitted);
}
$stored = str_repeat('a', 64); // Только фиксированный тест, не генератор!
foreach ([$stored, str_repeat('b',64), ['token']] as $candidate) {
echo validCsrf($candidate,$stored) ? "accept
" : "reject
";
}
Ожидаемый результат
accept
reject
reject
В приложении сохранённый токен создаётся bin2hex(random_bytes(32)) и хранится в сессии. Фиксированная строка нужна лишь для воспроизводимого теста. Секретный эталон — первый аргумент hash_equals.
На странице с несколькими формами не перезаписывайте единственный токен при каждом выводе input: первая форма станет недействительной. Можно использовать токен сессии или ограниченный набор токенов отдельных действий. Смена токена после авторизации полезна, а принудительное уничтожение после любой неудачной проверки ломает соседние вкладки. Политику срока жизни и повторного использования выбирают вместе с интерфейсом.
Токен передают скрытым полем или согласованным заголовком, не параметром URL: адреса попадают в историю, аналитику и журналы. SameSite снижает часть риска, но не служит универсальной заменой CSRF. Для JSON API с bearer-токеном модель отличается от cookie-аутентификации; решение зависит от того, прикладывает ли браузер учётные данные автоматически.
Сохранение, 303 и защита от дублей
Развитие собранной заявки: ниже те же проверки применяются к заказам. Вы уже видели HTML-поля, строковые значения, карту ошибок, сессию и запись. Теперь обсуждаем дополнительные бизнес-правила и ограничения.
После успешного сохранения перенаправьте на локальную страницу результата с 303. До этого сохраните короткое flash-сообщение в сессии, закройте её запись и завершите обработчик. PRG избавляет от обычного повторного POST при обновлении. Однако два нажатия, мобильный обрыв связи и повтор клиента всё ещё могут создать два заказа. Нужен ключ идемпотентности: храните его с пользователем и отпечатком существенных полей под UNIQUE-ограничением. Повтор того же ключа возвращает прежний результат; другой набор полей с тем же ключом отклоняется.
В журнал не записывают весь $_POST: там бывают пароли, документы и токены. Достаточно идентификатора запроса, типа операции, кодов ошибок и безопасных метаданных. Ошибки валидации обычно не требуют трассировки исключения. Не отправляйте письмо до подтверждения транзакции; потерянное письмо можно повторить через очередь, а откатить уже доставленное нельзя. Полный проект формы в мануале связывает эти решения в единый рабочий маршрут.
Лимиты формы и неожиданно отсутствующие поля
Развитие собранной заявки: ниже те же проверки применяются к заказам. Вы уже видели HTML-поля, строковые значения, карту ошибок, сессию и запись. Теперь обсуждаем дополнительные бизнес-правила и ограничения.
PHP и веб-сервер могут ограничить не только байты, но и число входных полей. Если импортёр прислал огромную форму с тысячами input, max_input_vars способен обрезать хвост. Обработчик не должен принимать неполную заявку как полноценную. Для больших пакетных операций лучше отдельный формат и контракт с количеством элементов, лимитами и явной проверкой целостности. JSON также ограничивают по байтам, глубине и числу записей.
Checkbox без отметки обычно отсутствует в POST. Поэтому для обычной формы «согласен» проверяют ожидаемое значение, а для PATCH отсутствие может означать «не менять». empty('0') возвращает true, что опасно для допустимого нулевого значения. Проверяйте условия, соответствующие смыслу поля, а не универсальную «непустоту».
strlen считает байты. Если интерфейс обещает до 100 символов имени, для UTF-8 нужен согласованный подсчёт через mb_strlen, а сложные пользовательские символы могут требовать grapheme_strlen. Одновременно сохраняйте байтовый предел, чтобы ограничить нагрузку. Политика нормализации Unicode должна быть одинаковой при регистрации, поиске и проверке уникальности; нельзя незаметно менять идентификатор только в одном месте.
Практика: проверьте решение, а не только успешный путь
Развитие собранной заявки: ниже те же проверки применяются к заказам. Вы уже видели HTML-поля, строковые значения, карту ошибок, сессию и запись. Теперь обсуждаем дополнительные бизнес-правила и ограничения.
Проверьте email[]=x, quantity=1e2, quantity=0, отсутствующий CSRF, чужой order_id и два одинаковых POST одновременно. Какие проверки должны остановить каждый случай?
Разбор решения
Тип, строгий формат, диапазон, CSRF, проверка владельца на сервере и UNIQUE ключ операции соответственно. Не пытайтесь свести все шесть случаев к одной filter_var: они принадлежат разным уровням ответственности.
Функции и методы этой темы
Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.
filter_varПроверить уже имеющееся значение: количество товара, адрес или настройку.
filter_var(mixed $value, int $filter = FILTER_DEFAULT, array|int $options = 0): mixed| Параметр / результат | Договор |
|---|---|
| value | Проверяемое значение. Скалярные значения внутри преобразуются в строку; это не проверка исходного PHP-типа. Для массива нужны структурные флаги. |
| filter | Идентификатор FILTER_VALIDATE_*, FILTER_SANITIZE_*, FILTER_UNSAFE_RAW или FILTER_CALLBACK. По умолчанию FILTER_DEFAULT = FILTER_UNSAFE_RAW: проверки нет. |
| options | Либо битовая маска флагов, либо массив: flags — маска, options — настройки конкретного фильтра. Флаги соединяет |, не массив строк. |
| Возврат | Данные согласно выбранному фильтру: int, float, bool, string, массив результатов либо произвольный результат callback. При отказе обычно false; NULL_ON_FAILURE даёт null. |
| Ошибки и ограничения | Неверный ID выдаёт предупреждение и false; неверный тип аргумента может дать TypeError. PHP 8.5+: THROW_ON_FAILURE для валидатора выбрасывает Filter\FilterFailedException; одновременно с NULL_ON_FAILURE запрещён (ValueError). |
Данные уже лежат в переменной, поэтому функция не читает ни форму, ни файл. Вызов получает само значение и константу правила; возвращённое значение надо сохранить или сразу использовать. filter_var не печатает сообщение об ошибке за вас. Первый опыт ниже различает число 0 и специальное значение false; затем тот же результат участвует в расчёте заказа.
- Текст количества "0"
- FILTER_VALIDATE_INT возвращает int(0)
- === false не срабатывает
- Количество 0 принято по договору
<?php
$stock = filter_var('0', FILTER_VALIDATE_INT);
if ($stock === false) {
echo "Некорректный остаток\n";
} else {
echo "Остаток: ", $stock, "\n";
}
Ожидаемый результат
Остаток: 0
Проверка ! $stock ошибочно сочла бы корректный ноль отказом. Нужен строгий тест специального результата false.
<?php
declare(strict_types=1);
function readOrderQuantity($text) {
return filter_var($text, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 20],
]);
}
$quantity = readOrderQuantity('3');
if ($quantity === false) {
echo "Укажите целое количество от 1 до 20\n";
} else {
echo 'В заказе: ', $quantity, " шт.\n";
echo 'Товаров на сумму: ', $quantity * 250, " руб.\n";
}
Ожидаемый результат
В заказе: 3 шт.
Товаров на сумму: 750 руб.
function объявляет своё действие, $text получает аргумент из readOrderQuantity('3'), return отдаёт результат фильтра. Вложенный массив options задаёт включённые границы. Только после проверки === false код использует результат в расчёте. Здесь 250 — учебная цена в целых рублях; дробные деньги удобнее хранить в целых копейках.
<?php
declare(strict_types=1);
$rows = ['Кисть' => '0', 'Молоток' => '12', 'Перчатки' => '3 пары'];
foreach ($rows as $name => $raw) {
$stock = filter_var($raw, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0, 'max_range' => 100],
]);
if ($stock === false) {
echo $name, ": строка отклонена, нужно целое от 0 до 100\n";
continue;
}
echo $name, ': остаток ', $stock, " шт.\n";
}
Ожидаемый результат
Кисть: остаток 0 шт.
Молоток: остаток 12 шт.
Перчатки: строка отклонена, нужно целое от 0 до 100
foreach берёт название и исходное значение каждой позиции. continue переходит к следующей строке, поэтому ошибочный остаток не попадает в успешную ветку. FILTER_SANITIZE_NUMBER_INT превратил бы «3 пары» в «3» и скрыл неверный формат. Здесь это не допускается: оператор должен исправить выгрузку. Такой договор принимается по задаче — не все импорты обязаны отклонять единицы измерения.
Применение и границы. Не проверяет права, CSRF, уникальность записи или безопасное назначение URL. Для теста исходного типа существуют is_int/is_string; для преобразования после проверки — отдельный шаг. Все валидаторы.
Сравните: filter_var_array, filter_input. Карта выбора.
Возвращает проверенное значение либо false, а с FILTER_NULL_ON_FAILURE — null для ошибки выбранной проверки. Фильтр по умолчанию не выполняет полезной валидации. Сначала проверяйте ожидаемый PHP-тип входа.
Рядом по задаче. filter_input читает исходный SAPI-ввод; preg_match подходит для точного формата; валидация формы объединяет уровни.
filter_inputПрочитать одно поле исходного HTTP-входа и сразу применить правило.
filter_input(int $type, string $var_name, int $filter = FILTER_DEFAULT, array|int $options = 0): mixed| Параметр / результат | Договор |
|---|---|
| type | INPUT_GET / POST / COOKIE / SERVER / ENV: данные, полученные SAPI. INPUT_SESSION и INPUT_REQUEST удалены в PHP 8.0, ранее не были реализованы. |
| var_name | Имя верхнего поля; для qty[]=2 пришёл массив, а не скаляр. |
| filter | Правило; FILTER_DEFAULT ничего не валидирует. |
| options | Маска либо flags/options, как у filter_var. |
| Возврат | Обычно: допустимое значение → результат, неверное → false, отсутствующее → null. С NULL_ON_FAILURE: неверное → null, отсутствующее → false. Опция default может подставить результат вместо обычного отказа/отсутствия. |
| Ошибки и ограничения | Структурный отказ (массив вместо скаляра) тоже требует обработки. При THROW_ON_FAILURE в PHP 8.5 исключение вызывает и неверное, и отсутствующее поле; если отсутствие допустимо, заранее проверьте его через filter_has_var. Доступность SERVER/ENV зависит от SAPI. |
Сохраните полный пример как example.php в пустой учебной папке, запустите в ней php -S 127.0.0.1:8080 и откройте http://127.0.0.1:8080/example.php. На первом открытии видна форма. Проверьте значения 0, 2, -1 и «две штуки»; у каждого отказа будет понятное сообщение. action="example.php" возвращает форму этому же файлу, method выбирает способ передачи, name становится именем поля. Подпись label помогает человеку, но ключом PHP служит именно name. Подробно о связи HTML-формы с PHP.
- Поле name=qty содержит 0
- Форма GET отправляет ?qty=0
- filter_input проверяет исходное поле как целое от 0 до 20
- Возвращённое число участвует в расчёте, false и null обрабатываются отдельно
<?php
header('Content-Type: text/html; charset=UTF-8');
$quantity = filter_input(INPUT_GET, 'qty', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0, 'max_range' => 20],
]);
if ($quantity === false) { http_response_code(422); }
?>
<!doctype html>
<meta charset="utf-8">
<title>Количество</title>
<form action="example.php" method="get">
<label>Количество <input name="qty" value="<?php echo is_int($quantity) ? $quantity : ""; ?>"></label>
<button>Проверить количество</button>
</form>
<?php
if ($quantity === null) {
echo '<p>Введите количество и нажмите кнопку.</p>';
} elseif ($quantity === false) {
echo '<p>Количество: нужно целое число от 0 до 20.</p>';
} else {
echo '<p>Количество: ', $quantity, '. Сумма: ', $quantity * 250, ' руб.</p>';
}
Ожидаемый результат
<!doctype html>
<meta charset="utf-8">
<title>Количество</title>
<form action="example.php" method="get">
<label>Количество <input name="qty" value="2"></label>
<button>Проверить количество</button>
</form>
<p>Количество: 2. Сумма: 500 руб.</p>Результат относится к example.php?qty=2. Поле повторно показывает проверенное число: is_int отличает его от false и null; при отказе поле пустое, чтобы не показывать другое количество. Ноль допустим: для qty=0 получится сумма 0. null означает, что поля ещё нет; false — его неверное значение или массив вместо одного числа. elseif выбирает следующую ветку только при невыполненном первом условии. Код ответа 422 устанавливается до HTML. Форма делает только расчёт и не создаёт заказ; для изменения данных понадобятся POST, права и CSRF. Полученное число безопасно выводится как число, произвольный текст потребовал бы htmlspecialchars.
Количество: 2. Сумма: 500 руб.
<?php
header('Content-Type: text/plain; charset=UTF-8');
var_dump(filter_input(INPUT_GET, 'qty', FILTER_VALIDATE_INT));
var_dump(filter_input(INPUT_GET, 'missing', FILTER_VALIDATE_INT));
var_dump(filter_input(INPUT_GET, 'missing', FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE));
Ожидаемый результат
int(0)
NULL
bool(false)
Откройте /example.php?qty=0. Для qty=bad первая строка bool(false), для qty[]=2 — тоже false; на пустой запрос первая строка NULL. Это самостоятельный HTTP-обработчик, не CLI-имитация. Сохраните код как example.php, в его каталоге запустите php -S 127.0.0.1:8080 и откройте указанный адрес. Встроенный сервер используется для локального опыта. header выбирает текстовый ответ до вывода. GET-поля находятся после ?; & разделяет поля. Как проходит HTTP-запрос.
<?php
header('Content-Type: text/plain; charset=UTF-8');
$_GET['qty'] = '9';
var_dump(filter_input(INPUT_GET, 'qty', FILTER_VALIDATE_INT));
var_dump(filter_var($_GET['qty'], FILTER_VALIDATE_INT));
Ожидаемый результат
int(2)
int(9)
Откройте /example.php?qty=2. Первый вызов читает исходный снимок, второй — значение 9 в текущем массиве. Нормализацию реального приложения удобнее хранить в отдельной переменной. Это самостоятельный HTTP-обработчик, не CLI-имитация. Сохраните код как example.php, в его каталоге запустите php -S 127.0.0.1:8080 и откройте указанный адрес. Встроенный сервер используется для локального опыта. header выбирает текстовый ответ до вывода. GET-поля находятся после ?; & разделяет поля. Как проходит HTTP-запрос.
<?php
header('Content-Type: text/plain; charset=UTF-8');
try {
filter_input(INPUT_GET, 'qty', FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE);
echo "Количество принято\n";
} catch (Filter\FilterFailedException $error) {
echo "Нужно передать корректное количество\n";
}
Ожидаемый результат
Нужно передать корректное количество
PHP 8.5+. Откройте /example.php без query: отсутствие тоже вызывает исключение. При ?qty=bad будет тот же выбранный ответ, при ?qty=2 — «Количество принято». Если отсутствие имеет отдельный смысл, проверяйте filter_has_var до этого вызова. Это самостоятельный HTTP-обработчик, не CLI-имитация. Сохраните код как example.php, в его каталоге запустите php -S 127.0.0.1:8080 и откройте указанный адрес. Встроенный сервер используется для локального опыта. header выбирает текстовый ответ до вывода. GET-поля находятся после ?; & разделяет поля. Как проходит HTTP-запрос.
Применение и границы. filter_input не читает JSON-тело как поля POST. Для application/json используйте php://input и json_decode с проверкой схемы. REQUEST_TIME добавляется PHP позднее и может отсутствовать в INPUT_SERVER. JSON-договоры.
Сравните: filter_has_var, filter_var, filter_input_array. Карта выбора.
INPUT_GET/INPUT_POST выбирают источник. По умолчанию отсутствующее значение даёт null, ошибка проверки — false; FILTER_NULL_ON_FAILURE меняет это различение. Функция читает исходный ввод SAPI, не изменённый вами $_POST.
Рядом по задаче. filter_var работает с переданным значением; filter_input_array проверяет набор полей.
filter_input_arrayПрочитать группу исходных полей запроса по общей карте правил.
filter_input_array(int $type, array|int $options = FILTER_DEFAULT, bool $add_empty = true): array|false|null| Параметр / результат | Договор |
|---|---|
| type | Один из пяти INPUT_* источников. Это не $_POST и не произвольный массив. |
| options | ID фильтра для всех входных полей либо карта имя → ID / [filter, flags, options]. Неописанные в карте поля игнорируются. |
| add_empty | true включает отсутствующие имена карты со значением null, false пропускает их. Если входной набор вообще не был заполнен, результат всего вызова может быть null. |
| Возврат | Массив результатов; при отсутствии входного набора null, при сбое функции возможен false. Ошибки отдельных полей находятся внутри массива. NULL_ON_FAILURE не меняет null отсутствующего ключа карты. |
| Ошибки и ограничения | Проверяйте тип верхнего результата до обхода. FORCE_ARRAY оборачивает и скалярный результат ошибки: [false] — непустой массив, но не успешная проверка. |
Сохраните полный пример как example.php в пустой учебной папке, запустите в ней php -S 127.0.0.1:8080 и откройте http://127.0.0.1:8080/example.php. Форма здесь проверяет два поля и показывает все ошибки. Никаких писем или заказов она не создаёт. При отправке POST данные идут в теле запроса, а не добавляются к URL. action="example.php" возвращает форму этому же файлу, method выбирает способ передачи, name становится именем поля. Подпись label помогает человеку, но ключом PHP служит именно name. Подробно о связи HTML-формы с PHP.
- HTML содержит name=qty и name=email
- Браузер посылает qty=2&email=buyer%40example.test
- Карта rules применяет отдельное правило к каждому исходному полю
- При успехе получаем число 2 и строку email; ошибки выводим до любого сохранения
<?php
header('Content-Type: text/html; charset=UTF-8');
$errors = [];
$values = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$rules = [
'qty' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 1, 'max_range' => 20]],
'email' => FILTER_VALIDATE_EMAIL,
];
$values = filter_input_array(INPUT_POST, $rules);
if (!is_array($values)) {
$errors[] = 'Не получены поля формы.';
} else {
if ($values['qty'] === false || $values['qty'] === null) {
$errors[] = 'Количество: целое от 1 до 20.';
}
if ($values['email'] === false || $values['email'] === null) {
$errors[] = 'Email: укажите адрес.';
}
}
if ($errors !== []) { http_response_code(422); }
}
?>
<!doctype html>
<meta charset="utf-8">
<title>Проверка полей</title>
<form action="example.php" method="post">
<label>Количество <input name="qty" value="2"></label>
<label>Email <input name="email" value="buyer@example.test"></label>
<button>Проверить поля</button>
</form>
<?php
foreach ($errors as $message) {
echo '<p>', htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</p>';
}
if (is_array($values) && $errors === []) {
echo '<p>Проверенные поля: ', $values['qty'], ' шт.; ',
htmlspecialchars($values['email'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</p>';
}
Ожидаемый результат
<!doctype html>
<meta charset="utf-8">
<title>Проверка полей</title>
<form action="example.php" method="post">
<label>Количество <input name="qty" value="2"></label>
<label>Email <input name="email" value="buyer@example.test"></label>
<button>Проверить поля</button>
</form>
<p>Проверенные поля: 2 шт.; buyer@example.test</p>Ключи rules совпадают с name в форме. Вложенные filter/options описывают проверку qty; у email достаточно ID фильтра. REQUEST_METHOD отделяет первый просмотр от отправки. Сначала проверяется весь результат: при пустом POST он может быть null. Затем false означает отказ отдельного фильтра, null — отсутствие поля. $errors[] добавляет сообщение, foreach выводит список. htmlspecialchars применяется при вставке текста в HTML даже после проверки адреса. Если заменить поля на qty=21 и email=broken, вы увидите обе ошибки и HTTP 422. Перед настоящим сохранением нужны отдельная проверка CSRF, полномочий и бизнес-правил; полный проект есть в главе о формах.
Проверенные поля: 2 шт.; buyer@example.test
<?php
header('Content-Type: text/plain; charset=UTF-8');
$rules = ['qty' => FILTER_VALIDATE_INT, 'email' => FILTER_VALIDATE_EMAIL];
$complete = filter_input_array(INPUT_POST, $rules);
$presentOnly = filter_input_array(INPUT_POST, $rules, false);
var_export($complete);
echo "\n";
var_export($presentOnly);
echo "\n";
Ожидаемый результат
array (
'qty' => 4,
'email' => NULL,
)
array (
'qty' => 4,
)
Отправьте обычный POST с телом qty=4&extra=discard и Content-Type application/x-www-form-urlencoded. В архиве есть готовый probe.py, который посылает именно такой запрос. По умолчанию карта добавит email=null, с третьим аргументом false ключа email не будет. Поле extra отсутствует в правилах и отбрасывается. Это полезно для частичного обновления: «поле не прислали» и «поле прислали пустым» могут означать разные действия. Сам пример ничего не обновляет.
<?php
header('Content-Type: text/plain; charset=UTF-8');
$rules = [
'ids' => ['filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_REQUIRE_ARRAY],
'single' => ['filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_FORCE_ARRAY],
];
echo json_encode(filter_input_array(INPUT_GET, $rules)), "\n";
Ожидаемый результат
{"ids":[2,false],"single":[false]}
Откройте /example.php?ids[]=2&ids[]=bad&single=bad. REQUIRE_ARRAY требует форму массива; FORCE_ARRAY меняет форму результата, а не исправляет неверное число. Это самостоятельный HTTP-обработчик, не CLI-имитация. Сохраните код как example.php, в его каталоге запустите php -S 127.0.0.1:8080 и откройте указанный адрес. Встроенный сервер используется для локального опыта. header выбирает текстовый ответ до вывода. GET-поля находятся после ?; & разделяет поля. Как проходит HTTP-запрос.
<?php
declare(strict_types=1);
header('Content-Type: text/plain; charset=UTF-8');
$current = ['newsletter' => true];
$patch = filter_input_array(INPUT_POST, [
'newsletter' => ['filter' => FILTER_VALIDATE_BOOL, 'flags' => FILTER_NULL_ON_FAILURE],
], false);
if (is_array($patch) && array_key_exists('newsletter', $patch)) {
if ($patch['newsletter'] === null) {
http_response_code(422);
echo "Неверное значение рассылки, прежнее состояние оставлено\n";
} else {
$current['newsletter'] = $patch['newsletter'];
echo "Поле применено к учебному массиву\n";
}
} else {
echo "Поле не прислано, прежнее состояние оставлено\n";
}
echo $current['newsletter'] ? "Рассылка включена\n" : "Рассылка выключена\n";
Ожидаемый результат
Поле применено к учебному массиву
Рассылка выключена
При POST newsletter=off результат false — правильное выключение. При newsletter=maybe результат null — ошибка; не присланное поле отсутствует в карте благодаря add_empty=false. Это три разных решения. Начальное состояние задано прямо в коде и существует только в памяти одного запроса: база данных не меняется, письма не отправляются. probe.py показывает все три запроса. Для настоящих пользовательских настроек добавьте аутентификацию, права и CSRF до изменения состояния.
Применение и границы. Фильтры не проверяют межполевые зависимости («доставка требует адрес»). Отдельно проверяйте метод запроса, CSRF, права и ошибки каждого поля перед SQL. Полный процесс безопасного приёма формы.
Сравните: filter_var_array, filter_input, filter_has_var. Карта выбора.
Карта связывает имена полей с фильтрами. add_empty управляет присутствием отсутствующих ключей. Возвращается массив, null при отсутствии данных либо false при ошибке; проверяйте общий результат и каждое поле.
Рядом по задаче. Для явных доменных ошибок часто понятнее собственная функция из главы о формах.
Источники и обсуждения
- PHP: function.filter-var
- PHP: function.filter-input
- OWASP: защита от CSRF
- Обсуждение: токен CSRF и несколько форм — обсуждение проблемы
- Обсуждение: токен CSRF в URL попадает в журналы — обсуждение проблемы
- PHP: language.variables.external
- PHP: reserved.variables.post
- PHP: function.htmlspecialchars
- HTML PHP form POST empty — обсуждение проблемы
- $_POST is empty after form submit — обсуждение проблемы
- $_POST is empty/null after form submission — обсуждение проблемы