Flotte

Warum ein einziges Plugin die ganze Website lahmlegen kann

Heckgalerie 1.2.5 und Leadlotse 1.4.3, zwei unserer Plugins aus dem WordPress-Verzeichnis, führten zusammen auf jeder Seite einer Website zu einem Fatal error. Kein Plugin war für sich kaputt. Der Fehler steckte in einer Bibliothek, die fünf unserer Plugins mitbringen, und zu einem guten Teil darin, dass wir zwei Eigenheiten von Composer und WordPress übersehen hatten. Das betrifft jeden, der Code zwischen Plugins teilt.

Die Fehlermeldung ist kurz. So sieht sie aus, im WordPress Playground nachgestellt:

PHP Fatal error:  Uncaught Error: Call to undefined method
Hafenstudios\Core\Admin\DesignSystem::action_button()
in /wordpress/wp-content/plugins/heckgalerie/heckgalerie.php:163

Das Merkwürdige daran: Die Methode gibt es. Heckgalerie bringt sie in ihrer eigenen Kopie der Bibliothek mit. Geladen hat PHP trotzdem eine ältere Kopie aus einem anderen Plugin, aus Leadlotse, in der es sie noch nicht gab.

Der Fehler lag bei uns. Zwei seiner drei Schichten stecken aber im Zusammenspiel von Composer und WordPress, und die betreffen jeden, der Code zwischen Plugins teilt. Deshalb schreiben wir sie hier auf.

Eine Bibliothek, fünf Kopien

Heckgalerie, Kurato, Leadlotse, Wellenbrecher und Wimpel teilen sich eine Bibliothek, den Hafenstudios Core (nicht zu verwechseln mit unserem Plugin Hafen Core): Einstellungen, Ereignisse, Mailversand, Einwilligungsprotokoll, die Bausteine der Admin-Oberfläche. Über sie arbeiten die Plugins zusammen. Meldet ein Leadlotse-Formular eine neue Anfrage, hört Kurato auf dasselbe Ereignis und legt einen Kontakt an.

Jedes Plugin bringt seine eigene Kopie mit, per Composer unter vendor/hafenstudios/core. Das muss so sein, denn ein Plugin aus dem Verzeichnis muss auch allein laufen. Weil jedes Plugin seinen eigenen Takt hat, liegen auf einer Website schnell mehrere Versionen nebeneinander:

Pluginbis 11.10.2026 im Verzeichnismitgebrachter Coreseit 12.10.2026mitgebrachter Core
Heckgalerie1.2.51.4.01.2.61.4.4
Leadlotse1.4.31.3.01.5.11.4.4
Wellenbrecher1.3.21.4.01.3.31.4.4
Wimpel1.2.01.4.11.3.11.4.4

PHP kennt je Seitenaufruf nur eine Klasse mit einem Namen. Wer Hafenstudios\Core\Core zuerst lädt, legt sie für alle fest. Die übliche Antwort darauf kommt aus dem Umfeld von WooCommerce: Action Scheduler wird von vielen Plugins mitgebracht, jede Kopie meldet ihre Version an, und auf dem Hook plugins_loaded wird nur die höchste geladen. Genau so haben wir es gebaut.

Die Wahl hat zwei Voraussetzungen, über die man selten nachdenkt. Jede Kopie muss sich anmelden. Und die gewählte Kopie muss die Klassen auch wirklich liefern. Beides war nicht erfüllt.

Erster Fund: Composer stellt sich nach vorn

Composer erzeugt für jedes Plugin einen eigenen Autoloader. Der registriert sich mit dem Schalter prepend, also am Anfang der Warteschlange, in der PHP nach unbekannten Klassen fragt. So steht es in der erzeugten vendor/composer/autoload_real.php:

$loader->register(true);

WordPress lädt aktive Plugins in der Reihenfolge der Liste active_plugins, und die sortiert WordPress beim Aktivieren alphabetisch. Jedes weitere Plugin schiebt seinen Composer-Lader vor die bisherigen. Ganz vorn steht am Ende der Lader des Plugins, dessen Ordnername im Alphabet zuletzt kommt.

Unsere Versionswahl lief danach, auf plugins_loaded, und hängte den Lader der gewählten Kopie hinten an. Bis eine Anfrage dort ankam, hatte längst ein Composer-Lader geantwortet. Wir haben das ohne WordPress nachgestellt, mit den echten Paketen von Heckgalerie 1.2.5 und Leadlotse 1.4.3 aus dem Verzeichnis, in der Reihenfolge, in der WordPress sie lädt. Das Ergebnis, zusammengefasst:

