Integrazione con sistemi ERP legacy tramite API PHP: pattern e insidie comuni

Integrazione con sistemi ERP legacy tramite API PHP: pattern e insidie comuni

Nel corso del 2024 e del 2025 ho integrato quattro gestionali italiani diversi con applicazioni Laravel in produzione, per altrettanti clienti del settore distribuzione e servizi. In tutti e quattro i casi il copione era identico fino alla caricatura: il gestionale espone un'API SOAP con documentazione in un PDF di 40 pagine risalente al 2008, la metà dei metodi documentati non funziona come descritto, l'altra metà ha comportamenti non documentati che scopri solo in produzione, il WSDL è valido ma incompleto, le risposte XML contengono campi con nomi italiani abbreviati senza legenda (CodArtDes, QtaMov, FlgAnn) e l'autenticazione è un token fisso inserito come header custom il cui formato cambia tra una versione e l'altra del gestionale. Se sei uno sviluppatore PHP che ha ricevuto il mandato "fai parlare Laravel con il nostro gestionale", questo articolo è la mappa dei problemi che incontrerai e dei pattern che ho sviluppato per sopravvivere a ciascuno.

Il costo di un'integrazione ERP fatta male non è solo tecnico - è un costo di business diretto. Quando il gestionale non parla correttamente con l'applicazione web, gli ordini non si sincronizzano, le giacenze di magazzino non sono allineate, le fatture escono con importi sbagliati o non escono affatto, e il personale amministrativo passa ore a riconciliare a mano dati che dovrebbero fluire automaticamente. In una PMI del settore distribuzione con 200-500 ordini al giorno, un'integrazione ERP che fallisce silenziosamente al 2% delle transazioni produce 4-10 errori al giorno - errori che si accumulano, che generano contenziosi con i clienti, e che nessuno nota fino a quando il commercialista non chiude il trimestre e i numeri non tornano.

Perché le API dei gestionali italiani sono un campo minato per gli sviluppatori PHP?

Il problema è strutturale, non tecnico. La maggior parte dei gestionali diffusi nelle PMI italiane è stata progettata negli anni '90 o nei primi 2000 come software desktop monolitico. L'API SOAP è stata aggiunta dopo, spesso da un team diverso, come layer di esposizione su una logica di business che non era stata pensata per essere consumata dall'esterno. Il risultato sono API che violano sistematicamente ogni best practice moderna: nessun versionamento (l'API cambia comportamento con gli aggiornamenti del gestionale senza preavviso), nessuna gestione strutturata degli errori (il server restituisce HTTP 200 con un messaggio di errore dentro il body XML, oppure un HTTP 500 generico senza dettagli), nessuna documentazione dei limiti di rate o delle dimensioni massime dei payload, e spesso nessuna distinzione tra ambiente di test e ambiente di produzione.

A questo si aggiunge un problema specifico dell'ecosistema italiano: la frammentazione dei vendor. Non esiste un solo gestionale dominante come può essere un ERP in altri mercati - ci sono decine di soluzioni verticali, ciascuna con la propria API proprietaria, i propri formati dati, le proprie convenzioni (o assenza di convenzioni) sui codici articolo, sui codici cliente, sulle unità di misura, sulla gestione dell'IVA. Integrare un gestionale con Laravel non è mai "un" problema da risolvere una volta: è un problema diverso per ogni vendor, e l'unico modo per renderlo sostenibile è costruire un'architettura che isoli il caos dietro un'interfaccia pulita. Questo è il motivo per cui l'adapter pattern è il primo strumento che tiro fuori in ogni progetto di integrazione, ed è il motivo per cui nel mio profilo professionale l'integrazione con sistemi legacy è una delle competenze che i clienti richiedono con più frequenza.

L'adapter pattern: isolare il caos dietro un'interfaccia prevedibile

Il principio è semplice: il tuo codice applicativo Laravel non deve mai sapere che dall'altra parte c'è un'API SOAP con naming inconsistente e comportamenti erratici. Il codice applicativo parla con un'interfaccia PHP pulita e tipizzata, e l'adapter si occupa di tradurre le chiamate verso il gestionale, gestire le eccezioni, normalizzare i dati in risposta e loggare ogni interazione per debugging e audit.

