Composer best practices: dependencies, autoload en versies

Composer best practices: dependencies, autoload en versies

Dependencybeheer in PHP is een stuk eenvoudiger geworden dankzij Composer. De release van Composer 2.0 is een goede aanleiding om een paar best practices te delen.

Wat is Composer?

Composer is een dependencymanager voor PHP, vergelijkbaar met NPM of Yarn voor JavaScript en pip voor Python. Hij vereenvoudigt het beheer van de packages waarvan je applicatie afhangt. Als extra “dienst” laat Composer packages ook een autoloader registreren. Die combinatie maakt Composer wat mij betreft tot een van de meest kritieke tools van het PHP-ecosysteem.

De package-repository die Composer het meest gebruikt is Packagist.org. Die repository accepteert alleen opensourcepackages. Wil je alles benutten wat Composer te bieden heeft, dan kun je terecht bij de private variant. Dat is een betaalde versie van Packagist, gebouwd door de makers van Composer, waarmee je closed-source packages gebruikt met fijnmazige toegangscontrole.

Ik ga hier niet uitleggen hoe je Composer installeert en configureert. De maintainers doen dat uitgebreid op de startpagina van het project: de introductie op de site van Composer. In alle voorbeelden waarin ik de CLI gebruik, ga ik ervan uit dat Composer globaal geïnstalleerd is en via composer wordt aangeroepen.

Reguliere dependencies en dev-dependencies scheiden

Composer maakt onderscheid tussen twee soorten dependencies: reguliere dependencies en dev-dependencies. Reguliere dependencies heeft je code altijd nodig, ongeacht de omgeving waarin hij draait. Dev-dependencies heb je alleen tijdens de ontwikkeling nodig: testframeworks zoals PHPUnit, statische analysers zoals Psalm en codestijlcontroles zoals PHP Code Sniffer. Om de code in productie te draaien zijn ze overbodig. Voeg je een dependency toe met composer require [package], dan wordt hij als “reguliere dependency” gemarkeerd. Met de vlag --dev wordt het package als “dev-dependency” gemarkeerd.

Bij composer install worden zowel de productie- als de dev-dependencies van je applicatie gedownload en geïnstalleerd. Wil je alleen de productiedependencies, voeg dan de vlag --no-dev toe. Dat is meestal wat je wilt in je deployment pipeline, want daar heb je de dev-dependencies niet nodig.

NB composer install downloadt en installeert alleen de dev-dependencies van jouw applicatie of package, niet die van de dependencies zelf. Heb je dus een dependency op “Package A” en heeft “Package A” een dev-dependency op “Package B”, dan wordt alleen “Package A” gedownload en in je project geïnstalleerd.

Dev-dependencies groeien al snel uit tot een respectabele lijst, en dus tot een flinke hoeveelheid bestanden. Al die bestanden zijn autoloadbaar via de autoloader. Het aantal productiedependencies in je applicatie beperken scheelt opslagruimte, overdrachtstijd (tijdens de deployment) en uitvoeringstijd. Op kleine schaal is de winst in opslag en overdracht verwaarloosbaar, maar zodra je applicatie groeit of het aantal maintainers toeneemt, telt die winst wel degelijk. Nog een voordeel van het markeren van dev-dependencies: ze belanden niet in de autoloader-builds van Composer wanneer je composer install --no-dev draait.

Autoloading ( autoload.php )

Zoals gezegd biedt Composer daarnaast gereedschap waarmee packages zich in je PHP-project haken via autoloading ( autoload.php ). Met die functie geven packages aan hoe Composer een klassenaam op een bestandsnaam moet mappen. Je hoeft je code dus niet langer vol te zetten met aanroepen van require_once en require. Het enige wat je nog doet, is vendor/autoload.php includen.

Net als bij de dependencies kent Composer twee soorten autoloaders: de gewone autoload-directives en die voor ontwikkeling. Packages gebruiken dat om hun package en hun autoloader kleiner te houden, maar applicaties hebben er net zo goed baat bij. In een goed getest project vormen de bestanden die alleen voor de tests dienen vaak een flink deel van de codebase. Hetzelfde geldt voor ontwikkelhulpmiddelen zoals commandoregeltools. In productie heb je die niet nodig. Vertel je de autoloader dat het bestanden voor een dev-autoloader zijn, dan blijven ze buiten beschouwing bij composer install --no-dev, wat de prestaties van je applicatie ten goede komt. Het onderscheid tussen reguliere autoload en dev-autoload maak je in het configuratiebestand composer.json:

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