Einträge in __composer_autoload_files:  1
Zur Wahl angemeldet:                    Core 1.4.0 (Heckgalerie)
Gewählt:                                Core 1.4.0 aus Heckgalerie
DesignSystem geliefert von:             Leadlotse (Core 1.3.0)
action_button() vorhanden:              nein

Die Wahl meldete 1.4.0, ausgeführt wurde 1.3.0. Unser eigener Versionswächter las nur die Konstante, die die Wahl setzt, und sah deshalb nichts.

Wie zufällig das Ergebnis war, zeigt ein Lauf im Playground mit allen vier alten Plugins: Die Seite lief. Wellenbrecher steht im Alphabet hinter Leadlotse, sein Lader stand also davor, und seine Kopie (1.4.0) kennt die Methode. Wimpel kommt im Alphabet noch später, bringt in Version 1.2.0 aber statt des Composer-Laders einen eigenen mit, der sich hinten anstellt. Ob eine Website lief, hing an der Reihenfolge der Plugin-Namen.

Die erste Korrektur, Core 1.4.2 vom 6. Oktober, registriert den Lader der gewählten Kopie ebenfalls mit prepend. Dazu fangen unsere Plugins in den neuen Versionen Fehler beim Start ab und melden sie im Backend, statt die Website mitzunehmen. Ausgeliefert wurde diese Korrektur erst am 12. Oktober, zusammen mit den beiden folgenden.

Zweiter Fund: Die Startdatei läuft nur einmal

Für Code, der beim Laden sofort laufen muss, etwa Funktionen oder eine Anmeldung, kennt Composer den files-Autoload. Unsere Bibliothek nutzt ihn für ihre bootstrap.php, in der sich jede Kopie zur Wahl meldet. In der composer.json der Bibliothek steht unter anderem:

"autoload": {
    "files": [ "bootstrap.php" ]
}

Composer schreibt daraus diese Sperre in jeden Autoloader:

if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
    $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
    require $file;
}

Die Kennung $fileIdentifier bildet Composer als md5 aus Paketname und Pfad. Für unsere Startdatei ist das md5('hafenstudios/core:bootstrap.php'), also b6716d317db73fd5f2cd1d7c8ab2ffd8, in jedem Plugin derselbe Wert. Und $GLOBALS gilt für den ganzen Seitenaufruf, nicht für ein einzelnes Plugin. Ist die Startdatei des ersten Plugins gelaufen, steht die Kennung in __composer_autoload_files, und Composer überspringt die Datei in allen weiteren Plugins.

In einem einzelnen Projekt ist das richtig, denn so läuft eine Funktionsdatei nicht zweimal, und PHP bricht nicht mit „Cannot redeclare“ ab. Auf einer WordPress-Seite mit mehreren vendor-Ordnern trifft dieselbe Sperre verschiedene Kopien in verschiedenen Versionen.

Für unsere Versionswahl hieß das: Angemeldet hat sich in aller Regel nur die Kopie des Plugins, das WordPress zuerst lädt, bei uns meist Heckgalerie. In der Nachstellung oben steht genau ein Eintrag in __composer_autoload_files, und Leadlotse taucht in der Liste der angemeldeten Kopien nicht auf, obwohl es seine eigene mitbringt. Die Wahl hatte einen einzigen Kandidaten. Bemerkt haben wir das erst am 11. Oktober, beim Bau von Wimpel 1.3.1 im Playground.

Ein Sonderfall unserer Bibliothek ist das nicht. Auch symfony/polyfill-mbstring lädt seine bootstrap.php auf diesem Weg, Guzzle seine Funktionsdatei. Bringen zwei Plugins dasselbe Paket mit, ohne den Namensraum zu ändern, läuft auf der Website die Datei des zuerst geladenen Plugins, gleich welche Version das andere mitbringt.

Die Korrektur in Core 1.4.4: Jedes Plugin bindet seine Startdatei zusätzlich selbst ein, direkt in der Hauptdatei. Vereinfacht:

require_once __DIR__ . '/vendor/autoload.php';
// Composer führt bootstrap.php nur für das zuerst geladene Plugin aus.
require_once __DIR__ . '/vendor/hafenstudios/core/bootstrap.php';

