Where Validation Belongs
Validate where untrusted data arrives, back a uniqueness rule with a database index, and keep validation separate from authorisation.
#[MapRequestPayload(validationGroups: ['Default', 'create'], validationFailedStatusCode: 422)]Validation answers one question: is this acceptable as data? It belongs at the edge, where data you did not create arrives: a submitted form, a JSON body, a CSV row, a message pulled from a queue. Every edge that can create the same record runs the same rules, which is the argument for declaring them on a class rather than writing checks in each controller.
The database constraint behind it
A uniqueness rule checked in PHP is a SELECT followed by an INSERT. Between the two, another request can insert the same value, and both requests pass validation. A unique index in the schema is the check that holds, because the database applies it at write time. Keep both: the constraint gives a readable message in the common case, the index makes the rule true. Then catch Doctrine's UniqueConstraintViolationException around flush() for the race you cannot prevent. The same reasoning applies to NOT NULL columns and foreign keys: the validator is the message, the schema is the guarantee.
Validation is not authorisation
A request can be perfectly well formed and still be something this caller must not do. Validation describes the data; authorisation decides whether the actor making the request may perform the action. They have different answers: a validation failure is a 422 with field messages, an authorisation failure is a 403 with none, because telling the caller why is often telling them too much. Keep them apart. A constraint validator that looks up who is making the request becomes impossible to reuse in a console command, where nobody is. The security stage covers how those checks are written.
Validation is not escaping either. Escaping happens at output, and Twig does it by default.
Example
<?php
// src/Service/SubscriberRegistrar.php
declare(strict_types=1);
namespace App\Service;
use App\Dto\SignupInput;
use App\Entity\Subscriber;
use App\Exception\DuplicateSubscriberException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class SubscriberRegistrar
{
public function __construct(
private readonly ValidatorInterface $validator,
private readonly EntityManagerInterface $entityManager,
) {
}
public function register(SignupInput $input): Subscriber
{
// The edge check: shape, format, application rules.
$violations = $this->validator->validate($input);
if (count($violations) > 0) {
throw new ValidationFailedException($input, $violations);
}
$subscriber = new Subscriber($input->email, $input->name);
try {
// persist() schedules the insert, flush() performs it.
$this->entityManager->persist($subscriber);
$this->entityManager->flush();
} catch (UniqueConstraintViolationException $e) {
// Two requests can both pass validation before either commits.
throw new DuplicateSubscriberException($input->email, $e);
}
return $subscriber;
}
}When to use it
- Run the same rules on a web form, a JSON endpoint and a CSV import by sharing one DTO.
- Add a unique index behind a uniqueness constraint so two simultaneous signups cannot both win.
- Return 422 for a malformed payload and leave 403 for actions the caller may not perform.
- Keep a constraint validator free of request and session lookups so a console command can use it.
More examples
The rule in two places, on purpose
UniqueEntity is a class-level constraint and queries the database, so it costs a round trip and can still be beaten by a concurrent insert. The column's `unique: true` is what makes the migration create the index. Writing both is deliberate: the constraint produces the message a user sees, the index means the data is never wrong even when the message is missed.
<?php
// src/Entity/Subscriber.php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\SubscriberRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: SubscriberRepository::class)]
#[ORM\Table(name: 'subscriber')]
// The readable message: a SELECT before the INSERT.
#[UniqueEntity(fields: 'email', message: 'That address is already subscribed.')]
class Subscriber
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
// unique: true puts a UNIQUE index in the generated migration. That
// index, not the constraint above, is what holds under concurrency.
#[ORM\Column(length: 180, unique: true)]
#[Assert\NotBlank]
#[Assert\Email]
private string $email;
#[ORM\Column(length: 60)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 60)]
private string $name;
public function __construct(string $email, string $name)
{
$this->email = $email;
$this->name = $name;
}
}Validating at the edge, short and long
The two actions do the same work, which is the point: the attribute is not magic, it is the deserialise-then-validate pair moved out of the method. Use the shorthand for ordinary endpoints and the longhand when you need to shape the error body yourself or validate something the serializer did not build. A POST with a malformed email returns 422 either way, and a successful one returns 201 with the new id.
<?php
// src/Controller/SubscriberController.php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\SignupInput;
use App\Service\SubscriberRegistrar;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class SubscriberController extends AbstractController
{
// Shorthand: deserialise, validate, 422 on failure, all before the body runs.
#[Route('/api/subscribers', name: 'api_subscriber_create', methods: ['POST'], format: 'json')]
public function create(
#[MapRequestPayload] SignupInput $input,
SubscriberRegistrar $registrar,
): JsonResponse {
return $this->json(['id' => $registrar->register($input)->getId()], Response::HTTP_CREATED);
}
// Longhand: the same three steps written out.
#[Route('/api/subscribers/manual', name: 'api_subscriber_manual', methods: ['POST'], format: 'json')]
public function createManually(
Request $request,
SerializerInterface $serializer,
ValidatorInterface $validator,
SubscriberRegistrar $registrar,
): JsonResponse {
$input = $serializer->deserialize($request->getContent(), SignupInput::class, 'json');
if (count($violations = $validator->validate($input)) > 0) {
return $this->json(['errors' => (string) $violations], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->json(['id' => $registrar->register($input)->getId()], Response::HTTP_CREATED);
}
}The same rules at a different edge
The command reuses the same DTO, the same constraints and the same service as the HTTP endpoint, so an import cannot create records the web edge would have rejected. Casting a violation list to a string gives the readable dump shown in the output, which is fine for an operator and wrong for an API. Note that each row is a separate unit of work: because a failed flush closes the entity manager, a long import that writes in batches has to decide what to do with the rest of the file when one batch fails.
<?php
// src/Command/ImportSubscribersCommand.php
declare(strict_types=1);
namespace App\Command;
use App\Dto\SignupInput;
use App\Service\SubscriberRegistrar;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Validator\Exception\ValidationFailedException;
#[AsCommand(name: 'app:import-subscribers', description: 'Imports subscribers from a CSV file')]
final class ImportSubscribersCommand extends Command
{
public function __construct(private readonly SubscriberRegistrar $registrar)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('file', InputArgument::REQUIRED, 'Path to the CSV file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$handle = fopen((string) $input->getArgument('file'), 'rb');
$imported = 0;
$row = 0;
while (false !== $line = fgetcsv($handle)) {
++$row;
$signup = new SignupInput();
$signup->email = (string) ($line[0] ?? '');
$signup->name = (string) ($line[1] ?? '');
try {
$this->registrar->register($signup);
++$imported;
} catch (ValidationFailedException $e) {
$io->error(sprintf('Row %d: %s', $row, (string) $e->getViolations()));
}
}
fclose($handle);
$io->success(sprintf('%d of %d rows imported.', $imported, $row));
return Command::SUCCESS;
}
}
// $ php bin/console app:import-subscribers var/data/subscribers.csv
//
// [ERROR] Row 3: Object(App\Dto\SignupInput).email:
// This value is not a valid email address.
//
// [OK] 2 of 3 rows imported.
Discussion