Search by

iltuostudiodigitale / crm-connector

iltuostudiodigitale

Contratto e client per inviare i contatti raccolti dai form al CRM del gestionale, con la protezione antispam dei moduli.

Package info

github.com/2P2srl/crm-connector

pkg:composer/iltuostudiodigitale/crm-connector

Statistics

Installs: 52

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.1.1 2026-11-05 07:25 UTC

This package is auto-updated.

Last update: 2026-11-05 07:26:25 UTC


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 usa withErrors($errors, 'contatti') (default default): 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.