Third time seeing this same little form — and that's deliberate. You now know how it's built, and you know how it validates input. This lesson closes the loop: what happens the instant validation passes, how to give the visitor feedback, and — just as important — where a real module would send them next.
What you'll learn in this lesson
- Exactly when
submitForm()runs, and why it can trust its own input completely - How to show a status message with the Messenger service
- Where redirect logic belongs after a successful submission
- Why
%placeholdersubstitution matters even in a "harmless" confirmation message
The source file
Path: modules/form_api_example/src/Form/SimpleForm.php
<?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]));
}
}
submitForm() only runs on trustworthy input
public function submitForm(array &$form, FormStateInterface $form_state) {
$title = $form_state->getValue('title');
$this->messenger()->addMessage($this->t('You specified a title of %title.', ['%title' => $title]));
}
By the time this method runs, every check has already passed — the #required check and your own validateForm() logic. That's a genuinely useful guarantee: this is the one place in your form class where you never have to write defensive re-validation. In a real module, this is exactly where you'd put the actual business logic: saving a record to the database, calling an external API, sending an email, dispatching an event (you'll meet Drupal's event system properly later in this course).
Showing feedback with the Messenger service
$this->messenger()->addMessage($this->t('You specified a title of %title.', ['%title' => $title]));
$this->messenger() is available on any class extending FormBase, no extra setup needed. addMessage() queues a message that renders on the very next page Drupal shows the visitor — green/status by default. Two siblings exist for different severities: addWarning() and addError(), which render with different styling to match the seriousness of what happened.
drupal_set_message() instead. That function was removed in Drupal 9 — $this->messenger()->addMessage() (or \Drupal::messenger()->addMessage() outside a class with the helper) is the only correct way to do this now.Why %title, not string concatenation
It would be tempting to write 'You specified a title of ' . $title instead. Don't. The %title placeholder inside $this->t() tells Drupal to safely escape whatever value gets substituted in — and to render it in italics as a visual cue that it's user-supplied content. If $title ever contained something like <script>, string concatenation would inject it straight into the page; the placeholder pattern neutralizes it automatically. This matters even for a message that feels completely harmless, because the title field's only real constraint is a minimum length — nothing stops someone from typing HTML into it.
Where redirects belong
Notice this form never explicitly redirects anywhere. When submitForm() finishes without setting a destination, Drupal falls back to reloading the same form URL — a standard POST/Redirect/GET pattern that prevents the classic "resubmit this form?" browser warning if someone refreshes the page.
To send the visitor somewhere else after a successful submission — a confirmation page, a newly-created entity's page, anywhere — call one of these inside submitForm():
$form_state->setRedirect('route.name');
// or, for an arbitrary URL:
$form_state->setRedirectUrl(Url::fromUri('internal:/some/path'));
header('Location: ...') or the long-removed drupal_goto() function inside a form handler. $form_state->setRedirect() is the only approach that correctly integrates with Drupal's page cache and the rest of the request lifecycle.See it for yourself
Visit /examples/form-api-example/simple-form on your DDEV site, type a valid title (5 characters or more), and submit.
Quick check: after submitting successfully, the browser lands back on the exact same form URL rather than some kind of "thank you" page. Is that a bug? No — it's the default POST/Redirect/GET behavior, since this form never calls
$form_state->setRedirect().
Key takeaways
submitForm()only ever runs after every validation check has passed — write business logic there without re-checking what's already guaranteed valid.- Use
$this->messenger()->addMessage()for feedback (addWarning()andaddError()for other severities) — never the removeddrupal_set_message(). - Always substitute user-supplied values into
t()strings with%placeholdertokens instead of concatenation, even in messages that feel low-risk. - With no explicit redirect, Drupal reloads the same form URL after submission — a safe default, not a bug.
- Use
$form_state->setRedirect('route.name')orsetRedirectUrl()to send visitors elsewhere after success — never raw HTTP redirect functions.
Coming up next
You've now covered a complete, single-page form end to end. But not every form fits on one page — sometimes you need to collect information across several steps, carrying state forward from one page to the next. That's exactly what the next lesson tackles: a real multi-step wizard form.