Das LimeSurvey Plugin-Event-System – eine kritische Betrachtung

Ein Plugin-System, das funktioniert – und trotzdem alles falsch macht

LimeSurvey verfügt über ein event-basiertes Plugin-System. Plugins abonnieren Events, reagieren darauf, und können Daten zurückgeben. Das Konzept ist solide – und weit verbreitet in modernen Frameworks. Die Implementierung erzählt eine andere Geschichte.


Was ein Event-System leisten soll

Ein Event kommuniziert, dass etwas passiert ist oder passieren wird. Der Name beschreibt das Ereignis. Die Parameter sind bekannt und garantiert. Der Empfänger reagiert – er fragt nicht erst nach, ob die Daten überhaupt vorhanden sind.

Das ist der Maßstab. Gemessen daran zeigt das LimeSurvey Plugin-Event-System die typischen Merkmale eines Systems, das organisch gewachsen ist.


Drei Muster, ein System

Ein Blick auf die Event-Liste offenbart drei grundlegend verschiedene Interaktionsmuster – ohne dass die Namen das signalisieren würden.

Domain Events beschreiben etwas, das passiert ist oder passieren wird: afterSurveyComplete, beforeSurveyPage, afterSurveyActivate. Das ist das klassische Event-Muster. Wobei selbst hier die Benennung nicht konsequent ist – afterSurveyComplete klingt wie eine Zustandsbeschreibung. SurveyCompleted wäre das treffendere Domain-Event-Muster: etwas ist eingetreten, nicht etwas ist abgeschlossen.

Query-Events fragen Plugins nach Daten: listExportOptions, listExportPlugins, listEmailPlugins, getGlobalBasePermissions, getValidScreenFiles. Das sind keine Events – das sind Abfragen. Ein Event signalisiert, dass etwas passiert ist. Eine Abfrage erwartet eine Antwort. Beides in dasselbe System zu stecken, vermischt zwei verschiedene Kommunikationsmuster.

Command-Events delegieren eine Aufgabe: createNewUser, createRandomPassword. Das ist weder ein Domain Event noch eine Abfrage – das ist ein Methodenaufruf, der als Event verkleidet wurde.

Drei Muster, eine Klasse, keine erkennbare Trennung. Wer das System neu kennenlernt, muss die Dokumentation jedes einzelnen Events lesen, um zu verstehen, was erwartet wird.


Kein zentrales Event-Register

Wer wissen will, welche Events LimeSurvey tatsächlich dispatcht, findet keine zentrale Quelle. Nicht im Interface, nicht in einer Enum, nicht in einer Konstanten-Klasse. Der einzige Weg ist ein rekursiver Scan des gesamten Quellcodes – und genau dafür gibt es im LimeSurvey-Core ein Script:

$regex = '/(.*)new[[:space:]]+PluginEvent[[:space:]]*\([[:space:]]*[\'"]+(.*)[\'"]+/';

