Config, content, state, and sessions
Drupal stores information in four distinct systems: content, configuration, state, and session data. Choosing the right one decides whether your data can be deployed, translated, cached, and safely lost, so this is one of the most consequential decisions you make when designing a module.
What and why
Section titled “What and why”A Drupal site is not one pool of data. The site name, an article, the timestamp of the last cron run, and a visitor’s shopping cart are all “data”, but they have opposite lifecycles: one is deployed from Git, one is created by editors in production, one is machine-written and environment-specific, and one belongs to a single browser session and should evaporate.
Core formalizes this as four information types, each with its own API and storage:
- Content: information meant to be displayed and edited on the site, stored as entities. Articles, users, files, media, custom blocks. See Entities, bundles, and fields.
- Configuration: information about how the site is built, human-edited but relatively permanent, and meant to travel between environments. The site name, content types, fields, views.
- State: machine-written facts about one environment that must survive cache clears but never deploy. The last cron run (
system.cron_last), whether the site is in maintenance mode. - Session data: information tied to one user’s browsing session.
Put data in the wrong system and the workflow breaks later: configuration stored as state cannot be deployed or translated; content stored as configuration gets overwritten on the next deployment; settings stored in a custom table are invisible to the whole config toolchain.
Mental model
Section titled “Mental model”Ask two questions about any piece of data: who writes it, and should it move between environments?
| Type | Written by | Deploys? | Survives cache clear? | API entry point |
|---|---|---|---|---|
| Content | Editors and visitors, in production | No (it is the production data) | Yes | Entity API |
| Configuration | Developers and site builders | Yes, by design | Yes | config.factory |
| State | Code | Never | Yes | state |
| Session / tempstore | Code, per user | Never | Yes (until expiry) | Request::getSession(), tempstore.private |
A cache is none of these: cached data can be rebuilt from its source at any time, so anything you could recompute belongs in a cache bin instead of state.
A routing decision in two questions. First, if the value can be recomputed from another source it belongs in a cache bin, not in any of the four stores. Otherwise route by who writes it and where: data written by editors and visitors in production is a content entity; data written by developers and meant to travel between environments in Git is configuration; machine-written facts about a single environment are state; and one user's in-progress working data belongs in the private tempstore, with a bare flag the only thing small enough for the session.
Configuration: simple config and config entities
Section titled “Configuration: simple config and config entities”Configuration comes in two shapes. Simple configuration is for values that exist exactly once, like a module’s settings object:
# Default configuration, imported once when the module is installed.# The language the translatable values below are written in.langcode: en# Machine names of the content types that accept RSVPs.enabled_types: - article# Message shown after a successful RSVP.confirmation_message: 'Thanks! Your RSVP has been recorded.'A module ships defaults in config/install/, imported once when the module is installed; after that, the active copy in the database is the source of truth and the YAML file is never read again. Read it with get('event_rsvp.settings') on the injected config.factory service (immutable, with overrides applied; controllers and forms also have a $this->config() shortcut); writing requires the editable copy from the config factory’s getEditable().
Configuration entities are for things users create zero-or-more of: content types, views, image styles, roles. They get the full Entity API (loading, querying, access) while still being exportable YAML. If your module lets a site builder create several of something, it is a config entity, not a list inside simple config. Implementation details live in the Configuration API topic and Define a custom entity type.
Schema and validation
Section titled “Schema and validation”Every configuration object has a schema in config/schema/, describing its structure, the types of its values, and which values are translatable:
# Configuration schema describes the structure and types of the module's# configuration so it can be validated, translated and typed.event_rsvp.settings: type: config_object label: 'Event RSVP settings' mapping: enabled_types: type: sequence label: 'Content types that accept RSVPs' sequence: type: string label: 'Content type machine name' confirmation_message: type: label label: 'Confirmation message'Schema is not optional paperwork. It is what lets configuration be typed on export, translated by the Configuration Translation module, and validated. The current idiom goes further than types: schemas attach validation constraints (the same Symfony-validator constraints entity fields use) so invalid configuration is rejected no matter how it arrives, whether through a form, a recipe, or drush config:set. Core’s own schemas declare constraints per key, and a schema marked with the FullyValidatable constraint promises every key is covered. Typed access and validation run through the config.typed service (\Drupal\Core\Config\TypedConfigManager).
Configuration moves between environments as a directory of YAML files under version control; the export, import, and override workflow is covered in Configuration management workflow, and Recipes and config actions covers applying configuration programmatically.
State is a key-value store for facts about this environment, accessed through \Drupal\Core\State\StateInterface. It survives cache rebuilds (that is its reason to exist) but is excluded from configuration export, so staging and production each keep their own values. The give-away that something is state: a human never edits it, and copying it to another environment would be wrong or meaningless.
Sessions and per-user data
Section titled “Sessions and per-user data”Session data rides along with one user’s browsing session. Get it from the request, never from superglobals: $request->getSession() returns a Symfony session object with get(), set(), and remove().
Two rules from core’s own guidance keep sessions healthy:
- Keep sessions minimal. The whole session loads on every request, and Drupal removes the session cookie as soon as the session is empty, which is what keeps anonymous traffic compatible with the page cache. Remove session data the moment it is no longer needed. An anonymous user with a session is an anonymous user the page cache cannot help.
- Namespace your keys and respect reserved ones. Prefix attributes with your module name (
my_module.counter). Theuidattribute is reserved for core’s authentication and must never be written by module code.
Modules that want a typed interface over their session data can register a session bag: a service tagged session_bag implementing Symfony’s SessionBagInterface, retrieved through the session object.
For anything beyond a flag or two, core’s guidance is to not put the data in the session at all. Use the private tempstore instead: per-user, expiring, key-value storage that holds work in progress without bloating the session.
<?php
declare(strict_types=1);
namespace Drupal\docs_snippets;
use Drupal\Core\State\StateInterface;use Drupal\Core\TempStore\PrivateTempStoreFactory;
/** * Shows where per-user working data and machine state belong. * * Register the class as a service (autowiring resolves both constructor * arguments). Neither value belongs in configuration: the draft is one * user's unsaved work, the import timestamp describes one environment. */class ConceptsSystemsDraftStore {
public function __construct( protected readonly PrivateTempStoreFactory $tempStoreFactory, protected readonly StateInterface $state, ) {}
/** * Keeps the current user's unfinished draft across requests. * * @param array<string, mixed> $draft * The draft values to keep. * * @throws \Drupal\Core\TempStore\TempStoreException */ public function saveDraft(array $draft): void { // Every user gets an isolated slice of the "docs_snippets" collection; // entries expire automatically (after one week by default). $this->tempStoreFactory->get('docs_snippets')->set('draft', $draft); }
/** * Returns the current user's draft, or NULL when there is none. * * @return array<string, mixed>|null * The stored draft values. */ public function loadDraft(): ?array { $draft = $this->tempStoreFactory->get('docs_snippets')->get('draft'); return is_array($draft) ? $draft : NULL; }
/** * Records when the last import ran on this environment. * * State is the right home: the value is machine-written, environment * specific, and must never be deployed from staging to production. */ public function markImported(int $timestamp): void { $this->state->set('docs_snippets.last_import', $timestamp); }
}\Drupal\Core\TempStore\SharedTempStore is the sibling for working data shared between users, such as “who is currently editing this thing”, with locking semantics built in.
Staging content with Workspaces
Section titled “Staging content with Workspaces”Configuration has a deployment story; content normally does not, because production is where content is born. When editors need to prepare and review a set of content changes and publish them together, the answer is not to bend configuration or state into holding content. Core’s Workspaces module exists for exactly this: it stages revisions of content in a named workspace (“New campaign”) and publishes them to the live workspace atomically. If you find yourself designing a “draft copy of the site’s content in YAML”, reach for Workspaces instead.
How core uses it
Section titled “How core uses it”- The site name is simple config (
system.site), which is why it deploys with your codebase and is translatable per language. - Cron’s bookkeeping is state. The
system.cron_lasttimestamp is machine-written, environment-specific, and worthless to deploy: the canonical state example, straight fromthe State API topic. - Node preview is private tempstore. When you preview a node, the form stores the unsaved entity in the
node_previewprivate tempstore collection, keyed per user, so an unfinished edit survives navigation without touching the session or the database tables that hold real content. - The Views editing UI is shared tempstore. Edits to a view accumulate in a shared tempstore entry (with a lock, so two admins cannot stomp each other) and only become configuration when you press Save. That “unsaved changes” banner is tempstore made visible.
Common misconceptions
Section titled “Common misconceptions”-
“State is a lighter config API.” State never deploys. The moment a value should be the same on staging and production, or a human should be able to change it deliberately, it is configuration. Putting settings in state hides them from export, review, and translation.
-
“I can tweak
config/install/YAML to change a live site.” Those files are read once, at module install time. After that, the active configuration in the database wins, and changes ship as config exports or update hooks, not edits to install-time defaults. -
“Per-user data goes in the session.” Session storage is for almost nothing: a returning visitor flag, a one-time notice. Work in progress, wizards, carts, and drafts belong in the private tempstore, which expires automatically and never inflates the session payload that loads on every request.
-
“It’s stored in the database either way, so it doesn’t matter.” Storage location is the least important difference. The systems differ in deployability, translatability, cacheability, validation, and what happens on
drush cache:rebuildversusdrush config:import. The workflow consequences, not the storage engine, are the point. -
“Schema files are only needed if I want translation.” Without schema, configuration cannot be validated, typed casting on export breaks, and the recipe and config-action tooling cannot reason about your values. Every config object your module defines needs a schema entry; the validation constraints are what catch a typo before it reaches production.
-
“Cache and state are interchangeable for computed values.” If you can recompute it, cache it (and let it be dropped at any time). State is precisely for what cannot be recomputed: facts about history, like “the last import ran at 14:02”.
Where to go next
Section titled “Where to go next”- Configuration management workflow for export, import, environment overrides, and deployment.
- Recipes and config actions for applying configuration programmatically.
- Entities, bundles, and fields for the content side of the divide.
- Build your first module 4: configuration and schema to put simple config and schema into practice.
- The
information types,Configuration API,State API, andSessionstopics on api.drupal.org.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.