Skip to main content
Skip to main content

Hi, I'm Karim Boudjema. I'm a Senior Backend Developer living in Montréal, Canada, passionate about Drupal, AI and automated testing.

Saving Temporary Form Values with PrivateTempStore in Drupal 11

In this post we'll see how to save the values of a form and read them back later in a controller. To do that we'll use the Form API and the PrivateTempStore, Drupal's temporary per-user storage.

The use case is a small RSS reader. A form asks the user for the URL of an RSS feed and how many items to read from it. Then, on a separate page (a controller), the app shows the list of items with a link to each one.

The easy way would be to read the values in buildForm(), process them and print the result in a field of the same form. But that's not our case: we want to process the values and show the result on another page. So we first need to store the form's values, and retrieve them later in the controller. How, and where?

Short story: store and retrieve data with PrivateTempStore

Drupal has a key/value system to store user-specific data temporarily, and to keep it available across several requests even when the user is not logged in. That is the PrivateTempStore. Here is the whole recipe:

// 1. Get the private tempstore factory (inject it in your form,
//    controller or service), then get a store collection.
$tempstore = \Drupal::service('tempstore.private');
$store = $tempstore->get('my_module');
// Set a key/value pair.
$store->set('key_name', $value);

// 2. Somewhere else in the app, read it back.
$tempstore = \Drupal::service('tempstore.private');
$store = $tempstore->get('my_module');
$value = $store->get('key_name');

// Delete the entry. Not required: it expires on its own after a week.
$store->delete('key_name');

Fairly simple, isn't it? A PrivateTempStore is a key/value store organised into named collections (by convention we use the module's name), keeping data available for one user across several page requests.

Now that you have the recipe, let's go back to the use case and see where we store and retrieve our form's values. One note on the code: calling \Drupal::service() statically is fine for a quick illustration, but in a real class we inject the service instead, and that's what we'll do below.

Types of data storage in Drupal 11

Drupal offers several storage APIs, and it's worth knowing which one fits:

  • Database API: to talk to the database directly.
  • State API: a key/value store for data tied to one environment (dev, staging, prod), like an external API key or the last cron run.
  • UserData API: data tied to one environment but specific to a given user, like a flag or a preference.
  • TempStore API: a key/value store for temporary data (private or shared) across several requests.
  • Entity API: to store content (node, user, comment) or configuration (views, roles).
  • TypedData API: a low-level API to describe data in a consistent way.

Our data is user-specific, needed only for a short time, and not tied to an environment, so the TempStore API is the right fit, in its private flavour because the values and results differ for each user. The only difference between private and shared tempstore is ownership: a private entry belongs strictly to one user; a shared entry can be read by several.

1. Store the form's values with PrivateTempStore

Our goal is a form where the user enters an RSS feed URL and a number of items, whose values we store so we can retrieve them later in a controller. Since we get the data from a form, we store it in submitForm(), the method called once the form is validated and submitted. Note the modern dependency injection: the class uses the core AutowireTrait, so instead of a hand-written create() method we put an #[Autowire] attribute on the promoted PrivateTempStoreFactory property to tell the container which service to pass in.

<?php

declare(strict_types=1);

namespace Drupal\ex_form_values\Form;

use Drupal\Core\DependencyInjection\AutowireTrait;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * Collects an RSS URL and an item count, and stores them in the tempstore.
 */
final class WithStoreForm extends FormBase {

  use AutowireTrait;

  public function __construct(
    #[Autowire(service: 'tempstore.private')]
    protected readonly PrivateTempStoreFactory $tempStoreFactory,
  ) {}

  /**
   * {@inheritdoc}
   */
  public function getFormId(): string {
    return 'ex_form_values_with_store_form';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state): array {
    $form['url'] = [
      '#type' => 'url',
      '#title' => $this->t('URL'),
      '#description' => $this->t('Enter the URL of the RSS feed.'),
      '#default_value' => 'https://www.drupal.org/planet/rss.xml',
      '#required' => TRUE,
    ];
    $form['items'] = [
      '#type' => 'select',
      '#title' => $this->t('Number of items'),
      '#description' => $this->t('How many items to retrieve.'),
      '#options' => ['5' => 5, '10' => 10, '15' => 15],
      '#default_value' => 5,
    ];
    $form['actions'] = ['#type' => 'actions'];
    $form['actions']['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Submit'),
    ];
    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state): void {
    // 1. Collect the form values.
    $params = [
      'url' => $form_state->getValue('url'),
      'items' => $form_state->getValue('items'),
    ];

    // 2. Get the store collection named after our module.
    $store = $this->tempStoreFactory->get('ex_form_values');

    // 3. Save the values, then redirect to the controller that reads them.
    try {
      $store->set('params', $params);
      $form_state->setRedirect('ex_form_values.show_items');
    }
    catch (\Exception $e) {
      $this->logger('ex_form_values')->error('Could not store form values: @err', ['@err' => $e->getMessage()]);
      $this->messenger()->addWarning($this->t('Unable to proceed, please try again.'));
    }
  }

}

