Zurück zum Blog
Shopware 6 Plugin-Entwicklung

Custom Entities in Shopware 6: Eigene Datenmodelle mit der DAL sauber bauen

Irgendwann reicht ein Textfeld am Produkt nicht mehr. Ein Plugin braucht eine eigene Tabelle: Service-Tickets, Lieferanten-Stammdaten, Buchungssätze, Retouren-Prognosen. Die Versuchung, einfach „mal eben" eine eigene Doctrine-Tabelle danebenzustellen, ist groß — und der schnellste Weg, sich aus dem Shopware-Ökosystem auszuklinken. Wie du Datenmodelle mit der Data Abstraction Layer baust, sodass API, Admin, Caching und Berechtigungen automatisch mitziehen — und wo der Weg über eine eigene EntityDefinition gegenüber den No-Code-Alternativen wirklich Sinn ergibt.

1. Drei Wege, eigene Daten zu speichern — und wann welcher passt

Bevor man eine Zeile Code schreibt, lohnt die Frage: Braucht es überhaupt eine eigene Entity? Shopware 6 kennt drei Stufen. Custom Fields hängen Zusatzattribute an bestehende Entities (Produkt, Kunde, Bestellung), ohne eigene Tabelle — perfekt für „ein paar Felder mehr", aber ungeeignet, sobald die Daten relational werden oder man danach filtern und sortieren will. custom_entities sind der deklarative Weg über eine entities.xml: Shopware legt Tabelle, API-Routen und ein Admin-Modul automatisch an, ganz ohne PHP. Das ist seit 6.4.11 stabil und besonders für Apps und einfache Stammdaten ideal.

Die dritte Stufe ist die eigene EntityDefinition — voller DAL-Stack in PHP. Den Weg geht man, wenn die Entity nicht nur Daten halten, sondern Verhalten haben soll: berechnete Felder, komplexe Assoziationen, eigene Events beim Schreiben, feingranulare Validierung. Faustregel aus der Praxis: Stammdaten, die ein Redakteur pflegt, liegen gut in custom_entities. Datenmodelle, an denen Geschäftslogik hängt, gehören in eine echte EntityDefinition. Wer beides verwechselt, baut entweder ein überladenes XML-Monster oder reibt sich an einer Definition für drei triviale Spalten auf.

2. Die EntityDefinition: das Herz jeder eigenen Entity

Eine eigene Entity besteht im Kern aus drei PHP-Klassen plus einem Service-Eintrag. Die EntityDefinition erbt von Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition und ist die Quelle der Wahrheit: Sie meldet über getEntityName() den Tabellennamen (Konvention: snake_case mit Plugin-Präfix, etwa futi_supplier) und definiert in defineFields() die Felder. Optional zeigen getEntityClass() und getCollectionClass() auf eine eigene Entity- und Collection-Klasse — fehlen sie, nutzt die DAL generische Fallbacks und liefert ArrayEntity-Objekte zurück.

Registriert wird die Definition in der services.xml mit dem DI-Tag shopware.entity.definition. Mit diesem einen Tag passiert eine Menge automatisch: Shopware generiert ein <entity>.repository im Container, hängt die Entity an die Admin-API, macht sie für Criteria-Queries auffindbar und kennt ihre Assoziationen. Genau das ist der Grund, warum man die DAL nicht umgeht: Eine Doctrine-Tabelle daneben bekäme nichts davon. Die offizielle Doku zeigt die vollständige XML-Struktur.

3. defineFields: Felder, Flags und warum Binär-UUIDs

defineFields() gibt eine FieldCollection zurück — eine Liste aus Field-Objekten wie IdField, StringField, BoolField, IntField oder FkField. Jedes Feld trägt Flags, die sein Verhalten steuern: PrimaryKey, Required und vor allem ApiAware. Ohne das ApiAware-Flag erscheint ein Feld nicht in der API-Antwort — was bei sensiblen internen Spalten erwünscht ist und bei einem vergessenen Feld zu der klassischen „warum ist die Spalte in der Datenbank, aber nicht in der Response?"-Stunde führt.

Die Primärschlüssel sind in der DAL grundsätzlich 128-Bit-UUIDs, in der Datenbank als BINARY(16) gespeichert, im Code und in der API als 32-stelliger Hex-String. Die DAL übernimmt die Umwandlung — du arbeitest immer mit Hex, nie mit dem Binärwert. Eine eigene UUID erzeugst du über Uuid::randomHex(). Wer hier aus alter PHP-Gewohnheit ein Auto-Increment-INT als Primärschlüssel setzt, kämpft anschließend gegen den ganzen Stack: Assoziationen, Sales-Channel-Vererbung und das Versionierungssystem setzen Binär-UUIDs voraus.

4. Migrations: das Schema gehört in die Datenbank, nicht in die Definition

Ein häufiges Missverständnis: Die EntityDefinition legt keine Tabelle an. Sie beschreibt nur, wie die DAL die bereits existierende Tabelle interpretiert. Das eigentliche Schema kommt aus einer Migration. Migrations liegen unter src/Migration, heißen Migration1719000000Supplier (die Zahl ist ein Unix-Timestamp und bestimmt die Reihenfolge) und implementieren getCreationTimestamp() sowie update().

Der wichtigste Punkt: Tabellenschema in der Migration und Felddefinition in der EntityDefinition müssen exakt zusammenpassen. Ein Feld, das in defineFields() steht, aber in der Tabelle fehlt, führt nicht zu einem sauberen Fehler beim Aktivieren — es kracht erst, wenn jemand die Entity tatsächlich liest oder schreibt. Deshalb gehört zu jeder Definitionsänderung eine neue Migration, und die destruktiven Operationen (Spalten löschen) trennt man bewusst in updateDestructive(), das Shopware separat und später ausführt — so verliert ein vorschnelles Update keine Kundendaten.

5. Das Repository: CRUD ohne eine Zeile SQL

Sobald die Definition registriert ist, steht im Container ein EntityRepository unter der Service-ID futi_supplier.repository bereit, das du dir per Constructor Injection holst. Über dieses Repository läuft das gesamte Lesen und Schreiben: create(), update(), upsert(), delete() und search(). Jeder dieser Aufrufe braucht ein Context-Objekt — das ist kein Boilerplate, sondern trägt Sprache, Währung, Berechtigungen und Versionskontext mit. Wer den Context aus dem aktuellen Request durchreicht statt einen Context::createDefaultContext() zu erfinden, bekommt Übersetzungen und Sales-Channel-Logik geschenkt.

Der Lohn dieser Disziplin: Jeder Schreibvorgang feuert automatisch written-Events, an die sich andere Plugins per Subscriber hängen können, und das Caching-System invalidiert die betroffenen Einträge. All das fällt weg, sobald man am Repository vorbei direkt SQL schreibt. Direkter DBAL-Zugriff ist legitim für reine Massen-Reads in Reports — aber nie für Schreibvorgänge, an denen Geschäftslogik hängt.

6. Criteria, Associations und der N+1-Klassiker

Gelesen wird mit einem Criteria-Objekt, das man an search() übergibt. Es kapselt das, wofür man sonst SQL schreiben würde: addFilter() mit EqualsFilter, RangeFilter oder ContainsFilter, dazu addSorting(), setLimit() und Pagination. Die DAL übersetzt das in eine einzige optimierte Query und liefert hydrierte Entity-Objekte zurück — kein manuelles Mapping von Zeilen auf Objekte.

Die häufigste Performance-Falle: verknüpfte Daten. Wer über eine Liste von Lieferanten iteriert und in der Schleife pro Eintrag das zugehörige Land nachlädt, produziert das klassische N+1-Problem — eine Query plus eine je Zeile. Die DAL löst das mit addAssociation('country'): Die Assoziation wird vorab geladen, in einem Rutsch, und steht am Entity-Objekt fertig bereit. Voraussetzung ist, dass die Assoziation in der EntityDefinition über ein ManyToOneAssociationField (oder das passende Gegenstück) deklariert ist. Genau hier zahlt sich die saubere Definition aus: Assoziationen, die man dort einmal richtig modelliert, machen jede spätere Query schlank.

7. Fazit: wann DAL, wann custom_entities

Eine eigene EntityDefinition ist mehr Aufwand als ein paar Zeilen XML — aber sie zahlt sich aus, sobald die Daten Teil der Geschäftslogik werden. Sie gibt dir Events, Caching-Integration, API-Anbindung, Berechtigungen und ein typsicheres Repository, ohne dass du eine dieser Schichten selbst bauen musst. Für interne Datenmodelle mit Verhalten, Validierung und Assoziationen führt kein vernünftiger Weg daran vorbei.

Umgekehrt gilt: Nicht jede Tabelle braucht den vollen Stack. Für Stammdaten, die jemand im Backend pflegt und die keine Logik tragen, sind custom_entities schneller gebaut und leichter zu warten — inklusive automatischem Admin-Modul. Die Kunst liegt nicht darin, immer die mächtigste Lösung zu nehmen, sondern die passende. Wer diese Entscheidung am Anfang bewusst trifft, baut Plugins, die sich auch nach drei Shopware-Updates noch sauber anfühlen — statt einer Doctrine-Insel, die mit jedem Core-Release ein Stück weiter vom Ökosystem abdriftet.

Eigenes Datenmodell im Shopware-Plugin — und es soll updatesicher bleiben?

Lass uns in 30 Minuten gemeinsam auf dein Datenmodell schauen — wo eine eigene EntityDefinition lohnt, wo custom_entities reichen und wie wir Migrations und Assoziationen sauber aufsetzen. Kein Sales-Call.

Google Meet Termin buchen

Bereit für den nächsten Sprint? Lass uns sprechen.

Google Meet Termin buchen

Dieser Link führt zu Google Calendar (Google Ireland Ltd.). Es gelten die Datenschutzbestimmungen von Google.