Every website needs forms — a login box, a contact form, a settings page. In Drupal, you never hand-write the HTML for these. Instead, you write a PHP class that describes the form as data, and Drupal's Form API turns that description into safe, accessible, CSRF-protected HTML for you. In this lesson you'll build the mental model for every form you'll ever write in Drupal, by reading the simplest one that exists.
What you'll learn in this lesson
- The three methods every Drupal form class must implement:
buildForm(),validateForm(), andsubmitForm() - Why forms extend
FormBase, and what that gives you for free - How a render array describes a text field, a label, and a submit button
- How server-side validation and success messages actually work under the hood
hook_form_alter() so other modules can safely modify your form, and generating consistent, accessible markup. You describe what the form contains; Drupal handles how it becomes safe HTML.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]));
}
}
How it works
Extending FormBase
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
class SimpleForm extends FormBase {
FormBase is the lightest form base class in Drupal. By extending it, this class immediately gets $this->t() for translatable strings, $this->messenger() for status messages, $this->config() for reading configuration, and $this->currentUser() for the logged-in user — all without writing a single line of plumbing. In exchange, Drupal only requires you to implement three methods: getFormId(), buildForm(), and submitForm(). validateForm() is optional (it does nothing by default), but overriding it is how you add your own validation rules.
getFormId()
public function getFormId() {
return 'form_api_example_simple_form';
}
A unique string that identifies this form across the entire Drupal site. The convention is {module_name}_{something_descriptive}. It matters for two reasons: Drupal itself uses it to build the form's cache key and CSRF token, and other developers can target it in hook_form_alter() or the more specific hook_form_FORM_ID_alter() to modify your form without touching your code — you actually saw this exact mechanism in action back in the Hooks topic. Always prefix it with your module's name to avoid clashing with someone else's form.
buildForm() and the render array
buildForm() returns an array describing every element on the form. Drupal's Render API — which you'll dig into properly later in this course — turns that array into HTML. Three pieces are worth looking at individually:
$form['description'] = [
'#type' => 'item',
'#markup' => $this->t('This basic example shows a single text input element and a submit button'),
];
An item element just renders static text — no input, no name attribute. #markup holds the HTML string. Notice it's still wrapped in $this->t(): any text a visitor might see should go through t(), even a plain instructional sentence, so the string can be translated later.
$form['title'] = [
'#type' => 'textfield',
'#title' => $this->t('Title'),
'#description' => $this->t('Title must be at least 5 characters in length.'),
'#required' => TRUE,
];
The array key 'title' is doing double duty: it becomes the HTML field's name attribute, and it's the exact key you'll use later to read the submitted value back out ($form_state->getValue('title')). #type => 'textfield' renders a plain text input, #title is the visible label, #description is the small helper text below the field, and #required => TRUE gets you a free non-empty check — Drupal blocks submission with a default error message before your own validateForm() even runs, so you never need to check for emptiness yourself.
$form['actions'] = [
'#type' => 'actions',
];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Submit'),
];
Wrapping buttons inside an #type => 'actions' container is a Drupal convention, not a technical requirement — but it's one worth following, because it gives your button consistent theme styling and lets other modules cleanly add their own buttons (like a "Preview" button) alongside yours.
validateForm() — checking input on the server
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.'));
}
}
This runs automatically after the built-in #required check passes. $form_state->getValue('title') pulls out whatever the visitor typed. If it's too short, setErrorByName('title', $message) does two things at once: it stops submitForm() from ever running, and it tells Drupal exactly which field to highlight when it re-renders the form — the visitor's other input is preserved, so they don't have to start over.
validateForm() is the only check you can actually trust.submitForm() — what happens on success
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]));
}
This only runs once every validation check — built-in and custom — has passed. Here it just shows a status message, but in a real module this is exactly where you'd save something to the database, call an external service, or redirect the visitor elsewhere with $form_state->setRedirect('route.name').
Look closely at the %title placeholder inside t() — that's not string concatenation, it's a safe substitution placeholder. Drupal automatically escapes whatever value gets inserted there, which is what stops a visitor from injecting malicious HTML into a message that echoes their own input back to them. Get in the habit of using placeholders like %title or @variable instead of gluing strings together with ..
See it for yourself
Visit /examples/form-api-example/simple-form on your DDEV site, type a title, and submit it.
That confirmation message — "You specified a title of..." — is exactly the string built in submitForm(), with your typed value safely substituted in through %title.
Quick check: if you leave the Title field completely empty and submit, which method actually stops the form — the built-in
#requiredcheck, or your customvalidateForm()? It's the built-in check, since it runs first and blocks the form before your 5-character-minimum logic ever gets a chance to run.
Key takeaways
- Every Drupal form is a PHP class in
src/Form/that extendsFormBase(or a related base class), following PSR-4 naming:SimpleForm.phpcontainsclass SimpleForm. getFormId()must return a unique, module-prefixed string — it's howhook_form_alter()andhook_form_FORM_ID_alter()target this specific form.buildForm()returns a render array; every element's behavior is controlled by#-prefixed properties like#type,#title,#required, and#markup.- Wrap submit and other action buttons in an
#type => 'actions'container — it's the Drupal convention for consistent styling and lets other modules extend your form safely. validateForm()reads values with$form_state->getValue('key')and flags problems with$form_state->setErrorByName('key', $message); setting any error stopssubmitForm()from running at all.- Use
%placeholderor@placeholdersubstitution insidet()instead of string concatenation — it's what keeps user-supplied text from becoming a security hole.
Coming up next
This form only has one field. Real forms need many — checkboxes, dropdowns, dates, numbers. In the next lesson, you'll tour Drupal's full catalog of built-in form element types by exploring a single page that puts nearly every one of them on display at once.