<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class PasswordNotContainingEmail extends Constraint
{
public string $message = 'Your password must not contain "{{ local }}" from your email address.';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}
<?php
namespace App\Dto;
use App\Validator\PasswordNotContainingEmail;
use Symfony\Component\Validator\Constraints as Assert;
#[PasswordNotContainingEmail]
final class SignupRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email(message: 'Please provide a valid email address.')]
public readonly string $email = '',
#[Assert\NotBlank]
#[Assert\Length(
min: 8,
max: 4096,
minMessage: 'Your password must be at least {{ limit }} characters.'
)]
public readonly string $plainPassword = '',
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 50)]
public readonly string $displayName = '',
) {
}
}
<?php
namespace App\Validator;
use App\Dto\SignupRequest;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
final class PasswordNotContainingEmailValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof PasswordNotContainingEmail) {
throw new UnexpectedTypeException($constraint, PasswordNotContainingEmail::class);
}
if (!$value instanceof SignupRequest) {
throw new UnexpectedValueException($value, SignupRequest::class);
}
$email = $value->email;
$password = $value->plainPassword;
if ('' === $email || '' === $password || false === strpos($email, '@')) {
return; // NotBlank/Email handle empties and malformed input
}
$local = strtolower(substr($email, 0, strpos($email, '@')));
if (strlen($local) < 3) {
return;
}
if (str_contains(strtolower($password), $local)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ local }}', $local)
->atPath('plainPassword')
->addViolation();
}
}
}
<?php
namespace App\Controller;
use App\Dto\SignupRequest;
use App\Service\UserRegistrar;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class RegistrationController extends AbstractController
{
#[Route('/api/register', name: 'api_register', methods: ['POST'])]
public function __invoke(
Request $request,
SerializerInterface $serializer,
ValidatorInterface $validator,
UserRegistrar $registrar,
): JsonResponse {
$dto = $serializer->deserialize($request->getContent(), SignupRequest::class, 'json');
$violations = $validator->validate($dto);
if (count($violations) > 0) {
$errors = [];
foreach ($violations as $violation) {
$errors[] = [
'field' => $violation->getPropertyPath(),
'message' => $violation->getMessage(),
];
}
return $this->json(['errors' => $errors], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$user = $registrar->register($dto);
return $this->json(['id' => $user->getId()], Response::HTTP_CREATED);
}
}
This snippet shows how a signup payload is validated in Symfony using a plain PHP DTO plus a reusable custom constraint that enforces a rule the built-in constraints cannot express: the requested password must not contain the local part of the email. The pattern keeps validation declarative and testable — the DTO carries attribute-based constraints, and a dedicated validator class holds the cross-field logic, so controllers stay thin.
In SignupRequest DTO, each property is annotated with framework constraints (Assert\NotBlank, Assert\Email, Assert\Length) using PHP 8 attributes. The class-level #[PasswordNotContainingEmail] attribute attaches the custom rule to the whole object, which is what allows the validator to see both email and plainPassword at once. Modeling the input as an immutable-ish DTO with public readonly properties gives a typed, serializer-friendly shape and separates transport concerns from the User entity.
In PasswordNotContainingEmail constraint, the constraint is a small metadata object. Setting getTargets() to CLASS_CONSTRAINT tells Symfony this rule applies to an entire object rather than a single field, and the $message property provides the default violation text with a {{ local }} placeholder that the validator fills in. The #[Attribute] marker makes it usable as an attribute on the DTO.
In PasswordNotContainingEmailValidator, validate() receives the whole SignupRequest as $value. It guards against the wrong constraint type with an UnexpectedTypeException, skips work when either field is empty (empty-value checks belong to NotBlank), then compares the lowercased password against the email's local part. When the rule is violated it calls buildViolation() and, crucially, atPath('plainPassword') so the error is attached to the correct field for form and API responses, not to the object root.
In RegistrationController, the JSON body is deserialized straight into the DTO via the Serializer, then passed to $validator->validate(). A non-empty ConstraintViolationList is turned into a 422 response; otherwise registration proceeds. This flow — deserialize, validate, branch — is idiomatic for API endpoints and avoids Symfony Forms entirely. The main trade-off is that cross-field logic lives in its own class rather than inline, which is more ceremony but far more reusable and unit-testable. A common pitfall is forgetting atPath(), which buries field errors at the root and confuses clients.
Related snips
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
Frontend: normalize and display server validation errors
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
Laravel form requests for validation
class SubscriptionsController < ApplicationController
def new
@subscription = current_account.subscriptions.new
end
def create
Turbo Streams: append server-side validation warnings
import { z } from "zod";
const booleanFromString = z.preprocess((val) => {
if (typeof val !== "string") return val;
return ["true", "1", "yes", "on"].includes(val.toLowerCase());
}, z.boolean());
Typed env parsing with zod
Share this code
Here's the card — post it anywhere.