All the action is in submitForm(), in these two lines:

$store = $this->tempStoreFactory->get('ex_form_values');
$store->set('params', $params);

The first line asks the PrivateTempStoreFactory for a store on the collection named ex_form_values (same name as our module, by convention). The second stores our key/value pair: the key is params and the value is the $params array with the form's values.

Under the hood the factory uses an expirable key/value store, so entries are written to the key_value_expire table and removed automatically when they expire. By default an entry lives for one week (604800 seconds); we can't change that per call, it's fixed by the store. On set(), Drupal makes sure even an anonymous user has a session, so it can tell whose data this is: an authenticated user is keyed by user ID, an anonymous one by session ID.

That was not so hard, was it? Now let's read the values back in a controller.

2. Retrieve the values in a controller

To read the data, process it and show the result, the form redirects to a controller. Here it is:

<?php

declare(strict_types=1);

namespace Drupal\ex_form_values\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\DependencyInjection\AutowireTrait;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * Reads the stored form values and renders the RSS items.
 */
final class ShowItemsController extends ControllerBase {

  use AutowireTrait;

  public function __construct(
    #[Autowire(service: 'tempstore.private')]
    protected readonly PrivateTempStoreFactory $tempStoreFactory,
  ) {}

  public function showItems(): array {
    // 1. Read the stored values for this user.
    $store = $this->tempStoreFactory->get('ex_form_values');
    $params = $store->get('params');

    if (!$params) {
      return ['#markup' => $this->t('No stored values found. Please submit the form first.')];
    }

    // 2. Optionally delete the entry now that we've used it. Not required:
    //    it would expire on its own after a week.
    // $store->delete('params');

    // 3. Show what we read back.
    $build['message'] = [
      '#markup' => $this->t('URL: @url, items: @items', [
        '@url' => $params['url'],
        '@items' => $params['items'],
      ]),
    ];

    // 4. A link back to the form.
    $build['back'] = [
      '#type' => 'link',
      '#title' => $this->t('Back to the form'),
      '#url' => Url::fromRoute('ex_form_values.with_store_form'),
    ];

    // 5. This page is per-user and short-lived, so don't cache it.
    $build['#cache']['max-age'] = 0;
    return $build;
  }

}

The two lines that matter are the ones dealing with the tempstore:

$store = $this->tempStoreFactory->get('ex_form_values');
$params = $store->get('params');

The first line is familiar now: it gets a store on the ex_form_values collection. The second reads the value stored under the key params. Because this is the private tempstore, get() only returns data that belongs to the current user: Drupal checks the owner behind the scenes, so one user never reads another's values.

Deleting the entry with $store->delete('params') is optional, since the data expires on its own after a week. But if you expect heavy use of the form, deleting it once read is a good habit, since you won't need it anymore.

The rest of the controller is routine: from here you'd fetch the feed with the injected http_client, build a render array from the items, and render it. Nothing new there.

Recap

We wanted a form to collect an RSS URL and an item count, and a controller to display the result. To carry the values from one page to the next we used the PrivateTempStore, because the data is user-specific (anonymous users included), needed only briefly, and not tied to an environment or a user profile.

To store the values we used, in the form's submitForm():

  • $store = $this->tempStoreFactory->get('ex_form_values'): get a store on our collection;
  • $store->set('params', $params): save the values under the key params.

And to read them back in the controller:

  • $store = $this->tempStoreFactory->get('ex_form_values'): the same store;
  • $params = $store->get('params'): the values, for this user only.

Modernised for Drupal 11, the change from the Drupal 8 original is autowired dependency injection (the #[Autowire] attribute in place of a hand-written create()) and strict types. The PrivateTempStore API itself is unchanged.

And you? Where would you reach for the private tempstore? Please share your ideas in the comments.

More info