// Interfaccia che il codice applicativo conosce
// Il codice Laravel non sa e non gli importa cosa c'è dietro
interface ErpGatewayInterface
{
    public function getArticolo(string $codice): ArticoloDTO;
    public function getGiacenza(string $codice, string $magazzino): GiacenzaDTO;
    public function inviaOrdine(OrdineDTO $ordine): RispostaOrdineDTO;
    public function getStatoOrdine(string $riferimento): StatoOrdineDTO;
}

// Adapter concreto per il gestionale specifico del cliente
// Qui dentro vive TUTTO il codice SOAP-specific
class GestionaleXAdapter implements ErpGatewayInterface
{
    private SoapClient $client;

    public function __construct(
        private readonly string $wsdlUrl,
        private readonly string $token,
        private readonly LoggerInterface $logger,
    ) {
        $this->client = new SoapClient($this->wsdlUrl, [
            'trace' => true,
            'exceptions' => true,
            'cache_wsdl' => WSDL_CACHE_BOTH,
            'connection_timeout' => 10,
            'features' => SOAP_SINGLE_ELEMENT_ARRAYS,
        ]);
    }

    public function getArticolo(string $codice): ArticoloDTO
    {
        try {
            // Il gestionale vuole "CodArt" non "codice"
            $response = $this->client->__soapCall('GetArticolo', [
                'parameters' => [
                    'Token' => $this->token,
                    'CodArt' => $codice,
                ],
            ]);

            // Normalizza la risposta in un DTO prevedibile
            return new ArticoloDTO(
                codice: $response->CodArt,
                descrizione: $this->decodeDescrizione($response->CodArtDes),
                prezzo: $this->parseDecimale($response->PrzLst),
                aliquotaIva: $this->mapAliquota($response->CodIva),
                unitaMisura: $response->UdM ?? 'PZ',
            );
        } catch (SoapFault $e) {
            $this->logger->error('ERP getArticolo fallito', [
                'codice' => $codice,
                'fault' => $e->getMessage(),
                'request' => $this->client->__getLastRequest(),
                'response' => $this->client->__getLastResponse(),
            ]);
            throw new ErpCommunicationException(
                "Impossibile recuperare articolo {$codice}: {$e->getMessage()}",
                previous: $e
            );
        }
    }

    // Metodi privati per gestire le inconsistenze del gestionale
    private function decodeDescrizione(string $raw): string
    {
        // Il gestionale restituisce le descrizioni in Latin-1, non UTF-8
        return mb_convert_encoding($raw, 'UTF-8', 'ISO-8859-1');
    }

    private function parseDecimale(string $valore): float
    {
        // Il gestionale usa la virgola come separatore decimale
        return (float) str_replace(',', '.', $valore);
    }
}

Tre aspetti di questo pattern meritano attenzione. Il primo è l'opzione SOAP_SINGLE_ELEMENT_ARRAYS passata al SoapClient di PHP, documentata nella reference ufficiale: senza questa opzione, se il gestionale restituisce un array XML con un solo elemento, PHP lo converte automaticamente in uno scalare invece che in un array con un elemento - un bug subdolo che scopri solo quando un articolo ha una sola riga invece di molte, e il tuo codice va in fatal error perché chiama foreach su una stringa. Il secondo è il logging delle richieste e risposte SOAP grezze via __getLastRequest() e __getLastResponse(): quando il gestionale si comporta in modo inatteso (e lo farà, sempre), quei log sono l'unico modo per capire cosa è realmente transitato sul filo senza dover attivare un debugger SOAP o un proxy come Wireshark. Il terzo è il DTO (Data Transfer Object): non restituire mai direttamente la risposta SOAP al codice applicativo. Sempre normalizzare in un oggetto PHP tipizzato con nomi di proprietà in italiano comprensibile, tipi corretti (float per i prezzi, non string) e valori di default sensati per i campi opzionali.

Gestire le incongruenze dei dati tra sistemi diversi

La parte più insidiosa dell'integrazione non è la connessione tecnica - è la semantica dei dati. Due sistemi che dovrebbero rappresentare la stessa realtà (un ordine, un articolo, un cliente) lo fanno in modi sottilmente diversi, e quelle differenze sono la fonte della maggior parte dei bug che scopri tre mesi dopo il go-live.

