A config form's save() call doesn't know or care whether the value it's about to write makes sense — that's validateForm()'s job, and it runs before submitForm() ever gets a chance. Reject bad input here, and a malformed or empty value never reaches disk in the first place; skip it, and your module has to defensively guard against garbage config everywhere it later reads that value.
What you'll learn in this lesson
- Where
validateForm()fits in a config form's request lifecycle - How to reject a submitted value with
$form_state->setErrorByName() - Why validation belongs on the form, not scattered across every place that later reads the config
- What happens to the form when validation fails
Adding validateForm() to a config form
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
$message = $form_state->getValue('message');
if (trim($message) === '') {
$form_state->setErrorByName('message', $this->t('The message cannot be empty.'));
}
if (mb_strlen($message) > 255) {
$form_state->setErrorByName('message', $this->t('The message must be 255 characters or fewer.'));
}
parent::validateForm($form, $form_state);
}
How it works
Validation runs before submission, on every config form
This is ordinary Form API behavior — nothing config-specific about the mechanism itself. Drupal always calls a form's validateForm() after the request arrives but before submitForm(). If any call to setErrorByName() registers an error, submitForm() is skipped entirely: the form re-renders with the error message shown next to the offending field, and nothing gets saved.
setErrorByName() — rejecting one specific field
$form_state->setErrorByName('message', $this->t('The message cannot be empty.'));
The first argument is the form element's name (matching the array key used in buildForm()), and the second is the translatable message shown to the visitor, displayed both inline next to the field and summarized at the top of the form. You can call this multiple times across different fields in one validateForm() run — Drupal collects and shows every error, not just the first one.
Validation belongs on the form, not scattered elsewhere
Putting the length/emptiness check here — rather than, say, silently truncating the value inside submitForm(), or defensively re-checking it everywhere get('message') is later called — means every consumer of this config can trust its shape without re-validating it themselves. This is the same principle behind schema typing from an earlier lesson: validate once, at the boundary, and trust the result everywhere after.
parent::validateForm(). ConfigFormBase's own parent validation checks whether the config is currently locked for editing (for example, during an active config import) — skip the parent call, and a form could let someone save changes at exactly the wrong moment.Quick check: if
validateForm()registers an error, does the value the visitor typed get lost when the form re-renders? (Answer: no — Drupal automatically repopulates the submitted values back into the form on a validation-failure re-render, so the visitor sees their own input plus the error message, not a form reset to the old saved value.)
Key takeaways
validateForm()always runs beforesubmitForm(); a registered error skips submission entirely and re-renders the form.$form_state->setErrorByName('field_name', $message)rejects one specific field, shown both inline and summarized at the top of the form.- Validate once on the form rather than defensively re-checking the value everywhere it's later read from config.
- Always call
parent::validateForm()— it enforces config-import locking on top of your own custom checks.
Coming up next
You've now covered a config form end to end — building, schema-typing, saving, reading, and validating. The last piece is what a brand-new install sees before anyone has touched the form at all: the next lesson, "Providing Default Config Values," covers exactly that.