Das Script sucht nach allen Stellen, an denen new PluginEvent( aufgerufen wird, extrahiert den Event-Namen, dedupliziert und sortiert. Das Ergebnis ist die Event-Liste – nicht als Kontrakt, sondern als Artefakt einer Suche.

Das ist kein Werkzeug für Plugin-Entwickler. Es ist ein Werkzeug für Entwickler, die verstehen wollen, was das System überhaupt tut.


Der unsichere Kontrakt

Alle Events teilen dieselbe Klasse: PluginEvent. Sie hat einen Namen, einen generischen Key-Value-Store für Parameter, und eine Content-Sammlung für HTML-Ausgaben. Typisierung gibt es nicht.

public function get($key = null, $default = null)
{
    if (!Hash::check($this->_parameters, $key)) {
        return $default;
    } else {
        return Hash::get($this->_parameters, $key);
    }
}

get() gibt mixed zurück. Was ein konkreter Event als Parameter mitbringt, steht nicht im Code – es steht bestenfalls in der Dokumentation, die im Fall von LimeSurvey oft spärlich ist oder fehlt. Der Empfänger weiß zur Compile-Zeit nicht, was er bekommt.

Dass das in der Praxis ein Problem ist, zeigt der Dispatch-Code von afterSurveyComplete:

$event = new PluginEvent('afterSurveyComplete');
if ($surveyActive && isset($_SESSION[$this->LEMsessid]['srid'])) {
    $event->set('responseId', $_SESSION[$this->LEMsessid]['srid']);
}
$event->set('surveyId', $this->iSurveyid);
App()->getPluginManager()->dispatchEvent($event);

responseId wird nur gesetzt, wenn die Bedingung erfüllt ist. Jedes Plugin, das afterSurveyComplete abonniert, muss defensiv prüfen, ob responseId vorhanden ist – obwohl der Event-Name das Gegenteil suggeriert. Eine abgeschlossene Umfrage hat eine Response-ID, oder sie hat keine – und das ist der Normalfall, den der Event kommunizieren soll.

Hinzu kommt: $_SESSION direkt im Dispatch-Aufruf. Die Session-Abhängigkeit ist nicht gekapselt, sondern inline. Und surveyId wird hier ohne Präfix gesetzt, während dieselbe Variable an anderer Stelle iSurveyId heißt – Hungarian Notation und modernes PHP im selben Atemzug.


Das Interface, das keines ist

iPlugin definiert den Kontrakt für alle Plugins. Zwei Entscheidungen fallen sofort auf.

Erstens: Das Interface schreibt einen Konstruktor vor.

public function __construct(PluginManager $manager, $id);

Ein Interface definiert Verhalten, kein Erzeugungsprotokoll. Konstruktoren in Interfaces sind in PHP syntaktisch erlaubt – aber konzeptuell falsch. Wer iPlugin implementiert, ist an eine Konstruktor-Signatur gebunden, die eigentlich eine interne Angelegenheit des PluginManager ist.

Zweitens: getName() und getDescription() sind als static deklariert.

public static function getName();
public static function getDescription();

Statische Methoden in Interfaces können in PHP nicht sinnvoll erzwungen werden. Polymorphismus – der eigentliche Zweck eines Interfaces – funktioniert mit statischen Methoden nicht. PHP lässt es zu, aber der PluginManager, der iPlugin voraussetzt, kann getName() nicht über eine Interface-Referenz aufrufen. Er muss den Klassenname kennen oder Reflection nutzen.


init() – der implizite Kontrakt

Jedes Plugin muss init() implementieren. Dort werden Events abonniert, Settings registriert, Abhängigkeiten geladen. Ohne init() tut ein Plugin nichts.

init() ist weder im Interface iPlugin deklariert noch in PluginBase als abstrakt markiert. Es ist ein impliziter Kontrakt – dokumentiert nirgendwo außer durch Konvention und Beispiele. Wer PluginBase erweitert und init() vergisst, bekommt keinen Compilerfehler, keine Exception, keine Warnung. Das Plugin lädt still – und macht nichts.

Der PluginManager selbst bestätigt das ungewollt. In loadPlugin() wird init() nicht über das Interface aufgerufen, sondern über eine Laufzeit-Prüfung:

if ($init && method_exists($this->plugins[$id], 'init')) {
    $this->plugins[$id]->init();
}

method_exists() ist die Antwort auf ein fehlendes Interface. Der Manager kann sich nicht auf den Kontrakt verlassen – also prüft er zur Laufzeit, ob die Methode überhaupt existiert. Das ist kein defensives Programmieren. Es ist der Beweis, dass das Interface seinen Zweck nicht erfüllt.


PluginEvent – eine Klasse für alles

getSender() gibt false zurück, wenn kein Sender gesetzt ist:

public function getSender()
{
    if (!is_null($this->_sender)) {
        return $this->_sender;
    } else {
        return false;
    }
}

Nicht null. Nicht eine Exception. Sondern false – ein Boolean als Sentinel-Wert für ein fehlendes Objekt. Der Rückgabetyp ist object|false, ohne dass das deklariert ist. Wer getSender() aufruft und das Ergebnis als Objekt behandelt, baut auf einem unsichtbaren Vertrag.

Das Muster zieht sich durch: get() gibt null zurück wenn ein Parameter fehlt, oder den übergebenen Default. _parameters ist ein Array ohne Typen. _content ist ein Array von PluginEventContent-Objekten, indiziert nach Plugin-Namen – aber auch nach numerischen Indizes, wenn kein Plugin-Name bekannt ist. Zwei Indexierungsstrategien im selben Array.


dispatchEvent() – kein Netz, kein Boden

Beim Laden eines Plugins gibt es ein Sicherheitsnetz. Die PluginManagerShutdownFunction fängt PHP-Fatal-Errors während loadPlugin() ab, markiert das fehlerhafte Plugin in der Datenbank und verhindert, dass es erneut geladen wird. Der PluginManager hat dieses Problem also gesehen – und für den Ladevorgang gelöst.

Beim Dispatchen gibt es dieses Netz nicht:

foreach ($this->subscriptions[$eventName] as $subscription) {
    $subscription[0]->setEvent($event);
    call_user_func($subscription);
}

Kein Try-Catch. Kein Logging. Kein Fallback. Wirft ein Plugin während der Event-Verarbeitung eine unbehandelte Exception, bricht der gesamte Dispatch ab – alle nachfolgenden Plugins, die denselben Event abonniert haben, werden nicht mehr aufgerufen. Das ist keine bewusste Entscheidung für Fail-Fast – es ist eine fehlende Entscheidung.

Die Konsequenz ist asymmetrisch: Ein fehlerhaftes Plugin kann alle anderen Plugins, die denselben Event abonniert haben, stillschweigend deaktivieren. Der Fehler tritt im Plugin auf, der Schaden entsteht systemweit. Das Laden ist abgesichert. Das Ausführen nicht.

Wie ein durchdachtes Event-System aussehen würde

Das Ziel wäre nicht ein Rewrite – sondern Orientierung. Drei Änderungen hätten den größten Effekt.

Typisierte Event-Klassen statt einer generischen PluginEvent-Klasse. Ein SurveyCompletedEvent mit deklarierten, garantierten Properties:

final class SurveyCompletedEvent
{
    public function __construct(
        public readonly SurveyId $surveyId,
        public readonly ResponseId $responseId
    ) {}
}

Kein get('responseId'), keine optionalen Parameter, keine Null-Checks. Der Kontrakt steht im Code.

Klare Trennung der Interaktionsmuster. Domain Events für Ereignisse, ein separates Interface für Queries, eine explizite Delegation für Commands. Drei verschiedene Konzepte, drei verschiedene Implementierungen.

init() als abstrakte Methode. Nicht als Konvention – als Kontrakt:

abstract public function init(): void;

Ein Plugin, das init() nicht implementiert, kompiliert nicht. Das ist die einfachste Form der Dokumentation: Code, der gar nicht erst falsch sein kann.


Fazit

Das LimeSurvey Plugin-Event-System funktioniert. Es ist in produktiven Systemen im Einsatz, es ermöglicht echte Erweiterbarkeit, und es hat über Jahre Bestand gehabt. Das ist nicht wenig.

Aber es trägt die Merkmale eines Systems, das gewachsen ist, ohne dass der zugrunde liegende Kontrakt jemals explizit formuliert wurde. Events, Queries und Commands teilen dieselbe Klasse. Parameter sind optional ohne Deklaration. Das Interface erzwingt das Falsche und schweigt über das Wesentliche. Und init() – die wichtigste Methode jedes Plugins – existiert als stille Konvention, nicht als deklarierter Vertrag.

Das ist kein Versagen der Entwickler. Es ist das Ergebnis eines Systems, das Feature für Feature gewachsen ist, ohne den Kontrakt mitzuschreiben. Der Code funktioniert – aber er kommuniziert nicht, was er erwartet.