You've now built a form with exactly one field. Real forms need much more: checkboxes, dates, dropdowns, file uploads, password confirmation. The good news is that you already know the pattern from the last lesson — every element is still just a PHP array with a #type key. This lesson is a tour through nearly every #type Drupal ships with, all demonstrated on a single real page.
What you'll learn in this lesson
- How to declare over 25 different form element types, from checkboxes to file uploads
- Which types are thin wrappers around plain HTML5 inputs, and which are Drupal-specific "compound" elements doing real work behind the scenes
- How to inject a Drupal service into a form class using constructor dependency injection
- How to read every submitted value back out in one pass with
$form_state->getValues()
The source file
Path: modules/form_api_example/src/Form/InputDemo.php
<?php
namespace Drupal\form_api_example\Form;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Extension\ExtensionPathResolver;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Implements InputDemo form controller.
*
* This example demonstrates the different input elements that are used to
* collect data in a form.
*
* @todo Change this class once either https://www.drupal.org/i/2940190 or
* https://www.drupal.org/i/2940481 are fixed.
*/
class InputDemo extends FormBase {
/**
* The extension path resolver.
*
* @var \Drupal\Core\Extension\ExtensionPathResolver
*/
protected $extensionPathResolver;
/**
* Constructs a new \Drupal\form_api_example\Form\InputDemo object.
*
* @param \Drupal\Core\Extension\ExtensionPathResolver $extension_path_resolver
* The extension path resolver.
*/
public function __construct(ExtensionPathResolver $extension_path_resolver) {
$this->extensionPathResolver = $extension_path_resolver;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
$form = new static($container->get('extension.path.resolver'));
$form->setMessenger($container->get('messenger'));
$form->setStringTranslation($container->get('string_translation'));
return $form;
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form['description'] = [
'#type' => 'item',
'#markup' => $this->t('This example shows the use of all input-types.'),
];
// CheckBoxes.
$form['tests_taken'] = [
'#type' => 'checkboxes',
'#options' => ['SAT' => $this->t('SAT'), 'ACT' => $this->t('ACT')],
'#title' => $this->t('What standardized tests did you take?'),
'#description' => 'Checkboxes, #type = checkboxes',
];
// Color.
$form['color'] = [
'#type' => 'color',
'#title' => $this->t('Color'),
'#default_value' => '#ffffff',
'#description' => 'Color, #type = color',
];
// Date.
$now = new DrupalDateTime();
$form['expiration'] = [
'#type' => 'date',
'#title' => $this->t('Content expiration'),
'#default_value' => $now->format('Y-m-d'),
'#description' => 'Date, #type = date',
];
// Date-time.
$form['datetime'] = [
'#type' => 'datetime',
'#title' => 'Date Time',
'#date_increment' => 1,
'#default_value' => $now,
'#description' => $this->t('Date time, #type = datetime'),
];
// URL.
$form['url'] = [
'#type' => 'url',
'#title' => $this->t('URL'),
'#maxlength' => 255,
'#size' => 30,
'#description' => $this->t('URL, #type = url'),
];
// Email.
$form['email'] = [
'#type' => 'email',
'#title' => $this->t('Email'),
'#description' => $this->t('Email, #type = email'),
];
// Number.
$form['quantity'] = [
'#type' => 'number',
'#title' => $this->t('Quantity'),
'#description' => $this->t('Number, #type = number'),
];
// Password.
$form['password'] = [
'#type' => 'password',
'#title' => $this->t('Password'),
'#description' => 'Password, #type = password',
];
// Password Confirm.
$form['password_confirm'] = [
'#type' => 'password_confirm',
'#title' => $this->t('New Password'),
'#description' => $this->t('PasswordConfirm, #type = password_confirm'),
];
// Range.
$form['size'] = [
'#type' => 'range',
'#title' => $this->t('Size'),
'#min' => 10,
'#max' => 100,
'#description' => $this->t('Range, #type = range'),
];
// Radios.
$form['settings']['active'] = [
'#type' => 'radios',
'#title' => $this->t('Poll status'),
'#options' => [0 => $this->t('Closed'), 1 => $this->t('Active')],
'#description' => $this->t('Radios, #type = radios'),
];
// Search.
$form['search'] = [
'#type' => 'search',
'#title' => $this->t('Search'),
'#description' => $this->t('Search, #type = search'),
];
// Select.
$form['favorite'] = [
'#type' => 'select',
'#title' => $this->t('Favorite color'),
'#options' => [
'red' => $this->t('Red'),
'blue' => $this->t('Blue'),
'green' => $this->t('Green'),
],
'#empty_option' => $this->t('-select-'),
'#description' => $this->t('Select, #type = select'),
];
// Multiple values option elements.
$form['select_multiple'] = [
'#type' => 'select',
'#title' => 'Select (multiple)',
'#multiple' => TRUE,
'#options' => [
'sat' => 'SAT',
'act' => 'ACT',
'none' => 'N/A',
],
'#default_value' => ['sat'],
'#description' => 'Select Multiple',
];
$form['phone'] = [
'#type' => 'tel',
'#title' => $this->t('Phone'),
'#description' => $this->t('Tel, #type = tel'),
];
$form['details'] = [
'#type' => 'details',
'#title' => $this->t('Details'),
'#description' => $this->t('Details, #type = details'),
];
// TableSelect.
$options = [
1 => ['first_name' => 'Indy', 'last_name' => 'Jones'],
2 => ['first_name' => 'Darth', 'last_name' => 'Vader'],
3 => ['first_name' => 'Super', 'last_name' => 'Man'],
];
$header = [
'first_name' => $this->t('First Name'),
'last_name' => $this->t('Last Name'),
];
$form['table'] = [
'#type' => 'tableselect',
'#title' => $this->t('Users'),
'#header' => $header,
'#options' => $options,
'#empty' => $this->t('No users found'),
];
// Textarea.
$form['text'] = [
'#type' => 'textarea',
'#title' => $this->t('Text'),
'#description' => $this->t('Textarea, #type = textarea'),
];
// Text format.
$form['text_format'] = [
'#type' => 'text_format',
'#title' => 'Text format',
'#format' => 'plain_text',
'#description' => $this->t('Text format, #type = text_format'),
];
// Textfield.
$form['subject'] = [
'#type' => 'textfield',
'#title' => $this->t('Subject'),
'#size' => 60,
'#maxlength' => 128,
'#description' => $this->t('Textfield, #type = textfield'),
];
// Weight.
$form['weight'] = [
'#type' => 'weight',
'#title' => $this->t('Weight'),
'#delta' => 10,
'#description' => $this->t('Weight, #type = weight'),
];
// 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.
$form['actions'] = [
'#type' => 'actions',
];
// Extra actions for the display.
$form['actions']['extra_actions'] = [
'#type' => 'dropbutton',
'#links' => [
'simple_form' => [
'title' => $this->t('Simple Form'),
'url' => Url::fromRoute('form_api_example.simple_form'),
],
'demo' => [
'title' => $this->t('Build Demo'),
'url' => Url::fromRoute('form_api_example.build_demo'),
],
],
];
// File.
$form['file'] = [
'#type' => 'file',
'#title' => 'File',
'#description' => $this->t('File, #type = file'),
];
// Manage file.
$form['managed_file'] = [
'#type' => 'managed_file',
'#title' => 'Managed file',
'#description' => $this->t('Manage file, #type = managed_file'),
];
// Image Buttons.
$form['image_button'] = [
'#type' => 'image_button',
'#value' => 'Image button',
'#src' => $this->extensionPathResolver->getPath('module', 'examples') . '/images/button.svg',
'#description' => $this->t('image file, #type = image_button'),
'#attributes' => [
'width' => 88,
],
];
// Button.
$form['button'] = [
'#type' => 'button',
'#value' => 'Button',
'#description' => $this->t('Button, #type = button'),
];
// Add a submit button that handles the submission of the form.
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Submit'),
'#description' => $this->t('Submit, #type = submit'),
];
// Add a reset button that handles the submission of the form.
$form['actions']['reset'] = [
'#type' => 'button',
'#button_type' => 'reset',
'#value' => $this->t('Reset'),
'#description' => $this->t('Submit, #type = button, #button_type = reset, #attributes = this.form.reset();return false'),
'#attributes' => [
'onclick' => 'this.form.reset(); return false;',
],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'form_api_example_input_demo_form';
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Find out what was submitted.
$values = $form_state->getValues();
foreach ($values as $key => $value) {
$label = $form[$key]['#title'] ?? $key;
// Many arrays return 0 for unselected values so lets filter that out.
if (is_array($value)) {
$value = array_filter($value);
}
// Only display for controls that have titles and values.
if ($value && $label) {
$display_value = is_array($value) ? preg_replace('/[\n\r\s]+/', ' ', print_r($value, 1)) : $value;
$message = $this->t('Value for %title: %value', ['%title' => $label, '%value' => $display_value]);
$this->messenger()->addMessage($message);
}
}
}
}
How it works
Injecting a service with the constructor
public function __construct(ExtensionPathResolver $extension_path_resolver) {
$this->extensionPathResolver = $extension_path_resolver;
}
public static function create(ContainerInterface $container) {
$form = new static($container->get('extension.path.resolver'));
$form->setMessenger($container->get('messenger'));
$form->setStringTranslation($container->get('string_translation'));
return $form;
}
This form needs to know the filesystem path of a module (to build an image button's URL), so it injects Drupal's extension.path.resolver service. The pattern is always the same: declare a typed constructor parameter, store it on a property, and implement a static create() method that pulls the actual service out of Drupal's service container by its ID. Whenever you see \Drupal::service('something') called directly inside a class method, that's usually a sign the class should be using constructor injection like this instead — it's more testable and makes a class's dependencies explicit.
The element catalog
Every one of the following is declared exactly the same way you saw in the last lesson: an array key becomes the field name, and a #type property picks the widget. Group them by family and this list gets much less intimidating:
Plain HTML5 inputs, with Drupal validation layered on top
textfield— a single line of text (#maxlength,#size)textarea— multiple lines of plain texturl,email,tel,search— semantic text inputs;urlandemailget real server-side format validation, not just a browser hintnumber,range— numeric input and a slider, both accept#min/#maxcolor— the browser's native color picker;#default_valuemust be a#rrggbbhex stringdate— an HTML5 date picker (#default_valueas a'Y-m-d'string)password— a masked text field that never re-populates on reloadfile— a raw upload input; Drupal gives you the uploaded data but does not save it anywhere automatically
Choice elements
checkboxes(plural) — a group of independent checkboxes from#options; submitted as an array where unchecked items come back as0radios— mutually-exclusive options; submitted value is the key of the one selectedselect— a dropdown; add#multiple => TRUEfor a multi-select listbox, or#empty_optionfor a "choose one" placeholder
Drupal-specific "compound" elements — these do real work for you
datetime— a date and time picker together; unlike plaindate, it accepts a fullDrupalDateTimeobject as its default value and is timezone-awarepassword_confirm— renders two password boxes and automatically validates that they match, with zero extra code from youtext_format— a textarea paired with a text-format selector (plain text, basic HTML, full HTML); submits as an array withvalueandformatkeystableselect— a full data table where every row has a checkbox, built from#headerand#optionsarrays — this is the same element Drupal's own admin bulk-action pages usemanaged_file— an AJAX-powered upload widget that (unlike plainfile) actually saves the upload as a tracked Drupal file entity and returns its file IDweight— a dropdown pre-filled with integers from-#deltato+#delta, used throughout Drupal's admin UI for drag-and-drop-style orderingdetails— a collapsible<details>/<summary>section that can contain other form elements as children
Actions and buttons
submit— runs full validation, then callssubmitForm()button— does not trigger full-form submission by default; here it's reused with#button_type => 'reset'plus a small inlineonclickto clear the form client-sidedropbutton— a primary action with a dropdown of secondary links, each needing atitleand aurlbuilt withUrl::fromRoute()image_button— an<input type="image">; its#srchere is built dynamically using the injectedextensionPathResolver
radios element is declared at $form['settings']['active'] instead of a flat top-level key. Nesting keys like this is purely organizational — Drupal reads and renders elements at any depth, and it doesn't change how you submit or retrieve the value.Reading everything back at once
public function submitForm(array &$form, FormStateInterface $form_state) {
$values = $form_state->getValues();
foreach ($values as $key => $value) {
$label = $form[$key]['#title'] ?? $key;
if (is_array($value)) {
$value = array_filter($value);
}
if ($value && $label) {
$message = $this->t('Value for %title: %value', ['%title' => $label, '%value' => $display_value]);
$this->messenger()->addMessage($message);
}
}
}
Instead of calling $form_state->getValue('key') once per field like the previous lesson did, this loops over the whole getValues() array at once — a handy pattern when a form has many fields and you want to process them generically. array_filter() strips out the 0s that unchecked checkboxes options leave behind, so only genuinely-filled-in fields produce a message.
See it for yourself
Visit /examples/form-api-example/input-demo on your DDEV site to see every element on this page rendered together.
Quick check: which element type on this page validates that two password fields match, without you writing any comparison code yourself?
password_confirm— it's a compound element that handles its own internal validation.
Key takeaways
- Every form element — from a plain textfield to a full data table — is declared the same way: an array key plus a
#typeproperty. Drupal renders, validates, and secures all of them for you. - Prefer Drupal's semantic HTML5 types (
url,email,tel,search) over plaintextfieldwhen the data has a specific shape — you get real server-side validation, not just a browser hint. - Compound elements like
password_confirm,text_format, anddatetimerender multiple widgets but manage their own internal logic and validation — use them instead of reinventing that behavior yourself. - Use constructor injection (a typed constructor parameter plus a static
create()method) to access Drupal services in a form class — avoid calling\Drupal::service()directly inside methods when injection is available. managed_filetracks uploads as real Drupal file entities; plainfilejust hands you raw upload data with no automatic storage or cleanup.$form_state->getValues()retrieves every submitted value in one array — useful for looping generically, versus callinggetValue('key')field by field.
Coming up next
Knowing every element type is only half the job — the other half is making sure what people type is actually valid before you do anything with it. The next lesson goes deeper into server-side validation: how error messages attach to specific fields, and what happens to the form when validation fails.