Not every validation rule belongs in one giant validateForm() method checking every field at once. If a single element's rule is genuinely local to that element — nothing else on the form affects it — Drupal lets you attach the validation directly to the element itself with #element_validate, keeping the logic right next to the field it governs.
What you'll learn in this lesson
- How
#element_validateattaches a validation callback to one specific element - The exact signature Drupal calls your callback with
- When to reach for
#element_validateinstead of the form-widevalidateForm() - How multiple element-level validators and the form-level validator interact
Attaching a validator to one element
$form['age'] = [
'#type' => 'number',
'#title' => $this->t('Age'),
'#element_validate' => ['::validateAge'],
];
public function validateAge(array &$element, FormStateInterface $form_state, array &$complete_form) {
$value = $element['#value'];
if ($value !== '' && (int) $value < 18) {
$form_state->setError($element, $this->t('You must be at least 18 years old.'));
}
}
#element_validate takes an array of callables, run in order, each receiving exactly three arguments: &$element (the specific render array for this one element, not the whole form), FormStateInterface $form_state, and &$complete_form (the entire form array, in case you need context from elsewhere). Reading the submitted value inside an element validator comes from $element['#value'], not $form_state->getValue() — at this point in the request lifecycle, the individual element's own value has already been extracted onto it directly.
Reporting the error against the right element
$form_state->setError($element, $this->t('You must be at least 18 years old.'));
Passing $element (not a field name string) to setError() ties the error message directly to that specific element's position on the rendered form — Drupal highlights the exact field and places the message right next to it, rather than relying on you knowing its machine name.
Element-level vs. form-level validation
Both run during the same validation phase, and both run every time, regardless of whether an earlier validator already set an error. Use #element_validate when a rule only needs that one element's own value — format checks, range checks, single-field business rules. Reach for the form-wide validateForm() method instead when a rule needs to compare two or more fields against each other (like "end date must be after start date") — no single element's own validator can see a sibling element's value cleanly.
#element_validate callbacks (for example, email elements validate their own format). Adding your own callback appends to that list rather than replacing it — both run. Don't duplicate a check core already performs for a given #type.See it for yourself
Add an #element_validate callback to a number field that rejects values under 18, submit the form with a value of 15, and confirm the error message appears directly beneath that field — not as a generic form-wide message at the top.
Quick check: your element validator signature is
function validateAge(array &$element, FormStateInterface $form_state, array &$complete_form)— why is$complete_formpassed in at all, if the validator is only supposed to care about one element? (Answer: sometimes a single element's validity genuinely depends on another field elsewhere on the form —$complete_formgives you an escape hatch to inspect the whole form array when that happens, without forcing every element-level validator to become a form-level one.)
Key takeaways
#element_validateattaches one or more validation callbacks directly to a single form element.- The callback signature is fixed:
(&$element, FormStateInterface $form_state, &$complete_form)— read the submitted value from$element['#value']. - Pass the
$elementarray (not a string field name) to$form_state->setError()so the message anchors to the right field. - Use element-level validation for single-field rules; use form-level
validateForm()for rules comparing multiple fields. - Core's built-in element types often already carry their own
#element_validatecallbacks — yours adds to that list, it doesn't replace it.
Coming up next
Next up: the single most common validation rule of all — making sure a field was filled in at all. You'll see how #required handles that automatically, and exactly when Drupal checks it relative to your own custom validators.