In the last lesson you saw the map — every AJAX demo in this module, laid out as a route. Now let's actually click into one and see, in real PHP code, what happens between a button press and a piece of the page updating itself. This is the lesson where the "magic" of AJAX stops being magic.
We're going to study a submit-driven AJAX form: a form with one button that, when clicked, swaps out a piece of markup on the page without ever reloading it. It's the smallest possible complete example of the pattern, which makes it the perfect one to learn from first.
What you'll learn in this lesson
- What an AJAX callback actually is — just a regular PHP method with one extra rule
- How the
#ajaxrender array property turns an ordinary submit button into an AJAX trigger - How Drupal decides exactly which piece of the page to replace
- The full request lifecycle, from click to DOM update, step by step
The source file
Path (relative to the Examples module's root): modules/ajax_example/src/Form/SubmitDriven.php
<?php
namespace Drupal\ajax_example\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Submit a form without a page reload.
*/
class SubmitDriven extends FormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'ajax_example_auto_text_fields';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
// This container wil be replaced by AJAX.
$form['container'] = [
'#type' => 'container',
'#attributes' => ['id' => 'box-container'],
];
// The box contains some markup that we can change on a submit request.
$form['container']['box'] = [
'#type' => 'markup',
'#markup' => '<h1>Initial markup for box</h1>',
];
$form['submit'] = [
'#type' => 'submit',
// The AJAX handler will call our callback, and will replace whatever page
// element has id box-container.
'#ajax' => [
'callback' => '::promptCallback',
'wrapper' => 'box-container',
],
'#value' => $this->t('Submit'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
}
/**
* Callback for submit_driven example.
*
* Select the 'box' element, change the markup in it, and return it as a
* renderable array.
*
* @return array
* Renderable array (the box element)
*/
public function promptCallback(array &$form, FormStateInterface $form_state) {
// In most cases, it is recommended that you put this logic in form
// generation rather than the callback. Submit driven forms are an
// exception, because you may not want to return the form at all.
$element = $form['container'];
$element['box']['#markup'] = "Clicked submit ({$form_state->getValue('op')}): " . date('c');
return $element;
}
}
Under sixty lines, and it does everything: builds a form, wires up an AJAX trigger, and returns a fresh fragment of HTML on demand. Let's walk through it top to bottom.
How it works
The class itself
namespace Drupal\ajax_example\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
Nothing AJAX-specific yet — this is an entirely ordinary Form API class, living in the Drupal\ajax_example\Form namespace and extending FormBase, exactly like every non-configuration form you'll ever write. FormStateInterface represents the complete state of the form across the current request; you'll see it used to read a submitted value in a moment.
getFormId()
public function getFormId() {
return 'ajax_example_auto_text_fields';
}
Every Drupal form needs a unique machine-name ID. Drupal uses it to generate hidden form_id/form_build_id fields, to let other modules target this specific form with hook_form_FORM_ID_alter(), and — this matters especially for AJAX — to correctly cache and retrieve the form's state across the background request the AJAX click triggers.
The replaceable container
$form['container'] = [
'#type' => 'container',
'#attributes' => ['id' => 'box-container'],
];
$form['container']['box'] = [
'#type' => 'markup',
'#markup' => '<h1>Initial markup for box</h1>',
];
#type => 'container' renders as a plain <div> with no meaning of its own — it's purely a wrapper. But the id="box-container" attribute assigned to it is the single most important line in this whole file: it's the AJAX target. When the callback returns its result, Drupal's AJAX framework finds the element on the page with this exact ID and replaces it entirely. The child box element, rendered with #markup, is the visible content that will change.
The submit button with #ajax
$form['submit'] = [
'#type' => 'submit',
'#ajax' => [
'callback' => '::promptCallback',
'wrapper' => 'box-container',
],
'#value' => $this->t('Submit'),
];
This is the whole trick. Adding a #ajax array to any form element — a button, a select, a checkbox, whatever — is what turns it from an ordinary element into an AJAX-enabled one. It accepts several keys, but only two are required, and they're both here:
callback— a callable Drupal invokes server-side when this element triggers an AJAX request.'::promptCallback'is Drupal shorthand for[$this, 'promptCallback']— call thepromptCallback()method on this very form object. You could equally supply a fully-qualified static method string.wrapper— the HTMLidof the element to replace, matching theid="box-container"you saw above exactly. Drupal's JavaScript locates that element in the DOM and swaps in whatever HTML the callback returns.
An empty (but required) submitForm()
public function submitForm(array &$form, FormStateInterface $form_state) {
}
submitForm() is required by FormInterface — every concrete form class must implement it, or PHP throws a fatal error. This particular form doesn't need one, because promptCallback() below handles the entire response. Leaving it empty (rather than omitting it) satisfies the contract while making it obvious to the next reader that this is intentional, not an oversight.
The callback itself
public function promptCallback(array &$form, FormStateInterface $form_state) {
$element = $form['container'];
$element['box']['#markup'] = "Clicked submit ({$form_state->getValue('op')}): " . date('c');
return $element;
}
This is the payoff. When the AJAX button is clicked, Drupal quietly re-runs buildForm() to reconstruct the form with the latest $form_state, then calls this method to decide what to send back:
$form['container']— the callback receives the fully rebuilt$formarray, so it can reach into any part of it by key. Copying it into$elementgives a local value to mutate.$element['box']['#markup'] = ...— the markup is overwritten with a freshly-computed string. In a real module this is exactly where you'd run a database query or call an API.$form_state->getValue('op')—getValue()reads any submitted form value by key. The special key'op'holds whichever button's label triggered the submission — here, the string"Submit". It's a handy way to let one callback branch on which of several buttons was pressed.return $element— the callback returns a plain renderable array. Drupal automatically renders it to HTML and wraps it in anAjaxResponsecontaining aReplaceCommandtargeting yourwrapperID. (You'll meetAjaxResponseand its full command vocabulary directly in the next lesson.)
buildForm()? Usually you should. The docblock above promptCallback() even says so. Submit-driven callbacks like this one are the deliberate exception, because sometimes you genuinely don't want to return the whole form again — just one small, targeted fragment of it.The full request lifecycle
Putting it all together, here's exactly what happens on a single click:
- The visitor clicks Submit; Drupal's
ajax.jsintercepts the click before the browser can do a normal form submission. - A background XHR (AJAX) POST request is sent to the form's URL.
- Drupal rebuilds the form via
buildForm(), using the freshly submitted$form_state. promptCallback()runs and returns the updatedcontainerelement.- Drupal renders that element to HTML and serialises it into a small JSON payload describing a DOM operation.
- The browser's
ajax.jsreceives that JSON and executes it — in this case, replacing#box-containerwith the new HTML.
No full page reload happens anywhere in that sequence — only the one <div> changes.
See it for yourself
Click the Submit button on the live example, and watch the box above it update instantly with a fresh timestamp — with no page flash, no scroll-position jump, nothing but that one line of text changing.
"Clicked submit (Submit): <timestamp>" is exactly the string built inside promptCallback() — $form_state->getValue('op') resolved to "Submit", and date('c') supplied the ISO 8601 timestamp. Click it again and the timestamp changes; nothing else on the page does.
Quick check: which two keys inside
#ajaxare required for any element to become AJAX-enabled? (Answer:callbackandwrapper.)
Key takeaways
- The
#ajaxproperty on any form element — button, select, checkbox — turns it into an AJAX trigger; its two required keys arecallbackandwrapper. - The
::methodNamecallback syntax is shorthand for[$this, 'methodName'], keeping the callback co-located with the form class it belongs to. - An AJAX callback receives the fully rebuilt
$formarray and$form_state— returning any sub-element of$formis the simplest way to send back a targeted HTML fragment. - Returning a plain render array from a callback is enough for single-element replacement; Drupal wraps it in an
AjaxResponsewith aReplaceCommandautomatically. $form_state->getValue('op')tells you which button triggered the request, letting one callback serve several buttons if needed.- Always give any container you intend to AJAX-replace a stable, unique
idvia#attributes— that ID is the anchor the JavaScript layer uses to find and swap the element.
Coming up next
Returning a plain render array works beautifully when you only need to replace one element. But what if you need to update two different parts of the page at once, or pop open a dialog, or trigger a redirect — all from a single click? That's what AjaxResponse and its full vocabulary of command objects are for, and it's exactly what the next lesson covers.