iltuostudiodigitale / crm-connector
Contratto e client per inviare i contatti raccolti dai form al CRM del gestionale, con la protezione antispam dei moduli.
Requires
- php: ^8.3
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
- spatie/laravel-data: ^4.15
Requires (Dev)
- driftingly/rector-laravel: ^2.7
- larastan/larastan: ^3.14
- laravel/pint: ^1.27
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-rector: ^5.0
- phpstan/extension-installer: ^1.4
- rector/rector: ^2.8
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Contratto e client per inviare al CRM del gestionale i contatti raccolti dai form di un sito, con la protezione antispam dei moduli (vedi Protezione dei moduli).
Il pacchetto esiste per un motivo solo: il sito non decide il formato. La submission si costruisce
passando da CrmContactData; se i dati non rispettano il contratto, la richiesta non parte.
Installazione
composer require iltuostudiodigitale/crm-connector php artisan crm-connector:install
Il comando pubblica la configurazione e deposita le istruzioni per gli agenti AI
(AGENTS.md) dentro il progetto — in .ai/rules/crm-connector.md se il progetto usa
quella convenzione, altrimenti nella root. Dentro vendor/ nessun agente le leggerebbe.
Credenziali
Le rilascia il gestionale in Amministrazione → CRM → Siti, con l'azione «Credenziali». Il client secret è visibile solo al momento della generazione: se lo si perde va rigenerato, e i token già emessi smettono di funzionare.
CRM_BASE_URL=https://gestionale.esempio.it CRM_SUBMISSION_URL=https://crm.esempio.it/api/v1/form-submission CRM_CLIENT_ID=9f8e... CRM_CLIENT_SECRET=...
Il secret va tenuto lato server. Se il form invia da JavaScript nel browser, la POST deve comunque passare da un controller del sito: il secret non va mai messo in una pagina.
Uso
use IlTuoStudioDigitale\CrmConnector\Data\CrmContactData; use IlTuoStudioDigitale\CrmConnector\Facades\Crm; $contact = CrmContactData::validateAndCreate([ 'form_name' => 'Contattaci', 'first_name' => $request->input('nome'), 'last_name' => $request->input('cognome'), 'email' => $request->input('email'), 'phone' => $request->input('telefono'), 'message' => $request->input('messaggio'), 'privacy_accepted' => $request->boolean('privacy'), 'source_url' => $request->fullUrl(), ]); $result = Crm::submit($contact); // ['contact_id' => 123, 'created_at' => '...']
In caso di errore viene sollevata CrmSubmissionFailedException, con ->status e ->errors
(la mappa di validazione restituita dal gestionale).
Protezione dei moduli
Ogni modulo che manda contatti al CRM va protetto dallo spam, e il pacchetto ha già tutto: niente reCAPTCHA (cookie di Google, consenso, script su ogni pagina) ma quattro controlli che insieme fermano i bot senza disturbare le persone.
| Controllo | Cosa ferma | Esito |
|---|---|---|
Campo nascosto website |
i bot che compilano tutti i campi | scartato in silenzio |
| Tempo minimo (3 secondi, firmato) | i bot che inviano appena scaricata la pagina | scartato in silenzio |
Link nel testo (http://, www., [url, <a) |
lo spam pubblicitario | scartato in silenzio |
| Cloudflare Turnstile | i bot che usano un browser vero | errore visibile, si può riprovare |
I primi tre sono segni certi di un bot: al bot si risponde che è andato tutto bene e non si invia nulla; il motivo finisce nel log (con email e IP) per recuperare un contatto vero scartato per errore. Turnstile invece può fallire anche a una persona (pagina aperta da ore, rete instabile), quindi mostra un errore e chi compila riprova.
Chiavi
Dal pannello Cloudflare, Turnstile → Add widget, con i domini del sito (modalità Managed):
TURNSTILE_SITE_KEY=0x4AAAA... TURNSTILE_SECRET_KEY=0x4AAAA...
Senza chiavi Turnstile è spento e restano gli altri tre controlli (in produzione il log
lo segnala a ogni invio). In locale si usano le
chiavi di prova
di Cloudflare: 1x00000000000000000000AA / 1x0000000000000000000000000000000AA passano
sempre, 2x00000000000000000000AB / 2x0000000000000000000000000000000AA falliscono sempre.
Nella vista
Dentro il <form>:
<form method="post" action="{{ route('contatti.store') }}"> @csrf <x-crm-connector::form-guard bag="contatti" action="contatti" error-class="text-sm text-red-600" /> ... </form>
bag: il gruppo di errori del modulo, se il controller usawithErrors($errors, 'contatti')(defaultdefault): lì compare il messaggio quando Turnstile fallisce.action: un nome per le statistiche di Cloudflare (facoltativo).error-class: le classi del messaggio d'errore.
Lo script di Cloudflare si carica solo quando qualcuno tocca il modulo, non con la pagina: nessun peso per chi non compila, e funziona anche nei moduli dentro un popup. Il widget è invisibile salvo i rari casi in cui Cloudflare chiede un clic. Se si invia prima che il token sia pronto, l'invio lo aspetta.
Nel controller
Una sola chiamata, uguale per tutti i siti, prima di qualunque invio al CRM:
use IlTuoStudioDigitale\CrmConnector\Protection\FormGuard; if (! FormGuard::allows($request)) { return redirect()->route('contatti.grazie'); // al bot si dice che è andato tutto bene } // ... Crm::submit(...)
allows() restituisce false per i segni certi di un bot e solleva una
ValidationException sul campo cf-turnstile-response quando Turnstile non conferma:
Laravel torna al modulo con l'errore, come per qualunque campo. Con un FormRequest non
cambia nulla: le regole del FormRequest validano i campi, allows() sta nel controller.
Se un campo può contenere un link legittimo (il sito aziendale in un questionario),
va dichiarato: FormGuard::allows($request, ['answers.sito']) (accetta anche *).
Il limite di invii per IP (throttle) resta sulla rotta: risolve un altro problema.
Nei test
Senza chiavi Turnstile è spento, quindi i test esistenti non cambiano. Per provarlo:
use Illuminate\Support\Facades\Http; use IlTuoStudioDigitale\CrmConnector\Protection\Turnstile; config(['crm-connector.turnstile.site_key' => 'site', 'crm-connector.turnstile.secret_key' => 'secret']); Http::fake([Turnstile::SITEVERIFY_URL => Http::response(['success' => true])]); $this->post('/contatti', [..., Turnstile::FIELD => 'token']);
Un modulo inviato meno di 3 secondi dopo la pagina è scartato: nei test si genera il
campo form_started con FormGuard::token() e si va avanti nel tempo ($this->travel(5)->seconds()).
Versioni
Il major cambia a ogni rottura del contratto: un campo che sparisce, uno che diventa obbligatorio, una regola più stretta. Mai come minor.
L'URL dell'API (/api/v1) cambia invece solo quando il gestionale deve servire due
contratti contemporaneamente, perché ci sono siti in produzione fermi alla versione
precedente. Finché tutti i siti installati parlano lo stesso contratto, una major del
pacchetto non comporta una nuova versione dell'API.
Il contratto
| Campo | Obbligatorio | Regole |
|---|---|---|
first_name |
sì | max 150 |
last_name |
sì | max 150 |
privacy_accepted |
sì | deve essere true |
email |
se manca phone |
email valida, max 255 |
phone |
se manca email |
max 20 |
business_name |
no | max 150 |
fiscal_code |
no | 11–16 caratteri alfanumerici |
message |
no | testo libero |
newsletter_accepted |
no | default false |
source_url |
no | URL valido |
referrer_url |
no | URL valido |
form_name |
sì | max 150 — il nome del modulo che ha prodotto il contatto |
custom |
no | mappa di risposte libere: max 200 chiavi, ogni valore max 5000 |
form_name: da dove arriva il dato
È obbligatorio su ogni invio, anche per un semplice modulo di contatto, perché è la chiave con cui il gestionale raggruppa quello che riceve: senza, tutte le richieste di tutti i moduli di un sito finiscono in un unico mucchio indistinto.
Il modo consigliato è un campo nascosto nel modulo, così il nome viaggia insieme ai dati e non va ricordato lato server:
<input type="hidden" name="form_name" value="Questionario mutuo 2026">
'form_name' => $request->input('form_name', 'Contattaci'),
Due accortezze:
- Usa lo stesso nome, scritto sempre allo stesso modo. Non esiste un elenco dei moduli nel gestionale: il nome è l'unica cosa che li distingue, quindi «Questionario mutuo» e «Questionario mutui» diventano due moduli diversi nei filtri e nei conteggi.
- Cambia nome quando cambia il modulo in modo incompatibile (per esempio riscrivi le domande di un questionario): così lo storico resta leggibile invece di mescolare risposte a domande diverse sotto la stessa etichetta.
Campi custom e questionari
I campi fissi sopra sono il minimo che ogni form deve avere. Tutto il resto — un
questionario di quaranta domande, un preventivatore, una richiesta con campi specifici
del sito — va in custom, che accetta qualunque mappa chiave/valore: stringhe, numeri,
booleani, null e liste di scalari.
CrmContactData::validateAndCreate([ 'first_name' => 'Mario', 'last_name' => 'Rossi', 'email' => 'mario@example.com', 'privacy_accepted' => $request->boolean('privacy'), 'form_name' => 'Questionario mutuo 2026', 'custom' => [ 'reddito_annuo' => 32000, 'tipo_immobile' => 'prima_casa', 'ha_mutui_in_corso' => false, 'province_interesse' => ['NA', 'SA'], 'note_libere' => null, ], ]);
Dentro custom un null è una risposta lasciata in bianco, ed è diverso da una
domanda mai posta: per questo custom viene inviato così com'è, senza ripulire i valori
vuoti. I limiti (200 chiavi, 5000 caratteri per valore) esistono per non trasformare
l'endpoint in un deposito.
Non sono inviabili: il sito di provenienza (deriva dalle credenziali), l'indirizzo IP e lo user agent (li rileva il gestionale), le note interne.
Siti non-Laravel
Il pacchetto è una comodità, non un obbligo tecnico: il contratto è HTTP. Due chiamate:
# 1. token (client credentials) curl -X POST https://gestionale.esempio.it/oauth/token \ -d grant_type=client_credentials \ -d client_id=$CRM_CLIENT_ID \ -d client_secret=$CRM_CLIENT_SECRET \ -d scope=crm:submit # 2. submission curl -X POST https://crm.esempio.it/api/v1/form-submission \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"form_name":"Contattaci","first_name":"Mario","last_name":"Rossi","email":"mario@example.com","privacy_accepted":true}'
Risposte: 201 con {"success":true,"data":{"contact_id":…}}, 422 con errors,
401 token assente o scaduto, 403 sito non attivo o origine non consentita, 429 rate limit.