require_once merkt sich Dateien nach ihrem Pfad. Die Kopie, die Composer schon ausgeführt hat, läuft kein zweites Mal, jede andere genau einmal. Action Scheduler macht es übrigens genauso, dort bindet laut Anleitung jedes Plugin die Startdatei selbst ein. Zusätzlich führt jede Kopie seit 1.4.4 einen Eintrag unter ihrem Verzeichnis, damit auch zwei Kopien mit derselben Nummer sichtbar bleiben.

Dritter Fund: die gekürzte Fassade

Die dritte Schicht war hausgemacht. Unsere Pakete fürs WordPress-Verzeichnis sind schlanker als die aus dem Eigenvertrieb: Teile der Bibliothek, die ein Plugin nicht braucht, bleiben draußen, bei Wimpel 1.2.0 etwa Mailversand, Einwilligungsprotokoll und Datenschutz. Das Bauskript hat dabei auch die zentrale Klasse Core.php angepasst, über die alle Plugins die Dienste abrufen, und Methoden wie consent(), mailer() und privacy() herausgeschnitten.

Im Playground-Lauf mit allen vier alten Plugins blieb das folgenlos. Gewählt war dort die gekürzte Kopie von Wimpel 1.2.0, Core 1.4.1, damals die höchste Nummer im Verzeichnis. Angemeldet hatte sie sich an Composers Sperre vorbei, weil der eigene Lader von Wimpel 1.2.0 die Startdatei selbst per require_once einbindet. Die geladenen Klassen kamen trotzdem alle aus Wellenbrecher. Seit der ersten Korrektur steht der Lader der gewählten Kopie aber vorn. Gewinnt dann eine gekürzte Kopie die Wahl, fehlen diese Methoden allen anderen Plugins, und jede Leadlotse-Absendung bricht ab. Darauf sind wir beim Bau der neuen Versionen gestoßen, bevor eine davon ausgeliefert war.

Die Korrektur hat zwei Teile. Core.php bleibt seit 1.4.4 in jedem Paket vollständig, und beim Start prüft die Bibliothek per class_exists, ob ein Dienst da ist. Außerdem füllt der Lader der gewählten Kopie Lücken selbst: Fehlt ihr eine Datei, sucht er in allen angemeldeten Kopien der Website, absteigend nach Version. Erst wenn keine sie hat, sind die Composer-Lader der Plugins dran.

Am 12. Oktober kam beim Integrationstest noch ein Fall dazu. In einem Zwischenstand von 1.4.4, noch vor der Auslieferung, schnitt das Bauskript für Pakete ohne KI-Funktionen weiter ai() aus Core.php. Diese gekürzte Kopie aus dem Verzeichnis-Paket von Leadlotse und die vollständige aus Kurato trugen beide die Nummer 1.4.4. Bei gleicher Nummer gewann die zuletzt geladene, also die gekürzte, und Kurato brach bei einer KI-Funktion ab. Seitdem bleibt auch ai() stehen, und bei gleicher Nummer steht die vollständigste Kopie in der Wahlliste. Eine Versionsnummer sollte für genau einen Inhalt stehen. Wo sie das nicht kann, braucht die Wahl ein zweites Kriterium.

Nachgemessen im WordPress Playground

Vor der Auslieferung liefen die Pakete in elf Kombinationen im WordPress Playground, mit WordPress 7.1.3 und PHP 8.3, externe Anfragen gesperrt. Am Tag der Auslieferung haben wir unter denselben Bedingungen nachgemessen, was ein einzelnes neues Plugin bei der früheren Absturzkombination bewirkt. Die wichtigsten Ergebnisse:

