Contact Form 7, WPForms, Gravity Forms – wszystkie działają, ale do prostego formularza „imię, e-mail, wiadomość” często wciągasz dziesiątki plików PHP, własne skrypty i style na każdej podstronie oraz kolejną wtyczkę do aktualizowania. Tymczasem WordPress ma wszystko, czego taki formularz potrzebuje: shortcode’y, bezpieczną obsługę POST przez admin-post.php, nonce’y, funkcje sanityzujące i wp_mail(). Pokazuję kompletny formularz w jednym pliku, z ochroną przed spamem i poprawnym przepływem Post/Redirect/Get.
Co jest w formularzu
shortcode
[wl_contact], który wstawisz na dowolną stronę,obsługę wysyłki przez
admin-post.php(bez własnych endpointów i bez AJAX-a),zabezpieczenie nonce przed CSRF,
sanityzację danych wejściowych i escapowanie danych wyjściowych,
trzy warstwy antyspamu: honeypot, time-trap i limit wiadomości z jednego IP,
komunikaty sukcesu i błędu po przekierowaniu (wzorzec PRG).
Gdzie umieścić kod
Masz trzy opcje:
functions.phpmotywu – najszybciej, ale formularz zniknie przy zmianie motywu, a aktualizacja motywu nadrzędnego nadpisze zmiany.Własna wtyczka – plik w
wp-content/plugins/, który aktywujesz w panelu.Wtyczka must-use – plik w
wp-content/mu-plugins/, ładowany zawsze, bez możliwości przypadkowego wyłączenia.
Polecam trzecią opcję: to nadal „bez wtyczek” w sensie zewnętrznych zależności, a funkcjonalność nie jest przyklejona do motywu. Utwórz plik wp-content/mu-plugins/wl-contact.php. Cały kod poniżej to jeden plik podzielony na trzy części.
Krok 1: stałe i rejestracja haków
<?php
/**
* Plugin Name: Wirelab – formularz kontaktowy
* Description: Shortcode [wl_contact] z obsługą przez admin-post.php.
*/
if (!defined('ABSPATH')) {
exit;
}
const WL_CONTACT_ACTION = 'wl_contact';
const WL_CONTACT_MIN_SECS = 3; // time-trap: minimum sekund na wypełnienie
const WL_CONTACT_LIMIT = 3; // maks. wiadomości z jednego IP…
const WL_CONTACT_WINDOW = 10 * MINUTE_IN_SECONDS; // …w tym oknie czasu
add_shortcode('wl_contact', 'wl_contact_render');
add_action('admin_post_nopriv_' . WL_CONTACT_ACTION, 'wl_contact_handle');
add_action('admin_post_' . WL_CONTACT_ACTION, 'wl_contact_handle');
Hak admin_post_nopriv_{akcja} obsługuje niezalogowanych, a admin_post_{akcja} – zalogowanych użytkowników. Potrzebujesz obu, inaczej formularz nie zadziała, gdy testujesz go jako administrator (albo odwrotnie – nie zadziała dla gości). Nazwa akcji pochodzi z ukrytego pola action w formularzu.
Krok 2: renderowanie formularza
function wl_contact_render(): string
{
$messages = [
'ok' => ['success', 'Dziękuję! Wiadomość została wysłana.'],
'invalid' => ['error', 'Uzupełnij poprawnie wszystkie pola (wiadomość: min. 10 znaków).'],
'spam' => ['error', 'Nie udało się wysłać formularza. Odśwież stronę i spróbuj ponownie.'],
'limit' => ['error', 'Wysłano zbyt wiele wiadomości. Spróbuj ponownie za kilka minut.'],
'mail' => ['error', 'Serwer nie mógł wysłać wiadomości. Napisz bezpośrednio na e-mail.'],
];
$status = isset($_GET['kontakt']) ? sanitize_key(wp_unslash($_GET['kontakt'])) : '';
$ts = (string) time();
ob_start();
?>
<div id="kontakt" class="wl-contact">
<?php if (isset($messages[$status])) : [$type, $text] = $messages[$status]; ?>
<div class="wl-contact__notice wl-contact__notice--<?php echo esc_attr($type); ?>"
role="<?php echo $type === 'error' ? 'alert' : 'status'; ?>">
<?php echo esc_html($text); ?>
</div>
<?php endif; ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="<?php echo esc_attr(WL_CONTACT_ACTION); ?>">
<input type="hidden" name="wl_ts" value="<?php echo esc_attr($ts . '|' . wp_hash($ts)); ?>">
<?php wp_nonce_field(WL_CONTACT_ACTION, 'wl_nonce'); ?>
<p>
<label for="wl-name">Imię</label>
<input id="wl-name" name="wl_name" type="text" required maxlength="80" autocomplete="name">
</p>
<p>
<label for="wl-email">E-mail</label>
<input id="wl-email" name="wl_email" type="email" required maxlength="254" autocomplete="email">
</p>
<p>
<label for="wl-message">Wiadomość</label>
<textarea id="wl-message" name="wl_message" rows="6" required minlength="10" maxlength="5000"></textarea>
</p>
<p class="wl-contact__hp" aria-hidden="true">
<label for="wl-website">Zostaw to pole puste</label>
<input id="wl-website" name="wl_website" type="text" tabindex="-1" autocomplete="off">
</p>
<p><button type="submit">Wyślij wiadomość</button></p>
</form>
</div>
<?php
return (string) ob_get_clean();
}
Na co zwrócić uwagę:
Escapowanie przy wyświetlaniu. Każda wartość wstawiana do HTML przechodzi przez
esc_html(),esc_attr()alboesc_url(). Status z adresu URL nie jest nigdy wyświetlany bezpośrednio – służy tylko jako klucz do tablicy gotowych komunikatów, więc spreparowany link w stylu?kontakt=<script>nic nie zdziała.wp_nonce_field()generuje ukryte pole z nonce’em oraz pole_wp_http_referer, dzięki któremu handler wie, dokąd wrócić.Znacznik czasu z podpisem. Pole
wl_tszawiera czas wygenerowania formularza i jego HMAC zwp_hash(). Bot nie podrobi podpisu bez znajomości soli zwp-config.php.Honeypot to zwykłe pole tekstowe ukryte przed ludźmi.
aria-hidden="true"itabindex="-1"sprawiają, że nie trafią na nie też użytkownicy czytników ekranu ani klawiatury.role="alert"irole="status"sprawiają, że czytnik ekranu odczyta komunikat po przeładowaniu.Shortcode musi zwracać HTML, a nie go wypisywać. Stąd
ob_start()iob_get_clean().
Ukryj honeypot w CSS motywu. Nie używaj display: none w atrybucie style – część botów to sprawdza:
.wl-contact__hp {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
Krok 3: obsługa wysyłki
function wl_contact_redirect(string $status): void
{
$back = remove_query_arg('kontakt', wp_get_referer() ?: home_url('/'));
wp_safe_redirect(add_query_arg('kontakt', $status, $back) . '#kontakt', 303);
exit;
}
function wl_contact_handle(): void
{
// 1. Nonce: czy formularz pochodzi z naszej strony
$nonce = isset($_POST['wl_nonce']) ? sanitize_text_field(wp_unslash($_POST['wl_nonce'])) : '';
if (!wp_verify_nonce($nonce, WL_CONTACT_ACTION)) {
wl_contact_redirect('spam');
}
// 2. Honeypot: człowiek nie widzi tego pola
if (!empty($_POST['wl_website'])) {
wl_contact_redirect('ok'); // udajemy sukces – bot nie dostaje wskazówki
}
// 3. Time-trap: podpisany znacznik czasu z formularza
$raw = isset($_POST['wl_ts']) ? sanitize_text_field(wp_unslash($_POST['wl_ts'])) : '';
[$ts, $sig] = array_pad(explode('|', $raw, 2), 2, '');
if (!hash_equals(wp_hash($ts), $sig) || time() - (int) $ts < WL_CONTACT_MIN_SECS) {
wl_contact_redirect('spam');
}
// 4. Rate limit na transientach (klucz z hasza IP – nie trzymamy IP w bazie)
$ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
$key = 'wl_contact_' . md5(wp_hash($ip));
$count = (int) get_transient($key);
if ($count >= WL_CONTACT_LIMIT) {
wl_contact_redirect('limit');
}
// 5. Sanityzacja i walidacja
$name = sanitize_text_field(wp_unslash($_POST['wl_name'] ?? ''));
$email = sanitize_email(wp_unslash($_POST['wl_email'] ?? ''));
$message = sanitize_textarea_field(wp_unslash($_POST['wl_message'] ?? ''));
if ($name === '' || mb_strlen($name) > 80 || !is_email($email)
|| mb_strlen($message) < 10 || mb_strlen($message) > 5000) {
wl_contact_redirect('invalid');
}
set_transient($key, $count + 1, WL_CONTACT_WINDOW);
// 6. Wysyłka
$site = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
$subject = sprintf('[%s] Wiadomość od: %s', $site, $name);
$body = "Imię: {$name}\nE-mail: {$email}\n\n{$message}\n";
$headers = ['Content-Type: text/plain; charset=UTF-8', 'Reply-To: ' . $email];
$sent = wp_mail(get_option('admin_email'), $subject, $body, $headers);
wl_contact_redirect($sent ? 'ok' : 'mail');
}
Kolejność kontroli ma znaczenie: najtańsze i najbardziej rozstrzygające sprawdzenia są na początku.
Nonce
wp_verify_nonce() potwierdza, że formularz został wygenerowany przez twoją stronę w ciągu ostatnich kilkunastu godzin (nonce WordPressa żyje od 12 do 24 godzin). To ochrona przed CSRF, a przy okazji odsiewa najprostsze boty, które wysyłają POST bez pobrania strony.
Uwaga na cache stron. Jeśli strona z formularzem jest serwowana z cache (np. przez wtyczkę cache albo reverse proxy), wszyscy dostają ten sam, z czasem przeterminowany nonce, a po dobie formularz zacznie zwracać błąd. Wyklucz stronę kontaktu z cache albo skróć czas życia jej kopii poniżej 12 godzin.
Honeypot
Jeśli pole wl_website nie jest puste, przekierowujesz ze statusem „ok”, ale nic nie wysyłasz. Bot „myśli”, że mu się udało, i nie próbuje obejść zabezpieczenia.
Time-trap
Człowiek potrzebuje co najmniej kilku sekund, żeby wypełnić trzy pola. Skrypt – ułamka sekundy. Porównanie przez hash_equals() zamiast === chroni przed atakami czasowymi. Nie ustawiaj progu zbyt wysoko: menedżery haseł i autouzupełnianie potrafią wypełnić formularz bardzo szybko. Trzy sekundy to rozsądny kompromis.
Rate limit na transientach
Transient to wartość w bazie (albo w Redisie/Memcached, jeśli masz obiektowy cache) z czasem wygaśnięcia. Klucz budujesz z hasza adresu IP, więc sam adres nie trafia do bazy. Dwie uwagi:
set_transient()przy każdym zapisie odnawia czas wygaśnięcia, więc to okno „przesuwne” – limit resetuje się 10 minut po ostatniej wiadomości. Dla formularza kontaktowego to w zupełności wystarczy.Za Cloudflare lub innym reverse proxy
REMOTE_ADDRto adres proxy i wszyscy użytkownicy dzielą jeden limit. Wtedy pobierz prawdziwe IP z nagłówka ustawianego przez proxy (np.CF-Connecting-IP), ale tylko wtedy, gdy serwer przyjmuje ruch wyłącznie od tego proxy.
Sanityzacja i walidacja
To dwa różne etapy. Sanityzacja czyści dane: sanitize_text_field() usuwa tagi i znaki nowej linii, sanitize_textarea_field() robi to samo, ale zachowuje nowe linie, a sanitize_email() wycina niedozwolone znaki. Walidacja sprawdza, czy wynik ma sens: is_email(), długości pól. Zwróć uwagę na wp_unslash() – WordPress dodaje ukośniki do danych z $_POST, więc bez tego apostrof w nazwisku O’Brien zamieniłby się w O\'Brien.
Wiadomość wysyłasz jako zwykły tekst, więc nawet gdyby coś przeszło przez sanityzację, klient pocztowy nie wyrenderuje tego jako HTML.
Wysyłka i nagłówki
Adres nadawcy zostaw domyślny (albo ustaw adres w swojej domenie), a e-mail użytkownika wstaw w Reply-To. Wstawienie adresu z formularza w From to najprostszy sposób, żeby wiadomość trafiła do spamu – serwer pocztowy odbiorcy zobaczy, że twój serwer wysyła „w imieniu” obcej domeny, co nie przejdzie weryfikacji SPF i DMARC. Nagłówki są bezpieczne przed wstrzyknięciem, bo sanitize_email() nie przepuszcza znaków nowej linii.
Post/Redirect/Get
Każda ścieżka kończy się wl_contact_redirect(), czyli przekierowaniem 303 z powrotem na stronę formularza. Dzięki temu odświeżenie strony po wysyłce nie wyśle wiadomości drugi raz, a komunikat wynika z parametru kontakt w adresie. wp_safe_redirect() pozwala przekierować tylko na adresy z twojej domeny, więc podrobiony referer nie zrobi z formularza otwartego przekierowania.
Deliverability: dlaczego wp_mail() to nie wszystko
Domyślnie wp_mail() korzysta z funkcji mail() PHP, czyli z lokalnego sendmaila. Na wielu hostingach i VPS-ach to przepis na wiadomości w spamie albo w ogóle niedostarczone. Lepiej wysyłać przez SMTP z uwierzytelnieniem, a dla domeny skonfigurować SPF, DKIM i DMARC. Wtyczka nie jest do tego potrzebna – wystarczy hak phpmailer_init:
add_action('phpmailer_init', function (PHPMailer\PHPMailer\PHPMailer $mailer): void {
if (!defined('WL_SMTP_HOST')) {
return;
}
$mailer->isSMTP();
$mailer->Host = WL_SMTP_HOST;
$mailer->Port = WL_SMTP_PORT;
$mailer->SMTPAuth = true;
$mailer->Username = WL_SMTP_USER;
$mailer->Password = WL_SMTP_PASS;
$mailer->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
$mailer->setFrom(WL_SMTP_FROM, get_bloginfo('name'));
});
Stałe WL_SMTP_* zdefiniuj w wp-config.php, żeby hasło nie trafiło do repozytorium. Zawsze sprawdzaj wartość zwracaną przez wp_mail() – false oznacza, że PHPMailer nie przekazał wiadomości dalej. Szczegóły błędu dostaniesz z haka wp_mail_failed.
Jak to przetestować
Wstaw shortcode
[wl_contact]na stronę „Kontakt”. W edytorze blokowym użyj bloku „Shortcode”.Wyślij poprawny formularz – powinien pojawić się komunikat sukcesu, a w adresie
?kontakt=ok#kontakt.Odśwież stronę – wiadomość nie powinna przyjść drugi raz.
W narzędziach deweloperskich przeglądarki wypełnij ukryte pole
wl_websitei wyślij – zobaczysz „sukces”, ale e-mail nie przyjdzie.Wyślij formularz czwarty raz w ciągu 10 minut – dostaniesz komunikat o limicie.
Lokalnie zamiast prawdziwej poczty użyj Mailpita albo tymczasowego filtra
pre_wp_mail, który zapisze wiadomość do pliku i zwrócitrue.
Co dalej
Zachowanie wpisanych danych po błędzie. Zapisz wartości pól w krótkim transiencie pod losowym kluczem, dołącz klucz do adresu przekierowania i uzupełnij nimi formularz.
Zapis wiadomości w bazie. Własny typ treści (
register_post_type) z wyłączonym frontendem da ci historię zapytań w panelu, nawet gdy poczta zawiedzie.Blok zamiast shortcode’u. Dynamiczny blok z
render_callbackmoże wywoływać tę samą funkcjęwl_contact_render().
Na koniec
Nieco ponad sto linii PHP wystarcza na formularz, który nie ładuje dodatkowych skryptów ani stylów i robi tylko to, czego od niego potrzeba. Gdy wiadomości nie dochodzą, zaczynam od SMTP i rekordów DNS, a nie od kodu formularza. Jeśli potrzebujesz takiego formularza na swojej stronie albo w sklepie, zajrzyj na strony strony internetowe i sklepy WooCommerce.
