Form Validation in Drupal: validateForm() and setErrorByName() Explainedfor Drupal 11 , and 10

Last updated :  

You've already seen this exact form once, back in the first lesson of this topic — but now we're going to look at it through a completely different lens. Instead of focusing on how the form is built, this lesson zooms in on how Drupal decides whether the input a visitor typed is actually acceptable, and what happens the moment it isn't.

What you'll learn in this lesson

  • Where validateForm() sits in the Form API's request lifecycle
  • The difference between free, built-in validation (#required) and validation you write yourself
  • How setErrorByName() connects an error message to a specific field
  • Why submitForm() never has to re-check anything validateForm() already confirmed

The source file

Path: modules/form_api_example/src/Form/SimpleForm.php — the same file as the Simple Form lesson.

<?php

namespace Drupal\form_api_example\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Implements the SimpleForm form controller.
 *
 * This example demonstrates a simple form with a single text input element. We
 * extend FormBase which is the simplest form base class used in Drupal.
 *
 * @see \Drupal\Core\Form\FormBase
 */
class SimpleForm extends FormBase {

  /**
   * Build the simple form.
   *
   * A build form method constructs an array that defines how markup and
   * other form elements are included in an HTML form.
   *
   * @param array $form
   *   Default form array structure.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   Object containing current form state.
   *
   * @return array
   *   The render array defining the elements of the form.
   */
  public function buildForm(array $form, FormStateInterface $form_state) {

    $form['description'] = [
      '#type' => 'item',
      '#markup' => $this->t('This basic example shows a single text input element and a submit button'),
    ];

    $form['title'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Title'),
      '#description' => $this->t('Title must be at least 5 characters in length.'),
      '#required' => TRUE,
    ];

    // Group submit handlers in an actions element with a key of "actions" so
    // that it gets styled correctly, and so that other modules may add actions
    // to the form. This is not required, but is convention.
    $form['actions'] = [
      '#type' => 'actions',
    ];

    // Add a submit button that handles the submission of the form.
    $form['actions']['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Submit'),
    ];

    return $form;
  }

  /**
   * Getter method for Form ID.
   *
   * The form ID is used in implementations of hook_form_alter() to allow other
   * modules to alter the render array built by this form controller. It must be
   * unique site wide. It normally starts with the providing module's name.
   *
   * @return string
   *   The unique ID of the form defined by this class.
   */
  public function getFormId() {
    return 'form_api_example_simple_form';
  }

  /**
   * Implements form validation.
   *
   * The validateForm method is the default method called to validate input on
   * a form.
   *
   * @param array $form
   *   The render array of the currently built form.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   Object describing the current state of the form.
   */
  public function validateForm(array &$form, FormStateInterface $form_state) {
    $title = $form_state->getValue('title');
    if (strlen($title) < 5) {
      // Set an error for the form element with a key of "title".
      $form_state->setErrorByName('title', $this->t('The title must be at least 5 characters long.'));
    }
  }

  /**
   * Implements a form submit handler.
   *
   * The submitForm method is the default method called for any submit elements.
   *
   * @param array $form
   *   The render array of the currently built form.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   Object describing the current state of the form.
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    /*
     * This would normally be replaced by code that actually does something
     * with the title.
     */
    $title = $form_state->getValue('title');
    $this->messenger()->addMessage($this->t('You specified a title of %title.', ['%title' => $title]));
  }

}

The Form API lifecycle, in order

Every time this form is submitted, Drupal walks through the same fixed sequence:

  1. getFormId() — identifies which form is being processed.
  2. buildForm() — runs on every request, including the re-render after a failed validation.
  3. Built-in element validation#required, #maxlength, and any element-level #element_validate callbacks run automatically, before your code gets a chance.
  4. validateForm() — your custom rules run here.
  5. submitForm() — only reached if steps 3 and 4 produced zero errors.

That ordering is the whole point of this lesson: by the time your code inside submitForm() runs, you already know the input is valid. You never need to defensively re-check something validateForm() already confirmed.

Two layers of validation, working together

$form['title'] = [
  '#type' => 'textfield',
  '#title' => $this->t('Title'),
  '#description' => $this->t('Title must be at least 5 characters in length.'),
  '#required' => TRUE,
];

#required => TRUE gives you a free "this field cannot be empty" check, entirely handled by Drupal core before validateForm() is even called. But "not empty" and "at least 5 characters" are two different rules — the second one is business logic specific to this form, so it has to be written by hand.

validateForm() — where your rules live

public function validateForm(array &$form, FormStateInterface $form_state) {
  $title = $form_state->getValue('title');
  if (strlen($title) < 5) {
    $form_state->setErrorByName('title', $this->t('The title must be at least 5 characters long.'));
  }
}

$form_state->getValue('title') is the safe, Form-API-approved way to read what the visitor typed — always use this instead of reading $_POST directly, since FormStateInterface normalizes the value regardless of how the form was actually submitted (including via AJAX, which you'll meet later in this topic).

The check itself, strlen($title) < 5, is just plain PHP. The interesting part is what happens next.

setErrorByName() — attaching an error to a specific field

$form_state->setErrorByName('title', $this->t('The title must be at least 5 characters long.')) does two things at the same instant:

  • It flags the title element specifically, so Drupal's theme layer adds the visual error styling and correct ARIA attributes to that field — not the whole form generically.
  • It registers the translated message text to display to the visitor.

The moment any error is registered this way, the Form API automatically cancels the rest of the request: submitForm() is skipped entirely, and the form is re-rendered showing the error, with whatever the visitor already typed still filled in — they don't lose their other work correcting one field.

Why not just throw a PHP exception? Because a thrown exception would produce a raw error page, not a friendly inline message next to the field that caused the problem. setErrorByName() is specifically designed to keep the visitor on the form, with a clear, accessible, field-level explanation of what to fix.

See it for yourself

Visit /examples/form-api-example/simple-form on your DDEV site and submit the form with a title shorter than 5 characters.

The simple form showing a validation error message after submitting a title that is too short

Notice two things happening together: the general error message at the top of the page, and the field itself outlined in red with its own inline error text directly below it — both produced by that single setErrorByName() call.

Quick check: if you leave the Title field completely empty and submit, does validateForm()'s strlen() check even run? No — the built-in #required check (step 3 in the lifecycle) catches the empty value and blocks the form before step 4, your custom validation, ever executes.

Key takeaways

  • The Form API lifecycle runs in a fixed order every time: build → built-in validation → your validateForm()submitForm() — and any step failing stops everything after it.
  • #required => TRUE gives you free non-empty validation; write validateForm() for anything more specific, like minimum length, format rules, or database lookups.
  • Always read submitted input with $form_state->getValue('key'), never directly from $_POST — it's the only approach that works consistently across normal and AJAX submissions.
  • setErrorByName('key', $message) attaches an error to one specific field and simultaneously blocks submitForm() from ever running.
  • Because validation runs to completion before submission logic starts, submitForm() can trust its inputs completely — no defensive re-validation needed.
  • A failed validation re-renders the form with the visitor's existing input intact, so correcting one field never means retyping everything else.

Coming up next

Validation stops bad data from being processed — but what happens after good data passes? The next lesson looks specifically at the submission side: reading the final validated value, showing a confirmation, and where you'd redirect a visitor or save data in a real module.