Composer biedt nog extra gereedschap om het automatisch laden van functies en klassen sneller te maken, via de optimalisatie van de autoloader.

Versieconstraints

Vóór Composer downloadde een ontwikkelaar een specifieke versie van een dependency en zette die in versiebeheer. Sinds Composer kunnen we van die vaste versie af en laat je Composer bepalen welke versie van een dependency precies geïnstalleerd wordt. Om dat te bepalen leunt Composer sterk op het idee van semantic versioning. Composer doet dat niet alleen voor je applicatie, maar trekt de functie door naar de dependencies zelf. Zo hebben packages hun eigen dependencies, die op hun beurt weer eigen dependencies hebben, enzovoort. Dat leidt tot meer, maar kleinere packages. Zulke kleine packages richten zich op één functionaliteit, waardoor die functionaliteit op één plek terechtkomt.

Vergeleken met bijvoorbeeld NPM staat Composer maar één versie van een package per applicatie toe. Er hoeven dus minder packages geïnstalleerd te worden, maar er ontstaat een extra risico op versiebotsingen. Dat is een goede reden om geen specifieke versie van een dependency te vragen, maar een versiebereik. Meestal doe je dat met de caret-operator: ^1.0. Daarmee wordt elke versie >= 1.0.0 en < 2.0.0 geaccepteerd. Omdat het om semantic versioning gaat, weet je zeker dat je de vereiste functionaliteit hebt en dat er geen breaking changes bij komen.

Door een bereik op te geven in plaats van een specifieke versie voorkom je versiebotsingen zodra een andere dependency hetzelfde package gebruikt. Stel je de volgende situatie voor:

De composer.json van je applicatie:

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

De composer.json van vendor/package:

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

Bij het installeren van de dependencies ziet Composer dat another_vendor/package meerdere keren gevraagd wordt, maar omdat het om constraints gaat, kan Composer vaststellen dat hij versie 1.1.5 beter kan vermijden en in plaats daarvan 1.2.8 moet installeren.

Composer heeft meerdere constraint-operatoren en laat toe die te combineren. Een constraint als “php” : “^7.2” omvat PHP 8 dus niet. Wil je aangeven dat zowel PHP 7.2 als PHP 8 ondersteund wordt, dan combineer je twee operatoren: “php” : “^7.2 || ^8.0”.

NB Een andere optie is de aanpak die Symfony koos, met "php": >= 7.2". Zelf ben ik daar geen voorstander van, want het zegt dat de code ook op PHP 9 en hoger werkt. Omdat niemand weet welke breaking changes PHP 9 meebrengt, doe ik die belofte liever niet en houd ik het in mijn repositories op "php": "^7.2 || ^8.0".

Root of geen root

Zodra een composer.json is gelezen, kun je onderscheid maken tussen de dependencies die je project rechtstreeks in de root vereist en de rest. Root packages zijn in de terminologie van Composer de dependencies in de root die zelf weer eigen dependencies meebrengen.

Neem deze composer.json:

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

Draai je composer install op dit bestand, dan blijkt dat niet alleen laminas/laminas-diactoros gedownload wordt, maar ook laminas/laminas-zendframework-bridge, psr/http-factory en psr-http-message. In dit voorbeeld is laminas/laminas-diactoros een root package, fundamenteel in ons project.

Het verschil tussen een package in de root en een afgeleid package zit in het aantal directe dependencies van je project. Over het algemeen betekenen minder root packages minder packages en minder conflicten bij het bijwerken. Minder packages, omdat een nieuwe versie van een package andere dependencies kan hebben, die Composer automatisch afhandelt. En minder conflicten, omdat er minder constraints in je dependencyboom staan.

Maar wat maakt een package geschikt voor de root?

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

Kijk naar het voorbeeld waarin we alleen op laminas/laminas-diactoros leunen. Diactoros levert een implementatie van de interfaces uit psr-http-message en psr/http-factory. Verwijs ik in mijn code uitsluitend naar objecten van laminas/laminas-diactoros, dan is dat een geldige Composer-configuratie. Maar verwijs ik naar de interfaces uit psr-http-message en psr/http-factory, wat wat mij betreft een best practice is, dan is dat een reden om psr-http-message en psr/http-factory tot root packages van ons project te maken.

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

Je omgeving vastleggen

Je bouwt je applicatie of je package op een bepaalde PHP-versie. En alleen op die versie kun je de goede werking van je code garanderen. Misschien vraagt je code bovendien om een geïnstalleerde PHP-extensie. Daarom is het een best practice om vast te leggen welke PHP-versie(s) vereist of ondersteund zijn en welke extensies nodig zijn.

