Работа с формами в Albireo CMS

23-03-2025Время чтения ~ 12 мин.Albireo Framework / CMS 76

Работа с формами в PHP имеет много подводных камней. Парадокс, но в PHP до сих пор нет полноценной встроенной поддержки работы с формами, поэтому каждый разработчик вынужден изобретать свой «велосипед».

Как работает форма?

Я уже ранее писал о формах «Обработка форм в PHP. Как это делать правильно в 2020 году», где привожу примеры того, как правильно организовывать файлы.

Если кратко, то смысл в том, что нужно разделять html-код формы от её php-обработчика. С практической точки зрения это обычно выражается в том, чтобы создать отдельный handler формы, который и будет проверять все ошибки формы.

В Albireo CMS для создания формы используется любая страница с любым html-кодом формы. То есть верстка не имеет никаких ограничений. Обработчик формы указывается в параметре action и если это обычный post-запрос, то в Albireo CMS такой обработчик — это самая обычная страница, где просто указывается адрес и http-метод:

slug: form-handler
method: POST
layout: empty.php
Строчка layout: empty.php используется для того, чтобы отключить вывод всех шапок, подвалов и т.п.

То есть форма — это всегда два файла — html-код формы и php-обработчик. Формально можно всю логику разместить в одном файле. Для этого просто указывается method: ALL, то есть форма будет отдаваться по любому http-методу (GET, POST и т.д.)

POST и AJAX

Как правило форма с POST делает редирект на страницу обработчика. Если обработчик найдёт ошибки, то он должен их выдать и/или сделать дополнительный редирект на страницу формы, чтобы пользователь её поправил. Если же ошибок нет, то выдаётся некое сообщение и/или происходит редирект на целевую страницу, например к опубликованному комментарию.

Работа с POST — это самая распространенная работа с формой, но с точки зрения удобства пользователя, это не самый лучший вариант. Намного удобней использовать AJAX-запросы, где нет перезагрузки страницы после отправки формы.

Формально AJAX — это тот же самый POST, только используется дополнительный заголовок X-Requested-With: XMLHttpRequest. Именно по нему и происходит определение, что это AJAX, а не обычный POST. Во всём остальном между ним нет никакой разницы.

Чтобы показать, что форма отправляет http-метод AJAX, в форме формируется скрытое поле _method со значением AJAX. Когда роутер системы видит это поле, что он понимает, что это POST, но с «особым типом». Этот подход используется в большинстве современных php-фреймворков.

Работа с AJAX в Albireo CMS

Для меня очень важна именно практическая сторона работы с формами. Есть очень много теоретических моментов, есть много примеров, много фреймворков, много самых разных подходов, но я постепенно отработал свой вариант, который меня устраивает именно из-за легкости и практичности.

Это важно вот почему.

Например вы используете готовый лендинг, где верстальщик уже предусмотрел форму. Там свои css-классы, html-разметка. Многие php-системы для адаптации такой формы, потребуют её переписать. Это касается не только вёрстки, но и php-кода, которым обеспечивается работа формы.

Поэтому я решил, что в Albireo CMS изменения html-формы будут самыми минимальными и затрагивать только то, что действительно необходимо.

Для того, чтобы организовать AJAX-отправку я использую AlpineJS. Это сильно упрощает js-код. Альпина делает несколько вещей сразу:

  • формирует fetch,
  • формирует http-заголовок X-Requested-With,
  • формирует массив FormData из текущей формы,
  • принимает ответ в виде готового html,
  • связывает элементы формы между собой, например опцию «Согласен на персональные данные» с кнопкой отправки.

Я придумал алгоритм, который позволяет отправлять все запросы на один URL. Это некий «системный» адрес (его можно менять), который Albireo CMS обслуживает особым образом. Если приходит запрос на этот адрес, то система сама проверит наличие X-Requested-With, выполнит проверку CSRF (защита) и проверит поле, где указывается php-обработчик формы.

Сам же php-обработчик — это обычный php-файл, который система подключит и выполнит, а после этого прекратит исполнение (по exit).

В простом виде форма может выглядеть так.

<div x-data="{result: '', hasErrors: false, agreement: true}">
    <p x-show="!result || hasErrors">Заполните поля и отправьте форму.</p>

    <form method="post" 
        x-show="!result || hasErrors" 
        x-transition 
        x-ref="form" 
        @submit.prevent="
            fetch('<?= REQUEST_AJAX ?>', {
                method: 'POST', 
                headers: {'X-Requested-With': 'XMLHttpRequest'},
                body: new FormData($refs.form)
            })
            .then(r => r.text())
            .then(r => {
                result = r;
                hasErrors = result.includes('error-form');
            });">
        
        <?= METHOD_AJAX ?> 
        <?= CSRF ?> 
        <?= formHandler(__DIR__ . '/form_handler.php') ?>
        
        <!-- произвольные поля -->

        <input type="text" name="form[name]" placeholder="your name..." required>
        <input type="email" name="form[email]" placeholder="your email...">
        <textarea name="form[message]" required></textarea>
        
        <button type="submit" :disabled="!agreement">Submit</button>
        <label><input type="checkbox" x-model="agreement"> I agree</label>
    </form>

    <div x-html="result"></div>
