Composer: buone pratiche per gestire le dipendenze PHP

Composer: buone pratiche per gestire le dipendenze PHP

La gestione delle dipendenze in PHP è diventata molto più semplice con Composer. L’uscita della versione 2.0 è una buona occasione per condividere qualche buona pratica.

Che cos’è Composer?

Composer è un gestore di dipendenze per PHP, l’equivalente di NPM o Yarn per JavaScript e di pip per Python. Semplifica la gestione dei pacchetti da cui dipende la tua applicazione. Come «servizio» aggiuntivo, offre ai pacchetti la possibilità di registrare un autoloader. È questa combinazione che ne fa, a mio parere, uno degli strumenti più critici dell’ecosistema PHP.

Il repository di pacchetti più usato da Composer è Packagist.org, che accetta solo pacchetti open source. Se vuoi sfruttare tutte le funzionalità offerte da Composer puoi passare alla sua offerta privata: una versione a pagamento di Packagist, realizzata dagli autori di Composer, che permette di usare pacchetti a sorgente chiuso con un controllo degli accessi granulare.

Non spiego qui come installare e configurare Composer: i manutentori lo documentano nel dettaglio sul sito ufficiale, nell’introduzione a Composer. In tutti gli esempi in cui uso la CLI do per scontato che Composer sia installato globalmente e che si richiami con composer.

Separare le dipendenze normali da quelle di sviluppo

Composer distingue due tipi di dipendenze: quelle normali e quelle di sviluppo. Le dipendenze normali sono quelle di cui il codice ha sempre bisogno, qualunque sia l’ambiente in cui gira. Le dipendenze di sviluppo servono solo mentre scrivi il codice: framework di test come PHPUnit, analizzatori statici come Psalm, controllori di stile come PHP Code Sniffer. Per eseguire il codice in produzione non servono. Quando aggiungi una dipendenza con composer require [package] viene marcata come «dipendenza normale». Con il flag --dev il pacchetto viene marcato come «dipendenza di sviluppo».

Quando esegui composer install vengono scaricate e installate sia le dipendenze di produzione sia quelle di sviluppo. Per prendere solo quelle di produzione aggiungi il flag --no-dev. È quasi sempre quello che vuoi nella tua pipeline di deploy, dove le dipendenze di sviluppo non servono a niente.

NB composer install scarica e installa soltanto le dipendenze di sviluppo della tua applicazione o del tuo pacchetto, non quelle delle dipendenze. Se dipendi da «Package A» e «Package A» ha una dipendenza di sviluppo su «Package B», nel tuo progetto finisce solo «Package A».

Le dipendenze di sviluppo si accumulano fino a diventare una lista rispettabile e, con lei, una discreta quantità di file. Tutti questi file sono caricabili tramite l’autoloader. Tenere al minimo le dipendenze di produzione fa risparmiare spazio su disco, tempo di trasferimento (durante il deploy) e tempo di esecuzione. Su piccola scala il risparmio di spazio e di banda è trascurabile, ma appena l’applicazione cresce o aumenta il numero di persone che la mantengono diventa significativo. Un altro vantaggio di marcare le dipendenze di sviluppo è che restano fuori dall’autoloader generato da Composer quando esegui composer install --no-dev.

Autoloading ( autoload.php )

Come dicevo, un’altra caratteristica di Composer è che fornisce gli strumenti perché i pacchetti si innestino nel tuo progetto PHP tramite l’autoloading ( autoload.php ). Grazie a questa funzione i pacchetti indicano a Composer come far corrispondere un nome di classe a un nome di file. Non devi più riempire il codice di chiamate require_once e require: ti basta includere vendor/autoload.php.

Come per le dipendenze, Composer prevede due tipi di autoloader: le direttive di autoload normali e quelle di sviluppo. I pacchetti se ne servono per ridurre la propria dimensione e quella del loro autoloader, ma anche le applicazioni ci guadagnano. In un progetto ben testato i file usati solo per i test possono rappresentare una fetta importante del codice, come del resto tutti gli strumenti di sviluppo tipo i comandi da riga di comando. In produzione non ti servono. Dichiarandoli come file dell’autoloader di sviluppo restano esclusi quando esegui composer install --no-dev, con un guadagno sulle prestazioni dell’applicazione. La distinzione tra autoload normale e autoload di sviluppo si fa nel file di configurazione composer.json:

javascript
{
"autoload": {
"psr-4": {
"MyApp\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"MyApp\Tests": "tests/"
}
}
}

Composer offre strumenti aggiuntivi per migliorare le prestazioni di funzioni e classi caricate automaticamente, tramite l’ottimizzazione dell’autoloader.

Vincoli di versione

