06/11/2015

Adding a form error from a controller in Symfony

Every field in a Symfony form is a form object in its own right, so an error can be attached to one of them directly:

use Symfony\Component\Form\FormError;

$form->get('email')->addError(new FormError('That address is already registered.'));

It renders wherever that field's form_errors() is, which for a form_row() is immediately under the input. The user sees it against the thing that is wrong, which is the entire point of doing it this way rather than with a flash message.

Attaching it to the form rather than a field

Some failures are not any one field's fault:

$form->addError(new FormError('Those dates overlap a booking you already have.'));

That one lands in form_errors(form) — the call with the form itself rather than a field. If your template does not have that call, the error is added and never appears anywhere, which is a confusing half-hour.

Where this goes in the controller

After handleRequest(), and inside the valid branch, because a form that already failed validation does not need more errors on it:

$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
    if ($userRepository->findOneByEmail($form->get('email')->getData())) {
        $form->get('email')->addError(new FormError('That address is already registered.'));
    } else {
        // save, redirect
    }
}

return $this->render('signup.html.twig', ['form' => $form]);

Adding an error inside that branch does not retroactively make isValid() return false — it has already returned. The else is what keeps the save from happening, and forgetting it is how you get a form that shows an error and saves the record anyway.

When a constraint is the better answer

Reach for addError() when the check needs something the form has no access to — the current user, an external service, another record. For anything that is a property of the data itself, a constraint belongs on the entity, where it also applies to the API endpoint and the console command that touch the same object:

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[UniqueEntity(fields: ['email'], message: 'That address is already registered.')]
class User
{
    #[Assert\NotBlank]
    #[Assert\Email]
    private string $email;
}

The uniqueness example above is the honest case for this: UniqueEntity does that job properly, including the race the manual version loses. The controller version is what you write when the rule is genuinely about the request rather than about the data.

The one that catches people

An error added to a field that the template never renders is invisible, and so is a form-level error in a template without form_errors(form). Both submit, both fail, and the page comes back looking like nothing happened. {{ form_rest(form) }} covers the first case; only an explicit form_errors(form) covers the second.