Bouw je een package, dan gebruik je PHP-versies en PHP-extensies alsof het gewone packages zijn:

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

Dat we de wildcard * voor de cURL-extensie mogen gebruiken, komt doordat de extensie aan PHP vastzit: een versie opgeven is dus niet nodig.

Bij het bouwen van een applicatie werkt die aanpak ook, naast het vastleggen van diezelfde restricties als platformconfiguratie:

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

Het voordeel is dat Composer PHP 7.4.4 als PHP-versie aanhoudt, ook al staat er een andere versie op het systeem waarop de Composer-commando’s draaien. Met de opmars van virtualisatie zoals Docker en Vagrant draai je de Composer-commando’s zo lokaal, terwijl de applicatie in de gevirtualiseerde omgeving draait. Het voorkomt bovendien dat een collega met een bleeding-edge PHP-versie dependencyversies installeert die de productieomgeving niet aankan.

Werken met versiebeheer ( Git, Mercurial, Subversion )

In je project laat Composer drie dingen op je bestandssysteem achter: twee bestanden, composer.json en composer.lock, en een map vendor*. Die drie horen niet alle drie in versiebeheer.

Het bestand composer.json bevat alle vereiste packages met hun versieconstraints, zie de sectie “Versieconstraints” hierboven. Dit bestand hoort er altijd in. Zonder dit bestand heeft iemand die je code wil gebruiken geen idee welke packages nodig zijn.

Het bestand composer.lock bevat de exacte versies, inclusief checksums, van alle packages. Is dat bestand aanwezig wanneer je composer install draait, dan installeert Composer precies de versies uit composer.lock. Zo weet je zeker dat alle omgevingen dezelfde dependencies hebben. Bouw je een applicatie, dan zet je dit bestand in versiebeheer. De belangrijkste reden: applicaties (of andere packages) die jouw package gebruiken negeren dit bestand, terwijl je lokale ontwikkelomgeving dat niet doet. Daardoor ontwikkel of test je mogelijk met verouderde versies van je dependencies, terwijl de gebruikers van je package de nieuwste draaien.

De map vendor bevat de code van de packages. De inhoud van die map hoort niet in versiebeheer. Gebruik je Git, dan zet je hem in een .gitignore zodat hij niet meegaat.

* Je kunt de naam en de locatie van die map wijzigen met de configuratieoptie vendor-dir, maar voor dit artikel houd ik de standaardwaarde aan.

Dependencies bijwerken

Stel dat je een dependency van je applicatie wilt bijwerken. Of misschien wel meerdere. Je draait een paar keer composer update en ziet composer.lock veranderen. Je maakt een commit. Nog een ronde updates, nog een commit. Een paar dagen later pak je de draad weer op. Je werkt in een druk repository, dus voordat je verdergaat rebase je om je wijzigingen op de laatste versie van de code te zetten. Merge conflict! Een merge conflict in composer.json los je op zoals elk ander conflict, maar een conflict in composer.lock is lastiger.

Een merge conflict in een composer.lock los je op door de binnenkomende wijzigingen te accepteren, dat zijn de nieuwste, en daarna opnieuw de Composer-commando’s te draaien waarmee je de wijzigingen oorspronkelijk maakte. Zo landen je aanpassingen op de meest recente versie van je code. Commit de wijzigingen in composer.lock en ga verder met de rebase. Heb je in een volgende commit nog meer Composer-werk staan, dan doorloop je die stappen opnieuw.

Conclusie

Composer is een essentieel onderdeel van PHP-ontwikkeling geworden. Zoals elke package manager kan hij optimalisatie gebruiken. Ik werk dagelijks met Composer in meerdere repositories en heb in die tijd een aantal gewoontes overgehouden die ik als best practices beschouw:

  • Splits je dependencies in productie- en dev-dependencies om de applicatie kleiner te houden.
  • Splits je autoloader in een productie- en een dev-autoloader om hem kleiner en sneller te maken.
  • Leun niet op een vaste versie van je dependencies maar op een bereik, zodat het upgraden van packages minder pijn doet.
  • Neem alleen de dependencies die je in je applicatie echt aanroept rechtstreeks op, zodat upgraden of overstappen op een alternatief eenvoudiger wordt.
  • Zet composer.json altijd in versiebeheer, maar composer.lock alleen bij applicaties, zodat gebruikers van je package andere packages nog kunnen bijwerken.
  • Houd de wijzigingen in composer.json en composer.lock zo veel mogelijk in één commit per pull request of merge request, om merge conflicts in die bestanden te beperken.

PHP

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.