KombinationErgebnis
Heckgalerie 1.2.5 und Leadlotse 1.4.3 aus dem VerzeichnisFatal error in heckgalerie.php:163, die Meldung von oben
Nur Heckgalerie 1.2.6 neu, Leadlotse 1.4.3 altläuft, DesignSystem aus Heckgalerie (Core 1.4.4)
Nur Leadlotse 1.5.1 neu, Heckgalerie 1.2.5 altläuft, DesignSystem aus Leadlotse (Core 1.4.4)
Nur Wimpel 1.3.1 neu, Heckgalerie und Leadlotse altläuft, Formular abgeschickt, Mail mit korrekten Umlauten
Nur Kurato 1.9.3 neu, Heckgalerie 1.2.5 und Leadlotse 1.4.3 altläuft, DesignSystem aus Kurato (Core 1.4.4)
Heckgalerie, Leadlotse, Wellenbrecher und Wimpel neu aus dem Verzeichnisläuft, alle vier Kopien angemeldet
Kurato 1.9.3 aus dem Eigenvertrieb, Heckgalerie, Leadlotse und Wellenbrecher neu aus dem Verzeichnisläuft, alle vier Kopien angemeldet
Von alt auf neu, ein Plugin nach dem anderen per Upload ersetzt, mit einer Galerie und einem Formulareintragnach jedem Tausch alle geprüften Seiten mit Status 200, Inhalte erhalten

Die Nachstellung ohne WordPress zeigt dasselbe im Kleinen. Mit Heckgalerie 1.2.6 statt 1.2.5 kommt DesignSystem aus Heckgalerie (Core 1.4.4), mit Leadlotse 1.5.1 statt 1.4.3 aus Leadlotse (Core 1.4.4). Die Methode ist in beiden Fällen da.

Was der Test nicht abdeckt: Klicks in einem echten Browser, Multisite, das Update über den Button „Jetzt aktualisieren“, die automatische Aktualisierung per Cron, andere PHP- und WordPress-Versionen und beim Tausch von alt auf neu nur eine von 24 möglichen Reihenfolgen.

Was du als Betreiber tun kannst

  • Plugins eines Herstellers zusammen aktualisieren. Teilen sich Plugins eine Bibliothek, ist der gemischte Stand der riskante. Bei uns heißt das: Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3 und Wimpel 1.3.1 stehen seit dem 12. Oktober 2026 im WordPress-Verzeichnis, Kurato 1.9.3 kommt über unseren eigenen Update-Kanal.
  • Wenn es schnell gehen muss, hilft schon eins. Jedes dieser Pakete bringt Core 1.4.4 mit. Diese Kopie gewinnt die Wahl, und weil ihr Lader vorn steht, liefert sie die Klassen, auch für die älteren Plugins. Im Playground lief die frühere Absturzkombination aus Heckgalerie 1.2.5 und Leadlotse 1.4.3 ohne Fatal error, sobald eines der beiden aktualisiert war. Dasselbe galt, wenn stattdessen Wimpel 1.3.1 oder Kurato 1.9.3 dazukam. Alle Mischstände haben wir nicht getestet, fertig ist der Stand erst, wenn alle aktualisiert sind.
  • Ist die Seite schon weg, hilft der Link in den Wiederherstellungsmodus, den WordPress nach einem schweren Fehler an die Admin-Adresse schickt. Ohne Mail: per SFTP oder Dateimanager den Ordner des zuletzt aktivierten oder aktualisierten Plugins unter wp-content/plugins/ umbenennen. Was bei einer weißen Seite sonst noch hilft, steht in unserem Beitrag zum White Screen of Death.
  • Die Fehlermeldung genau lesen. Nennt sie eine Methode, die es angeblich nicht gibt, in einer Klasse aus einem Namensraum, den mehrere deiner Plugins mitbringen, ist ein Versionskonflikt wahrscheinlicher als ein Programmierfehler im genannten Plugin. Welches Plugin beteiligt ist, findest du heraus, indem du Plugins einzeln deaktivierst. Ohne Folgen für Besucher geht das mit dem Plugin Health Check & Troubleshooting: In dessen Modus zur Fehlersuche sind alle Plugins nur für deine eigene Sitzung abgeschaltet, und du schaltest sie einzeln wieder zu.

Wie du Updates insgesamt so organisierst, dass dich ein Ausfall nicht überrascht, steht in WordPress-Plugins aktualisieren.

