für komplexe Validierungsregeln schreiben
Die eingebauten Symfony Validator-Constraints decken Standardfälle ab – aber Geschäftslogik wie eindeutige Slugs, domänenspezifische Formate oder Abhängigkeiten zwischen Feldern erfordern eigene Constraint-Klassen. Wer sie richtig implementiert, hält Validierungslogik testbar, wiederverwendbar und sauber von Controllern getrennt.
Inhaltsverzeichnis
- 1. Warum eigene Constraints statt Validierungslogik im Controller
- 2. Aufbau einer Constraint-Klasse in Symfony
- 3. Den ConstraintValidator implementieren
- 4. Services in ConstraintValidatoren injizieren
- 5. Class-Level-Constraints für Cross-Field-Validierung
- 6. Validation Groups: Regeln kontextabhängig aktivieren
- 7. Constraints als PHP-Attribute einsetzen
- 8. Eigene Constraints unit-testen
- 9. Vergleich: Built-in vs. Custom Constraints
- 10. Zusammenfassung
- 11. FAQ
1. Warum eigene Constraints statt Validierungslogik im Controller
Der häufigste Fehler bei der Symfony Validator-Integration ist das Verlagern von Validierungslogik in Controller oder Services, statt sie in eigenständige Constraint-Klassen auszulagern. Ein Controller, der prüft ob ein Slug eindeutig ist, ob eine IBAN zu einem Bankkonto passt oder ob Startdatum vor Enddatum liegt, enthält damit implizit Geschäftsregeln, die an vielen Stellen im Code gelten sollen. Diese Logik lässt sich nicht ohne Duplizierung wiederverwenden – und sie ist schwerer zu testen als eine isolierte Validator-Klasse.
Der Symfony Validator bietet mit dem Constraint-System genau dafür die richtige Abstraktion. Eine eigene Constraint-Klasse kapselt die Fehlermeldungen und Optionen, der zugehörige ConstraintValidator enthält die Prüflogik. Beide zusammen sind über PHP-Attribute an jede Eigenschaft oder Klasse anheftbar – ohne dass der Controller auch nur eine Zeile Validierungscode enthält. Symfony erkennt eigene Constraints automatisch per Autowiring, wenn der Validator die richtige Namenskonvention oder das ConstraintValidatorInterface implementiert.
Die Trennung hat einen weiteren Vorteil: Fehlersammlungen bleiben konsistent. Der Symfony Validator aggregiert alle Verletzungen über einen Validierungsdurchlauf, egal ob der Fehler aus einem Built-in-Constraint wie NotBlank oder aus einem eigenen Constraint kommt. Das Ergebnis ist ein ConstraintViolationList-Objekt, das einheitlich in Formular-Fehler, API-Antworten oder Logeinträge umgewandelt werden kann.
2. Aufbau einer Constraint-Klasse in Symfony
Eine eigene Constraint-Klasse in Symfony erbt von Symfony\Component\Validator\Constraint. Die Klasse definiert zwei Dinge: die Fehlermeldung als öffentliche Konstante oder Eigenschaft und optionale Konfigurationsparameter, die beim Einsetzen des Constraints angegeben werden können. Seit PHP 8.0 kann dieselbe Klasse als PHP-Attribut verwendet werden – dafür muss sie das Attribut #[Attribute] tragen. In Symfony 7 ist das der bevorzugte Ansatz, weil er keine separate YAML- oder XML-Konfiguration benötigt.
Die Methode validatedBy() gibt den Service-Namen des zugehörigen Validators zurück. Symfony leitet ihn per Konvention automatisch ab: Für App\Validator\UniqueSlug sucht der Container nach einem Service mit dem Namen App\Validator\UniqueSlugValidator. Wer eigene Service-Namen verwenden will, überschreibt validatedBy() und gibt den vollqualifizierten Klassennamen zurück. Die Methode getTargets() gibt an, ob der Constraint auf Properties (PROPERTY_CONSTRAINT), auf Klassen (CLASS_CONSTRAINT) oder auf beides angewendet werden darf. Ein fehlerhafter Target führt beim Validierungsdurchlauf zu einer klaren Ausnahme, nicht zu stiller Ignoranz.
Constraint-Optionen werden als konstruktorlose öffentliche Eigenschaften definiert. Seit PHP 8.1 mit Constructor Property Promotion werden sie direkt im Konstruktor deklariert, mit Standardwerten versehen und sind damit sofort per Attribut-Parameter setzbar: #[UniqueSlug(field: 'slug', message: 'Dieser Slug ist bereits vergeben.')]. Die Basisklasse Constraint übernimmt das Mapping der benannten Argumente auf die Eigenschaften im Konstruktor.
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
/**
* Constraint that validates the uniqueness of a slug in the database.
* Usage: #[UniqueSlug] on a string property or via YAML/XML config.
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class UniqueSlug extends Constraint
{
/**
* Error message shown when the slug is already taken.
* The { { value } } placeholder is replaced with the submitted value.
*/
public string $message = 'Der Slug "{ { value } }" ist bereits vergeben.';
/**
* Entity class to check against — defaults to null (caller must set it).
*/
public ?string $entityClass = null;
/**
* Field name to check for uniqueness.
*/
public string $field = 'slug';
/**
* Optional: exclude a specific ID from the uniqueness check (for edit forms).
*/
public ?int $excludeId = null;
/**
* @param string|null $message Custom error message
* @param string|null $entityClass FQCN of the entity to query
*/
public function __construct(
public readonly array $groups = [],
public readonly mixed $payload = null,
?string $message = null,
?string $entityClass = null,
string $field = 'slug',
?int $excludeId = null,
array $options = [],
) {
parent::__construct($options, $groups, $payload);
if ($message !== null) {
$this->message = $message;
}
if ($entityClass !== null) {
$this->entityClass = $entityClass;
}
$this->field = $field;
$this->excludeId = $excludeId;
}
}
3. Den ConstraintValidator implementieren
Der ConstraintValidator ist die eigentliche Prüflogik. Er erbt von Symfony\Component\Validator\ConstraintValidator und implementiert die Methode validate(mixed $value, Constraint $constraint). Die erste Aufgabe in jeder validate()-Methode: Typprüfung des Constraints und Early Returns für Null- oder Leerstring-Werte, die der Symfony Validator mit separaten Constraints wie NotBlank behandeln soll. Das hält jeden Validator auf seinen Zuständigkeitsbereich fokussiert.
Wenn die Validierung fehlschlägt, erzeugt man über $this->context->buildViolation($constraint->message) eine Verletzung. Die Methoden der Builder-API erlauben es, Platzhalterwerte für die Fehlermeldung zu setzen (setParameter('{ { value } }', $value)), den Pfad der Verletzung innerhalb eines Objekts zu präzisieren (atPath('email')) und einen spezifischen Fehlercode zu vergeben (setCode(UniqueSlug::NOT_UNIQUE_ERROR)). Der abschließende Aufruf addViolation() trägt den Fehler in die ConstraintViolationList ein, ohne die Validierung abzubrechen – alle weiteren Constraints werden weiterhin geprüft.
Wichtig: Der ConstraintValidator darf keinen Zustand zwischen zwei Validierungsaufrufen halten. Er ist ein Service und wird als Singleton vom Container verwaltet. Alle zustandsbehafteten Daten gehören in lokale Variablen der validate()-Methode oder werden über Constructor Injection als stateless Services eingebracht. Diese Regel gilt auch für injizierte Repositories: Das Repository selbst ist zustandslos, nur der EntityManager hat Zustand – und der ist als Service scoped verwaltet.
<?php
declare(strict_types=1);
namespace App\Validator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
/**
* Validates that a slug value is unique within a given entity class.
*/
final class UniqueSlugValidator extends ConstraintValidator
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
/**
* Check that the given slug does not already exist in the database.
*
* @throws UnexpectedTypeException if the constraint is not a UniqueSlug instance
* @throws UnexpectedValueException if the value is not a string
*/
public function validate(mixed $value, Constraint $constraint): void
{
// Type guard — ensures correct constraint class
if (!$constraint instanceof UniqueSlug) {
throw new UnexpectedTypeException($constraint, UniqueSlug::class);
}
// Skip validation for null and empty values — use NotBlank for those
if (null === $value || '' === $value) {
return;
}
if (!\is_string($value)) {
throw new UnexpectedValueException($value, 'string');
}
if ($constraint->entityClass === null) {
throw new \LogicException('UniqueSlug constraint requires entityClass to be set.');
}
// Build query dynamically based on entity class and field name
$qb = $this->entityManager->createQueryBuilder()
->select('COUNT(e.id)')
->from($constraint->entityClass, 'e')
->where('e.' . $constraint->field . ' = :value')
->setParameter('value', $value);
// Exclude current record when editing (prevents false positives)
if ($constraint->excludeId !== null) {
$qb->andWhere('e.id != :excludeId')
->setParameter('excludeId', $constraint->excludeId);
}
$count = (int) $qb->getQuery()->getSingleScalarResult();
if ($count > 0) {
// Build violation with placeholder replacement and specific error code
$this->context->buildViolation($constraint->message)
->setParameter('{ { value } }', $value)
->setCode('UNIQUE_SLUG_NOT_UNIQUE')
->addViolation();
}
}
}
4. Services in ConstraintValidatoren injizieren
Der Symfony Validator integriert sich vollständig mit dem Symfony-Service-Container. Ein ConstraintValidator ist ein normaler Service und unterstützt Constructor Injection mit Autowiring. Damit lassen sich Repository-Klassen, externe API-Clients, Caches oder andere Services direkt in den Validator injizieren – ohne manuelle Service-Definition in services.yaml, solange Autowiring aktiviert ist. Das ist der entscheidende Vorteil gegenüber Validierungslogik im Controller: Der Validator bekommt alle Abhängigkeiten vom Container, nicht vom Controller.
Wer mehrere ConstraintValidatoren mit derselben Abhängigkeit hat, kann einen abstrakten Basis-Validator einführen, der die gemeinsamen Services per Constructor Property Promotion erhält. Alle konkreten Validators erben dann davon. Alternativ – und sauberer in modernem Symfony – ist das Delegate-Pattern: Der Validator delegiert komplexe Datenbankabfragen an einen dedizierten ValidationQueryService, der die SQL-Logik kapselt. So bleibt der Validator dünn und der Query-Service unabhängig testbar.
Ein häufiger Fehler bei Service-Injection in ConstraintValidatoren: zirkuläre Abhängigkeiten, wenn ein Validator eine Entity-bezogene Service-Klasse injiziert, die ihrerseits den Validator nutzt. Die Lösung ist eine saubere Schichtentrennung: Der Validator kennt nur das Repository oder einen Query-Service, niemals Application-Services oder Command-Handler, die selbst die Validierung triggern könnten.
5. Class-Level-Constraints für Cross-Field-Validierung
Property-Level-Constraints validieren eine einzelne Eigenschaft isoliert. Für Validierungsregeln, die mehrere Felder eines Objekts gleichzeitig betreffen – etwa dass Enddatum nach Startdatum liegt oder dass mindestens eine von zwei optionalen E-Mail-Adressen angegeben ist – braucht man Class-Level-Constraints im Symfony Validator. Der Unterschied liegt in der Methode getTargets(): Sie gibt Constraint::CLASS_CONSTRAINT zurück, und der ConstraintValidator empfängt das gesamte Objekt statt eines einzelnen Feldwerts.
In der validate()-Methode eines Class-Level-Validators ist das übergebene $value das vollständige Objekt. Man greift auf alle Felder zu und kann beliebig komplexe Abhängigkeiten prüfen. Verletzungen werden mit atPath('feldname') dem spezifischen Feld zugewiesen, damit Formular-Renderer die Fehlermeldung an der richtigen Stelle anzeigen. Ohne atPath() erscheint der Fehler auf Objekt-Ebene, was in Formularen oft am falschen Ort dargestellt wird.
Class-Level-Constraints werden als Attribut auf der Klasse deklariert, nicht auf einer Eigenschaft. Das Attribut-Target muss dann \Attribute::TARGET_CLASS beinhalten. Der häufige Anwendungsfall in Symfony-Projekten sind DTO-Klassen für Formulare und API-Requests: Das DTO enthält alle eingehenden Daten, der Class-Level-Constraint validiert das Zusammenspiel der Felder bevor irgendein Service die Daten verarbeitet.
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Class-level constraint: validates that the end date is after the start date.
* Applied on the DTO class, not on individual properties.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class DateRangeValid extends Constraint
{
public string $message = 'Das Enddatum muss nach dem Startdatum liegen.';
// Returns CLASS_CONSTRAINT so Symfony passes the whole object to validate()
public function getTargets(): string|array
{
return self::CLASS_CONSTRAINT;
}
}
/**
* Validator for the DateRangeValid constraint.
* Receives the full DTO object and checks start/end date relationship.
*/
final class DateRangeValidValidator extends ConstraintValidator
{
/**
* Validate that endDate > startDate on the submitted DTO.
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof DateRangeValid) {
throw new UnexpectedTypeException($constraint, DateRangeValid::class);
}
// $value is the whole object (DTO/Entity) when getTargets() = CLASS_CONSTRAINT
if (!method_exists($value, 'getStartDate') || !method_exists($value, 'getEndDate')) {
return;
}
$startDate = $value->getStartDate();
$endDate = $value->getEndDate();
// Skip if either date is null — use NotNull for that separately
if ($startDate === null || $endDate === null) {
return;
}
if ($endDate <= $startDate) {
// atPath() pins the violation to the endDate field in the form
$this->context->buildViolation($constraint->message)
->atPath('endDate')
->addViolation();
}
}
}
// Usage on a DTO class:
// #[DateRangeValid]
// final class EventCreateDto
// {
// public ?DateTimeImmutable $startDate = null;
// public ?DateTimeImmutable $endDate = null;
// }
6. Validation Groups: Regeln kontextabhängig aktivieren
Der Symfony Validator unterstützt Validation Groups, um denselben Datentyp in unterschiedlichen Kontexten mit unterschiedlichen Regeln zu validieren. Beim Erstellen eines Benutzers ist ein Passwort-Feld Pflicht, beim Bearbeiten optional. Mit Validation Groups wird derselbe DTO-Klasse für beide Szenarien genutzt, und die Constraints erhalten eine groups-Option. Die Gruppe Default gilt immer, sofern keine expliziten Gruppen übergeben werden.
In Symfony-Formularen setzt man Gruppen über die validation_groups-Option des Formular-Typs oder über einen GroupSequenceProvider. Letzterer ermöglicht dynamische Gruppenauswahl basierend auf dem Objektzustand: Ist das Objekt neu, gelten Erstellungsregeln; ist es bereits persistiert, gelten Aktualisierungsregeln. Eigene Constraint-Klassen akzeptieren das groups-Attribut automatisch, weil es in der Basisklasse Constraint definiert ist.
Für API-Plattform-Integrationen im Symfony-Projekt steuert man Validation Groups per Operations-Konfiguration: Die POST-Operation verwendet ['Default', 'Create'], die PUT-Operation ['Default', 'Update']. Die eigenen Constraint-Klassen tragen dann gezielt die passende Gruppe, ohne dass ein eigener Validator dupliziert werden muss. Das reduziert den Wartungsaufwand erheblich, wenn sich Validierungsregeln zwischen Erstellung und Aktualisierung unterscheiden.
7. Constraints als PHP-Attribute einsetzen
Seit PHP 8.0 können Constraint-Klassen als native PHP-Attribute eingesetzt werden. Im Symfony-Ökosystem ist das seit Symfony 5.2 offiziell unterstützt und seit Symfony 7 der bevorzugte Konfigurationsweg. Der Vorteil: Die Validierungsregeln stehen direkt bei den Eigenschaften, sind IDE-unterstützt (Autocomplete, Typchecks) und unterliegen dem PHP-Parsing statt YAML-Parsing. Für eigene Constraint-Klassen muss die Klasse das native PHP-Attribut #[\Attribute] tragen, und der Konstruktor muss die Parameter definieren, die als Attribut-Argumente übergeben werden.
Beim Einsatz als Attribut ist zu beachten, dass PHP-Attribute keine Vererbung von Argumenten zwischen Klassen kennen. Der Konstruktor der eigenen Constraint-Klasse muss alle gewünschten Parameter explizit definieren. Die Basisklasse Constraint erwartet als erstes Argument im Konstruktor entweder ein Options-Array oder einzelne benannte Argumente. Symfony 7 empfiehlt die benannte Argumente-Variante, weil sie lesbar und IDE-freundlich ist.
Ein praktischer Tipp für Projekte mit vielen eigenen Constraints: Alle Constraint-Klassen in einem einzigen Namespace App\Validator zu halten und die Validator-Klassen direkt daneben (App\Validator\UniqueSlugValidator). Das macht die Konvention ClassName + Validator für den Symfony Validator automatisch nutzbar, ohne manuelle validatedBy()-Überschreibungen. PhpStan- und Psalm-Level-8-kompatible eigene Constraints sind möglich, wenn Generics für den Constraint-Typ verwendet werden.
8. Eigene Constraints unit-testen
Das Testen eigener ConstraintValidator-Klassen ist mit dem Symfony Validator-TestCase-Framework einfach. Die abstrakte Klasse ConstraintValidatorTestCase aus dem Paket symfony/validator stellt den Validierungskontext bereit und enthält Hilfsmethoden zum Prüfen von Verletzungen. Man erbt von ihr, gibt den vollqualifizierten Klassennamen des Validators zurück und kann in den Testmethoden direkt $this->validator->validate($value, new MyConstraint()) aufrufen. Nach der Validierung prüft man mit $this->assertNoViolation() oder $this->buildViolation($message)->assertRaised().
Für ConstraintValidatoren mit Service-Abhängigkeiten verwendet man PHPUnit-Mocks. Das Repository wird als Mock übergeben, der für bekannte Eingaben definierte Rückgabewerte liefert. So ist der Unit-Test vollständig von der Datenbank isoliert: Man testet ausschließlich die Entscheidungslogik des Validators, nicht die SQL-Korrektheit des Repositorys. Letztere testet man in separaten Repository-Integrationstests, die eine echte Datenbankverbindung nutzen.
Integrationstests mit dem vollständigen Symfony Validator-Service prüfen, ob die Constraint-Konfiguration im Container korrekt ist – also ob Autowiring greift, der Service gefunden wird und alle Dependencies gelöst werden. Dafür bootet man den Symfony-Kernel im Test und ruft den Validator aus dem Container. Diese Tests sind langsamer, aber notwendig, um Konfigurationsfehler zu finden, die im Unit-Test nicht sichtbar sind.
9. Vergleich: Built-in vs. Custom Constraints
Der Symfony Validator kommt mit über 70 eingebauten Constraints. Bevor man eine eigene Constraint-Klasse schreibt, lohnt ein Blick in die Dokumentation: Compound-Constraints kombinieren mehrere Built-in-Constraints zu einem, Callback-Constraints ermöglichen Inline-Validierung direkt in der Entity-Klasse, und Expression-Constraints nutzen die Symfony ExpressionLanguage für einfache Regel-Ausdrücke. Eigene Klassen sind nur dann wirklich nötig, wenn die Prüflogik Service-Abhängigkeiten, eigene Fehlertypen oder Class-Level-Kontext benötigt.
| Anforderung | Built-in Constraint | Eigene Constraint-Klasse | Empfehlung |
|---|---|---|---|
| Datenbankprüfung (Eindeutigkeit) | UniqueEntity (Doctrine) | Custom UniqueSlug mit Repository | Custom für komplexe Queries |
| Cross-Field-Validierung | Nicht direkt möglich | Class-Level Constraint | Immer eigene Klasse |
| Einfache Regelkombination | Compound-Constraint | Unnötig komplex | Compound bevorzugen |
| Externe API-Validierung | Nicht vorhanden | Custom + Service-Injection | Eigene Klasse mit HTTP-Client |
| Inline-Logik ohne Wiederverwendung | Callback-Constraint | Overkill | Callback ausreicht |
Die Entscheidung für eine eigene Constraint-Klasse fällt immer dann, wenn die Prüflogik wiederverwendet wird, externe Services benötigt werden oder die Verletzung mit einem spezifischen Fehlercode gemeldet werden soll. Callback-Constraints haben keinen Service-Zugriff und sind schwerer zu testen. Expression-Constraints sind gut für einfache Bedingungsausdrücke, die keine PHP-Logik brauchen.
Mironsoft
Symfony-Entwicklung, Validierungsarchitektur und PHP-Backend
Validierungslogik sauber in Symfony strukturieren?
Wir entwickeln eigene Symfony Validator Constraints, Class-Level-Validierungen und testbare Validierungsarchitekturen für komplexe PHP-Backends – vom DTO über den ConstraintValidator bis zur API-Integration.
Constraint-Design
Eigene Constraint-Klassen und Validator-Services für domänenspezifische Validierungsregeln
Cross-Field-Validation
Class-Level-Constraints für Abhängigkeiten zwischen Feldern und Kontext-sensitive Gruppen
Test-Abdeckung
Unit- und Integrationstests für alle Validators mit ConstraintValidatorTestCase und Mocks
10. Zusammenfassung
Eigene Symfony Validator Constraint-Klassen sind der richtige Weg, sobald Validierungslogik wiederverwendet werden soll, Service-Abhängigkeiten wie Repositories oder externe APIs benötigt werden oder mehrere Felder gemeinsam geprüft werden müssen. Die Trennung zwischen der Constraint-Klasse (Metadaten, Fehlermeldung, Optionen) und dem ConstraintValidator (Prüflogik) hält beide Teile klein, testbar und verständlich. Class-Level-Constraints lösen Cross-Field-Validierungen elegant ohne Duplizierung.
Die Integration mit dem Symfony-Service-Container über Autowiring macht ConstraintValidatoren zu vollwertigen Services, die beliebige Dependencies injiziert bekommen. Validation Groups erlauben kontextabhängige Aktivierung von Regeln, ohne separate DTOs für Erstellung und Aktualisierung schreiben zu müssen. Wer eigene Constraints konsequent einsetzt, hält Controller dünn, macht Validierungslogik unabhängig von der Transport-Schicht testbar und sorgt für konsistente Fehlermeldungen über die gesamte Anwendung.
Symfony Validator: Eigene Constraints — Das Wichtigste auf einen Blick
Constraint-Klasse
Erbt von Constraint, trägt #[\Attribute], definiert Fehlermeldung und Optionen. getTargets() bestimmt ob Property- oder Class-Level.
ConstraintValidator
Erbt von ConstraintValidator, implementiert validate(). Typprüfung zuerst, Early Return für null/leer, Verletzung mit buildViolation()->addViolation().
Service-Injection
Validator ist normaler Symfony-Service. Constructor Injection mit Autowiring – Repository, HTTP-Client oder Cache direkt injizierbar ohne manuelle Konfiguration.
Testen
ConstraintValidatorTestCase für Unit-Tests mit Mock-Dependencies. assertNoViolation() und buildViolation()->assertRaised() für klare Assertions.