</div>

Я специально убрал всё оформление, но оставил только несколько примеров полей. Для упрощения кода используются следующие php-вставки:

  • REQUEST_AJAX — это «специальный» адрес, на который уходит post-запрос.
  • METHOD_AJAX — скрытое поле с _method="AJAX"
  • CSRF — также скрытое поле для защиты
  • formHandler() — скрытое поле, где кодируется файл-обработчик. В данном примере файл располагается в этом же каталоге.

Если вы не используете Альпину, то нужно навесить js-обработчик формы отдельно. Обратите внимание, что данные мы отправляем именно как стандартный post-запрос, поскольку все поля формы преобразуем через FormData($refs.form). Это позволяет вообще не привязываться к полям через id, как это часто требуется.

Небольшой лайфхак по этой форме. Обработчик отдаёт готовый html-код, но это может быть как сообщение об ошибке, так и итоговое сообщение. Чтобы их разделить и вместо того, чтобы использовать JSON, мы в html-коде ответа отправляем произвольную фразу, например «error-form». Если она есть, значит это сообщение об ошибках и форма не будет скрываться.

Обработчик формы

Обработчик — это обычный php-файл в котором нужно проверить входящие данные из $_POST. Поскольку система уже проверила CSRF, METHOD_AJAX и другие данные, то в обработчике уже не нужно это ещё раз проверять.

Общая схема будет такой:

if (isset($_POST['form'])) {
    // работаем с данными
    
} else {
    echo '<div class="mar20-tb">Error submitting form</div>';
}

Обратите внимание на имена полей формы. Они задаются в виде «массива» form[]. Эта особенность позволяет получить в $_POST ассоциативный массив. То есть PHP обрабатывает эти поля именно как часть многомерного массива. Если бы мы задавали поля простыми именами, например form-name, form-email, form-message, то проверку следовало бы начинать с isset() поля (если они обязательные), примерно так:

if (isset($_POST['form-name']) and isset($_POST['form-email']) and isset($_POST['form-message'])) {
    // работаем с данными
...

Это не то что бы проблема — просто проверить один элемент намного проще.

Валидация данных

Первое, что нужно сделать в обработчике — это проверить корректность присланных данных. Конечно, сейчас мы полагаемся на проверку данных на стороне браузера. Именно поэтому используются разные type для INPUT. Также часто навешивают на элементы js-код, который сразу проверяет корректность ввода.

Но — это всё лишь расчёт на обычных пользователей. Дело в том, что формы — это один из самых часто используемых способов взлома сайтов. Например если форма принимает поле ввода URL, который потом публикуется на странице в виде рабочей ссылки, то злоумышленник может указать что-то вроде такого: javascript://%0Aalert(document.cookie). Если на сайте используется стандартная php-проверка через filter_var(), то для неё это совершенно корректный адрес, а значит он будет опубликован.

Другой способ — это изменить type у элемента INPUT. Например стоит type="url", то очень просто его изменить на type="text" и ввести любой текст.

Ну и третий вариант — он более сложен, но в принципе можно просто отправить post-запрос вне браузера, через простенькую python-программу. Поскольку post-запрос будет формироваться как угодно, то предварительной валидации через браузер просто не будет.

Поэтому при приёме POST всегда важно проверять каждое поле на соответствие правилам.

Правила валидации данных

Правила валидации (rules) — это набор условий, которое должно пройти поле. Например если мы принимаем имя пользователя, то логично предположить, что оно от 3 символов, менее 20 символов, и не содержит символов ?!#$<> (как пример). Также это поле обязательное.

Таким образом для поля $_POST['form']['name'] мы предусмотрим правила, например так:

$rules = [
    'name' =>
        'required' => [],
        'min_length' => 3,
        'max_length' => 20,
        'chars_forbidden' => '?!#$<>',
    ],
];

Если вы немного знакомы с другими CMS или php-фреймворками, то знаете, что часто правила задаются в виде строк. Например в CodeIgniter можно задать так:

$rules = [
    'username' => 'required|max_length[30]',
    'password' => 'required|max_length[255]|min_length[10]',
    'passconf' => 'required|max_length[255]|matches[password]',
    'email'    => 'required|max_length[254]|valid_email',
];

В Laravel 11 похожая схема:

$request->validate([
    'title' => 'required|unique:posts|max:255',
    'author.name' => 'required',
    'author.description' => 'required',
]);

