Czym jest Composer?
Composer to menedżer zależności dla PHP, odpowiednik NPM czy Yarna w JavaScripcie i pipa w Pythonie. Upraszcza zarządzanie pakietami, od których zależy twoja aplikacja. W ramach dodatkowej „usługi” pozwala pakietom rejestrować autoloader. To połączenie sprawia, moim zdaniem, że Composer jest jednym z najważniejszych narzędzi w ekosystemie PHP.
Najczęściej używanym repozytorium pakietów jest Packagist.org. Przyjmuje ono wyłącznie pakiety open source. Jeśli chcesz korzystać ze wszystkich możliwości Composera, masz do dyspozycji ofertę prywatną. To płatna wersja Packagista zbudowana przez twórców Composera, która pozwala używać pakietów o zamkniętym kodzie źródłowym z precyzyjną kontrolą dostępu.
Nie będę tu tłumaczył, jak zainstalować i skonfigurować Composera. Opiekunowie projektu opisują to szczegółowo na jego stronie: Wprowadzenie do Composera w oficjalnej dokumentacji. We wszystkich przykładach z wierszem poleceń zakładam, że Composer jest zainstalowany globalnie i wywołuje się go przez composer.
Rozdzielenie zależności produkcyjnych i deweloperskich
Composer rozróżnia dwa rodzaje zależności: zwykłe i deweloperskie. Zwykłe to te, których twój kod potrzebuje zawsze, niezależnie od środowiska, w którym działa. Deweloperskie są potrzebne wyłącznie podczas pracy nad kodem: frameworki testowe w rodzaju PHPUnita, statyczne analizatory kodu takie jak Psalm czy narzędzia pilnujące stylu, na przykład PHP Code Sniffer. Do uruchomienia kodu na produkcji nie są potrzebne. Kiedy dodajesz zależność poleceniem composer require [package], trafia ona do zależności zwykłych. Z flagą --dev pakiet zostanie oznaczony jako zależność deweloperska.
Polecenie composer install pobiera i instaluje zarówno zależności produkcyjne, jak i deweloperskie. Żeby ograniczyć się do produkcyjnych, dodaj flagę --no-dev. Zwykle właśnie tego chcesz w pipelinie wdrożeniowym, bo zależności deweloperskie nie są tam do niczego potrzebne.
Uwaga composer install pobiera zależności deweloperskie wyłącznie twojej aplikacji lub twojego pakietu, a nie zależności deweloperskie samych zależności. Jeśli więc zależysz od „Pakietu A”, a „Pakiet A” ma deweloperską zależność od „Pakietu B”, w twoim projekcie wyląduje tylko „Pakiet A”.
Zależności deweloperskie potrafią urosnąć do sporej listy, a więc i do sporej liczby plików. Wszystkie one są objęte autoloaderem. Ograniczenie liczby zależności produkcyjnych oszczędza miejsce na dysku, czas transferu przy wdrożeniu i czas wykonania. Przy małej skali oszczędność miejsca i transferu bywa pomijalna, ale rośnie razem z aplikacją i z liczbą osób, które ją utrzymują. Druga korzyść z oznaczania zależności deweloperskich jest taka, że nie trafiają one do autoloadera budowanego przez composer install --no-dev.
Automatyczne ładowanie klas ( autoload.php )
Jak wspomniałem, drugą mocną stroną Composera są narzędzia pozwalające pakietom wpiąć się w twój projekt PHP przez automatyczne ładowanie klas ( autoload.php ). Dzięki nim pakiet określa, jak Composer ma powiązać nazwę klasy z nazwą pliku. Nie musisz już zaśmiecać kodu wywołaniami require_once i require. Wystarczy dołączyć vendor/autoload.php.
Podobnie jak przy zależnościach, Composer zna dwa rodzaje autoloadera: zwykłe dyrektywy autoload i dyrektywy autoload dla środowiska deweloperskiego. Pakiety korzystają z tego, żeby zmniejszyć swój rozmiar i rozmiar autoloadera, ale aplikacje też na tym zyskują. W dobrze przetestowanym projekcie pliki używane wyłącznie w testach potrafią stanowić sporą część bazy kodu. To samo dotyczy narzędzi deweloperskich, choćby skryptów wiersza poleceń. Na produkcji nie są potrzebne. Jeśli zadeklarujesz je w autoloaderze deweloperskim, polecenie composer install --no-dev je pominie, co poprawia wydajność aplikacji. Rozróżnienie między zwykłym autoloadem a deweloperskim zapisuje się w pliku konfiguracyjnym composer.json:
{
"autoload": {
"psr-4": {
"MyApp\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"MyApp\Tests": "tests/"
}
}
}
Composer daje też dodatkowe narzędzia poprawiające wydajność automatycznie ładowanych funkcji i klas, służy do tego optymalizacja autoloadera.
Ograniczenia wersji
Przed Composerem programista pobierał konkretną wersję zależności i wrzucał ją do systemu kontroli wersji. Dziś możemy odejść od sztywnej wersji i pozwolić Composerowi zdecydować, którą dokładnie zainstalować. Opiera się przy tym mocno na wersjonowaniu semantycznym. Robi to nie tylko dla twojej aplikacji, ale rozciąga ten mechanizm na zależności: pakiet może mieć własne zależności, te kolejne i tak dalej. Efekt to więcej pakietów, ale mniejszych. Taki mały pakiet skupia się na jednej funkcji, dzięki czemu ta funkcja żyje w jednym miejscu.
W odróżnieniu na przykład od NPM-a Composer dopuszcza tylko jedną wersję danego pakietu w aplikacji. Instaluje się więc mniej pakietów, ale rośnie ryzyko kolizji wersji. To dobry powód, żeby nie wskazywać konkretnej wersji zależności, tylko zakres wersji. Służy do tego zwykle operator daszka: ^1.0. Oznacza on, że akceptowana jest każda wersja >= 1.0.0 i < 2.0.0. Skoro mowa o wersjonowaniu semantycznym, masz gwarancję, że dostajesz potrzebne funkcje i że nie wchodzi żadna zmiana łamiąca wsteczną zgodność.
Podając zakres zamiast konkretnej wersji, unikasz kolizji, gdy z tego samego pakietu korzysta inna zależność. Weźmy taką sytuację:
composer.json twojej aplikacji:
{
"require": {
"vendor/package": "^1.4",
"another_vendor/package": "^1.0"
}
}
composer.json pakietu vendor/package:
{
"require": {
"another_vendor/package": "^1.2"
}
}
Przy instalacji zależności Composer wykryje, że another_vendor/package jest wymagany w kilku miejscach, ale ponieważ mówimy o ograniczeniach, ustali, że powinien pominąć wersję 1.1.5 i zainstalować 1.2.8.
Composer ma kilka operatorów ograniczeń i pozwala je nawet łączyć. Ograniczenie „php”: „^7.2” nie obejmie PHP 8. Jeśli chcesz zadeklarować obsługę PHP 7.2 i PHP 8, złóż dwa operatory: „php”: „^7.2 || ^8.0”.
Uwaga Inną drogą jest podejście, które wybrało Symfony, czyli "php": >= 7.2". Osobiście za nim nie przepadam, bo zapowiada, że kod zadziała także na PHP 9 i nowszym. Nie da się przewidzieć, jakie zmiany łamiące wsteczną zgodność przyniesie PHP 9, więc wolę takich obietnic nie składać, dlatego w swoich repozytoriach używam "php": "^7.2 || ^8.0".
Pakiet główny czy nie
Analizując composer.json, można oddzielić zależności wymagane bezpośrednio przez twój projekt od pozostałych. W terminologii Composera root package to zależności z korzenia, które same ciągną własne zależności.
Weźmy taki composer.json:
{
"require": {
"laminas/laminas-diactoros": "^2.0"
}
}
Uruchomienie composer install na tym pliku pokaże, że pobierany jest nie tylko laminas/laminas-diactoros, ale również laminas/laminas-zendframework-bridge, psr/http-factory i psr-http-message. W tym przykładzie laminas/laminas-diactoros jest pakietem głównym, kluczowym dla naszego projektu.
Różnica między pakietem głównym a pochodnym sprowadza się do liczby bezpośrednich zależności projektu. Zwykle im mniej pakietów głównych ( package root ), tym mniej pakietów i mniej konfliktów przy aktualizacji. Mniej pakietów, bo nowa wersja pakietu może mieć inne zależności, którymi Composer zajmie się sam. Mniej konfliktów, bo w drzewie zależności zostaje mniej ograniczeń.
Co więc kwalifikuje pakiet do roli pakietu głównego?
{
"require": {
"laminas/laminas-diactoros": "^2.0"
}
}
Wróćmy do przykładu, w którym opieramy się wyłącznie na laminas/laminas-diactoros. Diactoros dostarcza implementację interfejsów opisanych w psr-http-message i psr/http-factory. Jeśli w kodzie odwołuję się tylko do obiektów z laminas/laminas-diactoros, taka konfiguracja Composera jest poprawna. Ale jeśli sięgam po interfejsy zdefiniowane w psr-http-message i psr/http-factory, a to moim zdaniem dobra praktyka, to jest powód, żeby psr-http-message i psr/http-factory stały się pakietami głównymi projektu ( package root ).
{
"require": {
"laminas/laminas-diactoros": "^2.0",
"psr-http-message": "^1.0",
"psr/http-factory": "^1.0"
}
}
Zadeklaruj swoje środowisko
Aplikację albo pakiet budujesz na konkretnej wersji PHP i tylko na niej możesz zagwarantować, że kod działa. Być może wymaga on też zainstalowanego rozszerzenia PHP. Dlatego dobrą praktyką jest wskazanie, których wersji PHP kod wymaga lub które obsługuje i jakie rozszerzenia są potrzebne.
Przy pracy nad pakietem wersje PHP i rozszerzenia PHP deklaruje się tak jak zwykłe pakiety:
{
"require": {
"php": "^7.2",
"ext-curl": "*"
}
}
Symbolu wieloznacznego * przy rozszerzeniu cURL możemy użyć dlatego, że rozszerzenie jest związane z samym PHP i nie trzeba podawać jego wersji.
To samo podejście sprawdza się w aplikacji, gdzie dodatkowo zapiszesz te ograniczenia jako konfigurację platformy:
{
"config": {
"platform": {
"php": "7.4.4",
"ext-curl": "*"
}
}
}
Zaletą jest to, że Composer przyjmie PHP 7.4.4 za wersję odniesienia, nawet jeśli na maszynie, na której uruchamiasz polecenia, stoi inna. Przy dzisiejszej popularności wirtualizacji w rodzaju Dockera czy Vagranta pozwala to uruchamiać polecenia Composera lokalnie, a samą aplikację w środowisku wirtualnym. Chroni też przed sytuacją, w której kolega z najnowszym PHP zainstaluje wersje zależności nieobsługiwane przez środowisko produkcyjne.
Praca z systemem kontroli wersji ( Git, Mercurial, Subversion )
W projekcie Composer zostawia trzy byty w systemie plików: dwa pliki, composer.json i composer.lock, oraz katalog vendor*. Nie wszystkie trzy muszą trafić do kontroli wersji.
Plik composer.json zawiera wszystkie wymagane pakiety wraz z ograniczeniami wersji, szczegóły w sekcji „Ograniczenia wersji”. Ten plik zawsze trafia do kontroli wersji. Bez niego ktoś, kto chce użyć twojego kodu, nie ma pojęcia, jakich pakietów potrzebuje.
Plik composer.lock przechowuje dokładne wersje wszystkich pakietów wraz z sumami kontrolnymi (checksum). Jeśli jest obecny, composer install zainstaluje dokładnie te wersje, które są w nim zapisane. Dzięki temu we wszystkich środowiskach masz identyczne zależności. W aplikacji ten plik warto wersjonować. Głównym powodem jest to, że aplikacje (albo inne pakiety) korzystające z twojego pakietu i tak go zignorują, ale twoje lokalne środowisko deweloperskie już nie. Możesz więc pracować i testować na przestarzałych wersjach zależności, podczas gdy użytkownicy twojego pakietu mają najnowsze.
Katalog vendor zawiera kod pakietów. Jego zawartość nie powinna trafiać do kontroli wersji. Jeśli używasz Gita, dopisz go do .gitignore.
* Nazwę i położenie tego katalogu możesz zmienić opcją konfiguracyjną vendor-dir, ale w tym artykule zostaję przy wartości domyślnej.
Zmiany w zależnościach
Załóżmy, że chcesz zaktualizować jedną zależność aplikacji. Albo kilka. Uruchamiasz serię poleceń composer update i widzisz, jak zmienia się composer.lock. Robisz commit. Kolejna porcja aktualizacji, kolejny commit. Kilka dni później wracasz do tego miejsca. Repozytorium jest bardzo żywe, więc zanim ruszysz dalej, robisz rebase, żeby mieć pewność, że twoje zmiany pasują do najnowszego kodu. Konflikt scalania! Konflikt w composer.json rozwiązuje się jak każdy inny, ale konflikt w composer.lock jest znacznie trudniejszy.
Konflikt w composer.lock rozwiązuje się tak: przyjmij zmiany przychodzące, to one są nowsze, i powtórz polecenia Composera, którymi wprowadziłeś swoje zmiany. Dzięki temu wylądują one na najświeższej wersji kodu. Zatwierdź zmieniony composer.lock i kontynuuj rebase. Jeśli w kolejnym commicie ruszałeś Composera jeszcze raz, przejdziesz przez te kroki ponownie.
Podsumowanie
Composer stał się nieodłączną częścią pracy w PHP. Jak każdy menedżer pakietów, wymaga czasem dostrojenia. Używam go codziennie w kilku repozytoriach i przez ten czas wypracowałem kilka zasad, które uważam za dobre praktyki:
- Rozdziel zależności na produkcyjne i deweloperskie, żeby zmniejszyć rozmiar aplikacji.
- Rozdziel autoloader na produkcyjny i deweloperski, żeby go odchudzić i przyspieszyć.
- Nie przypinaj zależności do sztywnej wersji, tylko podawaj zakres, aktualizacje będą mniej bolesne.
- Deklaruj bezpośrednio tylko te zależności, po które naprawdę sięgasz w kodzie: łatwiej je zaktualizować albo wymienić na inne.
- Zawsze wersjonuj
composer.json, acomposer.locktylko w aplikacjach, żeby użytkownicy twojego pakietu mogli aktualizować pozostałe pakiety. - Staraj się zamykać zmiany w
composer.jsonicomposer.lockw jednym commicie na pull request / merge request, żeby ograniczyć konflikty scalania w tych plikach.



