DEV Community

Arnaud POINTET
Arnaud POINTET

Posted on Originally published at apprendre.forelse.fr on

400 or 422? Designing validation errors for a Symfony API

Since Symfony 8.2, an uncaught ValidationFailedException no longer results in a 500 error: Symfony itself turns it into a 422 response, complete with the list of violations. The Practice exercise that showcases this change is mostly about deleting code.

But behind that deleted line lies a real design question, one I often see poorly settled in the APIs I audit: what should an API return when the request is invalid? Which HTTP status code, which format, and what information does the client need in order to react? This article starts from the exercise and works through the whole question.

The exercise: one line fewer

A registration controller validated an object by hand, then had to wrap the validation exception in an HTTP exception:

$violations = $validator->validate($registration);
if (\count($violations) > 0) {
    throw new UnprocessableEntityHttpException(
        'Invalid registration.',
        new ValidationFailedException($registration, $violations),
    );
}

Enter fullscreen mode Exit fullscreen mode

Without that wrapper, the exception turned into a 500 error. Since Symfony 8.2, you just throw it:

if (\count($violations) > 0) {
    throw new ValidationFailedException($registration, $violations);
}

Enter fullscreen mode Exit fullscreen mode

The response is then exactly the one #[MapRequestPayload] produces when validation fails:

{
    "type": "https://symfony.com/errors/validation",
    "title": "Validation Failed",
    "status": 422,
    "detail": "email: This value is not a valid email address.\nusername: This value is too short. It should have 2 characters or more.",
    "violations": [
        {
            "propertyPath": "email",
            "title": "This value is not a valid email address.",
            "template": "This value is not a valid email address.",
            "parameters": { "{{ value }}": "\"not-an-email\"" },
            "type": "urn:uuid:bd79c0ab-ddba-46cc-a703-a7a4b08de310"
        }
    ]
}

Enter fullscreen mode Exit fullscreen mode

(Only the first violation is shown here; the one for the username follows the same pattern.)

Before going further, let's look at how Symfony gets there. You'll be able to use the same mechanism for your own exceptions.

How Symfony picks the HTTP status code for an exception

The ValidationFailedException class now carries an attribute:

#[WithHttpStatus(422)]
class ValidationFailedException extends RuntimeException

Enter fullscreen mode Exit fullscreen mode

When an exception bubbles up to the kernel, Symfony's ErrorListener looks for this attribute (parent classes included). If it finds it, it wraps the exception in an HttpException with the right status code: exactly what the controller used to do by hand. Then, if the Serializer component is installed and the client expects JSON (an Accept: application/json header, or the json format on the route), the Serializer formats the error: its ProblemNormalizer notices that the original exception is a validation error and delegates the list of violations to the ConstraintViolationListNormalizer. Otherwise, the 422 is rendered as HTML, which is the first thing to check when "it doesn't work" with a plain curl.

The same attribute works for your domain exceptions:

#[WithHttpStatus(409)]
final class UsernameAlreadyTaken extends \DomainException
{
}

Enter fullscreen mode Exit fullscreen mode

One detail tends to reassure hexagonal architecture purists: a PHP attribute creates no runtime dependency. As long as nobody reads the attributes through reflection, the WithHttpStatus class isn't even loaded. Your domain can carry the attribute without actually depending on HttpKernel. If you'd rather not write anything in the domain, configuration does the same thing from the outside:

# config/packages/framework.yaml
framework:
    exceptions:
        App\Domain\UsernameAlreadyTaken:
            status\_code: 409
            log\_level: info

Enter fullscreen mode Exit fullscreen mode

400, 422, 409: a status code for each problem

The HTTP status code is the first thing a client reads, and often the only thing it reads automatically. So it has to say who needs to fix what.

  • 400 Bad Request : the request can't be parsed, for instance malformed JSON. The client has a bug: a human has to fix the code that sends the request.
  • 415 Unsupported Media Type : the Content-Type declares a format the API doesn't read. Same consequence: it's the client code that needs fixing.
  • 422 Unprocessable Content : the request is readable and well formed, but its content is invalid. The email address isn't one, the username is too short. Often it's the end user who has to correct their input, and the client has to display the errors next to the right fields.
  • 409 Conflict : the request is valid, but it conflicts with the current state of the resource. The username is already taken, the order has already shipped, the version being edited is no longer the latest.

422 comes from WebDAV, but it has been part of general HTTP semantics since RFC 9110. There's no reason to avoid it anymore.

Symfony already follows this distinction with #[MapRequestPayload]: an unsupported format gives a 415 and an unreadable JSON body a 400, whereas a field of the wrong type (a string where an integer is expected) or a failed constraint gives a 422, with the violations.

The username-already-taken case

"This username is already taken": 409 or 422? Both are defensible. I lean towards 422 with a violation on username , for a practical reason: the client displays this error exactly like the others, under the relevant field. That's what the UniqueEntity constraint from Symfony's Doctrine bridge does.

But then you have to think about the case the constraint can't see. Two simultaneous registrations with the same username both pass validation, then the second one hits the database's unique index. Left unhandled, that's a Doctrine UniqueConstraintViolationException, and therefore a 500. I convert it into a violation, so the client gets the same response in every case:

try {
    $this->entityManager->flush();
} catch (UniqueConstraintViolationException) {
    throw new ValidationFailedException($registration, new ConstraintViolationList([
        new ConstraintViolation(
            'This username is already taken.',
            'This username is already taken.',
            [],
            $registration,
            'username',
            $registration->username,
            code: UniqueEntity::NOT\_UNIQUE\_ERROR,
        ),
    ]));
}

Enter fullscreen mode Exit fullscreen mode

The UniqueEntity::NOT_UNIQUE_ERROR code is not a detail: without it, the violation goes out with no type member, and the client loses the only stable marker it has (see below). And nothing else in that catch: after this exception, Doctrine has closed the EntityManager, so a second flush() would fail.

With Symfony 8.2, this conversion boils down to a single throw, with no HttpException around it.

The format: Problem Details

The JSON produced by Symfony follows the Problem Details for HTTP APIs format, standardized by RFC 9457 (which superseded RFC 7807). The format defines five standard members:

  • type: a URI that identifies the kind of problem. That's what the client should check, not the title;
  • title: a human-readable summary of that kind of problem, identical from one occurrence to the next;
  • status: the HTTP status code, repeated in the body;
  • detail: the explanation for this occurrence;
  • instance: a URI that identifies this specific occurrence, handy for tracking an error down in the logs.

Everything else, such as violations, is an extension member, which the standard explicitly provides for. Adopting this format lets clients use existing libraries rather than learn yet another home-grown format. The RFC also recommends the application/problem+json media type, which some clients use to detect an error. Symfony sets it automatically as soon as the body has a type, a title and a status; a hand-built error response has to remember to do so.

What the client actually needs

This is where most APIs fall short. They return errors written for a human reading the response, when it's a program that receives them.

A path that matches the request

propertyPath must point to the field as the client sent it. If your API receives snake_case and your DTOs use camelCase, the normalizer applies the name converter configured in the Serializer. The path becomes date_of_birth rather than dateOfBirth, provided that converter is properly configured. For a nested structure, the path takes the form addresses[0].postalCode, which lets the client find the offending row in a repeated form.

A stable code, not a message

A violation's title is a translated message: it changes with the language, and on the day someone fixes a typo in a translation file. A client that checks title === "This value is too short..." will break sooner or later.

The type member of each violation, on the other hand, is stable: urn:uuid: followed by the constraint's code. For example, Length::TOO_SHORT_ERROR and Email::INVALID_FORMAT_ERROR are UUIDs set in stone in Symfony's code. Yours should be too, if you write custom constraints: give them error constants with a UUID. That code is what the client should check to display its own message or trigger specific behavior.

Document these codes alongside your API (in your OpenAPI schema, for example). It's the most useful part of an error contract, and the one most often forgotten.

The useful data, and nothing more

The invalid value itself is not returned as such: ValidationFailedException::getValue() isn't serialized. Only the {{ value }} parameter of some messages echoes it. Keep that in mind for sensitive fields: a password rejected by a Regex constraint must not come back in plain text in the response, let alone in your logs.

In debug mode, the response also includes the exception class and the full stack trace. That's invaluable in development, and an information leak if APP_DEBUG stays on in production. This isn't hypothetical: I run into it regularly during audits.

Logging: a 422 is not an incident

Symfony logs 4xx HTTP errors at the warning level. That is in fact new in 8.2 for exceptions that get their status code through #[WithHttpStatus] or framework.exceptions, such as ValidationFailedException: before, they were logged at error, which triggered the fingers_crossed handler from the Monolog recipe. For a public API, validation errors are part of normal operation: thousands of incorrect inputs a day mean as many warning lines, and the real warnings get lost in the noise. I take them down one level:

framework:
    exceptions:
        Symfony\Component\Validator\Exception\ValidationFailedException:
            log\_level: info

Enter fullscreen mode Exit fullscreen mode

On the other hand, I keep an alert on the rate of 422s per endpoint. A sudden spike after a deployment is often a mobile client that hasn't been updated, or a constraint that has become too strict. A single validation error is normal; a wave of them is an incident.

Test the contract, not the messages

The exercise's tests check what a client really expects: the 422 status code, the problem type and the list of fields in error.

$this->assertResponseStatusCodeSame(422);
$problem = json\_decode((string) $client->getResponse()->getContent(), true);
$this->assertSame('https://symfony.com/errors/validation', $problem['type']);
$this->assertSame(['email', 'username'], array\_column($problem['violations'], 'propertyPath'));

Enter fullscreen mode Exit fullscreen mode

No test on the message text. If you want to go further, also check each violation's type: that's what belongs to the contract you've promised your clients.

My takeaways

The new feature in Symfony 8.2 is small: an exception that knows which HTTP status code it stands for. But it brings consistency where there was none: manual validation, #[MapRequestPayload] and the conversion of a database error now all go through the same path and produce the same response.

The rest is up to you: pick the right status code, stick to a standard format, give clients stable codes rather than sentences, and don't mistake invalid input for an incident.

The exercise takes a few minutes in the browser, on a development build of Symfony 8.2. The two requests in the "Requests" tab show the response before and after.

Top comments (0)