Drupal 9: Creating A GET Form

I've been building Drupal forms for a number of years so I'm quite familiar with to putting together a Drupal form using the FormBase class and the form API. When I attempted to create a GET form this week I realised that there is actually quite a bit to think about. All forms are built using GET requests, it's the submission that I am specifically talking about. By default, forms in Drupal use POST requests to submit their data, and although it is possible to convert a form to use GET to submit data, it isn't well documented.

There are a couple of GET forms already available in Drupal. If you look at the Views filter form or the Search form they both process submissions through a GET request. These forms tend to use a combination of a form, a hook and a controller to manage their rendering and results. What I wanted was an example of a GET form that was more self contained inside a Drupal form object.

To set a form to submit using the GET method you use the setMethod() function of the form state.

$form_state->setMethod('GET');

This is by no means the whole story as there are a few other things to consider. Everything from how you get data from the form input to considerations of the URL structure is important. In fact, I would say that creating GET forms requires a slightly different way of looking at forms than the normal POST request forms.

As an example of how to set up GET forms, I will take a simple POST form and turn into a GET form, detailing the steps involved. The following code is a standard Drupal form that uses a POST request and prints out the input field as a message.

<?php

namespace Drupal\mymodule\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;

class MyModuleGetForm extends FormBase {

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'mymodule_get_form';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    $form['input'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Input'),
    ];

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => 'Submit',
    ];

    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $this->messenger()->addMessage($form_state->getValue('input'));
  }

}

The first thing to do is to change the action of the form to be GET, which will cause the form to submitted through a GET request. To do this we just add the setMethod() call, passing the parameter of GET.

  public function buildForm(array $form, FormStateInterface $form_state) {
    $form_state->setMethod('GET');

    $form['input'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Input'),
    ];

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => 'Submit',
    ];

    return $form;
  }

After doing this you will quickly realise that the form doesn't submit correctly anymore. Clicking the submit button will refresh the page, but it doesn't actually call the submit handler or print out the message any more. This is because when you make a GET form it essentially disconnects any validation and submission handlers form the form itself. I'll come back to this later.

One side effect of swapping to a GET form is that the the form data will be added to the URL (which is how GET requests work). This means that any data the user entered will be added to the URL of the page. Additionally, because Drupal adds additional elements to the form for security, your URL will be quite long. You can expect the form above to produce a URL that will look something like this.

/mymodule-form?text=some+text&form_build_id=form-BquQgIxCGifjd6UvsK_alG8kDsV45khPG4VKm1AnMeI&form_id=mymodule_get_form&op=Submit

Technically, we only need the start of this (i.e. the "text=some-text" part) for the form to work, but Drupal's internal form protection mechanism adds form_build_id and form_id to the URL. The submit button also adds the op=Submit to the URL. 

Let's first solve the URL problem so that we can produce some nicer looking URLs. This is done in part by setting the '#name' parameter of the submit button to be blank. This causes the 'op' to be blank, which means it is not added to the URL. The form_build_id and form_id items are added to the form after our buildForm() method (where we define the form) so we need to add a post-build step so that we can remove them again. This is done by using the '#after_build' item in the form signature, the parameter of this is a callback so we reference a static method in the same class. 

Here is the new buildForm() method with the addition of the '#after_build' step and the blank #name of the submit field.

  public function buildForm(array $form, FormStateInterface $form_state) {
    $form_state->setMethod('GET');

    $form['input'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Input'),
    ];

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => 'Submit',
      // Prevent op from showing up in the query string.
      '#name' => '',
    ];

    // Add an after build step.
    $form['#after_build'][] = [get_class($this), 'afterBuild'];

    return $form;
  }

The afterBuild() static method is pretty simple. We just need to remove the items we don't want in the URL and return the changed form.

  public static function afterBuild(array $form, FormStateInterface $form_state) {
    unset($form['form_token']);
    unset($form['form_build_id']);
    unset($form['form_id']);
    return $form;
  }

We can now submit the form and see a much nicer URL.

/mymodule-form?text=some+text

Remember I mentioned that Drupal adds a bunch of security items that we just removed? Well for this reason you should be very careful about what you allow your GET form to do. Never accept user data of any kind through a GET form.

The form submits and has a nice URL, but the form itself still does nothing when submitted. To solve this we need to use the $form_state object to alter some aspects of how the form is created in Drupal. We need to call the following methods.

  • setAlwaysProcess(TRUE) - Without this set to true our submit handlers will not fire at all. This does mean that the submit is called when the form is built, so you need to make sure that you ignore empty values. I'll address form values later on.
  • setCached(FALSE) - Drupal will attempt to cache our form and the submission of the form. By calling this method we bypass the page caches for the submission.
  • disableRedirect() - This isn't always needed, but is important if you find that the form is redirecting to itself infinitely. The form submit handlers always being processed can cause this to happen.

In addition to this we also inform Drupal that there is no cache on this form by setting the '#cache' value of the form.

We can chain these functions together at the top of the buildForm() method and include the form '#cache' setting like this.

  public function buildForm(array $form, FormStateInterface $form_state) {
    $form_state
      ->setMethod('GET')
      ->setAlwaysProcess(TRUE)
      ->setCached(FALSE)
      ->disableRedirect();

    $form['#cache'] = [
      'max-age' => 0,
    ];

    $form['input'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Input'),
    ];

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => 'Submit',
      // Prevent op from showing up in the query string.
      '#name' => '',
    ];

    // Add an after build step.
    $form['#after_build'][] = [get_class($this), 'afterBuild'];

    return $form;
  }

