Symfony Validator Component: Custom Constraints für komplexe Geschäftslogik

#symfony validator custom constraint
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Die meisten Symfony-Projekte starten mit den Constraints, die das Framework mitliefert. #[Assert\NotBlank], #[Assert\Email] und #[Assert\Length] decken die ersten sechs Monate ab. Dann kommt eine Anforderung, die keine davon ausdrücken kann: ein Rabattcode, der zum Plan des Kunden passen muss, eine Buchung, die sich nicht mit einer bestehenden überschneiden darf, ein Feld, das für einen Tenant Pflicht und für einen anderen optional ist. An diesem Punkt brauchst du eine Symfony Validator Custom Constraint, und die offizielle Dokumentation wird genau dort dünn, wo die eigentliche Arbeit beginnt.

Dieser Beitrag sammelt die Muster, die wir verwenden, wenn Validierungslogik die Datenbank berührt, Services aus dem Container braucht oder sich je nach Aufrufer ändert. Das durchgehende Beispiel ist ein Multi-Tenant-SaaS, in dem jeder Tenant seine eigenen Regeln konfiguriert.

Anatomie einer Symfony Validator Custom Constraint

Eine Constraint in Symfony ist immer ein Paar. Die Constraint-Klasse ist ein kleines Datenobjekt, das die Konfiguration und die Fehlermeldung trägt. Die Validator-Klasse enthält die Logik. Symfony verknüpft beide per Konvention: UniqueBookingSlot wird zu UniqueBookingSlotValidator aufgelöst, sofern du validatedBy() nicht überschreibst.

Seit PHP 8 sind Constraints Attribute. Falls du noch annotationsbasierte Constraints aus der Symfony-4-Ära pflegst, ist die Migration mechanisch, aber lohnend, denn Attribute werden vom PHP-Parser selbst validiert und IDEs vervollständigen sie korrekt.

#[\Attribute(\Attribute::TARGET_CLASS)]
class UniqueBookingSlot extends Constraint
{
    public string $message = 'This slot overlaps an existing booking for {{ resource }}.';

    public function getTargets(): string|array
    {
        return self::CLASS_CONSTRAINT;
    }
}

Beachte das Target. Constraints auf Property-Ebene sehen einen Wert isoliert. Eine Regel wie "Start und Ende dürfen sich nicht mit einer anderen Buchung für dieselbe Ressource überschneiden" braucht drei Felder gleichzeitig, also zielt die Constraint auf die Klasse und der Validator erhält das gesamte Objekt. Das ist derselbe Ansatz, den UniqueEntity für zusammengesetzte Eindeutigkeit verfolgt, aber wenn du es selbst schreibst, kontrollierst du die Query, was zählt, sobald "eindeutig" mit Bedingungen wie dem Ignorieren stornierter Buchungen kommt.

Services in den Validator injizieren

Der Teil, den die Basis-Tutorials überspringen: Validatoren sind reguläre Services. Mit aktivierter Autokonfiguration wird jede Klasse, die ConstraintValidator erweitert, automatisch registriert und getaggt, sodass Constructor Injection einfach funktioniert.

class UniqueBookingSlotValidator extends ConstraintValidator
{
    public function __construct(
        private readonly BookingRepository $bookings,
    ) {
    }

    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof UniqueBookingSlot) {
            throw new UnexpectedTypeException($constraint, UniqueBookingSlot::class);
        }

        if (!$value instanceof Booking) {
            throw new UnexpectedValueException($value, Booking::class);
        }

        $conflict = $this->bookings->findOverlap(
            $value->getResource(),
            $value->getStartsAt(),
            $value->getEndsAt(),
            excludeId: $value->getId(),
        );

        if ($conflict === null) {
            return;
        }

        $this->context->buildViolation($constraint->message)
            ->setParameter('{{ resource }}', $value->getResource()->getName())
            ->atPath('startsAt')
            ->addViolation();
    }
}

Drei Details sparen hier später Debugging-Zeit. Der Parameter excludeId verhindert, dass die Constraint eine unveränderte Entität beim Bearbeiten ablehnt. Der Aufruf atPath() hängt die Violation an eine konkrete Property, was die Form-Komponente aufgreift (mehr dazu weiter unten). Und die Type Guards am Anfang sind keine Zeremonie: Wenn jemand deine Constraint in zwei Jahren auf die falsche Klasse anwendet, ist eine Exception, die den erwarteten Typ nennt, besser als ein stilles Durchwinken.