Prima di Composer si scaricava una versione precisa di una dipendenza e la si metteva sotto controllo di versione. Con Composer possiamo staccarci da una versione precisa e lasciare che sia lui a stabilire quale versione installare. Per farlo si appoggia in larga parte al concetto di versionamento semantico. E non lo fa solo per la tua applicazione: estende il meccanismo alle dipendenze, che possono avere le proprie dipendenze, le quali a loro volta ne hanno altre, e così via. Il risultato sono pacchetti più numerosi ma più piccoli, ognuno concentrato su una singola funzionalità, che viene così centralizzata.

Rispetto a NPM, per esempio, Composer ammette una sola versione di un pacchetto per applicazione. Significa meno pacchetti da installare, ma anche un rischio in più di collisioni tra versioni. È un’ottima ragione per non fissare una versione precisa di una dipendenza e chiedere invece un intervallo di versioni. Di solito lo si fa con l’operatore caret: ^1.0. Vuol dire che va bene qualsiasi versione >= 1.0.0 e < 2.0.0. Trattandosi di versionamento semantico, hai la garanzia di avere le funzionalità che ti servono e che non venga introdotta nessuna rottura di compatibilità.

Indicando un intervallo invece di una versione precisa eviti le collisioni quando un’altra dipendenza usa lo stesso pacchetto. Prendi questa situazione.

Il composer.json della tua applicazione:

javascript
{
"require": {
"vendor/package": "^1.4",
"another_vendor/package": "^1.0"
}
}

Il composer.json di vendor/package:

javascript
{
"require": {
"another_vendor/package": "^1.2"
}
}

Durante l’installazione delle dipendenze Composer rileva più richieste di another_vendor/package, trattandosi di vincoli e non di versioni fisse, stabilisce che deve evitare la versione 1.1.5 e installare invece la 1.2.8.

Composer ha diversi operatori di vincolo e permette anche di combinarli. Un vincolo come “php”: “^7.2” non include PHP 8. Per dichiarare che sono supportati sia PHP 7.2 sia PHP 8 si combinano due operatori: “php”: “^7.2 || ^8.0”.

NB Un’altra opzione è l’approccio adottato da Symfony, che ha optato per "php": >= 7.2". Personalmente non lo condivido, perché lascia intendere che il codice funzionerà anche con PHP 9 e successivi. Dato che non c’è modo di sapere quali rotture di compatibilità porterà PHP 9, preferisco non fare promesse del genere: ed è la ragione per cui nei miei repository uso "php": "^7.2 || ^8.0".

Root o non root

Analizzando un composer.json si può distinguere tra le dipendenze richieste direttamente alla radice del progetto e le altre. Nella terminologia di Composer i root package sono le dipendenze dichiarate alla radice, che a loro volta si portano dietro le proprie dipendenze.

Prendi questo composer.json:

javascript
{
"require": {
"laminas/laminas-diactoros": "^2.0"
}
}

Eseguendo composer install su questo file vedresti che non viene scaricato solo laminas/laminas-diactoros, ma anche laminas/laminas-zendframework-bridge, psr/http-factory e psr-http-message. In questo esempio laminas/laminas-diactoros è un root package fondamentale nel nostro progetto.

La differenza tra un pacchetto alla radice e un pacchetto derivato sta nel numero di dipendenze dirette del progetto. In generale, meno root package significa meno pacchetti e meno conflitti quando aggiorni. Meno pacchetti perché una nuova versione può avere dipendenze diverse, che Composer gestisce da solo. Meno conflitti perché ci sono meno vincoli nell’albero delle dipendenze.

Ma cosa rende un pacchetto adatto a stare alla radice?

code
{
"require": {
"laminas/laminas-diactoros": "^2.0"
}
}

Riprendi l’esempio in cui dipendiamo soltanto da laminas/laminas-diactoros: diactoros fornisce un’implementazione delle interfacce definite in psr-http-message e psr/http-factory. Se nel mio codice faccio riferimento solo a oggetti dell’interfaccia laminas/laminas-diactoros, la configurazione di Composer va bene così. Ma se faccio riferimento alle interfacce definite in psr-http-message e psr/http-factory, che a mio parere è la cosa giusta da fare, allora ho un buon motivo per promuovere psr-http-message e psr/http-factory a root package del progetto.

js
{
"require": {
"laminas/laminas-diactoros": "^2.0",
"psr-http-message": "^1.0",
"psr/http-factory": "^1.0"
}
}

Dichiarare il tuo ambiente

Costruisci l’applicazione o il pacchetto su una versione precisa di PHP, ed è solo su quella che puoi garantire il buon funzionamento del codice. Magari il codice richiede anche l’installazione di un’estensione PHP. Per questo è buona pratica indicare quali versioni di PHP sono richieste o supportate e quali estensioni servono.

Quando sviluppi un pacchetto, versioni ed estensioni di PHP si dichiarano come se fossero pacchetti ordinari:

