Контактная форма на Bootstrap 5, PHP и AJAX
Контактная форма на Bootstrap 5 принимает имя, email и сообщение без перезагрузки страницы. Браузер проверяет поля, JavaScript отправляет FormData через fetch, а PHP повторяет проверку и передает письмо через PHPMailer по SMTP. Вы получите структуру проекта, защиту от спама и три полных файла. Перед запуском останется установить библиотеку, вписать SMTP-параметры и ключи капчи.
- Что понадобится и структура проекта
- Разметка формы на Bootstrap 5
- Проверка полей на стороне браузера
- Отправка формы через AJAX
- Обработчик на PHP: проверка данных и отправка письма
- Сообщения об успехе и ошибке
- Защита формы от спама
- Почему письма с формы не доходят
- Формы в Bootstrap 4 и Bootstrap 5: что изменилось
- Готовый код целиком
Что понадобится и структура проекта
Подготовьте хостинг с PHP, SMTP-ящик на домене сайта и библиотеку PHPMailer. Для установки через Composer выполните команду в корне проекта:
composer require phpmailer/phpmailerComposer создаст каталог vendor с автозагрузчиком библиотеки. Запускайте установку в той же среде PHP, где будет работать обработчик. Для примера также нужны расширения curl, mbstring и поддержка TLS.
Если Composer недоступен, загрузите исходный код PHPMailer и подключите файлы из каталога PHPMailer/src. В обоих вариантах имена формы, скрипта и обработчика оставьте одинаковыми.
bootstrap-form/
├── index.html
├── js/
│ └── form-scripts.js
├── php/
│ └── form-process.php
└── vendor/
└── autoload.phpФорма обращается к php/form-process.php, поэтому этот путь должен совпадать в HTML и JavaScript. Открывайте страницу через домен сайта, чтобы браузер отправлял запрос на тот же хостинг.
Сохраните полные файлы из раздела «Готовый код целиком». Каталог vendor добавляется после установки зависимости и не входит в эти три файла.
Разметка формы на Bootstrap 5
Подключите CSS Bootstrap через CDN и добавьте поля с атрибутами name. Именно эти имена JavaScript передаст PHP. В комплекте используется фиксированная ссылка на Bootstrap 5.3.8 из исходного примера.
Класс form-control оформляет поля ввода и растягивает их по ширине колонки. form-label оформляет подпись, а row g-3 создает сетку с промежутками.
Классы col-md-6 ставят имя и email рядом на широком экране. col-12 растягивает сообщение на всю строку. Скрытое поле website служит honeypot-ловушкой для простых ботов.
Класс container ограничивает ширину содержимого, а py-4 добавляет вертикальные отступы. Кнопка использует базовый класс btn, зеленое оформление btn-success и размер btn-lg. JavaScript Bootstrap для этой формы не нужен.
Проверка полей на стороне браузера
Атрибут required запрещает пустое значение, а type="email" проверяет формат адреса. Класс needs-validation отмечает форму, novalidate отключает стандартные подсказки браузера, а was-validated включает оформление результата проверки Bootstrap.
Блок invalid-feedback показывает собственный текст ошибки рядом с полем. Разметка соответствует схеме валидации Bootstrap и не требует плагинов.
Метод checkValidity() проверяет ограничения полей при отправке. Если проверка не проходит, скрипт добавляет was-validated и останавливает запрос. Атрибут maxlength ограничивает имя 100 символами, email – 254, сообщение – 2000.
Клиентскую проверку легко обойти прямым POST-запросом. PHP должен повторить проверку обязательных полей, длины и email перед отправкой письма.
Отправка формы через AJAX
Объект FormData собирает поля с атрибутом name и кодирует значения сам. Не склеивайте строку запроса вручную: символы & и + могут изменить текст сообщения. jQuery для этого решения не нужен.
Сохраните клиентский код в js/form-scripts.js. HTML подключает этот файл после разметки формы, поэтому элементы уже доступны скрипту. Обработчик submit регистрируется независимо от загрузки капчи.
Скрипт создает виджет капчи только через явный вызов grecaptcha.render(). Функция onCaptchaLoad запускается после загрузки библиотеки и сохраняет идентификатор виджета. Если библиотека еще недоступна, форма покажет сообщение вместо отправки.
Перед запросом код блокирует кнопку и сохраняет данные формы. После ответа PHP скрипт сбрасывает капчу по идентификатору созданного виджета. Посетитель проходит проверку заново после ошибки или следующего сообщения.
Метод fetch() не считает HTTP-ошибку сетевым сбоем, поэтому код проверяет response.ok. Блок catch обрабатывает разрыв соединения и ответ, который нельзя разобрать как JSON. Блок finally возвращает кнопку в рабочее состояние.
Обработчик на PHP: проверка данных и отправка письма
Сохраните полный серверный файл из комплекта в php/form-process.php. Укажите SMTP-сервер, порт, логин, пароль ящика и адрес получателя. Пример использует порт 465 и шифрование SMTPS.
Если ваш SMTP-сервер принимает STARTTLS на порту 587, задайте SMTP_PORT = 587 и SMTP_SECURE = PHPMailer::ENCRYPTION_STARTTLS. Адрес сервера и учетные данные возьмите из параметров подключения вашего почтового ящика.
Адрес из формы передавайте только в Reply-To. Поле From должно содержать ваш доменный ящик. Непроверенные значения в почтовых заголовках создают риск инъекции, а чужой адрес отправителя ухудшает доставку.
Код проверяет метод запроса, honeypot, типы полей и кодировку UTF-8. Затем обработчик ограничивает длину имени, email и сообщения. Функция filter_var с фильтром FILTER_VALIDATE_EMAIL проверяет адрес.
Функция htmlspecialchars экранирует данные перед вставкой в HTML-тело письма. Переменная $body инициализирована до оператора .=, переносы строк записаны через \n. Текстовая версия письма передается через AltBody.
При ручной установке PHPMailer замените строку подключения автозагрузчика следующими строками. Каталог PHPMailer должен лежать рядом с index.html:
require __DIR__ . '/../PHPMailer/src/Exception.php';
require __DIR__ . '/../PHPMailer/src/PHPMailer.php';
require __DIR__ . '/../PHPMailer/src/SMTP.php';Функция mail() подходит как запасной вариант при настроенной почтовой системе сервера. Она зависит от настроек хостинга, поэтому ее успешный возврат не гарантирует доставку. PHPMailer подключается к SMTP с авторизацией и дает доступ к диагностике ErrorInfo.
Не размещайте SMTP-пароль в HTML или JavaScript. Для рабочего сайта вынесите секреты в переменные окружения либо конфигурацию вне публичного каталога. В учебном комплекте они собраны в начале PHP-файла.
Сообщения об успехе и ошибке
PHP возвращает поля ok и message, а JavaScript выводит текст через textContent. Такой вывод не превращает ответ сервера в HTML.
После успешной отправки скрипт вызывает form.reset() и снимает was-validated. При ошибке введенные поля сохраняются. Капча сбрасывается после каждой попытки запроса, включая сетевой сбой: сервер мог успеть получить токен.
Ответ 422 сообщает об ошибке полей или капчи, 429 – о слишком частой отправке, 500 – о сбое SMTP. При недоступности сервиса проверки капчи обработчик возвращает 503. Подробности почтовой ошибки попадают в PHP-лог через ErrorInfo.
Класс h5 увеличивает текст результата, text-center выравнивает его по центру, mt-3 добавляет верхний отступ. Скрипт переключает text-success и text-danger для успеха и ошибки. Атрибут role="status" обозначает область уведомлений для вспомогательных технологий.
Для необязательной анимации подключите Animate.css. В четвертой версии используются классы animate__animated, animate__shakeX и animate__tada. Готовый комплект работает без анимации.
Защита формы от спама
Honeypot и ограничение частоты сдерживают простых ботов. Капча добавляет проверку, которую сервер выполняет перед отправкой письма. Эти меры дополняют друг друга.
Honeypot-поле
Поле website скрыто классом d-none. Бот может заполнить его вместе с видимыми полями, после чего PHP вернет успешный ответ без отправки письма. Атрибуты tabindex="-1" и autocomplete="off" исключают поле из последовательной навигации и отключают автозаполнение.
Ограничение частоты
Сессия хранит время последней допущенной попытки отправки. Новый запрос той же сессии в течение 30 секунд получает код 429. Время записывается перед внешней проверкой капчи и SMTP, поэтому ошибочная попытка тоже расходует интервал.
Ограничение по сессии не заменяет фильтр по IP при массовой атаке. Бот может отказаться от cookies и создавать новые сессии. Для публичного сайта добавьте серверный счетчик запросов с общим хранилищем.
Google reCAPTCHA с серверной проверкой
Комплект использует видимый виджет Google reCAPTCHA v2. Зарегистрируйте сайт для варианта с флажком и укажите его домен. Полученный ключ сайта вставьте в data-sitekey файла index.html, секретный ключ – в RECAPTCHA_SECRET обработчика.
В RECAPTCHA_HOSTNAME укажите имя хоста без протокола и пути, например www.your-site.ru. Оно должно совпадать с доменом, на котором вы открываете форму. Секретный ключ остается только на сервере.
JavaScript получает токен через grecaptcha.getResponse() и добавляет его в FormData. PHP отправляет поля secret и response на сервер проверки. Обработчик принимает только успешный ответ с ожидаемым hostname. Схему описывает документация серверной проверки reCAPTCHA.
После любого запроса скрипт вызывает grecaptcha.reset() для своего виджета. Поэтому ошибка SMTP не мешает повторной отправке: текст остается в полях, а посетитель проходит капчу заново. Если сервис проверки недоступен, PHP не отправляет письмо.
Почему письма с формы не доходят
Сначала откройте PHP-лог и найдите запись Form mail error. Если SMTP принял письмо, проверьте папку «Спам» и настройки домена отправителя.
- mail() отключена. Используйте SMTP через PHPMailer.
- Чужой адрес стоит в From. Оставьте доменный ящик в From, а email посетителя передайте в Reply-To.
- SMTP отклоняет пароль. Проверьте логин, пароль и доступ SMTP в настройках почтового ящика.
- Письмо попадает в спам. Проверьте SPF, DKIM и DMARC домена отправителя.
- Путь обработчика неверен. Код 404 в сетевой панели означает, что URL не совпал с деревом проекта.
Предупреждения PHP перед JSON мешают JavaScript разобрать ответ. Исправьте причину в логе и не показывайте ошибки посетителю. На рабочем сервере отключите display_errors и включите log_errors.
Для диагностики откройте сетевую панель инструментов разработчика браузера и отправьте форму. Найдите запрос form-process.php, посмотрите HTTP-код и тело ответа. JSON с ok: true означает успешное завершение SMTP-отправки, но не подтверждает попадание письма во входящие.
Формы в Bootstrap 4 и Bootstrap 5: что изменилось
В Bootstrap 5 контейнер form-group убрали. Отступ задают классом mb-3 или сеточными классами. При переносе формы с Bootstrap 4 замените контейнеры отступов. У текстовых полей input из Bootstrap 4 сохраните класс form-control.
| Bootstrap 4 | Bootstrap 5 |
|---|---|
form-group | mb-3 или сеточная колонка |
form-row | row с настройкой промежутков, например g-3 |
custom-select | form-select |
float-right | float-end |
sr-only | visually-hidden |
| обычная подпись | form-label |
Плавающие подписи используют оболочку form-floating. Полный список изменений опубликован в Migration guide Bootstrap.
Готовый код целиком
Сохраните каждый листинг в файл с указанным именем. Установите PHPMailer, замените ключи капчи и почтовые параметры своими значениями. HTML подключает собственный JavaScript до загрузчика капчи, поэтому функция обратного вызова уже существует к моменту загрузки виджета.
index.html
Этот файл содержит страницу целиком: подключение Bootstrap, форму и оба скрипта. Ключ сайта укажите в атрибуте data-sitekey.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Форма обратной связи</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<main class="container py-4">
<h2>Напишите нам</h2>
<form id="contactForm" action="php/form-process.php" method="post" class="needs-validation" novalidate>
<div class="row g-3">
<div class="col-md-6">
<label for="name" class="form-label">Имя</label>
<input type="text" class="form-control" id="name" name="name" maxlength="100" required>
<div class="invalid-feedback">Введите имя до 100 символов.</div>
</div>
<div class="col-md-6">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" maxlength="254" required>
<div class="invalid-feedback">Введите корректный email до 254 символов.</div>
</div>
<div class="col-12">
<label for="message" class="form-label">Сообщение</label>
<textarea class="form-control" id="message" name="message" rows="5" maxlength="2000" required></textarea>
<div class="invalid-feedback">Напишите сообщение до 2000 символов.</div>
</div>
<div class="d-none" aria-hidden="true">
<label for="website">Сайт</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="col-12">
<div id="captcha-container" data-sitekey="КЛЮЧ_САЙТА"></div>
</div>
<div class="col-12">
<button type="submit" id="submitBtn" class="btn btn-success btn-lg">Отправить</button>
</div>
</div>
<div id="formMessage" class="h5 text-center mt-3" role="status"></div>
</form>
<noscript>Для отправки формы включите JavaScript.</noscript>
</main>
<script src="js/form-scripts.js"></script>
<script src="https://www.google.com/recaptcha/api.js?onload=onCaptchaLoad&render=explicit" async defer></script>
</body>
</html>js/form-scripts.js
Скрипт не зависит от порядка завершения сетевых запросов. Загрузчик капчи вызывает именованную функцию, а обработчик формы работает независимо от нее.
const form = document.getElementById('contactForm');
const button = document.getElementById('submitBtn');
const messageBox = document.getElementById('formMessage');
let captchaWidget = null;
function showMessage(success, text) {
messageBox.textContent = text;
messageBox.classList.remove('text-success', 'text-danger');
messageBox.classList.add(success ? 'text-success' : 'text-danger');
}
window.onCaptchaLoad = function () {
const container = document.getElementById('captcha-container');
captchaWidget = window.grecaptcha.render(container, {
sitekey: container.dataset.sitekey
});
};
function resetCaptcha() {
if (window.grecaptcha && captchaWidget !== null) {
window.grecaptcha.reset(captchaWidget);
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (button.disabled) return;
if (!form.checkValidity()) {
form.classList.add('was-validated');
showMessage(false, 'Проверьте заполнение полей.');
return;
}
if (!window.grecaptcha || captchaWidget === null) {
showMessage(false, 'Капча еще не загрузилась. Попробуйте позже.');
return;
}
const token = window.grecaptcha.getResponse(captchaWidget);
if (!token) {
showMessage(false, 'Пройдите проверку капчи.');
return;
}
const data = new FormData(form);
data.set('g-recaptcha-response', token);
button.disabled = true;
showMessage(true, 'Отправляем сообщение…');
try {
const response = await fetch('php/form-process.php', {
method: 'POST',
body: data,
headers: { Accept: 'application/json' }
});
const result = await response.json();
const success = response.ok && result.ok === true;
const text = typeof result.message === 'string'
? result.message
: 'Сервер не вернул сообщение.';
showMessage(success, text);
if (success) {
form.reset();
form.classList.remove('was-validated');
}
} catch (error) {
showMessage(false, 'Не удалось получить ответ сервера. Поля сохранены.');
} finally {
resetCaptcha();
button.disabled = false;
}
});php/form-process.php
Укажите свои SMTP-параметры и секрет капчи в начале файла. В RECAPTCHA_HOSTNAME впишите точный хост страницы формы, включая www, если он используется.
<?php
declare(strict_types=1);
use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\PHPMailer;
require __DIR__ . '/../vendor/autoload.php';
const SMTP_HOST = 'smtp.your-provider.example';
const SMTP_PORT = 465;
const SMTP_SECURE = PHPMailer::ENCRYPTION_SMTPS;
const SMTP_USER = 'form@your-site.ru';
const SMTP_PASS = 'ПАРОЛЬ_SMTP';
const MAIL_TO = 'owner@your-site.ru';
const RECAPTCHA_SECRET = 'СЕКРЕТНЫЙ_КЛЮЧ';
const RECAPTCHA_HOSTNAME = 'www.your-site.ru';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
function respond(int $code, string $message): void
{
http_response_code($code);
echo json_encode([
'ok' => $code < 300,
'message' => $message
], JSON_UNESCAPED_UNICODE);
exit;
}
function captchaPassed(string $token): bool
{
$ch = curl_init('https://www.google.com/recaptcha/api/siteverify');
if ($ch === false) {
respond(503, 'Проверка капчи временно недоступна.');
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'secret' => RECAPTCHA_SECRET,
'response' => $token
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10
]);
$response = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false || $status !== 200) {
error_log('Form captcha transport error: ' . $curlError);
respond(503, 'Проверка капчи временно недоступна.');
}
$data = json_decode($response, true);
if (!is_array($data)) {
respond(503, 'Проверка капчи временно недоступна.');
}
return ($data['success'] ?? false) === true
&& ($data['hostname'] ?? '') === RECAPTCHA_HOSTNAME;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
header('Allow: POST');
respond(405, 'Метод не поддерживается.');
}
if (!session_start()) {
respond(500, 'Не удалось обработать запрос.');
}
$lastSubmit = (int) ($_SESSION['last_form_submit'] ?? 0);
if (time() - $lastSubmit < 30) {
header('Retry-After: 30');
respond(429, 'Подождите 30 секунд перед повторной отправкой.');
}
foreach (['name', 'email', 'message', 'website', 'g-recaptcha-response'] as $field) {
if (isset($_POST[$field]) && !is_string($_POST[$field])) {
respond(422, 'Некорректный формат полей.');
}
}
if (trim($_POST['website'] ?? '') !== '') {
respond(200, 'Сообщение отправлено.');
}
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$message = trim($_POST['message'] ?? '');
$token = trim($_POST['g-recaptcha-response'] ?? '');
foreach ([$name, $email, $message] as $value) {
if (!mb_check_encoding($value, 'UTF-8')) {
respond(422, 'Некорректная кодировка полей.');
}
}
$errors = [];
if ($name === '' || mb_strlen($name, 'UTF-8') > 100) {
$errors[] = 'Укажите имя до 100 символов.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)
|| mb_strlen($email, 'UTF-8') > 254
|| strpbrk($email, "\r\n") !== false) {
$errors[] = 'Укажите корректный email.';
}
if ($message === '' || mb_strlen($message, 'UTF-8') > 2000) {
$errors[] = 'Напишите сообщение до 2000 символов.';
}
if ($errors !== []) {
respond(422, implode(' ', $errors));
}
if ($token === '') {
respond(422, 'Пройдите проверку капчи.');
}
$_SESSION['last_form_submit'] = time();
session_write_close();
if (!captchaPassed($token)) {
respond(422, 'Проверка капчи не пройдена. Пройдите ее заново.');
}
$safeName = str_replace(["\r", "\n"], ' ', $name);
$safeEmail = $email;
$body = '';
$body .= 'Имя: ' . htmlspecialchars($safeName, ENT_QUOTES, 'UTF-8') . "<br>\n";
$body .= 'Email: ' . htmlspecialchars($safeEmail, ENT_QUOTES, 'UTF-8') . "<br>\n";
$body .= 'Сообщение: ' . nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8')) . "\n";
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = SMTP_HOST;
$mail->Port = SMTP_PORT;
$mail->SMTPSecure = SMTP_SECURE;
$mail->SMTPAuth = true;
$mail->Username = SMTP_USER;
$mail->Password = SMTP_PASS;
$mail->Timeout = 15;
$mail->CharSet = 'UTF-8';
$mail->setFrom(SMTP_USER, 'Форма на сайте');
$mail->addAddress(MAIL_TO);
$mail->addReplyTo($safeEmail, $safeName);
$mail->isHTML(true);
$mail->Subject = 'Новое сообщение с сайта';
$mail->Body = $body;
$mail->AltBody = "Имя: $safeName\nEmail: $safeEmail\nСообщение: $message\n";
$mail->send();
respond(200, 'Сообщение отправлено. Спасибо!');
} catch (Exception $exception) {
error_log('Form mail error: ' . $mail->ErrorInfo);
respond(500, 'Не удалось отправить сообщение. Попробуйте позже.');
}После загрузки откройте страницу через домен сайта. Пустая форма покажет ошибки Bootstrap. Корректная заявка с пройденной капчей очистит поля после SMTP-отправки. При серверной ошибке текст сохранится, а капчу придется пройти заново.
Проверьте повторную отправку после ошибки: выдержите интервал 30 секунд и снова пройдите капчу. Убедитесь, что кнопка становится доступной после ответа сервера. Если заменяете существующую форму, сохраните ее файлы перед загрузкой: копия позволит вернуть прежний вариант.
Совет от нашей редакции: отправьте тестовое письмо и ответьте на него. Ответ должен уйти на email посетителя из Reply-To.
Популярное за неделю
- Как создать сайт самому в 2026: 4 способа + пошаговая инструкция 34 мин чтения
- Лучшие конструкторы для создания сайтов — плюсы, минусы и сравнения 22 мин чтения
- Bootstrap 5 для начинающих: верстаем адаптивную страницу 9 мин чтения
- Как перейти с Bootstrap 4 на Bootstrap 5 10 мин чтения
- Как начать работу с Foundation 6 9 мин чтения
- Как создать RESTful API на node js mysql 7 мин чтения
Комментарии
Хотелось бы, чтобы в будущем Вы добавили в вашу форму поле для номера телефона.
Здравствуйте. Благодарю за статью. Как добавить 2 формы на одну страницу?
$.ajax({
type: "POST",
url: "php/form-process.php",
сначала речь о файле form-process.php
а потом " Откройте файл process.php"