Form Submission and Redirects in Drupal: submitForm() Explainedfor Drupal 11 , and 10

Last updated :  

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 %placeholder substitution 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.

You may see older Drupal code call a function named 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'));
Never use PHP's 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.

The simple form success message after a valid submission

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() and addError() for other severities) — never the removed drupal_set_message().
  • Always substitute user-supplied values into t() strings with %placeholder tokens 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') or setRedirectUrl() 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.