Ecco le incongruenze più comuni che ho incontrato nei quattro progetti di integrazione, con il pattern che uso per gestirle:

  • Encoding dei caratteri: il gestionale lavora in Latin-1 (ISO-8859-1), l'applicazione Laravel in UTF-8. Le lettere accentate (fondamentali in italiano: è, à, ù) arrivano corrotte se non converti esplicitamente. Soluzione: mb_convert_encoding() nell'adapter, mai nel codice applicativo
  • Separatore decimale: il gestionale usa la virgola italiana (12,50), Laravel/PHP usa il punto (12.50). Se non converti, il prezzo 1.250,00 diventa 1.25 - e il cliente riceve una fattura da 1,25 euro invece di 1.250 euro. Soluzione: parsing esplicito nell'adapter con regex che gestisca sia il formato 1.250,00 sia il formato 1250.00
  • Date e timezone: il gestionale lavora in formato DD/MM/YYYY o YYYYMMDD senza timezone, Laravel lavora con Carbon in UTC. La conversione deve essere esplicita e deve gestire il caso di date nulle, date con formato 00/00/0000 (che il gestionale usa come "data non impostata"), e date impossibili come 31/02/2025
  • Codici IVA: il gestionale usa codici numerici interni (22, 10, 4, 0) che non corrispondono direttamente alle aliquote percentuali (il codice 0 può significare esente, non imponibile, o fuori campo IVA - tre regimi fiscali completamente diversi). Serve una tabella di mappatura che il commercialista del cliente deve validare prima del go-live

La regola operativa che applico è: ogni campo che transita tra i due sistemi deve avere una funzione di conversione esplicita nell'adapter, anche quando sembra che i formati siano compatibili. I formati "sembrano" compatibili fino a quando non scopri il caso edge - il cliente con il nome che contiene un apice (D'Angelo), la fattura con importo negativo (nota di credito), l'articolo con codice che inizia con zero (00452) troncato a 452 perché qualcuno lo ha trattato come intero. Il costo di una funzione di conversione esplicita è dieci minuti di codice. Il costo di un bug di conversione scoperto in produzione dopo tre mesi di dati errati è incalcolabile. Questo tipo di disciplina nella gestione dei dati tra sistemi è lo stesso principio che applico nel refactoring di codice PHP legacy, dove il primo passo è sempre mappare i confini tra i componenti prima di toccare la logica interna.

Sincronizzazione e idempotenza: il pattern che salva dalle duplicazioni

Il secondo grande problema delle integrazioni ERP, dopo le incongruenze dei dati, è la sincronizzazione. Quando due sistemi devono mantenere una copia coerente degli stessi dati (ordini, giacenze, anagrafiche), ogni operazione di sincronizzazione deve essere idempotente: eseguirla due volte deve produrre lo stesso risultato di eseguirla una volta. Senza idempotenza, un retry dopo un timeout (evento normalissimo con API SOAP lente) crea un ordine duplicato nel gestionale, o peggio, due movimenti di magazzino per la stessa riga.

Il pattern che uso è un sync log - una tabella MySQL nell'applicazione Laravel che registra ogni operazione di sincronizzazione con il suo stato, il reference esterno e un hash del payload:

// Migration per la tabella di sync log
Schema::create('erp_sync_log', function (Blueprint $table) {
    $table->id();
    $table->string('entity_type');     // 'ordine', 'articolo', 'cliente'
    $table->unsignedBigInteger('entity_id');
    $table->string('erp_reference')->nullable();  // ID nel gestionale
    $table->string('direction');        // 'push' o 'pull'
    $table->string('status');           // 'pending', 'sent', 'confirmed', 'failed'
    $table->string('payload_hash');     // SHA-256 del payload inviato
    $table->json('response_raw')->nullable();
    $table->text('error_message')->nullable();
    $table->integer('attempt_count')->default(0);
    $table->timestamp('last_attempt_at')->nullable();
    $table->timestamps();

    // Indice per idempotenza: stessa entity + stesso hash = già inviato
    $table->unique(['entity_type', 'entity_id', 'payload_hash', 'direction']);
});

Prima di inviare un ordine al gestionale, il codice calcola l'hash SHA-256 del payload e verifica se esiste già un record nel sync log con lo stesso hash e stato confirmed. Se esiste, l'invio viene skippato - è una duplicazione. Se esiste con stato sent ma senza conferma, è un retry legittimo (il precedente potrebbe essere andato in timeout). Se non esiste, è un invio nuovo. Dopo la risposta del gestionale, il record viene aggiornato con la risposta grezza e lo stato finale. Questo pattern ha un effetto collaterale prezioso: il sync log diventa un audit trail completo di tutte le comunicazioni con il gestionale, consultabile dal backoffice per diagnosticare problemi senza dover leggere i log di sistema. Quando il commercialista chiede "perché questa fattura non è nel gestionale?", la risposta è nel sync log: timestamp dell'invio, payload esatto, risposta del gestionale, messaggio di errore se presente.