javascript
{
"require": {
"php": "^7.2",
"ext-curl": "*"
}
}

Possiamo usare il carattere jolly * per l’estensione cURL perché l’estensione è legata a PHP: non c’è bisogno di indicarne la versione.

Lo stesso approccio vale anche quando costruisci un’applicazione, con in più la possibilità di dichiarare questi vincoli come configurazione della piattaforma:

javascript
{
"config": {
"platform": {
"php": "7.4.4",
"ext-curl": "*"
}
}
}

Il vantaggio è che Composer considera PHP 7.4.4 come versione di riferimento, anche se sulla macchina dove giri i comandi ne è installata un’altra. Con la diffusione di soluzioni di virtualizzazione come Docker e Vagrant, questo permette di lanciare i comandi di Composer in locale e far girare l’applicazione nell’ambiente virtualizzato. Evita anche che un collega con una versione di PHP di ultimissima generazione installi versioni di dipendenze non supportate dall’ambiente di produzione.

Lavorare con un sistema di controllo di versione ( Git, Mercurial, Subversion )

Nel tuo progetto Composer lascia tre elementi sul filesystem: due file, composer.json e composer.lock, e una cartella chiamata vendor*. Non è detto però che tutti e tre debbano finire sotto controllo di versione.

Il file composer.json elenca tutti i pacchetti richiesti con i loro vincoli di versione, vedi la sezione «Vincoli di versione» per i dettagli. Questo file va sempre messo sotto controllo di versione: senza, chi vuole usare il tuo codice non ha modo di sapere quali pacchetti servono.

Il file composer.lock contiene le versioni esatte, checksum compresi, di tutti i pacchetti. Se è presente quando esegui composer install, vengono installate esattamente le versioni indicate nel composer.lock. È così che ottieni le stesse dipendenze in tutti gli ambienti. Quando costruisci un’applicazione conviene aggiungerlo al controllo di versione. Il motivo principale è che le applicazioni (o gli altri pacchetti) che usano il tuo pacchetto non ne terranno conto, mentre il tuo ambiente di sviluppo locale sì: rischi di sviluppare e testare con versioni obsolete delle tue dipendenze mentre chi usa il tuo pacchetto lavora con le ultime.

La cartella vendor contiene il codice dei pacchetti. Il suo contenuto non va aggiunto al controllo di versione. Se usi Git, basta metterla nel .gitignore per tenerla fuori.

* Puoi cambiare nome e posizione di questa cartella con l’opzione di configurazione vendor-dir, ma in questo articolo resto sul valore predefinito.

Modificare le dipendenze

Mettiamo che tu voglia aggiornare una dipendenza dell’applicazione. O magari più di una. Lanci qualche composer update e vedi cambiare il composer.lock. Fai un commit. Un altro giro di aggiornamenti, un altro commit. Qualche giorno dopo riprendi da dove avevi lasciato. Il repository è molto attivo, quindi prima di continuare fai un rebase per allineare le tue modifiche all’ultima versione del codice. Conflitto di merge! Un conflitto in composer.json si risolve come qualsiasi altro conflitto, ma un conflitto in composer.lock è più ostico.

Il modo di risolvere un conflitto di merge in un composer.lock è accettare le modifiche in arrivo, sono le più recenti, e rieseguire i comandi Composer che avevano prodotto le tue modifiche. Così le tue modifiche vengono applicate alla versione più recente del codice. Committi il composer.lock aggiornato e prosegui il rebase. Se in un commit successivo hai fatto altro lavoro su Composer, dovrai ripetere gli stessi passaggi.

Conclusione

Composer è diventato una parte essenziale dello sviluppo in PHP. Come ogni gestore di pacchetti, ha bisogno di qualche accorgimento. Lo uso tutti i giorni su più repository e nel tempo ho adottato alcune pratiche che considero buone:

  • Dividi le dipendenze in dipendenze di produzione e di sviluppo, per ridurre la dimensione dell’applicazione.
  • Dividi l’autoloader in autoloader di produzione e di sviluppo, per ridurne la dimensione e guadagnare in prestazioni.
  • Non fissare una versione precisa delle dipendenze: chiedi un intervallo, così aggiornare i pacchetti fa meno male.
  • Dipendi direttamente solo dai pacchetti che chiami davvero nella tua applicazione, così è più facile aggiornarli o sostituirli con un’alternativa.
  • Metti sempre composer.json sotto controllo di versione, ma composer.lock solo per le applicazioni, per non impedire a chi usa il tuo pacchetto di aggiornare gli altri pacchetti.
  • Cerca di tenere al massimo un commit per pull request o merge request con le modifiche a composer.json e composer.lock, per ridurre al minimo i conflitti di merge su questi file.

PHP

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.