Przejdź do treści
Wszystkie wpisy

8 października 20269 min czytania

Formularz kontaktowy w WordPress bez wtyczek

Prosty formularz „imię, e-mail, wiadomość” w jednym pliku mu-plugin: shortcode, obsługa przez admin-post.php, nonce, sanityzacja, antyspam bez CAPTCHA i wysyłka przez SMTP.

Formularz kontaktowy w WordPress bez wtyczek

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:

  1. functions.php motywu – najszybciej, ale formularz zniknie przy zmianie motywu, a aktualizacja motywu nadrzędnego nadpisze zmiany.

  2. Własna wtyczka – plik w wp-content/plugins/, który aktywujesz w panelu.

  3. 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
<?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

PHP
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() albo esc_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_ts zawiera czas wygenerowania formularza i jego HMAC z wp_hash(). Bot nie podrobi podpisu bez znajomości soli z wp-config.php.

  • Honeypot to zwykłe pole tekstowe ukryte przed ludźmi. aria-hidden="true" i tabindex="-1" sprawiają, że nie trafią na nie też użytkownicy czytników ekranu ani klawiatury.

  • role="alert" i role="status" sprawiają, że czytnik ekranu odczyta komunikat po przeładowaniu.

  • Shortcode musi zwracać HTML, a nie go wypisywać. Stąd ob_start() i ob_get_clean().

Ukryj honeypot w CSS motywu. Nie używaj display: none w atrybucie style – część botów to sprawdza:

CSS
.wl-contact__hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

Krok 3: obsługa wysyłki

PHP
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_ADDR to 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:

PHP
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ć

  1. Wstaw shortcode [wl_contact] na stronę „Kontakt”. W edytorze blokowym użyj bloku „Shortcode”.

  2. Wyślij poprawny formularz – powinien pojawić się komunikat sukcesu, a w adresie ?kontakt=ok#kontakt.

  3. Odśwież stronę – wiadomość nie powinna przyjść drugi raz.

  4. W narzędziach deweloperskich przeglądarki wypełnij ukryte pole wl_website i wyślij – zobaczysz „sukces”, ale e-mail nie przyjdzie.

  5. Wyślij formularz czwarty raz w ciągu 10 minut – dostaniesz komunikat o limicie.

  6. Lokalnie zamiast prawdziwej poczty użyj Mailpita albo tymczasowego filtra pre_wp_mail, który zapisze wiadomość do pliku i zwróci true.

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_callback moż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.

Napisz