Before we are able to submit the form there is one last thing to do. Although our submit handler is now being triggered it is not being done in the normal way, at least, not in a way that you are used to. In POST forms the submit handler is triggered after the form is submitted, but when we set always process the form is triggered on every page load. This means that we need to tweak the form submit handler slightly to not print out the message as the form is loaded. Also, since the form isn't submitted in the usual way we can't use the getValue() method of the $form_state object to find the values that the user has inputted (these values have a habit of being blank in GET forms). We can, however, use the components of the page URL to do this as the URL will contain this information.

This changes the submit handler to look like this. We check that the parameter is set in the URL and then print it in a message if it is.

  public function submitForm(array &$form, FormStateInterface $form_state) {
    if ($this->getRequest()->query->get('text')) {
      $this->messenger()->addMessage($this->getRequest()->query->get('text'));
    }
  }

One thing to be aware of is that the validation handler is no longer triggered automatically. If you try to add form validation in your submit handler it will simply crash as Drupal throws an exception at the use of setError() or setErrorByName() in submit handlers. This means that you must validate your form in the formBuild() method by directly referencing it.

Here is the form building method with our validation handler added.

  public function buildForm(array $form, FormStateInterface $form_state) {
    $form_state
      ->setMethod('GET')
      ->setAlwaysProcess(TRUE)
      ->setCached(FALSE)
      ->disableRedirect();

    $form['#cache'] = [
      'max-age' => 0,
    ];

    $form['input'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Input'),
    ];

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => 'Submit',
      // Prevent op from showing up in the query string.
      '#name' => '',
    ];

    // Add an after build step.
    $form['#after_build'][] = [get_class($this), 'afterBuild'];

    $this->validateForm($form, $form_state);

    return $form;
  }

Thankfully, if any validation errors are found then Drupal will not trigger the submit handler, which means we can give the user a nicer experience.

Finally, although all of the above is possible you should only use GET forms when you have good reason to. In fact, I have put together a list of things to be aware of when selecting the GET method on Drupal forms.

  • Only use GET methods on forms that do not process any sensitive information whatsoever. The lack of form security measures, the presence of URLs in logs and the ability for users to share the URLs of their submitted forms means you can't transmit secure information in this way and ensure it will be secure.
  • Do not use GET methods when idempotency is important. In other words, don't use GET when your user needs to submit something to the database as there is a very good chance that duplicate submissions can happen.
  • Because the Drupal GET form I have detailed above will be submitted as the page is loaded you should strive to ensure that your forms will behave correctly when submitted with empty data. Default values and blank strings should behave nicely.
  • As a GET form will decide on its state depending on the URL it's a good idea to have a reset button in the form to allow the URL to be cleared and the form to be restored back to its original state. This just needs to redirect back to the form URL without any parameters.
  • Make sure your GET form doesn't interfere with the cache levels on pages it is added to. In other words, if you inject your form into a page it shouldn't knock out the cache for that page. It should only do that when a user submits it, and in some cases not even then (eg, when used for searching). This can be achieved by setting the cache of the form using setCached(TRUE) if the form is rendered into a block.
  • If you are having problems in getting a GET form to work correctly then remember to clear your caches. Drupal's form cache can get in the way of you developing forms and can made debugging forms difficult.

Fundamentally, Drupal GET forms don't behave in the same way as Drupal POST forms so you should make changes to how you build your form and interpret your data accordingly. If you want to see a Drupal GET form in action then you can see the timestamp conversion form in our tools area, which uses the techniques involved above.

Comments

 Interesting article - given the issues that you document, can you explain why you might ever want or need to do this? 

Regards

Ian

PS - tiny typo "All forms are build using GET requests..." should be "All forms are built ..."

Permalink

Hi Ian,

Whilst I agree that it was a little annoying to get working, this method does have its uses. POST style forms need to be submitted before the user can interact with the results. GET forms can be made to load with the results intact since it just needs the URL to initialise the form. It also changes the way in which caching mechanisms work since the URL becomes the variant of the cache and not the form state.

Examples of forms that would be good for GET forms are search forms or simple tools. Anything that you can visit using a URL and change the output of the page.

You would never convert a user login form to use GET as that would be a security issue.

Also, thanks for the typo correction. Fixed that :)

Name
Philip Norton
Permalink

Great write up.

I'm not sure if this was intended, but I think the "setMethod('GET')" was left out on the last 2 buildForm() code blocks.

I ended up adding it in the $form_state and it worked fine (see below).

$form_state
  ->setMethod('GET')
  ->setAlwaysProcess(TRUE)
  ->setCached(FALSE)
  ->disableRedirect();



Permalink

Hi Abraham, thanks for reading.

You are right! Not sure how I missed that out. I've updated the post to allow the code examples to work as intended.

Thank you very much for the correction :)

Name
Philip Norton
Permalink

BTW you don't need to call validateForm inside of buildForm method. It will be triggered automatically on submit.

Permalink

You're right Dimas, I think that may have been fixed recently as when I wrote the article I'm sure the validation handler was not triggered.

To anyone else, if you hard code it into the form build function in Drupal 9.5+ then you'll see the following error.

Warning: Undefined array key "#parents" in /var/www/html/drupal/web/core/lib/Drupal/Core/Form/FormState.php on line 1117
The website encountered an unexpected error. Please try again later.
TypeError: implode(): Argument #1 ($pieces) must be of type array, string given in implode() (line 1117 of core/lib/Drupal/Core/Form/FormState.php).

In which case, just remove the direct call to the validateForm() method and it will work fine.

Name
Philip Norton
Permalink

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
10 + 4 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.