— Artikel — № 012

012 —PHP

Composer op legacy PHP: eerst inventariseren, dan init

Je hebt een WordPress-installatie geërfd met drie plugins, drie versies van Guzzle en geen composer.json. Composer init is de verkeerde eerste stap.

Eikenhouten gereedschapslade half open op donkere werkbank, messing grepen, benen elzen in vilten vakken, papieren label aan ring.
Hero · gestileerd stilleven№ 012

De Loom kwam binnen om 23:41. Zesentwintig minuten, zonder cut. De agency lead deelde zijn scherm met een WordPress-installatie die ze hadden overgenomen van een vorig intern team, ongeveer 320MB aan PHP buiten wp-content/uploads. Drie custom plugins, elk met zijn eigen /lib/-map: eentje met Guzzle 5, eentje met Guzzle 6, en eentje met wat een handmatig aangepaste fork leek van stripe-php uit 2018. Nergens een composer.json. De Stripe-integratie moest SCA goed gaan afhandelen vóór de volgende factuurcyclus. Hun eerste reactie, redelijk en bijna juist, was om composer init te draaien en opnieuw te beginnen.

Dit is de post die ik stuur als iemand vraagt waarom we niet gewoon eerst moderniseren. Composer is een van de beste dingen die PHP is overkomen. Maar op een legacy PHP-site is het de verkeerde eerste stap.

Composer veronderstelt een wereld waar legacy PHP nooit in leefde

De autoloader van Composer werkt omdat elk package zich aan PSR-4 houdt, zijn namespace declareert en een composer.json meelevert. Draai composer require guzzlehttp/guzzle op een schone Laravel-installatie en het werkt gewoon. Hetzelfde commando op een WordPress-site uit 2014 kan al bij de eerste request een fatal opleveren:

PHP Fatal error: Cannot redeclare class GuzzleHttp\Client
in /wp-content/plugins/abc-integration/lib/guzzle/src/Client.php on line 41

De legacy plugin laadt zijn eigen Guzzle via require_once __DIR__ . '/lib/guzzle/autoload.php' bij plugin-init. De autoloader van Composer, die ná WordPress wordt geregistreerd, vindt dezelfde fully-qualified class al gedeclareerd en valt om. Er is geen schone fix vanuit Composer zelf. Je kunt namespaces hernoemen met php-scoper, de oudere kopie van een prefix voorzien, of de legacy include eruit slopen. Elk pad is echt werk, en op een site met honderd geïnstalleerde plugins maak je die afweging steeds opnieuw.

De legacy site heeft, met andere woorden, zijn eigen autoload-verhaal. Soms zeven verschillende. Composer vervangt die verhalen niet. Het voegt er een achtste aan toe.

Inventariseer voordat je de autoloader aanraakt

Voordat je composer init draait, doe eerst het saaie werk. Lijst elke gevendorde library op, elke versiestring die je kunt vinden, en elke plek waar een globale functie of class wordt gedeclareerd. Klinkt vervelend. Kost je een ochtend. Bespaart je een week.

Op het filesystem:

find . -path '*/vendor/*' -name 'composer.json' 2>/dev/null
find . -path '*/lib/*' -name '*.php' | xargs grep -l 'const VERSION' 2>/dev/null
grep -rE "define\(\s*['\"][A-Z_]+_VERSION" wp-content/ 2>/dev/null

Draai die drie en je hebt een eerlijk beeld van wat er feitelijk wordt geladen. In de helft van de gevallen vind je twee kopieën van dezelfde library op verschillende versies, beide geïnclude, waarbij de tweede wint omdat die later laadt. Dat is geen Composer-probleem. Dat is een site die in kaart moet worden gebracht.

Voor class-conflicten is de goedkoopste scan:

grep -rE "^(class|interface|trait) [A-Z]" wp-content/plugins/ \
  | awk '{print $1, $2, $3}' | sort | uniq -c | sort -rn | head -20

Als bovenaan classes staan die in drie bestanden zijn gedeclareerd, dan is dat je volgende ticket. Los de botsingen op voordat je een tool introduceert die ervan uitgaat dat ze er niet zijn.

Composer naast, niet over

Als de audit klaar is, heb je twee eerlijke opties. Alles omzetten naar Composer (een project, geen taak), of Composer naast de legacy code laten draaien (kleinere scope, vaak beter).

Het naast-elkaar-patroon ziet er zo uit. Maak een composer.json in de project root met een aangepaste vendor-dir zodat die niet botst met iets dat al op disk staat:

{
  "config": {
    "vendor-dir": "vendor-managed"
  },
  "require": {
    "stripe/stripe-php": "^13.0"
  },
  "autoload": {
    "psr-4": {
      "Acme\\Integration\\": "wp-content/mu-plugins/acme-integration/src/"
    }
  }
}

Laad vendor-managed/autoload.php in vanuit één mu-plugin, en alleen de code die jij beheert gebruikt hem. De legacy plugins houden hun eigen include-ketens. De nieuwe Stripe-integratie krijgt een actuele SDK. Verder verandert er niets. Die wijziging kun je op een dinsdagmiddag deployen en alsnog op tijd aan tafel zitten.

Wat dit kost

Je geeft de hoofdbelofte van Composer op, namelijk dat het al je dependencies beheert. Die oude Guzzle 5 blijft gewoon in abc-integration/lib/ staan, ongepatcht, zijn ding doend. Het is dan wel een bekende onbekende geworden in plaats van een verborgen. Documenteer het, zet het op de upgrade-backlog en verder. Het alternatief, zes plugins herschrijven om één autoloader te delen, is precies het werk dat de vorige eigenaar tien jaar lang heeft uitgesteld. Je hebt het uitstel geërfd, niet de verplichting.

Het .htaccess-detail dat niemand noemt

Als je wél Composer introduceert op een shared-hosting WordPress, weiger dan directe toegang tot de vendor-map. De standaardinstallatie serveert vrolijk vendor-managed/composer/installed.json als een nieuwsgierige aanvaller erom vraagt, en dat bestand is een dependency-vingerafdruk die ze in ongeveer dertig seconden kunnen matchen tegen de PHP security advisories database.

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteRule ^vendor-managed/ - [F,L]
</IfModule>

<FilesMatch "(composer\.(json|lock)|\.env)$">
  Require all denied
</FilesMatch>

Test met curl -I https://yoursite.test/composer.json en bevestig een 403. Dit is geen Composer-tekortkoming. Het is een deploy-gewoonte die de moderne framework-wereld oplost via document-root conventies, en die de meeste WordPress shared hosts simpelweg niet hebben ingericht.

Wanneer het antwoord nog niet is

Soms is het juiste antwoord: Composer helemaal niet introduceren op een bepaalde site. Een custom PHP-applicatie op PHP 7.4, drie gevendorde libraries, geen actief feature-werk. Composer toevoegen verandert het deploy-verhaal (je hebt nu composer install in CI nodig, of een gecommitte vendor-managed/) en levert nauwelijks iets op. Houd de dependencies bij in een platte tekst-inventaris, pin ze in Git, en laat de site saai blijven. Saai is een feature op een legacy site die al tien jaar werkt.

Het audit-werk hierboven verdwijnt niet als je Composer overslaat. De gevendorde kopieën moeten nog steeds in kaart, de botsende classes moeten nog steeds opgelost, de .htaccess moet nog steeds de verkeerde paden blokkeren. Composer adopteren is een afgeleide beslissing. Het upstream-werk is weten wat er feitelijk op de server staat.

Waar dit landt

Toen we Pier bouwden, kwamen we dit tegen op ongeveer veertig klantsites, waarvan vrijwel geen enkele een composer.json had die klopte met wat er op disk stond. We hebben het uiteindelijk opgelost door het live filesystem en de live MySQL editor in hetzelfde venster te zetten met volledige version history, zodat de audit gebeurt op de plek waar je toch al aan het werk bent.

Het kleinste wat je vandaag kunt doen: draai de drie find- en grep-commando's hierboven op de site waar je je het meeste zorgen over maakt, plak de output in een document, en lees het door. Je lost in dat kwartier niets op. Maar je weet daarna wél waarmee je te maken hebt, en dat is meer dan de meeste geërfde sites ooit krijgen.

— Vragen —

Kan ik gewoon composer require nieuwe packages draaien op een legacy WordPress-site?

Soms. Als de classes van het nieuwe package niet botsen met iets wat al gevendord is, dan ja. Grep eerst op de namespace, en gebruik een aangepaste vendor-dir zodat de autoloader geïsoleerd blijft.

Wat is de veiligste manier om een gevendorde Stripe SDK uit 2018 te upgraden?

Niet in-place vervangen. Installeer de nieuwe versie via Composer in een aparte vendor-dir, vervang de include op één integratiepunt, en laat de oude kopie op disk staan tot elk code-pad getest is.

Moet ik de vendor-map committen op een legacy site?

Op shared hosting zonder SSH, ja. Op een host die composer install in de deploy draait, nee. De doorslag geeft of je deploy-pipeline Composer betrouwbaar kan draaien, niet je voorkeur.