Waarom één plugin een hele website plat kan leggen
Heckgalerie 1.2.5 en Leadlotse 1.4.3, twee van onze plugins uit de WordPress-directory, gaven samen op elke pagina van een site een fatale fout. Geen enkele plugin was op zichzelf kapot. De fout zat in een bibliotheek die vijf van onze plugins meeleveren, en voor een groot deel in twee eigenaardigheden van Composer en WordPress die we over het hoofd hadden gezien. Dat raakt iedereen die code tussen plugins deelt.
De foutmelding is kort. Zo ziet ze eruit, nagebootst in WordPress Playground:
PHP Fatal error: Uncaught Error: Call to undefined method
Hafenstudios\Core\Admin\DesignSystem::action_button()
in /wordpress/wp-content/plugins/heckgalerie/heckgalerie.php:163
Het vreemde: de methode bestaat. Heckgalerie levert haar mee in een eigen kopie van de bibliotheek. Toch laadde PHP een oudere kopie uit een andere plugin, Leadlotse, waarin ze nog niet zat.
De fout lag bij ons. Twee van de drie lagen zitten echter in de manier waarop Composer en WordPress samenwerken, en die raken iedereen die code tussen plugins deelt. Daarom schrijven we ze hier op.
Eén bibliotheek, vijf kopieën
Heckgalerie, Kurato, Leadlotse, Wellenbrecher en Wimpel delen één bibliotheek, de Hafenstudios Core (niet te verwarren met onze plugin Hafen Core): instellingen, gebeurtenissen, mailverzending, het toestemmingslogboek, de bouwstenen van de beheerschermen. Via die bibliotheek werken de plugins samen. Meldt een formulier van Leadlotse een nieuwe aanvraag, dan luistert Kurato naar dezelfde gebeurtenis en maakt een contact aan.
Elke plugin levert een eigen kopie mee, via Composer onder vendor/hafenstudios/core. Dat moet ook: een plugin uit de directory moet ook alleen werken. En omdat elke plugin zijn eigen releaseritme heeft, staan er op een website al snel meerdere versies naast elkaar:
| Plugin | in de directory tot 11-10-2026 | meegeleverde Core | sinds 12-10-2026 | meegeleverde Core |
|---|---|---|---|---|
| Heckgalerie | 1.2.5 | 1.4.0 | 1.2.6 | 1.4.4 |
| Leadlotse | 1.4.3 | 1.3.0 | 1.5.1 | 1.4.4 |
| Wellenbrecher | 1.3.2 | 1.4.0 | 1.3.3 | 1.4.4 |
| Wimpel | 1.2.0 | 1.4.1 | 1.3.1 | 1.4.4 |
Binnen één verzoek kent PHP maar één klasse met een bepaalde naam. Wie Hafenstudios\Core\Core als eerste laadt, legt haar voor iedereen vast. Het gebruikelijke antwoord komt uit de wereld van WooCommerce: Action Scheduler wordt door veel plugins meegeleverd, elke kopie meldt haar versie aan en op de hook plugins_loaded wordt alleen de hoogste geladen. Precies zo hebben wij het gebouwd.
De keuze rust op twee voorwaarden waar zelden iemand bij stilstaat. Elke kopie moet zich aanmelden. En de gekozen kopie moet de klassen ook echt leveren. Aan geen van beide was voldaan.
Eerste vondst: Composer zet zich vooraan
Composer maakt voor elke plugin een eigen autoloader. Die registreert zich met de optie prepend, dus vooraan in de rij die PHP raadpleegt bij onbekende klassen. Zo staat het in het gegenereerde vendor/composer/autoload_real.php:
$loader->register(true);
WordPress laadt actieve plugins in de volgorde van de lijst active_plugins, die het bij het activeren alfabetisch sorteert. Elke volgende plugin schuift zijn Composer-loader voor de vorige. Uiteindelijk staat helemaal vooraan de loader van de plugin waarvan de mapnaam als laatste in het alfabet komt.
Onze versiekeuze liep daarna, op plugins_loaded, en hing de loader van de gekozen kopie achteraan. Tegen de tijd dat een zoekvraag daar aankwam, had een Composer-loader allang geantwoord. We hebben dat zonder WordPress nagebootst, met de echte directorypakketten van Heckgalerie 1.2.5 en Leadlotse 1.4.3, in de volgorde waarin WordPress ze laadt. Het resultaat, samengevat:
Vermeldingen in __composer_autoload_files: 1
Aangemeld voor de keuze: Core 1.4.0 (Heckgalerie)
Gekozen: Core 1.4.0 uit Heckgalerie
DesignSystem geleverd door: Leadlotse (Core 1.3.0)
action_button() aanwezig: nee
De keuze meldde 1.4.0, uitgevoerd werd 1.3.0. Onze eigen versiecontrole las alleen de constante die de keuze zet, en zag daardoor niets.
Hoe willekeurig de uitkomst was, laat een run in Playground met alle vier oude plugins zien: de site werkte. Wellenbrecher komt in het alfabet na Leadlotse, zijn loader stond dus vooraan, en zijn kopie (1.4.0) kent de methode. Wimpel komt in het alfabet nog later, maar versie 1.2.0 brengt in plaats van de Composer-loader een eigen loader mee, en die sluit achteraan aan. Of een website draaide, hing af van de volgorde van de pluginnamen.
De eerste correctie, Core 1.4.2 van 6 oktober, registreert de loader van de gekozen kopie ook met prepend. Daarnaast vangen de nieuwe versies van onze plugins fouten bij het opstarten af en melden ze in het beheer, in plaats van de website mee te sleuren. Uitgeleverd is deze correctie pas op 12 oktober, samen met de twee volgende.
Tweede vondst: het opstartbestand draait maar één keer
Voor code die direct bij het laden moet draaien, zoals functies of een aanmelding, kent Composer de files-autoload. Onze bibliotheek gebruikt die voor haar bootstrap.php, waarin elke kopie zich aanmeldt voor de keuze. In de composer.json van de bibliotheek staat onder meer:
"autoload": {
"files": [ "bootstrap.php" ]
}
Daaruit schrijft Composer deze grendel in elke autoloader:
if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
$GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
require $file;
}
Composer vormt het kenmerk $fileIdentifier als md5 van pakketnaam en pad. Voor ons opstartbestand is dat md5('hafenstudios/core:bootstrap.php'), oftewel b6716d317db73fd5f2cd1d7c8ab2ffd8, in elke plugin dezelfde waarde. En $GLOBALS geldt voor het hele verzoek, niet voor één plugin. Zodra het opstartbestand van de eerste plugin heeft gedraaid, staat het kenmerk genoteerd en slaat Composer het bestand in alle andere plugins over.
Binnen één project is dat juist: een functiebestand draait niet twee keer en PHP stopt niet met ‘Cannot redeclare’. Op een WordPress-site met meerdere vendor-mappen treft dezelfde grendel verschillende kopieën in verschillende versies.
Voor onze versiekeuze betekende dat: in de regel meldde zich alleen de kopie aan van de plugin die WordPress als eerste laadt, bij ons meestal Heckgalerie. In de nabootsing hierboven staat precies één vermelding genoteerd, en Leadlotse komt niet voor in de lijst van aangemelde kopieën, hoewel het een eigen kopie meelevert. De keuze had één enkele kandidaat. Dat merkten we pas op 11 oktober, bij het bouwen van Wimpel 1.3.1 in Playground.
Dit is geen eigenaardigheid van onze bibliotheek. Ook symfony/polyfill-mbstring laadt zijn bootstrap.php via deze weg, en Guzzle zijn functiebestand. Leveren twee plugins hetzelfde pakket mee zonder de namespace te wijzigen, dan draait op de website het bestand van de plugin die als eerste laadt, ongeacht welke versie de andere meebrengt.
De correctie in Core 1.4.4: elke plugin laadt zijn opstartbestand daarnaast zelf, direct in het hoofdbestand. Vereenvoudigd:
require_once __DIR__ . '/vendor/autoload.php';
// Composer voert bootstrap.php alleen uit voor de plugin die als eerste laadt.
require_once __DIR__ . '/vendor/hafenstudios/core/bootstrap.php';
require_once onthoudt bestanden op hun pad. De kopie die Composer al heeft uitgevoerd, draait geen tweede keer, elke andere precies één keer. Action Scheduler doet het trouwens net zo: volgens de handleiding laadt elke plugin het opstartbestand zelf. Daarnaast houdt elke kopie sinds 1.4.4 een vermelding bij onder haar eigen map, zodat ook twee kopieën met hetzelfde nummer zichtbaar blijven.
Derde vondst: de ingekorte façade
De derde laag was eigen schuld. Onze pakketten voor de WordPress-directory zijn slanker dan die uit onze eigen distributie: delen van de bibliotheek die een plugin niet nodig heeft, blijven weg, bij Wimpel 1.2.0 bijvoorbeeld mailverzending, het toestemmingslogboek en de privacyhulpmiddelen. Het buildscript paste daarbij ook de centrale klasse Core.php aan, waarlangs alle plugins deze diensten aanroepen, en knipte methoden als consent(), mailer() en privacy() eruit.
In de run in Playground met alle vier oude plugins bleef dat zonder gevolgen. Daar was de ingekorte kopie van Wimpel 1.2.0 gekozen, Core 1.4.1, destijds het hoogste nummer in de directory. Ze had zich ondanks de grendel aangemeld, omdat de eigen loader van Wimpel 1.2.0 het opstartbestand zelf met require_once laadt. De geladen klassen kwamen toch allemaal uit Wellenbrecher. Sinds de eerste correctie staat de loader van de gekozen kopie echter vooraan. Wint dan een ingekorte kopie de keuze, dan ontbreken deze methoden voor alle andere plugins, en mislukt elke inzending via Leadlotse. Daar liepen we tegenaan bij het bouwen van de nieuwe versies, voordat er één was uitgeleverd.
De correctie bestaat uit twee delen. Sinds 1.4.4 blijft Core.php in elk pakket volledig, en bij het opstarten controleert de bibliotheek met class_exists of een dienst er is. Daarnaast vult de loader van de gekozen kopie gaten zelf op: ontbreekt haar een bestand, dan zoekt hij in alle aangemelde kopieën op de site, in aflopende versievolgorde. Pas als geen enkele het heeft, komen de Composer-loaders van de plugins aan de beurt.
Op 12 oktober bracht de integratietest nog een geval aan het licht. In een tussenstand van 1.4.4, nog voor de uitlevering, knipte het bouwscript ai() nog uit Core.php voor pakketten zonder AI-functies. Die ingekorte kopie uit het directorypakket van Leadlotse en de volledige uit Kurato droegen allebei nummer 1.4.4. Bij een gelijk nummer won de kopie die als laatste laadde, de ingekorte, en Kurato liep vast op een AI-functie. Sindsdien blijft ook ai() staan, en komt bij een gelijk nummer de meest volledige kopie op de keuzelijst. Een versienummer zou voor precies één inhoud moeten staan. Waar dat niet kan, heeft de keuze een tweede criterium nodig.
Nagemeten in WordPress Playground
Voor de uitlevering draaiden de pakketten in elf combinaties in WordPress Playground, met WordPress 7.1.3 en PHP 8.3, externe verzoeken geblokkeerd. Op de dag van de uitlevering hebben we onder dezelfde omstandigheden opnieuw gemeten wat één nieuwe plugin met de vroegere crashcombinatie doet. De belangrijkste resultaten:
| Combinatie | Resultaat |
|---|---|
| Heckgalerie 1.2.5 en Leadlotse 1.4.3 uit de directory | fatale fout in heckgalerie.php:163, de melding van hierboven |
| Alleen Heckgalerie 1.2.6 nieuw; Leadlotse 1.4.3 oud | werkt, DesignSystem uit Heckgalerie (Core 1.4.4) |
| Alleen Leadlotse 1.5.1 nieuw; Heckgalerie 1.2.5 oud | werkt, DesignSystem uit Leadlotse (Core 1.4.4) |
| Alleen Wimpel 1.3.1 nieuw; Heckgalerie en Leadlotse oud | werkt, formulier verzonden, mail met correcte umlauten |
| Alleen Kurato 1.9.3 nieuw; Heckgalerie 1.2.5 en Leadlotse 1.4.3 oud | werkt, DesignSystem uit Kurato (Core 1.4.4) |
| Heckgalerie, Leadlotse, Wellenbrecher en Wimpel nieuw uit de directory | werkt, alle vier kopieën aangemeld |
| Kurato 1.9.3 uit eigen distributie; Heckgalerie, Leadlotse en Wellenbrecher nieuw uit de directory | werkt, alle vier kopieën aangemeld |
| Van oud naar nieuw, de ene plugin na de andere per upload vervangen, met een galerij en een formulierinzending | na elke wissel alle gecontroleerde pagina’s met status 200, inhoud behouden |
De nabootsing zonder WordPress laat hetzelfde in het klein zien. Met Heckgalerie 1.2.6 in plaats van 1.2.5 komt DesignSystem uit Heckgalerie (Core 1.4.4), met Leadlotse 1.5.1 in plaats van 1.4.3 uit Leadlotse (Core 1.4.4). In beide gevallen is de methode er.
Wat de test niet dekt: klikken in een echte browser, multisite, bijwerken via de knop ‘Nu bijwerken’, automatische updates via cron, andere PHP- en WordPress-versies en, bij het wisselen van oud naar nieuw, maar één van 24 mogelijke volgordes.
Wat je als sitebeheerder kunt doen
- Werk plugins van dezelfde maker samen bij. Delen plugins een bibliotheek, dan is de gemengde stand de riskante. Bij ons betekent dat: Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3 en Wimpel 1.3.1 staan sinds 12 oktober 2026 in de WordPress-directory, Kurato 1.9.3 komt via ons eigen updatekanaal.
- Moet het snel, dan helpt er één al. Elk van deze pakketten levert Core 1.4.4 mee. Die kopie wint de keuze, en omdat haar loader vooraan staat, levert zij de klassen, ook voor de oudere plugins. In Playground draaide de vroegere crashcombinatie van Heckgalerie 1.2.5 en Leadlotse 1.4.3 zonder fatale fout zodra één van de twee was bijgewerkt. Hetzelfde gold als er in plaats daarvan Wimpel 1.3.1 of Kurato 1.9.3 bij kwam. Niet elke mix hebben we getest; klaar ben je pas als alles is bijgewerkt.
- Ligt de site al plat, gebruik dan de link naar de herstelmodus die WordPress na een ernstige fout naar het beheerdersadres mailt. Geen mail? Hernoem via SFTP of de bestandsbeheerder van je host de map van de plugin die je als laatste hebt geactiveerd of bijgewerkt, onder
wp-content/plugins/. - Lees de foutmelding goed. Noemt ze een zogenaamd ontbrekende methode in een klasse uit een namespace die meerdere van je plugins meeleveren, dan is een versieconflict waarschijnlijker dan een programmeerfout in de genoemde plugin. Welke plugin erbij betrokken is, vind je door plugins één voor één uit te schakelen. Zonder dat bezoekers er iets van merken, kan dat met de plugin Health Check & Troubleshooting: in de probleemoplossingsmodus staan alle plugins alleen voor je eigen sessie uit, en je zet ze één voor één weer aan.
Hoe je updates zo regelt dat een storing je niet overvalt, lees je in WordPress-plugins bijwerken.
Wat plugin-ontwikkelaars hieruit kunnen meenemen
- Laat de aanmelding niet over aan de files-autoload. Wat elke kopie moet doen, hoort met
require_oncein het hoofdbestand. Defiles-autoload voert een bestand één keer per verzoek uit, niet één keer per plugin. - Registreer de loader van de gekozen kopie met prepend. Anders antwoorden de Composer-loaders van de plugins, en wint wie als laatste laadde:
spl_autoload_register( $loader, true, true ); // derde argument: prepend - Kort de façade nooit per pakket in. Laat bestanden weg als het moet, maar niet de methoden van de klasse die alle plugins aanroepen. Of een dienst er is, controleert
class_existstijdens de uitvoering. - Eén versienummer, één inhoud. Dragen een ingekorte en een volledige kopie hetzelfde nummer, dan kan de keuze ze niet onderscheiden. Dan is er een tweede criterium nodig; bij ons is dat volledigheid.
- Controleer de herkomst, niet de constante. Wat PHP echt heeft geladen, verraadt het bestand achter de klasse:
$bestand = ( new \ReflectionClass( \Hafenstudios\Core\Admin\DesignSystem::class ) )->getFileName(); - Test naast oude versies. De fout verschijnt pas als een nieuwe plugin naast een oude draait, nooit los. De WordPress-directory biedt oudere versies nog steeds als ZIP aan.
- Hoef je niets te delen, gebruik dan prefixen. Hulpmiddelen als PHP-Scoper of Strauss geven elke plugin een eigen namespace, dan is er geen gedeelde klasse om over te ruziën. Wij delen bewust, omdat onze plugins via de bibliotheek met elkaar praten. Heb je dat niet nodig, dan zijn prefixen eenvoudiger.
Ligt je site plat?
Wit scherm, fatale fout na een update, een formulier dat niets meer verstuurt: bij onze WordPress-spoedhulp beginnen we op werkdagen binnen vier uur, zonder contract en zonder vooruitbetaling. 185 € per uur.
Veelgestelde vragen
Waarom kan één plugin een hele WordPress-site plat leggen?
Alle actieve plugins draaien in hetzelfde PHP-proces. Stopt er één met een fatale fout, dan stopt het hele verzoek. Leveren meerdere plugins dezelfde bibliotheek mee, dan komt er een tweede route bij: PHP kent per naam maar één klasse, en de kopie van de ene plugin kan die van de andere vervangen, ook als ze ouder is.
Wat is de files-autoload van Composer?
Een vermelding in composer.json waarmee Composer bestanden direct uitvoert zodra de autoloader laadt, bijvoorbeeld voor functies of registraties. Elk bestand krijgt een kenmerk uit de md5 van pakketnaam en pad, en Composer voert het per verzoek maar één keer uit. Leveren meerdere plugins hetzelfde pakket mee, dan hebben de bestanden hetzelfde kenmerk en draait alleen dat van de plugin die als eerste laadt.
Hoe zie ik uit welke plugin een klasse is geladen?
Met de reflectie van PHP: getFileName() op een ReflectionClass van de klasse geeft het pad van het bestand waaruit PHP haar heeft geladen. Wijst dat naar de vendor-map van een andere plugin, dan levert een vreemde kopie de klasse. Een versieconstante bewijst dat niet, die zegt alleen welke kopie is gekozen.
Moet ik alle Hafenstudios-plugins tegelijk bijwerken?
Ja, het liefst allemaal tegelijk. Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3, Wimpel 1.3.1 en Kurato 1.9.3 leveren Core 1.4.4 mee. In WordPress Playground draaide de vroegere crashcombinatie van Heckgalerie 1.2.5 en Leadlotse 1.4.3 al zonder fatale fout zodra Heckgalerie, Leadlotse, Wimpel of Kurato in de nieuwe versie aanwezig was. Niet elke mix hebben we getest. Compleet is de installatie pas als alles is bijgewerkt.
Hoe voorkomen plugin-ontwikkelaars conflicten met gedeelde bibliotheken?
Heb je geen gedeelde toestand nodig, prefix dan de namespaces per plugin, bijvoorbeeld met PHP-Scoper of Strauss. Deel je een bibliotheek bewust, meld dan elke kopie zelf aan met require_once, registreer de loader van de gekozen kopie met prepend, kort de gedeelde klasse nooit per pakket in en gebruik elk versienummer voor precies één inhoud.