Такой вариант создания правил требует меньше кода, но при этом имеет одну фундаментальную проблему: в ней сложно создавать сообщения об ошибках. Скажем поле не прошло проверку на условие и нужно выдать какое-то сообщение, чтобы пользователь понял где ошибка. В CodeIgniter'е для этого нужно создавать отдельный массив ошибок по каждому полю и по каждому правилу. Laravel вообще выдаёт некий код ошибки, которую потом нужно отдельно обрабатывать.

Поэтому в Albireo CMS немного другой подход, где правила можно задавать прямо с текстом ошибки. Рассмотрим такой пример:

$rules = [
    'email' => [
        'required' => [],
        'email' => [],
    ],
    
    'site' => [
        'default' => '', // значение по умолчанию, если в POST нет ключа «site»
        
        'url' => [
            'error' => 'Incorrect URL address',
        ],
        
        'min_length' => [
            'options' => 10,
            'error' => 'Минимальный адрес сайта 10 символов',
        ],
        
        'max_length' => [
            'options' => 50,
            'error' => 'Максимальный адрес сайта 50 символов',
        ],
    ],
    
    'name' => [
        'required' => [],
        'min_length' => 3,
        'max_length' => 20,
        
        'chars_forbidden' => [
            'options' => '?!#$<>',
            // или указать свой текст ошибки
            // 'error' => 'Недопустимые символы в имени (?!#$<>)',
        ],
    ],
];

$result = arrayValidate($_POST['form'], $rules);
    
if ($result['errors']) {
    // нужно указать фразу error-form, которая указывает, что этот текст сообщение об ошибках
    echo '<ul error-form class="t-red600">';
    
    foreach ($result['errors'] as $error) {
        echo '<li>' . htmlspecialchars($error) . '</li>';
    }
    
    echo '</ul>';
} else {
    // итоговые данные в $result['data']
    
    echo '<div class="t-green600">Ok!</div>';
}

Все поля формы мы получаем в виде $_POST['form'] — это массив, где ключи — это имена полей формы. Поэтому в массиве правил мы также указываем ключи, для которых требуется проверка.

Каждое поле содержит список проверок («required», «email», «default», «url», «min_length», «max_length» и т.д.). Правило может содержать два элемента: options и error. Поле options используется для опции указанного правила. Скажем для «min_length» нужно указать число. Поле error — содержит текст, если проверка не пройдена.

Поскольку мы проверяем элементы массива (а $_POST всегда массив), то мы не привязываемся именно к post-запросу, как это встречается в других фреймворках. То есть для Albireo CMS всё равно откуда данные — можно проверять любой массив.

Функция arrayValidate() возвращает массив из двух элементов errors и data. В $result['errors'] окажется список накопленных ошибок, а в $result['data'] — итоговый массив для дальнейшей работы.

