Think of a Drupal form the way you'd think of a restaurant order pad: it's a structured template — name, table number, dish, quantity — that turns a customer's spoken request into something the kitchen can act on reliably. FormBase is Drupal's order pad. It gives every form the same predictable lifecycle — build it, validate what came back, act on it — so you never have to reinvent how user input gets collected and processed.
What you'll learn in this lesson
- What
FormBaseactually provides for you, and why nearly every custom form starts by extending it - The three methods every form class needs:
buildForm(),getFormId(), andsubmitForm() - How to define a single text field and a submit button using Form API's render-array syntax
- Where a form class lives in your module, and why the folder path matters
Extending FormBase
FormBase is an abstract class in Drupal core's Form API. On its own it does nothing visible — its value is in the methods it requires you to implement and the helpers it hands you for free, like $this->t() for translatable strings and $this->messenger() for status messages. Every form you build, from a one-field contact box to a multi-step wizard, follows this same contract.
namespace Drupal\your_module\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
class SimpleForm extends FormBase {
public function buildForm(array $form, FormStateInterface $form_state) {
$form['title'] = [
'#type' => 'textfield',
'#title' => $this->t('Title'),
'#description' => $this->t('Title must be at least 5 characters in length.'),
'#required' => TRUE,
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Submit'),
];
return $form;
}
public function getFormId() {
return 'your_module_simple_form';
}
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->messenger()->addMessage($this->t('You have submitted: %title', [
'%title' => $form_state->getValue('title'),
]));
}
}
Where this file lives
Form classes belong in your module's src/Form/ directory, one class per file, filename matching the class name exactly — SimpleForm.php for class SimpleForm. This isn't a style preference; it's how PHP's PSR-4 autoloading finds the class at all. Get the path or filename wrong and Drupal simply won't be able to load your form when a route tries to render it.
buildForm() — the render array that becomes your form
Everything inside buildForm() is just a nested PHP array — the same render-array system that builds every page in Drupal. Each key becomes one form element; #type says what kind of widget to draw, and every other #-prefixed key configures that widget. Grouping the submit button inside an actions array with #type => 'actions' is a convention, not a requirement — but it's the convention Drupal core itself uses, so other modules that alter your form (via hook_form_alter()) know where to look for it.
getFormId() — the form's unique fingerprint
This one method has an outsized job: the string it returns is how Drupal's Form API, the render cache, and any hook_form_FORM_ID_alter() implementation all identify this specific form. Prefix it with your module's machine name (your_module_simple_form, not just simple_form) so it can never collide with another module's form of the same short name.
submitForm() — what happens after Submit is clicked
Once validation passes (you'll build real validation in the next lesson), submitForm() runs. $form_state->getValue('title') retrieves whatever the visitor typed into the field keyed title — the key you chose in buildForm(). This is the entire round trip: define a field, read its submitted value, do something with it.
_form: '\Drupal\your_module\Form\SimpleForm' in your .routing.yml) or to invoke it manually via \Drupal::formBuilder()->getForm(SimpleForm::class) from a controller. A form class with no route is just an unused PHP file.Quick check: if two different modules each define a form class with
getFormId()returning'settings_form', what breaks? Drupal's Form API, form-alter hooks, and the form cache all key off that ID — a collision means whichever form loads last effectively hijacks the other's alterations and cached state. That's exactly why the convention is always{module_name}_{form_purpose}.
Key takeaways
- Every custom form starts by extending
FormBase(or a more specific base class likeConfigFormBasefor settings forms) — it's what gives your class the build/validate/submit lifecycle. - Form classes live in
src/Form/, one class per file, filename matching the class name — required by PSR-4 autoloading, not just convention. buildForm()returns a render array;#typepicks the widget, other#-keys configure it.getFormId()must be globally unique — always prefix it with your module's machine name.- A form class needs a route (or manual
\Drupal::formBuilder()->getForm()call) to actually be reachable — the class alone does nothing.
Coming up next
You've got a form with one field and a submit button. Next, we widen the toolbox: the full range of Form API element types — checkboxes, selects, file uploads, and more — so you can build forms that actually match what your module needs to collect.