Eine Warnung zu datenbankgestützten Validatoren: Die Repository-Query läuft bei jedem Validierungsdurchlauf. Sitzt die Constraint in einem Hot Path wie einer Import-Schleife, bündle die Prüfungen entweder außerhalb des Validators oder stelle sicher, dass die Query einen Index trifft. Wir haben mehr als ein System auditiert, in dem eine gut gemeinte Eindeutigkeitsprüfung pro Zeile eines CSV-Imports mit 50.000 Zeilen einen Table Scan ausgeführt hat. Langsame Validierung dieser Art ist ein wiederkehrender Fund in unseren Code-Audits.

Regeln pro Tenant aus der Datenbank lesen

Jetzt der interessante Fall. In einem Multi-Tenant-Produkt konfigurieren Tenants oft ihre eigenen Regeln: Tenant A verlangt eine Kostenstelle auf jeder Bestellung, Tenant B deckelt Bestellwerte bei 10.000 Euro, Tenant C macht keines von beidem. Diese als separate Constraints hart zu kodieren skaliert nicht über den dritten Tenant hinaus. Stattdessen delegiert eine generische Constraint an die Konfiguration.

#[\Attribute(\Attribute::TARGET_PROPERTY)]
class TenantRule extends Constraint
{
    public function __construct(
        public string $rule,
        mixed $options = null,
        ?array $groups = null,
        mixed $payload = null,
    ) {
        parent::__construct($options, $groups, $payload);
    }
}

Der Validator ermittelt den aktuellen Tenant, lädt dessen Regelsatz und wendet an, was die Konfiguration vorgibt:

class TenantRuleValidator extends ConstraintValidator
{
    public function __construct(
        private readonly TenantContext $tenantContext,
        private readonly TenantRuleProvider $rules,
    ) {
    }

    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof TenantRule) {
            throw new UnexpectedTypeException($constraint, TenantRule::class);
        }

        $config = $this->rules->for(
            $this->tenantContext->current(),
            $constraint->rule,
        );

        if ($config === null) {
            return; // Tenant hat diese Regel nicht aktiviert
        }

        if ($config->required && ($value === null || $value === '')) {
            $this->context->buildViolation($config->requiredMessage)->addViolation();
            return;
        }

        if ($config->max !== null && $value > $config->max) {
            $this->context->buildViolation($config->maxMessage)
                ->setParameter('{{ max }}', (string) $config->max)
                ->addViolation();
        }
    }
}

Die Verwendung auf der Entität bleibt deklarativ: #[TenantRule('purchase_order.amount')]. Die Entität weiß nicht, welche Tenants was durchsetzen, und Product Manager können Regeln ohne Deployment ändern.

Zwei Hinweise für Produktion. Cache den Rule Provider aggressiv, denn dieser Validator läuft bei jedem Submit und die Regeln ändern sich selten; eine Request-scoped Memoization plus eine kurze Redis-TTL reicht meist. Und logge, welche Regel eine Violation erzeugt hat, inklusive Tenant-ID. Wenn ein Kunde meldet "das Formular lässt mich nicht speichern", muss der Support sehen, welche konfigurierte Regel ausgelöst hat, statt eine generische Meldung zu bekommen.

Validation Groups: Anlegen ist nicht Bearbeiten

Dasselbe Objekt hat häufig unterschiedliche Regeln in unterschiedlichen Kontexten. Ein Passwort ist bei der Registrierung Pflicht, beim Bearbeiten des Profils aber nicht vorhanden. Eine SKU ist beim Anlegen frei wählbar, danach unveränderlich. Validation Groups drücken das aus, ohne das Modell zu duplizieren.

#[Assert\NotBlank(groups: ['registration'])]
#[Assert\Length(min: 12, groups: ['registration'])]
private ?string $plainPassword = null;

Das Formular entscheidet, welche Gruppen gelten, und ein Callback macht diese Entscheidung dynamisch:

public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults([
        'data_class' => User::class,
        'validation_groups' => function (FormInterface $form): array {
            $user = $form->getData();

            return $user->getId() === null
                ? ['Default', 'registration']
                : ['Default'];
        },
    ]);
}