Создание правил через массив хоть и выглядит более громоздким, но с практической стороны, намного удобней, чем раздельная обработка каждой ошибки. Даже если не задавать свой текст ошибки, то функция всё равно вернёт сообщение вроде такого Field «Name» is not valid (chars_forbidden = ?!#$<>).

Ну и если использовать обычный php-массив, то правила можно формировать динамически. Скажем опции правила указать в конфигурации сайта.

Проверка checkbox

Тут ещё есть один момент, который выполняет валидация — это умение работать с полями, которые не передаются через POST. Например поле checkbox будет передано в POST только если оно отмечено. Это известная головная боль многих php-разработчиков. Чаще всего создают дополнительное скрытое поле, которое совпадает с именем чекбокса:

<input type="hidden" name="form[mycheck]" value="off">
<input type="checkbox" name="form[mycheck]" value="on">

В этом случае, если чекбокс не отмечен, то форма отправит значение «off».

Но такой приём — «костыль», который требует правки html-кода формы. В Albireo CMS для правила можно просто указать default-значение, которое сработает, если поля нет в проверяемом массиве:

    'mycheck' => [
        'default' => 'off',
    ],

Таким образом отпадает надобность в изменении html-кода формы.

Есть ещё одна хитрость, которая упрощает работу с формой. Например есть несколько чекбоксов, которые представляют собой единый массив. Например выбор цвета:

<input type="checkbox" name="form[color][]" value="red"> Red
<input type="checkbox" name="form[color][]" value="green"> Green
<input type="checkbox" name="form[color][]" value="blue"> Blue

Мы хотим получить php-массив из отмеченных чекбоксов. Поэтому название поля делаем как массив (form[color][]). В правилах задаём default-значение (как элемент массива), а также список допустимых значений:

    'color' => [
        'default' => [],
        'in_list' => ['options' => ['red', 'green', 'blue']],
    ],

Таким образом в итоговом массиве будет что-то вроде такого, если отмечены элементы:

[color] => Array
   (
       [0] => red
       [1] => green
       [2] => blue
   )

Если же чекбоксы не отмечены, то массив будет пустой (или равен default-значению):

[color] => Array
   (
   )

В Albireo CMS валидация решает сразу несколько задач:

  • Проверка полей на условия.
  • Создание отсутствующих в исходном массиве значений.
  • Формирование итогового массива со всем нужными полями и значениями.
  • Формирование массива ошибок.

То есть на выходе формируется сразу готовые для дальнейшей работы данные. Это сильно упрощает php-код. Обычно он изобилует проверками if (isset($_POST['form']['name']) ..., здесь это уже не нужно — если поле описано в правилах, то оно уже точно есть и точно прошло валидацию.

Свои правила валидации

В php-фреймворках обычно предлагается много самых разных проверок. С другой стороны есть много готовых фильтров в PHP, которые помогут провести нужную проверку. Поэтому я подхожу к этому вопросу чисто с практической точки зрения:

  • Первоначальная валидацию происходит средствами браузера.
  • Потом мы проверяем свои правила для каждого поля.
  • И если этого недостаточно, то проверяем поле дополнительным кодом.

На практике это выглядит очень просто. Например проверка IP. В форме это обычный type="text". В $rules задаём начальные правила:

    'ip' => [
        'default' => '0.0.0.0',
        'chars_allow' => '0123456789.', // разрешенные символы
        'min_length' => 7,  // 1.1.1.1
        'max_length' => 15, // 222.222.222.222
    ],

После arrayValidate(), можно выполнить дополнительную проверку:

$result = arrayValidate($_POST['form'], $rules);

// дополнительная проверка
if (!filter_var($result['data']['ip'], FILTER_VALIDATE_IP))
    $result['errors'][] = 'Incorrect IP';

if ($result['errors']) {
   ...

Этот пример — демонстрация того, как можно вводить свои дополнительные проверки. Посмотрите насколько это проще, чем в том же Laravel или CodeIgniter, где нужно создавать отдельный класс только ради строчки кода.

Не доверяйте filter_var()

Часто php-программисты используют filter_var() для проверки корректности ввода. Самые частые проверки — это URL и здесь есть подвох. У нас разработчики PHP живут в какой-то другой вселенной и проверки адресов предлагают по неким стандартам, игнорируя реальность.

Поэтому, когда вы используете FILTER_VALIDATE_URL, то знайте, что это «дырявая» проверка. Самые известные проколы описаны прямо на странице функции в комментариях. Поэтому просто приведу пример проверки из Albireo CMS.

$value = 'адрес для проверки';
$errors= []; // массив ошибок
$error = 'Invalid URL'; // сообщение об ошибке

if (!preg_match('#^https?://[a-zA-Z0-9]+\.[\p{L}]{2,15}(/[^\s]*)?$#u', $value)) {
    $errors[] = $error;
} elseif ($value !== htmlspecialchars($value, ENT_QUOTES, 'UTF-8')) {
    $errors[] = $error;
} else {
    $blacklist = ['javascript:', 'vbscript:', 'data:', '<script', '</script', 'onerror=', 'onload=', '%3C', '%3E', '%22', '%27', '%28', '%29', '%3D', 'document.'];
    
    foreach ($blacklist as $bad) {
        if (stripos($value, $bad) !== false) {
            $errors[] = $error;
            break;
        }
    }
}

Этот код защищает не только от неверного формата ссылок (вроде «http://test???test.com»), но и дополнительно проверит адрес на наличие возможной атаки. Так что используйте именно такую проверку, не полагаясь на filter_var().

Доступные правила проверок в Albireo CMS

В заключении, для полноты, приведу список возможных проверок в Albireo CMS. Их не так много, по сравнению с тем же Laravel, но я уверен, что пока их хватит для большинства задач.

  • default — значение, если нет поля в исходном массиве
  • required — обязательное поле
  • email — проверка email
  • url — проверка адреса
  • min_length — минимальная длина текста
  • max_length — максимальная длина текста
  • in_list — массив из допустимых значений
  • start_with — строка должна начинаться с указанной фразы
  • chars_forbidden — запрещенные символы
  • chars_allow — разрешенные символы
  • chars_normal — только буквы и цифры (любого языка), плюс дополнительные разрешенные символы
  • equals — точно равно
  • ip — проверка IP
  • numeric — любое число
  • numeric_int — целое число
  • numeric_positive — положительное число
  • numeric_negative — отрицательное число
  • numeric_max — максимальное значение числа
  • numeric_min — минимальное значение числа
  • date — дата (type="date", формат 'Y-m-d')
  • time — время (type="time", формат 'H:i')
Похожие записи
Оставьте комментарий!