In the last lesson you saw config.get() and config.set() in action. Now let's zoom out and look at the same file from a different angle: what exactly does Drupal require of a class before it will treat it as a working configuration form? This lesson is about the contract — the specific methods ConfigFormBase expects you to implement, why each one exists, and what breaks if you skip one.
We're studying the same real file as last time, config_simple_example's settings form, because seeing identical code through a second lens — "what's the required shape?" instead of "how do I read and write a value?" — is one of the fastest ways to make a pattern stick.
What you'll learn in this lesson
- The minimum set of methods every
ConfigFormBasesubclass must implement - Why forgetting
parent::buildForm()silently produces a form with no submit button - The difference between a form's public API methods and its internal helper methods
- How Drupal turns a form ID into routing, caching, and alter-hook targeting behind the scenes
The source file
Path (relative to the Examples module's root): modules/config_simple_example/src/Form/ConfigSimpleExampleSettingsForm.php
<?php
namespace Drupal\config_simple_example\Form;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Configure example settings for this site.
*/
class ConfigSimpleExampleSettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'config_simple_example_settings';
}
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return [
'config_simple_example.settings',
];
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config('config_simple_example.settings');
$form['message'] = [
'#type' => 'textarea',
'#title' => $this->t('Message'),
'#default_value' => $config->get('message'),
];
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Retrieve the configuration.
$this->config('config_simple_example.settings')
// Set the submitted configuration setting.
->set('message', $form_state->getValue('message'))
->save();
parent::submitForm($form, $form_state);
}
}
The four-method contract
Every working ConfigFormBase subclass implements exactly these four methods. Miss one, and something breaks in a specific, predictable way — which makes them worth memorizing as a checklist.
getFormId() — the form's unique name
public function getFormId() {
return 'config_simple_example_settings';
}
Every Drupal form, of any kind, must return a globally unique machine-readable string here. Drupal uses it for three distinct jobs at once: the HTML id attribute on the rendered <form> element, the key used to cache and rebuild the form across the multi-step submission cycle, and the target string other modules hook into with hook_form_FORM_ID_alter() (you'll meet that hook properly in the Hooks topic). The convention is <module_name>_<descriptive_suffix>, all lowercase, underscores only.
getEditableConfigNames() — the write-access contract
protected function getEditableConfigNames() {
return [
'config_simple_example.settings',
];
}
This one is specific to ConfigFormBase — it doesn't exist on plain FormBase forms. Notice it's declared protected, not public, because it's an internal detail that only ConfigFormBase itself calls; it isn't part of the public-facing FormInterface contract that Drupal's form subsystem invokes directly. Its job: declare, up front, every config object this form is permitted to modify.
buildForm() — assembling the render array
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config('config_simple_example.settings');
$form['message'] = [
'#type' => 'textarea',
'#title' => $this->t('Message'),
'#default_value' => $config->get('message'),
];
return parent::buildForm($form, $form_state);
}
This method's job is to return a Form API render array — the same kind of structure you'll study in depth in the Render API topic later in this course. The one line that's easy to overlook is the very last one: return parent::buildForm($form, $form_state);. Your own code builds the message field, but the parent class is what appends the standard Save configuration submit button and the CSRF security token. Skip that call — return $form directly instead — and you get a form that renders your textarea with no way to submit it at all. No error, no warning; the button just silently isn't there.
submitForm() — handling the submission
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->config('config_simple_example.settings')
->set('message', $form_state->getValue('message'))
->save();
parent::submitForm($form, $form_state);
}
Notice $form is passed by reference here (the & before $form) while it wasn't in buildForm() — this lets the method modify the original form array in place if needed, which some more advanced submit handlers rely on. The same rule as before applies in reverse this time: parent::submitForm($form, $form_state) must be called after your own logic, not before, because it's what displays Drupal's standard "The configuration options have been saved." status message. Call it too early and your own code technically still runs, but the confirmation message can appear before the save actually happens from the user's point of view.
public and which are protected. getFormId(), buildForm(), and submitForm() are all public because they're part of the FormInterface contract Drupal's form subsystem calls directly. getEditableConfigNames() is protected because only ConfigFormBase's own internals call it. Declaring it public by mistake won't break anything functionally, but it's worth knowing the difference is intentional, not arbitrary.See it for yourself
The real test of "does this form fulfill its contract correctly" isn't reading the code — it's whether a value actually survives a page reload. Visit /admin/config/form-api-example/config-simple-form on your DDEV site, save the form, then reload the page.
If the value you typed is still sitting in the textarea after the reload, every piece of the contract executed correctly: buildForm() rendered the submit button (thanks to that parent::buildForm() call), submitForm() persisted the value (thanks to ->save()), and buildForm() read it back correctly on the next page load (thanks to getEditableConfigNames() having granted write access in the first place).
Key takeaways
- Extending
ConfigFormBaseinstead ofFormBasegives you theconfig()helper, built-in submit messaging, and automatic cache invalidation — less boilerplate to write yourself. getEditableConfigNames()is a required contract: it declares which config objects the form can write to, and must return the exact string matching the.ymlfilename minus the extension.- Always call
parent::buildForm()at the end ofbuildForm()— it appends the submit button and security token; omitting it produces a form with no way to submit. - Call
parent::submitForm()after your own save logic insubmitForm()— it's what displays the standard confirmation message to the user. - The config key used in
->set()and->get()must match the key in yourconfig/install/<module>.settings.ymlfile — keeping the YAML and the form code consistent is entirely your responsibility.
Coming up next
Simple key/value config is great for a handful of settings, but what happens when you need to manage a whole collection of records — each with its own ID, label, and custom fields, creatable and deletable through an admin UI? That's what config entities are for, and it's exactly where we're headed next.