Error handling: quando l'API restituisce HTTP 200 con un errore dentro

L'ultima insidia, e la più pericolosa perché la più difficile da rilevare, è la gestione degli errori nelle API SOAP dei gestionali legacy. Lo standard SOAP prevede un meccanismo preciso per segnalare errori - il SOAP Fault - ma molti gestionali italiani lo ignorano completamente e restituiscono HTTP 200 con un body XML che contiene un nodo <Errore> o <Esito>NOK</Esito> che devi parsare manualmente. Il tuo SoapClient non lancia nessuna eccezione perché dal suo punto di vista la richiesta è andata a buon fine (HTTP 200, XML valido), e il tuo codice procede felicemente con dati che in realtà sono un messaggio di errore.

L'adapter deve intercettare questo pattern e tradurlo in eccezioni PHP:

// Nell'adapter: validazione della risposta post-chiamata
private function validateResponse(object $response, string $operazione): void
{
    // Pattern 1: nodo Esito esplicito
    if (isset($response->Esito) && $response->Esito !== 'OK') {
        throw new ErpBusinessException(
            "Operazione {$operazione} rifiutata dal gestionale: "
            . ($response->MessaggioErrore ?? 'nessun dettaglio'),
            context: ['response' => $response]
        );
    }

    // Pattern 2: nodo Errore presente (alcuni gestionali lo usano)
    if (isset($response->Errore) && !empty($response->Errore)) {
        throw new ErpBusinessException(
            "Errore gestionale in {$operazione}: {$response->Errore}",
            context: ['response' => $response]
        );
    }

    // Pattern 3: risposta completamente vuota (timeout silenzioso)
    if ($response === null || (is_object($response) && empty((array) $response))) {
        throw new ErpCommunicationException(
            "Risposta vuota dal gestionale per {$operazione}"
        );
    }
}

Questa validazione viene chiamata dopo ogni singola interazione SOAP nell'adapter, prima di procedere con la normalizzazione dei dati. La distinzione tra ErpBusinessException (il gestionale ha capito la richiesta ma l'ha rifiutata - ad esempio articolo inesistente, giacenza insufficiente) e ErpCommunicationException (il gestionale non ha risposto o ha risposto in modo non interpretabile) è fondamentale perché il codice applicativo deve reagire in modo diverso: un errore business è definitivo e va notificato all'utente, un errore di comunicazione è potenzialmente transitorio e può giustificare un retry.

Ho documentato un approccio simile alla gestione sistematica degli errori nel mio articolo sull'audit tecnico iniziale di un progetto PHP legacy, dove la mappatura dei pattern di errore esistenti è uno dei primi deliverable dei 30 giorni di assessment. La documentazione ufficiale del SoapClient PHP descrive le opzioni trace e exceptions che rendono possibile questo livello di debugging, ma è responsabilità dello sviluppatore costruire il layer di validazione sopra, perché il SoapClient da solo non distingue tra una risposta valida e una risposta che contiene un errore mascherato da successo.

L'integrazione con sistemi ERP legacy non è un problema che si risolve con una libreria o con un tutorial. È un problema di architettura, di disciplina nella gestione dei dati, e soprattutto di esperienza con i pattern di fallimento che si ripetono in modo prevedibile da un gestionale all'altro. L'adapter pattern, il sync log idempotente e la validazione esplicita delle risposte sono i tre strumenti che mi permettono di consegnare integrazioni che funzionano in produzione per anni senza sorprese - non perché il gestionale dall'altra parte si comporta bene (non lo fa mai), ma perché il mio codice è pronto a gestire ogni deviazione dallo standard. Se hai un progetto di integrazione ERP in corso o in pianificazione, e il tuo team sta scoprendo sulla propria pelle le insidie che ho descritto, contattami per una consulenza mirata: in una giornata di lavoro insieme possiamo progettare l'architettura dell'adapter, definire il sync log e impostare il framework di error handling che trasformerà un'integrazione fragile in un sistema robusto e manutenibile.

Ultima modifica: