Warum dieselbe Entity je nach Kontext unterschiedliche Regeln braucht, und wie man das ohne Duplikat-Klassen löst
Eine User-Entity, die bei der Registrierung ein Passwort mit Mindestlänge verlangt, beim späteren Profil-Update aber ein leeres Passwortfeld einfach ignorieren soll, stellt viele Teams vor dieselbe Frage: Braucht es zwei getrennte Entity-Klassen, oder reicht ein Satz Constraints mit etwas Zusatzlogik. Symfonys Validator-Component beantwortet das mit Validation Groups und Group Sequences, zwei Mechanismen, die dieselbe Klasse je nach Kontext unterschiedlich validieren, ohne dass Code dupliziert oder Validierungslogik in verstreute if-Abfragen ausgelagert werden muss. Dieser Artikel zeigt beide Werkzeuge an einem durchgängigen Registrierungs- und Profil-Update-Beispiel.
Inhaltsverzeichnis
- 1. Warum eine Entity je nach Kontext unterschiedliche Regeln braucht
- 2. Validation Groups: Constraints gezielt gruppieren
- 3. Group Sequences: Erst Basis-Constraints, dann teure Constraints
- 4. GroupSequenceProvider für dynamische Sequenzen
- 5. Praktisches DTO-Beispiel: Registrierung vs. Profil-Update
- 6. Teure Constraints erst nach bestandener Basis-Validierung
- 7. Groups in Formularen und API-Endpoints konsistent anwenden
- 8. Fehlermeldungen kontextabhängig gestalten
- 9. Häufige Fallstricke bei Gruppen und Sequenzen
- 10. Zusammenfassung
- 11. FAQ
1. Warum eine Entity je nach Kontext unterschiedliche Regeln braucht
Eine User-Entity taucht typischerweise in mehreren, fachlich ganz unterschiedlichen Formularen auf: bei der Registrierung mit Pflichtfeldern für E-Mail und Passwort, beim Profil-Update, wo das Passwort meist leer bleibt und nicht neu gesetzt wird, und vielleicht bei einem Admin-Formular, das zusätzliche interne Felder wie eine Kundennummer verlangt. Würde man alle Constraints ohne Gruppierung direkt an die Klasse hängen, müsste ein leeres Passwortfeld beim Profil-Update entweder als gültig durchgehen, obwohl bei der Registrierung dieselbe Leere ein harter Fehler ist, oder man baut zwei fast identische Entity-Klassen mit all den Nachteilen von Code-Duplikation.
Validation Groups lösen dieses Problem, indem sie Constraints nicht global, sondern gezielt einer oder mehreren benannten Gruppen zuordnen, und der aufrufende Code beim Validieren angibt, welche Gruppe gerade relevant ist. Dieselbe Klasse trägt damit alle möglichen Regeln in sich, aber welche davon tatsächlich greifen, entscheidet der jeweilige Anwendungsfall zur Laufzeit. Das ist nicht nur eleganter als Duplikation, sondern hält auch sicher, dass Änderungen an einer gemeinsamen Regel, etwa dem E-Mail-Format, automatisch in allen Kontexten wirken.
2. Validation Groups: Constraints gezielt gruppieren
In der Praxis weist man einem Constraint per groups-Parameter eine oder mehrere Gruppen zu, etwa #[Assert\NotBlank(groups: ['registration'])] für ein Feld, das nur bei der Registrierung Pflicht ist. Constraints ohne explizite Gruppenangabe landen automatisch in der Gruppe Default, die bei jeder Validierung ohne explizite Gruppenangabe verwendet wird. Beim eigentlichen Validieren übergibt man dann die gewünschte Gruppe als zweites Argument an $validator->validate($entity, null, ['registration']), und nur die Constraints dieser Gruppe (plus gegebenenfalls Default) werden ausgewertet.
Ein häufiger Stolperstein ist, dass die Default-Gruppe bei Entities, die selbst als Klassenname referenziert werden, implizit auch die Gruppe mit dem eigenen Klassennamen umfasst, was bei Vererbung zu unerwarteten Effekten führen kann. Deshalb ist es meist sauberer, bei jedem sicherheitsrelevanten Feld die Gruppe explizit zu benennen, statt sich auf implizites Verhalten zu verlassen. So bleibt für jeden, der die Klasse später liest, sofort erkennbar, in welchem Kontext ein Constraint tatsächlich greift.
3. Group Sequences: Erst Basis-Constraints, dann teure Constraints
Wenn ein DTO sowohl günstige Constraints wie NotBlank oder Length als auch teure Constraints enthält, die eine Datenbankabfrage auslösen, etwa die Prüfung, ob eine E-Mail-Adresse bereits vergeben ist, möchte man diese teure Prüfung nur dann ausführen, wenn die Basis-Constraints bereits bestanden haben. Ohne Steuerung würden bei einer komplett leeren Eingabe trotzdem alle Constraints parallel evaluiert, inklusive einer sinnlosen Datenbankabfrage für ein leeres Feld, das ohnehin schon als ungültig markiert wird.
Genau dafür existiert das #[GroupSequence]-Attribut: Es definiert eine geordnete Liste von Gruppen, die nacheinander abgearbeitet werden, wobei die Validierung bei der ersten Gruppe mit einem Fehler stoppt und nachfolgende Gruppen gar nicht erst geprüft werden. Das folgende Beispiel zeigt ein Registrierungs-DTO, bei dem zuerst die Basis-Constraints der Gruppe Default greifen, und die teure Eindeutigkeits-Prüfung der E-Mail erst in der zweiten Gruppe strict ausgeführt wird, wenn die erste Gruppe fehlerfrei durchlief.
<?php
declare(strict_types=1);
namespace App\Dto;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Attribute\GroupSequence;
use Symfony\Component\Validator\Constraints as Assert;
#[GroupSequence(['RegistrationDto', 'strict'])]
final class RegistrationDto
{
public function __construct(
#[Assert\NotBlank(message: 'Bitte eine E-Mail-Adresse angeben.')]
#[Assert\Email(message: 'Das E-Mail-Format ist ungültig.')]
#[AppAssert\UniqueEmail(groups: ['strict'])]
public readonly string $email = '',
#[Assert\NotBlank(message: 'Bitte ein Passwort wählen.')]
#[Assert\Length(min: 12, minMessage: 'Das Passwort muss mindestens 12 Zeichen haben.')]
#[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_MEDIUM, groups: ['strict'])]
public readonly string $plainPassword = '',
) {
}
}
4. GroupSequenceProvider für dynamische Sequenzen
Das statische #[GroupSequence]-Attribut reicht, solange die Reihenfolge der Gruppen zur Compile-Zeit feststeht. Manchmal hängt die richtige Sequenz aber vom Zustand des Objekts selbst ab, etwa ob ein Benutzer bereits ein bestätigtes Konto hat und dadurch strengeren Regeln unterliegt als ein frisch angelegter, noch unbestätigter Account. Für diesen Fall bietet Symfony GroupSequenceProviderInterface, das eine Methode getGroupSequence() vorschreibt, die zur Laufzeit basierend auf dem aktuellen Objektzustand entscheidet, welche Gruppen in welcher Reihenfolge greifen.
Diese Flexibilität hat ihren Preis: Der Code wird schwerer nachvollziehbar, weil die tatsächliche Validierungsreihenfolge nicht mehr direkt aus den Attributen der Klasse ablesbar ist, sondern erst zur Laufzeit in der Provider-Methode entsteht. In der Praxis lohnt sich GroupSequenceProviderInterface daher nur, wenn die Sequenz wirklich vom Datenzustand abhängt, während für die deutlich häufigeren Fälle unterschiedlicher Formulare das statische Attribut völlig ausreicht und leichter zu lesen bleibt.
5. Praktisches DTO-Beispiel: Registrierung vs. Profil-Update
Im Registrierungs-Kontext aus dem vorherigen Beispiel ist das Passwort ein Pflichtfeld mit Mindestlänge. Beim Profil-Update dagegen soll ein leeres Passwortfeld bedeuten, dass der Benutzer es unverändert lassen möchte, während ein ausgefülltes Feld weiterhin denselben Stärke-Anforderungen genügen muss wie bei der Registrierung. Anstatt ein komplett neues DTO zu bauen, lässt sich das elegant über die Kombination aus Assert\Length ohne NotBlank und einer eigenen Gruppe profile_update abbilden, die im Controller explizit angefordert wird.
Der Controller für das Profil-Update ruft den Validator dann mit $validator->validate($dto, null, ['profile_update']) auf, während der Registrierungs-Controller weiterhin die Standardgruppen nutzt. Diese saubere Trennung nach Kontext, nicht nach Feld, ist der Kern des Group-Musters: Ein Feld trägt mehrere mögliche Regeln in sich, aber der aufrufende Code entscheidet je nach Situation, welche davon tatsächlich zählen. Das hält die Entity oder das DTO als einzige Quelle der Wahrheit, ohne dass Validierungslogik in Controllern verstreut wird.
6. Teure Constraints erst nach bestandener Basis-Validierung
Datenbankabfragen, externe API-Calls oder rechenintensive Prüfungen wie eine Passwort-Stärke-Bewertung sollten grundsätzlich in einer späteren Gruppe einer Group Sequence stehen, niemals in der ersten. Der Grund ist simpel: Wenn ein Pflichtfeld leer ist, ist die teure Prüfung ohnehin sinnlos, denn das Ergebnis wird durch den bereits vorhandenen Basis-Fehler überschrieben. Bei einem stark frequentierten Registrierungsformular kann das den Unterschied zwischen einer trivialen String-Prüfung und hunderten unnötigen Datenbankabfragen pro Minute ausmachen, wenn Bots das Formular mit leeren oder zufälligen Werten fluten.
Ein weiterer Vorteil dieser Reihenfolge ist die Fehlermeldung selbst: Ein Benutzer, der ein komplett leeres Formular abschickt, sieht sofort 'Bitte eine E-Mail-Adresse angeben' statt einer verwirrenden Kombination aus Pflichtfeld- und Eindeutigkeitsfehler gleichzeitig. Das Interface bleibt dadurch nicht nur performanter, sondern auch für den Endnutzer klarer und weniger überladen, was gerade bei mehrstufigen Registrierungsformularen einen spürbaren Unterschied in der Abbruchrate machen kann.
7. Groups in Formularen und API-Endpoints konsistent anwenden
Im Symfony Form-Component lässt sich die zu verwendende Gruppe direkt in den Form-Options angeben, etwa 'validation_groups' => ['profile_update'], sodass das Formular automatisch die passende Teilmenge an Constraints prüft, ohne dass der Controller selbst den Validator manuell aufrufen muss. Bei API-Endpoints, die DTOs direkt aus dem Request-Body deserialisieren, etwa mit Symfonys #[MapRequestPayload]-Attribut, kann die Gruppe ebenfalls als Attribut-Parameter oder als expliziter $validator->validate()-Aufruf im Controller mitgegeben werden.
Wichtig ist, diese Zuordnung von Route zu Gruppe an einer zentralen, gut auffindbaren Stelle zu dokumentieren, etwa direkt als Kommentar über dem jeweiligen Controller-Endpoint, denn sonst verliert man schnell den Überblick, welcher Endpoint welche Gruppe erwartet. Ein Test, der für jeden Endpoint pruft, dass genau die erwartete Gruppe aktiv ist, etwa durch bewusstes Versenden eines Requests, der nur bei falscher Gruppenwahl durchrutschen würde, fängt Regressionsfehler zuverlässig ab, bevor sie in Produktion landen.
8. Fehlermeldungen kontextabhängig gestalten
Derselbe Constraint kann in unterschiedlichen Kontexten unterschiedliche Fehlermeldungen brauchen: Bei der Registrierung ist 'Diese E-Mail-Adresse ist bereits vergeben, bitte einloggen' eine hilfreiche Meldung, während beim Ändern der eigenen E-Mail-Adresse im Profil eine andere Formulierung wie 'Diese Adresse wird bereits von einem anderen Konto verwendet' passender ist. Da Constraints an ein und demselben Feld hängen, aber über unterschiedliche Gruppen unterschiedlich aktiviert werden, lassen sich pro Gruppe auch unterschiedliche Constraint-Instanzen mit eigenen message-Texten definieren.
In der Praxis bedeutet das, zwei separate Attribute mit demselben zugrunde liegenden Validator, aber unterschiedlichen Gruppen und Nachrichten an dasselbe Feld zu hängen, etwa einmal für die Gruppe Default mit einer registrierungsspezifischen Formulierung und einmal für profile_update mit einer profilspezifischen. Das wirkt auf den ersten Blick nach Redundanz, spart aber am Ende deutlich mehr Aufwand, als wenn man generische, für keinen Kontext wirklich passende Fehlermeldungen verwendet, die beim Support für Rückfragen sorgen.
9. Häufige Fallstricke bei Gruppen und Sequenzen
Ein klassischer Fehler ist, zu vergessen, dass ein Aufruf von $validator->validate($entity) ohne dritten Parameter implizit nur die Gruppe Default prüft, wodurch alle Constraints mit einer expliziten, abweichenden Gruppe stillschweigend übersprungen werden, ohne dass eine Warnung erscheint. Das führt dazu, dass ein neu hinzugefügter Constraint in einer eigenen Gruppe scheinbar wirkungslos bleibt, obwohl der Code syntaktisch vollkommen korrekt ist, und die Ursache erst nach längerer Fehlersuche im aufrufenden Code gefunden wird.
Ein zweiter häufiger Fallstrick betrifft #[GroupSequence] in Kombination mit Vererbung: Eine Kindklasse, die eigene Constraints hinzufügt, aber keine eigene GroupSequence definiert, erbt nicht automatisch die Sequenz der Elternklasse in der erwarteten Weise, da Group Sequences nicht klassenübergreifend vererbt werden wie normale Methoden. Wer mit Vererbung und Gruppen gleichzeitig arbeitet, sollte deshalb für jede konkrete Klasse explizit testen, welche Reihenfolge tatsächlich greift, statt sich auf intuitive Vererbungsannahmen zu verlassen.
| Mechanismus | Zweck | Wann sinnvoll | Aufruf |
|---|---|---|---|
| Validation Groups | Constraints kontextabhängig ein-/ausschalten | Registrierung vs. Profil-Update | validate($x, null, ['gruppe']) |
#[GroupSequence] |
Feste Reihenfolge, Abbruch bei erstem Fehler | Teure Constraints nach Basis-Prüfung | Attribut an der Klasse |
GroupSequenceProviderInterface |
Dynamische Reihenfolge je nach Objektzustand | Sequenz hängt von Datenwerten ab | getGroupSequence() |
Gruppe Default |
Wird ohne Angabe automatisch genutzt | Basis-Constraints ohne Kontextbezug | Implizit bei jedem Aufruf |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
Symfony Validation Groups: Das Wichtigste auf einen Blick
Validation Groups
Ein DTO, mehrere Regelsätze, aktiviert je nach aufrufendem Kontext.
Group Sequence
Basis-Constraints zuerst, teure Prüfungen wie DB-Abfragen erst danach.
GroupSequenceProvider
Nur bei wirklich dynamischer, vom Objektzustand abhängiger Reihenfolge.
Fallstrick
validate() ohne Gruppenangabe prüft nur Default, andere Gruppen werden stillschweigend übersprungen.