Was Plugin-Entwickler daraus mitnehmen können

  1. Die Anmeldung nicht dem files-Autoload überlassen. Was jede Kopie tun muss, gehört per require_once in die Hauptdatei. Der files-Autoload führt eine Datei je Seitenaufruf einmal aus, nicht einmal je Plugin.
  2. Den Lader der gewählten Kopie mit prepend registrieren. Sonst antworten die Composer-Lader der Plugins, und es gewinnt, wer zuletzt geladen wurde:
    spl_autoload_register( $lader, true, true ); // drittes Argument: prepend
  3. Die Fassade nie pro Paket kürzen. Lass Dateien weg, wenn es sein muss, aber nicht die Methoden der Klasse, die alle Plugins aufrufen. Ob ein Dienst da ist, prüft class_exists zur Laufzeit.
  4. Eine Versionsnummer, ein Inhalt. Tragen eine gekürzte und eine vollständige Kopie dieselbe Nummer, kann die Wahl sie nicht unterscheiden. Dann braucht sie ein zweites Kriterium, bei uns die Vollständigkeit.
  5. Die Herkunft prüfen, nicht die Konstante. Was PHP tatsächlich geladen hat, verrät die Datei hinter der Klasse:
    $datei = ( new \ReflectionClass( \Hafenstudios\Core\Admin\DesignSystem::class ) )->getFileName();
  6. Neben alten Fassungen testen. Der Fehler zeigt sich erst, wenn ein neues Plugin neben einem alten läuft, nicht im Einzeltest. Ältere Versionen bietet das WordPress-Verzeichnis weiter als ZIP an.
  7. Wer nichts teilen muss, präfixt. Werkzeuge wie PHP-Scoper oder Strauss geben jedem Plugin einen eigenen Namensraum, dann gibt es keine gemeinsame Klasse, um die gestritten wird. Wir teilen bewusst, weil unsere Plugins über die Bibliothek miteinander reden. Wer das nicht braucht, hat es mit Präfixen einfacher.

Website steht still?

Weiße Seite, Fatal error nach einem Update, Formular verschickt nichts mehr: Bei der WordPress-Soforthilfe fangen wir werktags innerhalb von vier Stunden an, ohne Vertrag und ohne Vorleistung. 185 € pro Stunde.

Soforthilfe anfragen

Häufige Fragen

Warum kann ein einzelnes Plugin die ganze WordPress-Website lahmlegen?

Alle aktiven Plugins laufen im selben PHP-Prozess. Bricht eines mit einem Fatal error ab, bricht der ganze Seitenaufruf ab. Bringen mehrere Plugins dieselbe Bibliothek mit, kommt ein zweiter Weg dazu: PHP kennt je Name nur eine Klasse, und die Kopie des einen Plugins kann die des anderen ersetzen, auch wenn sie älter ist.

Was ist der files-Autoload von Composer?

Ein Eintrag in der composer.json, mit dem Composer Dateien sofort beim Laden des Autoloaders ausführt, etwa für Funktionen oder Anmeldungen. Jede Datei bekommt eine Kennung aus dem md5 von Paketname und Pfad, und Composer führt sie je Seitenaufruf nur einmal aus. Bringen mehrere Plugins dasselbe Paket mit, haben die Dateien dieselbe Kennung, und es läuft nur die des zuerst geladenen Plugins.

Wie finde ich heraus, aus welchem Plugin eine Klasse geladen wurde?

Mit der Reflection von PHP: getFileName() auf einer ReflectionClass der Klasse liefert den Pfad der Datei, aus der PHP sie geladen hat. Steht dort der vendor-Ordner eines anderen Plugins, liefert eine fremde Kopie die Klasse. Eine Versionskonstante beweist das nicht, sie sagt nur, welche Kopie gewählt wurde.

Muss ich alle Hafenstudios-Plugins gleichzeitig aktualisieren?

Ja, am besten alle zusammen. Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3, Wimpel 1.3.1 und Kurato 1.9.3 bringen Core 1.4.4 mit. Im WordPress Playground lief die frühere Absturzkombination aus Heckgalerie 1.2.5 und Leadlotse 1.4.3 schon ohne Fatal error, sobald Heckgalerie, Leadlotse, Wimpel oder Kurato in der neuen Fassung dabei war. Alle Mischstände haben wir aber nicht getestet. Vollständig ist der Stand erst, wenn alle aktualisiert sind.

Wie verhindern Plugin-Entwickler Konflikte bei geteilten Bibliotheken?

Wer keinen gemeinsamen Zustand braucht, präfixt die Namensräume je Plugin, etwa mit PHP-Scoper oder Strauss. Wer eine Bibliothek bewusst teilt, meldet jede Kopie per require_once selbst zur Wahl an, registriert den Lader der gewählten Kopie mit prepend, kürzt die gemeinsame Klasse nie pro Paket und vergibt eine Versionsnummer nur für einen Inhalt.

Zurück zum Blog Ein Beitrag von hafenstudios