Performance beginnt bei der Verantwortung des Plugins
Ein Shopware-Plugin kann fachlich korrekt funktionieren und trotzdem zum Engpass des gesamten Shops werden. Das passiert selten durch eine einzelne spektakulär langsame Methode. Häufiger summieren sich viele kleine Entscheidungen: Eine Association wird auf jeder Produktseite vollständig geladen, ein Subscriber reagiert auf ein sehr häufiges Event, eine externe API wird synchron angesprochen oder ein Hintergrundjob verarbeitet mehr Daten als notwendig.
Die wichtigste Frage lautet deshalb nicht zuerst: „Wie machen wir diesen Code schneller?“ Sie lautet: „Welche Verantwortung muss in diesem Request überhaupt erfüllt werden?“ Alles, was nicht für die unmittelbare Antwort an den Browser notwendig ist, sollte aus dem kritischen Pfad entfernt werden. Genau hier greifen Data Abstraction Layer, Events und Message Queue ineinander.
Ein performantes Plugin erledigt im Request nur die Arbeit, die der Nutzer für die aktuelle Antwort wirklich benötigt.
PMMB Engineering
DAL-Abfragen so klein wie möglich halten
Der Shopware Data Abstraction Layer stellt eine einheitliche Schnittstelle für Entitäten, Versionierung, Übersetzungen und Erweiterungen bereit. Diese Abstraktion entbindet uns aber nicht von der Verantwortung, Abfragen bewusst zu bauen. Ein Repository liefert genau die Daten, die über das Criteria-Objekt angefordert werden. Werden zu viele Associations ergänzt oder große Datenmengen ohne Limit gelesen, steigt der Aufwand in Datenbank, Hydrierung und PHP-Speicher.
Für eine Produkt-ID und den zugehörigen Hersteller reicht eine gezielte Abfrage. Es gibt keinen Grund, zusätzlich Varianten, Medien, Kategorien, Preise und Eigenschaften zu laden, wenn die fachliche Aufgabe diese Daten nicht verwendet.
$criteria = new Criteria([$productId]);
$criteria->addAssociation('manufacturer');
$criteria->setLimit(1);
$product = $this->productRepository
->search($criteria, $context)
->first();
if ($product === null) {
return;
}
$manufacturer = $product->getManufacturer();
Ein Criteria sollte aus der fachlichen Ausgabe heraus gedacht werden. Welche Entität wird benötigt? Welche Beziehung wird im nächsten Schritt tatsächlich gelesen? Wie viele Ergebnisse können maximal sinnvoll sein? Muss die Gesamtzahl der Treffer berechnet werden? Gerade in Listen und Hintergrundprozessen können Limits, Pagination und eine reduzierte Total-Count-Strategie einen großen Unterschied machen.
Associations nicht vorsorglich laden
„Wir könnten die Daten später noch benötigen“ ist kein guter Grund für eine Association. Jede Beziehung erweitert die Abfrage oder führt zu zusätzlicher Verarbeitung. Tiefe Pfade sind legitim, wenn genau diese Informationen ausgegeben werden. Als Standard in einem allgemeinen Service führen sie dagegen schnell zu unnötig großen Entity-Graphen.
- Associations nur ergänzen, wenn der folgende Code sie unmittelbar nutzt.
- Bei großen Ergebnismengen konsequent paginieren oder mit IDs arbeiten.
- Wiederholte Repository-Suchen innerhalb von Schleifen vermeiden.
- Aggregationen verwenden, wenn nur Summen, Minima, Maxima oder Gruppen benötigt werden.
- Den Context des bestehenden Prozesses weiterreichen, statt unkontrolliert neue Kontexte zu erzeugen.
N+1-Abfragen früh erkennen
Ein typisches Performance-Problem entsteht, wenn zunächst eine Liste geladen und anschließend für jeden Eintrag eine weitere Repository-Suche ausgeführt wird. Bei zehn Datensätzen fällt das kaum auf. Bei mehreren hundert Produkten, Bestellpositionen oder Kategorien entstehen jedoch hunderte zusätzliche Abfragen.
Die bessere Lösung hängt vom Anwendungsfall ab: benötigte Associations in einer Abfrage laden, IDs sammeln und gesammelt suchen oder die fachliche Verarbeitung in sinnvolle Batches teilen. Entscheidend ist, das Muster sichtbar zu machen. Symfony Profiler, Datenbank-Logging oder ein Application-Performance-Monitor zeigen, ob dieselbe Query-Struktur wiederholt ausgeführt wird.
Event Subscriber schlank halten
Events sind ein zentraler Erweiterungspunkt in Shopware. Sie ermöglichen es Plugins, auf Änderungen zu reagieren, ohne Core-Code zu überschreiben. Gleichzeitig verleiten sie dazu, zu viel Logik an eine Stelle zu hängen, die sehr häufig ausgeführt wird. Ein Product-Written-Event kann bei Importen, Administration, Synchronisationen und Massenänderungen viele Datensätze umfassen. Führt der Subscriber dort direkt aufwendige Berechnungen aus, verlängert er den schreibenden Prozess und erhöht das Fehlerrisiko.
Ein Subscriber sollte deshalb prüfen, ob das Event für seine Aufgabe relevant ist, die minimal notwendigen Informationen extrahieren und aufwendige Folgearbeit an einen spezialisierten Service oder eine Message übergeben.
final class ProductWrittenSubscriber
implements EventSubscriberInterface
{
public function __construct(
private readonly MessageBusInterface $bus
) {
}
public static function getSubscribedEvents(): array
{
return [
ProductEvents::PRODUCT_WRITTEN_EVENT
=> 'onProductWritten',
];
}
public function onProductWritten(
EntityWrittenEvent $event
): void {
foreach ($event->getIds() as $productId) {
$this->bus->dispatch(
new RebuildProductProjectionMessage(
(string) $productId
)
);
}
}
}
Der Subscriber blockiert den ursprünglichen Schreibprozess damit nur kurz. Die eigentliche Berechnung wird unabhängig verarbeitet. Trotzdem muss die Anzahl der Nachrichten im Blick bleiben: Werden bei einem Import zehntausend Produkte geschrieben, können zehntausend einzelne Messages ebenfalls ineffizient sein. Je nach Aufgabe ist ein Batch mit mehreren IDs sinnvoller.
Message Queue für echte Hintergrundarbeit nutzen
Die Message Queue ist die richtige Ebene für Arbeiten, deren Ergebnis nicht in derselben HTTP-Antwort benötigt wird. Dazu gehören beispielsweise Exporte, Synchronisationen, aufwendige Indexierungen, Bildverarbeitung, Benachrichtigungen oder die Kommunikation mit externen Systemen. Eigene Plugins können sich in denselben asynchronen Mechanismus integrieren.
Eine Message sollte klein und serialisierbar bleiben. Statt vollständige Entity-Objekte zu transportieren, enthält sie stabile Identifikatoren und die für den Prozess notwendigen Parameter. Der Handler lädt den aktuellen Zustand anschließend selbst. Dadurch bleiben Nachrichten robust und enthalten keine veralteten Objektstrukturen.
final class RebuildProductProjectionMessage
{
public function __construct(
public readonly string $productId
) {
}
}
#[AsMessageHandler]
final class RebuildProductProjectionHandler
{
public function __construct(
private readonly ProjectionService $service
) {
}
public function __invoke(
RebuildProductProjectionMessage $message
): void {
$this->service->rebuild(
$message->productId
);
}
}
Asynchron bedeutet nicht automatisch schnell. Die Queue braucht dauerhaft laufende Worker, ausreichend Speicher, klare Fehlerbehandlung und Monitoring. Bleiben Nachrichten liegen, ist der HTTP-Request zwar schnell, aber die fachliche Aufgabe wird trotzdem nicht abgeschlossen. Der Betrieb gehört deshalb zur Plugin-Architektur und darf nicht erst nach dem Go-live betrachtet werden.
Idempotenz und Wiederholungen einplanen
Message Handler können erneut ausgeführt werden. Ein Worker kann nach einem Fehler neu starten, eine Nachricht kann wiederholt werden oder derselbe fachliche Auslöser kann mehrfach eintreffen. Ein robuster Handler produziert deshalb beim zweiten Lauf keinen doppelten Datensatz und keinen widersprüchlichen Zustand.
- Datensätze anhand stabiler fachlicher Schlüssel aktualisieren statt blind neu anlegen.
- Vor externen Schreiboperationen prüfen, ob der gewünschte Zustand bereits erreicht ist.
- Zwischenschritte so gestalten, dass ein Abbruch sauber fortgesetzt werden kann.
- Fehler mit ausreichend Kontext protokollieren, aber keine sensiblen Inhalte unnötig speichern.
- Unheilbare Fehler von temporären Netzwerk- oder Dienstproblemen unterscheiden.
Scheduled Tasks für regelmäßige Auslöser
Scheduled Tasks eignen sich für wiederkehrende Arbeiten wie Datenbereinigung, Synchronisationsläufe oder die regelmäßige Prüfung externer Zustände. Die Aufgabe wird in einem definierten Intervall eingeplant und über die Message Queue verarbeitet. Auch hier gilt: Der Task sollte nicht versuchen, einen kompletten großen Datenbestand in einer einzigen langen Ausführung zu bearbeiten.
Eine belastbare Strategie arbeitet mit Batches, speichert Fortschritt nachvollziehbar und begrenzt Laufzeit sowie Speicherbedarf. So blockiert ein einzelner Lauf nicht den Worker und kann beim nächsten Intervall fortgesetzt werden. Besonders bei Importen sollte jeder Schritt unabhängig committen können, statt eine lange Datenbanktransaktion über den gesamten Prozess offenzuhalten.
Cache nur mit klarer Invalidierung einsetzen
Caching kann wiederholte Berechnungen und Datenbankzugriffe stark reduzieren. Es verschiebt die Komplexität aber in die Invalidierung. Ein Plugin muss wissen, welche Änderung einen Cache-Eintrag ungültig macht und wie lange ein veralteter Wert akzeptabel wäre. Ein Cache ohne Strategie liefert irgendwann schnell die falschen Daten.
Für kurzlebige, nicht kritische Berechnungen kann eine TTL genügen. Für produkt-, preis- oder bestandsabhängige Daten ist eine ereignisbasierte Invalidierung oft sinnvoller. Dabei sollte wiederum vermieden werden, bei jeder kleinen Änderung den gesamten Cache zu leeren. Präzise Schlüssel und klar abgegrenzte Abhängigkeiten sind effizienter.
Externe APIs nie unkontrolliert in den Request legen
Ein externer Dienst kann langsam antworten, vorübergehend nicht erreichbar sein oder ein Rate Limit setzen. Wird eine solche Anfrage direkt beim Laden einer Produktseite ausgeführt, übernimmt der Shop die Verfügbarkeit des Fremdsystems. Der Nutzer wartet, obwohl Shopware selbst längst antworten könnte.
Wo fachlich möglich, werden externe Daten vorab synchronisiert und lokal bereitgestellt. Muss eine Anfrage unmittelbar erfolgen, braucht sie kurze Timeouts, definierte Fehlerfälle und einen sicheren Fallback. Hintergrundprozesse benötigen zusätzlich Wiederholungsregeln und eine Grenze, ab der ein Fehler sichtbar eskaliert wird.
Monitoring gehört zur Implementierung
Performance-Probleme zeigen sich nicht nur in durchschnittlichen Antwortzeiten. Relevant sind auch hohe Perzentile, Fehlerquoten, Queue-Rückstände, Speicherspitzen und besonders langsame einzelne Abfragen. Ein Plugin, das kritische Hintergrundarbeit ausführt, sollte messbare Zustände hinterlassen.
| Bereich | Sinnvolle Kontrolle |
|---|---|
| DAL | Anzahl und Laufzeit der Queries, Ergebnismenge, Speicherbedarf |
| Events | Ausführungshäufigkeit und Dauer des Subscribers |
| Queue | Rückstand, Fehlversuche, älteste offene Nachricht |
| Scheduled Tasks | Letzte erfolgreiche und nächste geplante Ausführung |
| Externe Dienste | Antwortzeit, Fehlerquote und Rate-Limit-Situation |
| Cache | Trefferquote, Speicherentwicklung und Invalidierungen |
Checkliste für performante Shopware-Plugins
- Der HTTP-Request erledigt nur unmittelbar notwendige Arbeit.
- DAL-Criteria laden nur benötigte Entitäten und Associations.
- Schleifen führen keine wiederholten Einzelabfragen aus.
- Event Subscriber filtern früh und bleiben klein.
- Aufwendige Folgeprozesse laufen über Message Queue oder Scheduled Tasks.
- Messages enthalten IDs und Parameter statt vollständiger Entities.
- Handler sind idempotent und können sicher wiederholt werden.
- Große Datenmengen werden in Batches verarbeitet.
- Cache-Einträge besitzen eine nachvollziehbare Invalidierungsstrategie.
- Worker, Queue-Rückstand und Fehler werden überwacht.
Fazit
Performante Shopware-Plugins entstehen nicht durch nachträgliche Mikrooptimierung. Entscheidend ist die Verteilung der Verantwortung: Das DAL lädt gezielt die wirklich benötigten Daten, Events lösen nur schlanke Reaktionen aus und die Message Queue übernimmt Arbeiten, die nicht in die aktuelle Antwort gehören.
Wer zusätzlich Idempotenz, Batching, Cache-Invalidierung und Monitoring von Beginn an einplant, entwickelt Erweiterungen, die nicht nur im Testsystem funktionieren. Sie bleiben auch bei größeren Katalogen, Importen und steigender Last nachvollziehbar und wartbar.
