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 anythingvalidateForm()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:
- getFormId() — identifies which form is being processed.
- buildForm() — runs on every request, including the re-render after a failed validation.
- Built-in element validation —
#required,#maxlength, and any element-level#element_validatecallbacks run automatically, before your code gets a chance. - validateForm() — your custom rules run here.
- 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
titleelement 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.
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.
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()'sstrlen()check even run? No — the built-in#requiredcheck (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 => TRUEgives you free non-empty validation; writevalidateForm()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 blockssubmitForm()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.