Reporting Errors

Show violations in a Twig form, return them as JSON with a 422 status, translate the messages, and key them by field name.

Syntax$errors[$violation->getPropertyPath()][] = (string) $violation->getMessage();

The validator produces a violation list; turning that list into something a person or a client can act on is a separate job. The list is flat, and every violation carries the message and the property path of the value it came from, such as email or lines[0].quantity. The property path is the hinge: it is what lets you put each message next to the right field.

In a Twig form

When a form is bound to an object, the form library runs the validator on submit and attaches each violation to the matching field. {{ form_row(form.email) }} is the shorthand that renders label, widget, help text and errors together; the longhand is form_label, form_widget, form_help and form_errors called one after another on the same field, which is what you write when the markup has to be yours. {{ form_errors(form) }} on the form itself renders errors with no field, such as those from a class-level constraint. For full control, field_errors(form.email) returns the messages to loop over. Print them plainly: they are escaped for you, and |raw would undo that.

As JSON

For an API, build a map from property path to a list of messages and answer 422 Unprocessable Content. A list per field matters because one field can break several rules at once.

Translation

Messages pass through the translator in the validators domain, so a built-in message is already in the request locale. Give your own constraints a key as the message option and put the text in translations/validators/validators.en.yaml. Placeholders such as {{ limit }} are passed to the translator as parameters.

Example

Example · php
<?php
// src/Controller/Api/SubscriberController.php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\SignupInput;
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\Validator\Validator\ValidatorInterface;

final class SubscriberController extends AbstractController
{
    #[Route('/api/subscribers', name: 'api_subscriber_create', methods: ['POST'], format: 'json')]
    public function create(Request $request, ValidatorInterface $validator): JsonResponse
    {
        // toArray() decodes the JSON request body.
        $payload = $request->toArray();

        $input = new SignupInput();
        $input->name = (string) ($payload['name'] ?? '');
        $input->email = (string) ($payload['email'] ?? '');

        $violations = $validator->validate($input);

        if (count($violations) > 0) {
            $errors = [];

            foreach ($violations as $violation) {
                // A list per field: one field can break several rules.
                $errors[$violation->getPropertyPath()][] = (string) $violation->getMessage();
            }

            return $this->json(['errors' => $errors], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        return $this->json(['status' => 'created'], Response::HTTP_CREATED);
    }
}

When to use it

  • Show each message under its own input while keeping class-level errors at the top of the form.
  • Return 422 with a field-keyed error object so a JavaScript client can mark the inputs.
  • Translate constraint messages for a site served in more than one language.
  • Collect several messages for one field instead of showing only the last one.

More examples

Rendering errors in a template

The three levels are the same data rendered with more or less of it under your control. Messages are printed through Twig's auto-escaping, which is the protection against a value echoed back into the page, so they go out plain and never through `|raw`. In the controller the matching test is `$form->isSubmitted() && $form->isValid()`; the template is only reached in the failing case, which is why the errors are there to render.

Example · twig
{# templates/subscriber/new.html.twig #}
{{ form_start(form) }}
    {# errors with no field, for example from a class-level constraint #}
    {{ form_errors(form) }}

    {# shorthand: label, widget, help and errors in one call #}
    {{ form_row(form.email) }}

    {# the same thing written out, when the markup has to be yours #}
    <div>
        {{ form_label(form.name) }}
        {{ form_widget(form.name) }}
        {{ form_help(form.name) }}
        {{ form_errors(form.name) }}
    </div>

    {# or take the messages and build the markup yourself #}
    {% if form.plan.vars.errors|length > 0 %}
        <ul>
            {% for error in field_errors(form.plan) %}
                <li>{{ error }}</li>
            {% endfor %}
        </ul>
    {% endif %}

    <button type="submit">Subscribe</button>
{{ form_end(form) }}

Letting the framework map and validate the payload

MapRequestPayload deserialises the body into the object and validates it before your code runs. On failure it throws an HttpException whose status is `validationFailedStatusCode` (422 by default) and whose previous exception is a ValidationFailedException, so a listener can call `getPrevious()->getViolations()` to build its own body. With the route declared as `format: 'json'`, the default response is problem JSON: `type`, `title`, `detail`, and a `violations` array whose entries hold `propertyPath`, `title`, `template` and `parameters`.

Example · php
<?php
// src/Controller/Api/InvoiceController.php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\InvoiceInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

final class InvoiceController extends AbstractController
{
    #[Route('/api/invoices', name: 'api_invoice_create', methods: ['POST'], format: 'json')]
    public function create(
        #[MapRequestPayload(
            acceptFormat: 'json',
            validationGroups: ['Default', 'create'],
            validationFailedStatusCode: Response::HTTP_UNPROCESSABLE_ENTITY,
        )]
        InvoiceInput $input,
    ): JsonResponse {
        // Reached only when the payload deserialised and validated.
        return $this->json(['reference' => $input->reference], Response::HTTP_CREATED);
    }
}

Message keys and the validators catalogue

Violation messages are translated in the `validators` domain, so built-in messages already follow the request locale and your own messages only need a key and an entry per locale. Placeholders are passed as translation parameters, which is why `{{ limit }}` works inside the translated string. Clear the cache after adding the catalogue for the first time, even in dev; a missing entry falls back to printing the key, which is the usual reason a form shows `signup.email.invalid` to a user.

Example · yaml
# translations/validators/validators.en.yaml
signup.email.invalid: Enter an address we can reach you at.
signup.name.too_short: Use at least {{ limit }} characters.

# The constraints refer to these keys instead of English text:
#
#   #[Assert\Email(message: 'signup.email.invalid')]
#   #[Assert\Length(min: 2, minMessage: 'signup.name.too_short')]
#   public string $name = '';
#
# And the French catalogue sits next to it:
#   translations/validators/validators.fr.yaml

Discussion

  • Be the first to comment on this lesson.