Constraints ohne explizite Gruppe gehören zu Default, also führt der Callback oben die gemeinsamen Regeln in beiden Kontexten aus und die Registrierungsregeln nur für neue Nutzer. Widerstehe der Versuchung, Gruppen pro Formularfeld oder pro Tenant anzulegen; Gruppen modellieren Kontexte wie Anlegen, Bearbeiten und Import. Die Varianz pro Tenant gehört in das TenantRule-Muster aus dem vorherigen Abschnitt, und das Vermischen beider Mechanismen erzeugt Regelsätze, über die niemand mehr nachdenken kann.

Group Sequences sind ebenfalls wissenswert. #[Assert\GroupSequence(['Basic', 'Expensive'])] auf der Klasse führt die günstigen Constraints zuerst aus und die datenbankgestützten nur, wenn die günstigen bestehen. Diese Reihenfolge erspart dir die Overlap-Query für ein Formular, dem ohnehin die Pflichtfelder fehlen.

Kaskadierung in verschachtelte Objekte und Collections

Validierung stoppt an Objektgrenzen, es sei denn, du sagst ihr etwas anderes. Eine Order mit einem Array von OrderLine-Objekten validiert ihre eigenen Properties und ignoriert die Zeilen komplett, was die meisten beim ersten Mal überrascht.

#[Assert\Valid]
#[Assert\Count(min: 1, minMessage: 'An order needs at least one line.')]
private Collection $lines;

#[Assert\Valid] kaskadiert in jede Zeile und führt alle Constraints aus, die die Klasse OrderLine deklariert, inklusive eigener. Violations tragen Property-Pfade wie lines[2].quantity, sodass Fehlermeldungen in der richtigen Zeile landen. Für skalare Collections gibt es #[Assert\All], das eine Liste von Constraints auf jedes Element anwendet, ohne eine eigene Klasse zu verlangen.

Fehler auf das richtige Formularfeld bringen

Constraints auf Klassenebene haben in der Form-Schicht eine raue Kante: Eine Violation ohne Property-Pfad hängt sich ans Formular selbst und wird oben gerendert, weit weg vom Feld, das der Nutzer korrigieren muss. Dafür hast du zwei Werkzeuge.

Das erste ist atPath() im Validator, wie oben gezeigt. Weil unsere Overlap-Violation auf startsAt zeigt, rendert das Formular sie ohne zusätzliche Konfiguration neben dem Startfeld. Das zweite ist die Form-Option error_mapping, nützlich, wenn der Violation-Pfad keinem Formularfeldnamen entspricht, etwa wenn eine gemappte Entitäts-Property auf zwei ungemappte Eingaben aufgeteilt ist. Bevorzuge atPath(), wenn du den Validator besitzt. Es hält das Wissen, wohin ein Fehler gehört, direkt bei der Logik, die ihn erzeugt, statt Mapping-Regeln über jedes Formular zu verstreuen, das die Constraint nutzt.

Wo sich das auszahlt

Das obige Muster-Set (Constraints auf Klassenebene für Regeln über mehrere Felder, Service Injection für Datenbankprüfungen, eine konfigurierbare Constraint für Tenant-Varianz, Gruppen für Kontext, Kaskadierung für Verschachtelung) deckt fast jede Validierungsanforderung ab, die uns in zehn Jahren Symfony-Arbeit begegnet ist. Der Gewinn ist, dass Geschäftsregeln an einem testbaren Ort leben, statt über Controller, Form Listener und JavaScript verschmiert zu sein.

Der letzte Punkt verdient einen Satz: Constraint-Validatoren sind einfache Klassen und lassen sich mit einem In-Memory Rule Provider und einem gestubbten Repository sauber unit-testen. Wenn deine aktuelle Codebasis in Controllern mit if-Statements validiert, ist das Verschieben dieser Logik in Constraints ein Refactoring mit schnellem Ertrag, und eines, das wir regelmäßig im Rahmen von Legacy-Modernisierungsprojekten durchführen.

Wenn du ein Multi-Tenant-Produkt baust und die Validierungsschicht der Ort ist, an dem saubere Architektur stirbt, können wir helfen. Wolf-Tech entwirft und baut individuelle Symfony-Anwendungen und prüft bestehende. Schreib an hello@wolf-tech.io oder schau dich auf wolf-tech.io um.