Dla administratorów: pełny przebieg z dokładnymi liczbami, poleceniami i konfiguracją. Wersje: stan na listopad 2026.
W skrócie
| Element | Szczegóły |
| Środowisko | WordPress 7.1.4, PHP 8.3.36 (Apache, mod_php), MariaDB 11.4.13, Elementor 4.3.5; EspoCRM 10.0.11 (PHP 8.4.27, MariaDB 11.4), Mailpit 1.31 (stan na 2026-11) |
| Wtyczka | leady (PHP + czysty JS, bez zależności): tabele wp_leady i wp_leady_zdarzenia, REST leady/v1/zgloszenie i /token, admin-post.php bez JS, WP-Cron, panel WP_List_Table |
| Przepływ | antyspam → walidacja → INSERT → wp_mail → wp_schedule_single_event + spawn_cron → GET /api/v1/Lead?where…cWpLeadId → POST /api/v1/Lead z X-Api-Key |
| Odporność | kolejka: odstępy 1, 2, 5, 15, 30, 60, 120 min, po 8. próbie „porzucono” + e-mail; zajęcie rekordu jednym UPDATE; idempotencja cWpLeadId; 409 → X-Skip-Duplicate-Check |
| Efekt | lead w CRM 607–693 ms po kliknięciu; awaria CRM 3 min 18 s: 3/3 dostarczone, 0 duplikatów; ręczny proces 85,1–110,8 s na zgłoszenie |
Kontekst
Strona z case study o dostępności: formularz motywu (wp_mail) zastąpiony formularzem wtyczki o tym samym, dostępnym
markupie (komunikaty przy polach, podsumowanie błędów, role="status"). Shortcode [rb_formularz] w widgecie tekstowym
Elementora, więc strona dalej nie ładuje skryptów frontendu Elementora. CRM to otwartoźródłowy EspoCRM w Dockerze: prawdziwe
REST API z kluczem, rolami i wykrywaniem duplikatów, więc test pokazuje zachowanie realnej integracji, a nie atrapy webhooka.
Konfiguracja CRM (crm-konfiguracja.sh): pole wpLeadId w encji Lead (EspoCRM nadaje mu nazwę cWpLeadId), rola
„Strona www (API)” z prawem tylko do tworzenia i odczytu leadów, użytkownik typu API z kluczem.
Diagnoza: jak mierzyliśmy
Przepływ: telefon 390 px, otwarcie /kontakt/, odczekanie 3,3 s (minimalny czas
antyspamu), wypełnienie, kliknięcie; czasy względem kliknięcia z dziennika wtyczki (wp_leady_zdarzenia.czas,
datetime(3), UTC), z Mailpit (Created) i z CRM. Zegar jeden (ten sam host).
Awaria: docker stop crm (CRM wyłączony), docker pause crm (CRM włączony, nie odpowiada), utrata odpowiedzi
symulowana cofnięciem statusu zgłoszenia już zapisanego w CRM.
Antyspam: curl na /wp-json/leady/v1/zgloszenie z tokenem z /token i bez niego.
Proces ręczny (model): ekran 1440×900; przygotowanie poza pomiarem (zalogowany CRM, otwarta
skrzynka i arkusz), potem: kliknięcie najnowszej wiadomości w Mailpit, czytanie (200 słów/min), przełączenie do EspoCRM,
#Lead/create, wypełnienie imienia, nazwiska, e-maila, telefonu i opisu, zapis, przełączenie do arkusza (lokalna strona
z tabelą), wiersz: data, imię i nazwisko, e-mail, telefon, źródło, temat, status. Wariant A: wszystko z klawiatury
(0,3 s/znak). Wariant B: pola dłuższe niż 12 znaków przez kopiuj-wklej (przełączenie 2 s, zaznaczenie 1,5 s, Ctrl+C 0,5 s,
przełączenie, kliknięcie pola 1 s, Ctrl+V 0,5 s). Po 3 przebiegi.
Wyniki
Przepływ, z JavaScriptem (10 zgłoszeń), ms od kliknięcia:
| Etap | Min | Maks |
| zapis w bazie | 438 | 509 |
| e-mail wysłany (dziennik) / w Mailpit | 489 / 485 | 560 / 556 |
| lead utworzony w CRM (1. próba) | 607 | 693 |
| komunikat „Dziękujemy” na ekranie | 628 | 713 |
Bez JavaScriptu (3 zgłoszenia, POST i przekierowanie): zapis 78–107 ms, e-mail 135–161 ms, CRM 284–334 ms, komunikat po
przeładowaniu 368–440 ms. Różnica względem JS to dodatkowe żądanie GET /wp-json/leady/v1/token (świeży nonce, patrz
pułapki).
Awaria CRM (wyłączony 3 min 18 s, 3 zgłoszenia):
22:05:17.623 zapis ok zapis w bazie po 10 ms
22:05:17.678 mail ok do biuro@…
22:05:18.695 kolejka ok przekazano do kolejki CRM, odpowiedź po 1082 ms
22:05:25.749 crm blad próba 1: http_request_failed: cURL error 28: Resolving timed out after 5001 milliseconds; następna za 1 min
22:06:32.715 crm blad próba 2: http_request_failed: cURL error 28: Resolving timed out after 5001 milliseconds; następna za 2 min
22:08:29.075 (CRM uruchomiony)
22:08:43.617 crm ok utworzono Lead … (próba 3)
Pozostałe dwa zgłoszenia: lead w CRM 75 s po uruchomieniu CRM (termin trzeciej próby). W CRM 3 rekordy z cWpLeadId
…-16, …-17, …-18, żadnego duplikatu. E-mail do biura wyszedł w każdym przypadku od razu.
CRM zawieszony: http_request_failed: cURL error 28: Operation timed out after 5002 milliseconds with 0 bytes received,
druga próba po minucie udana.
Utracona odpowiedź (symulacja): zgłoszenie już w CRM, status w WordPressie cofnięty na „błąd” → kolejka:
rekord już był w CRM: 6ae90aa091379cd1d (bez duplikatu), w CRM 1 rekord.
Duplikat: drugie zgłoszenie z tym samym imieniem i e-mailem → EspoCRM 409 Conflict z listą podobnych rekordów → zdarzenie
duplikat → ponowny POST z X-Skip-Duplicate-Check: true → lead utworzony.
Antyspam:
| Próba | Odpowiedź | Zapis w bazie |
| brak nonce | 400 {"status":"blad","kod":"nonce"} | nie |
| wysłane < 3 s po wyświetleniu | 201 {"status":"ok"} | nie (zdarzenie za-szybko) |
wypełnione pole-pułapka strona_www | 201 {"status":"ok"} | nie (pulapka) |
| podrobiony znacznik czasu | 201 {"status":"ok"} | nie (czas-podpis) |
| 6. zgłoszenie z jednego IP w 10 min | 429 {"status":"blad","kod":"limit"} | nie |
Proces ręczny (3 przebiegi na wariant):
| Wariant | Czas łącznie | Czytanie maila | CRM | Arkusz | Znaki | Kliknięcia | Przełączenia okien |
| A przepisywanie | 110,2–110,8 s | 14,2–14,3 s | 52,6 s | 40,7 s | 269 | 9 | 2 |
| B kopiuj-wklej | 85,0–85,4 s | 14,2–14,4 s | 29,6 s | 38,2 s | 36 | 9 | 16 |
Praca do zastąpienia automatem (bez czytania maila, które zostaje): A 96,5 s, B 70,7 s na zgłoszenie.
Co nie było oczywiste
spawn_cron() nie jest w pełni nieblokujący. WordPress wywołuje wp-cron.php żądaniem z blocking => false
i timeout => 0.01, a mimo to przy awarii CRM odpowiedź formularza przyszła po 1530–1547 ms zamiast 628–713 ms; zdarzenie
„kolejka” po 1078–1082 ms. Uruchomiony cron od razu łączy się z CRM i blokuje na rozwiązywaniu nazwy, a żądanie
wywołujące czeka na niego ok. 1 s. W normalnej pracy cron kończy się szybciej (lead w CRM 607–693 ms jeszcze przed
odpowiedzią do przeglądarki). Granica to ok. 1 s, nie 5 s limitu CRM; gdyby i to było za dużo: bez spawn_cron(), tylko
systemowy cron (lead w CRM do 60 s).
- Zatrzymany kontener to nie „connection refused”. Po
docker stop nazwa usługi przestaje się rozwiązywać w sieci
Dockera i cURL czeka na DNS: cURL error 28: Resolving timed out after 5000 milliseconds. Po docker pause połączenie
wisi: Operation timed out after 5002 milliseconds with 0 bytes received. Oba przypadki to błąd 28 po limicie 5 s
(ustawienie wtyczki „Limit czasu odpowiedzi CRM”, domyślnie tyle co w wp_remote_request). Odwiedzający na ten limit
nie czeka, bo wywołanie CRM idzie przez cron; czeka tylko na opóźnienie z punktu 1.
- Ponowienie bez idempotencji tworzy duplikaty. Jeśli CRM zapisze rekord, a odpowiedź przepadnie (limit czasu,
zerwane połączenie), kolejne
POST założy drugi rekord. Pole cWpLeadId (<host>:<port>-<id>) i GET przed POST
to rozwiązują; w teście: „rekord już był w CRM”.
- Dwa procesy, jedno zgłoszenie. Zdarzenie jednorazowe (
leady_crm_jeden) i przegląd kolejki co minutę mogą trafić
na to samo zgłoszenie. Zajęcie jednym zapytaniem: UPDATE … SET status_crm='w-toku' WHERE id=%d AND status_crm IN
('oczekuje','blad'); kto nie zmienił wiersza, nie wysyła. „W toku” dłużej niż 5 minut wraca do kolejki (proces przerwany).
- EspoCRM odrzuca duplikaty kodem 409. Domyślne wykrywanie duplikatów leadów (ta sama osoba / e-mail) zwraca
409 Conflict z tablicą podobnych rekordów zamiast tworzyć leada. Bez obsługi tego kodu każde kolejne zapytanie stałego
klienta kończyłoby się w kolejce jako „porzucone” po 8 próbach.
- Nonce w HTML i strony z pamięci podręcznej. Nonce WordPressa żyje 12–24 h; strona kontaktu podana z cache po dobie
ma nieważny nonce i formularz odrzuca ludzi (400). Dlatego JS pobiera świeży nonce z
GET /token (Cache-Control:
no-store) tuż przed wysłaniem; koszt: ok. 350 ms (438–509 ms do zapisu z JS wobec 78–107 ms bez JS).
- Limit na IP za pośrednikiem. W labie każde żądanie ma
REMOTE_ADDR bramki Dockera (192.168.48.1), więc 6. zgłoszenie
od kogokolwiek dostało 429. Za reverse proxy lub CDN limit musi liczyć adres z zaufanego nagłówka proxy (tylko od
znanych adresów pośrednika), inaczej blokuje wszystkich. IP przechowujemy tylko jako HMAC (hash_hmac('sha256', $ip, wp_salt())).
- EspoCRM 10 i montowanie katalogu. Montowanie całego
/var/www/html jako wolumenu daje przy starcie
„LEGACY INSTALLATION METHOD DETECTED … No further EspoCRM upgrades will be available.”; od wersji 10 montuje się osobno
data, custom i client/custom.
Rozwiązanie
Przyjęcie zgłoszenia (includes/formularz.php, ta sama funkcja dla REST i admin-post.php):
$spam = leady_sprawdz_spam( $post ); // nonce, pułapka, czas (HMAC), limit na skrót IP
if ( $spam ) { … return in_array( $spam, [ 'nonce', 'limit' ], true ) ? [ 'blad', $spam ] : [ 'ok', 0 ]; } // bot dostaje „ok”
$b = leady_waliduj( $d ); // te same komunikaty co w JS
$wpdb->insert( leady_tabela(), [ … ] ); // zapis
leady_wyslij_mail( leady_pobierz( $id ) ); // wp_mail, Reply-To klienta
wp_schedule_single_event( time(), 'leady_crm_jeden', [ $id ] );
spawn_cron(); // CRM w osobnym żądaniu
Przekazanie do CRM (includes/crm.php):
// 1. czy rekord już jest
leady_crm_api( 'GET', 'Lead?' . http_build_query( [ 'select' => 'id', 'maxSize' => 1,
'where' => [ [ 'type' => 'equals', 'attribute' => 'cWpLeadId', 'value' => $klucz ] ] ] ) );
// 2. utworzenie; 409 = możliwy duplikat → zapis z pominięciem sprawdzania
leady_crm_api( 'POST', 'Lead', $rekord ); // nagłówek X-Api-Key
leady_crm_api( 'POST', 'Lead', $rekord, [ 'X-Skip-Duplicate-Check' => 'true' ] );
// błąd: proby+1, nastepna_proba = now + [1, 2, 5, 15, 30, 60, 120][proby-1] min; po 8. próbie „porzucono” + e-mail
Kolejka (includes/kolejka.php): własny interwał rb_minuta (60 s), zdarzenie leady_kolejka, zajęcie rekordu
jednym UPDATE, na serwerze systemowy cron: wp cron event run --due-now co minutę (w labie kontener lab-cron).
Panel (includes/panel.php): „Zgłoszenia” (lista: data, osoba, e-mail, telefon, status e-mail i CRM, liczba prób,
ostatni błąd, „Ponów teraz”), szczegóły z dziennikiem przepływu,
ustawienia (adres i klucz CRM albo stała RB_LEADY_CRM_KEY w wp-config.php, limit czasu, limity antyspamu).
Dostępność i jakość: formularz wtyczki ma ten sam markup co po audycie WCAG (komunikaty przy polach, podsumowanie
błędów, role="status" z fokusem po wysłaniu, pole-pułapka aria-hidden z tabindex="-1"); axe 0 na stronie kontaktu
i w stanie z błędami, detektor impeccable 0.
Rezultaty
| Etykieta | Wartość |
| Od kliknięcia do leada w CRM | 607–693 ms (10/10 w pierwszej próbie) |
| Od kliknięcia do e-maila w skrzynce | 485–556 ms |
| Ręczne przepisanie zgłoszenia (mail → CRM → arkusz) | 85,0–85,4 s (kopiuj-wklej), 110,2–110,8 s (przepisywanie) |
| Oszczędność przy 50 zapytaniach w miesiącu (założenie) | 59 min (kopiuj-wklej) do 80 min (przepisywanie) |
| Awaria CRM 3 min 18 s | 3/3 zgłoszenia w CRM po powrocie (14,5 s i 75 s), 0 duplikatów, e-maile bez opóźnienia |
| Odpowiedź formularza przy awarii CRM | 1530–1547 ms (normalnie 628–713 ms) |
| Antyspam w teście | 4 rodzaje prób odrzucone, 0 zapisów od „botów”, 1 × 429 przy limicie |
Wnioski
- Integracja formularza z CRM to kilkaset wierszy PHP bez zależności; większość z nich obsługuje sytuacje, gdy coś nie działa.
- Mierzalna korzyść to czas biura (71–96 s na zgłoszenie w modelu) i czas reakcji (lead w CRM w niecałą sekundę).
- Odporność trzeba sprawdzić awarią: wyłączyć CRM, zawiesić go, zgubić odpowiedź; dopiero wtedy widać duplikaty i opóźnienia.
- Antyspam bez CAPTCHA wystarczył na testowe boty; przy prawdziwym ruchu warto po miesiącu przejrzeć dziennik zdarzeń
spam.