Problem/Motivation

Most settings in Drupal are described in two or more places: in their config schema, their settings form, Tool API attributes, and more.

Take the maximum cache age setting on Drupal Core's Performance page (/admin/config/development/performance). Its config schema specifies the following in system.schema.yml:

max_age:
  type: integer
  label: 'Max age'

Its settings form PerformanceForm has this code:

$period = [0, 60, 180, 300, 600, 900, 1800, 2700, 3600, 10800, 21600, 32400, 43200, 86400];
$period = array_map([$this->dateFormatter, 'formatInterval'], array_combine($period, $period));
$period[0] = '<' . $this->t('no caching') . '>';
$form['caching']['page_cache_maximum_age'] = [
  '#type' => 'select',
  '#title' => $this->t('Browser and proxy cache maximum age'),
  '#config_target' => 'system.performance:cache.page.max_age',
  '#options' => $period,
  '#description' => $this->t('This is used as the value for max-age in Cache-Control headers.'),
];

Compare the two:

  • The label differs: "Max age" vs "Browser and proxy cache maximum age".
  • Only the form has a description.
  • Only the form limits the value. The config schema accepts any integer, including a negative one. So config written without the form, by a recipe, a config import or code, can specify a negative value, and validation doesn't catch it.

It's not ideal, plus these gaps become increasingly painful the more Drupal interacts with external systems, which it does more and more. See two of my blog posts: One description, inside and outside Drupal and Drupal's role in agentic workflows for additional context.

Drupal Core's serialization module can generate JSON Schema for typed data values, including config values. Other software that sends data to Drupal through an API, like a JavaScript front end, an external workflow tool, or an AI agent, can check a value against a JSON Schema before it sends it. This is becoming the norm, if it isn't already.

For max_age, the generated JSON Schema is currently:

{
  "oneOf": [
    {
      "type": "integer"
    },
    {
      "type": "null"
    }
  ]
}

It basically specifies that max_age is an integer or null, but nothing more. (It allows null because system.performance doesn't have the FullyValidatable constraint.)

Needless to say, external software does not have access to Drupal's form specification. This means the specification should get to them through the generated JSON Schema. Long story short, we want Drupal to generate something like this for max_age:

{
  "title": "Browser and proxy cache maximum age",
  "description": "This is used as the value for max-age in Cache-Control headers, in seconds.",
  "type": "integer",
  "minimum": 0,
  "maximum": 31536000,
  "default": 0
}

Even where a config schema includes constraints today, the generated JSON Schema currently leaves them out. Take this example from Drupal Core's system.date:

first_day:
  type: integer
  label: 'First day of week'
  constraints:
    Range:
      min: 0
      max: 6

It currently generates {"type": "integer"} for its JSON Schema. Unlike max_age, it has no null option, which is good. However, we really want it to generate something like {"title": "First day of week", "type": "integer", "minimum": 0, "maximum": 6}, so software outside of Drupal knows we start numbering from 0, not 1 (as you do, of course).

Contributed modules like Tool API work around all these limitations with their own custom code. They turn constraints like Length, Range, Regex, Choice and Count into more detailed JSON Schema. As you can imagine, this is quite a bit of overhead for these modules, and it really belongs in Core in my opinion.

Continuing with our second example, system.date, its settings form, RegionalForm, basically repeats or duplicates the config schema:

$form['locale']['date_first_day'] = [
  '#type' => 'select',
  '#title' => $this->t('First day of week'),
  '#config_target' => 'system.date:first_day',
  '#options' => [
    0 => $this->t('Sunday'),
    1 => $this->t('Monday'),
    2 => $this->t('Tuesday'),
    3 => $this->t('Wednesday'),
    4 => $this->t('Thursday'),
    5 => $this->t('Friday'),
    6 => $this->t('Saturday'),
  ],
];

The keys of #options are the numbers 0 to 6 that the Range constraint allows. The weekday names also need to move to config schema, which has no way to give allowed values labels yet.

These gaps are widespread in Drupal Core and, by extension, in contrib. In the future, we'd like each setting's rules to live in its config schema: its label, its description, its validation constraints, and its allowed values with their labels. The form, the generated JSON Schema and every client would then read them from there. The MR in this issue shows how this could work end to end.

This work cuts across several parts of core: Typed Data and its validation constraints, the configuration system and its config schema, the serialization module, JSON Schema generation, and Form API. It will also touch Single Directory Components as they describe their props in JSON Schema. The prototype doesn't touch SDCs yet, but they won't be forgotten about. ;-)

Disclaimer: I'm using AI tools to help me learn and prototype. I will get it wrong in places, and so will AI. I'm not an expert on all these systems, and I'm learning a lot working on this. I've been really enjoying it so far. But getting it wrong is part of the point of this issue: it gives us something concrete to look at and react to.

As I said in my DrupalCon Rotterdam keynote: Drupal has more or less all the capabilities we need, but what is missing is "the last mile" that fully connects all the different parts. So I wanted to dig in more and see how all of this could fit together. The prototype in the MR is pretty straightforward right now. It does go end to end: from a constraint in config schema, to the JSON Schema Drupal generates, to the settings form.

It's straightforward because it builds on years of great work by many people in the community, including subsystem maintainers and core committers. I'm far from the first person to point this out.

Related work

For years, people have pointed out that a setting is described twice, once in its config schema and once in its form (#2913372: Allow forms to be defined in three segments: schema, UI, data, #2949888: Enhance config schema for richer default experiences). In fact, pieces and prototypes of a fix exist in various places:

Some of the ideas in the list above go in different directions. fago, michaellander, mglaman, e0ipso and many others have been doing so much great work on this. As part of Rosetta, I'm helping to bring it all together into one plan, and this prototype is a start.

Prototyped solution

This section provides a bit more detail on the prototyped solution to help anyone review the code and jump in.

The MR adds a new interface, JsonSchemaConstraintInterface, with one method, getJsonSchemaKeywords(). LengthConstraint, RangeConstraint and CountConstraint are its current implementations. Other constraints are left until we agree on the design/architecture.

When the serialization module generates a value's JSON Schema, we now add the "validation keywords" to the JSON Schema (e.g. "minimum": 0). We also added a value's label as title and its description as description. In other words, the MR improves the generated JSON Schema for first_day and max_age, consistent with the explanation above. I updated both in the MR as working examples to look at.

JSON:API builds its resources' JSON Schema with the serialization module, so the JSON:API output improved too.

I also modified ConfigFormBase so it uses the #title from the config value's label, #description from its description, and #maxlength, #min and #max from the constraints' keywords.

For backward compatibility, ConfigFormBase never overrides a property the form element sets itself. This allows us to gradually strip elements from form code. See the updated forms for first_day and max_age.

For max_age, the form code was:

$form['caching']['page_cache_maximum_age'] = [
  '#type' => 'select',
  '#title' => $this->t('Browser and proxy cache maximum age'),
  '#config_target' => 'system.performance:cache.page.max_age',
  '#options' => $period,
  '#description' => $this->t('This is used as the value for max-age in Cache-Control headers.'),
];

In the MR, max_age's config schema and form element read:

max_age:
  type: integer
  label: 'Browser and proxy cache maximum age'
  description: 'This is used as the value for max-age in Cache-Control headers.'
$form['caching']['page_cache_maximum_age'] = [
  '#type' => 'select',
  '#config_target' => 'system.performance:cache.page.max_age',
  '#options' => $period,
];

What's left in the form element is a select and its options. I believe this could/should move as well. It would get us basic form generation for free and simple settings form could go away entirely. See #3477363: Add a way to automatically generate basic config forms.

The first_day form code was simplified to:

$form['locale']['date_first_day'] = [
  '#type' => 'select',
  '#config_target' => 'system.date:first_day',
  '#options' => [
    0 => $this->t('Sunday'),
    1 => $this->t('Monday'),
    2 => $this->t('Tuesday'),
    3 => $this->t('Wednesday'),
    4 => $this->t('Thursday'),
    5 => $this->t('Friday'),
    6 => $this->t('Saturday'),
  ],
];

Once config schema can give allowed values a label, first_day's weekday names could come from the schema too, and this form could pretty much go away after #3477363.

A helper in JsonSchemaKeywordsTest, assertPublishedSchemaMatchesValidation(), checks fago's rule that a generated JSON Schema must never reject a value Drupal accepts. This felt useful for development, but we may not need it long-term. I left it in the MR for now.

How you can help

1. Software outside Drupal can see a setting's rules

A front end, an integration or an AI agent can check a value before sending it, instead of learning from a failed save.

2. A developer describes a setting once

Today a setting's label, description and limits are written in its config schema and again in its form. There are many things we can update, but I'd like to suggest the following first:

3. Software outside Drupal can change settings

A client reads a setting's schema, changes the setting through an API, and gets Drupal's own error message back when a value fails.

Issue fork drupal-3635315

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

dries created an issue. See original summary.

d34dman’s picture

Big +1.

When two descriptions of the same value disagree, something has to win. For settings, "config schema wins, everything else is generated from it" is the right answer. Less code and less drift to maintain!

One lesson from FlowDrop, where definitions travel through config, the database, a JS editor and external tools: most of our bugs came from systems disagreeing on "empty" (NULL, '', a missing key, ~, undefined), not from ranges or lengths.

I'd settle "required" versus "non-empty" versus "nullable" in config schema early, so NotBlank and #3627331: Nullability is inconsistent across JSON Schema normalizers build on one definition.

The parity test ("the published schema never rejects a value Drupal accepts") is the key piece. It turns the schema into a contract. Super excited and looking forward to pushing this further!

dries’s picture

Issue summary: View changes
michaellander’s picture

This is great! I haven't got through everything yet, but I do think we should focus this toward Constraint -> Json-Schema, and then we can split out the form work.

There's quite a bit TBD on the form side, and likely some discussion around where it should live, what it should look like and what it should derive from.

I'll try to get more pointed feedback asap as well.

ghost of drupal past’s picture

I haven't got through everything yet,

Of course not. The issue "summary" is one of the longest I've ever seen, it's unreviewed AI slop, a token vomit, and I would dread to even look at the code which is likely to be similar -- I certainly will not. And because it's from user/1 it's going to be really hard to resist such going into core. I wish you good fortune in the wars to come.

larowlan’s picture

acbramley’s picture

This summary needs trimming, happy to be wrong but it looks like copy-paste out of an LLM which goes against our AI policy

dries’s picture

I agree that the summary is long. I included all the detail to help people unfamiliar with the topic get up to speed and contribute. Some reviewers already know that context; others need it to get involved. Making it easier for more people to contribute is important to me.

I used LLMs for research and prototyping, as disclosed in the issue summary. I spent multiple days working on the proposal and at least three hours writing the issue summary.

I can certainly shorten the summary. Where would people prefer the detailed context to live? I could move it to my personal blog and link to it from the issue. I'm happy to work on that over the next week.

In the meantime, I'd welcome feedback on the technical approach.

acbramley’s picture

@dries as I said, happy to be wrong :) that's a lot of writing!

marcus_johansson’s picture

I've read through the summary and I think the idea with the JsonSchemaConstraintInterface is great. If we start creating tools and forms automatically from this it might mean that more people take the config schemas more seriously. Right now there is nothing really forcing you to write good or even correct schemas.

Early on when we were designing the first functional call interfaces we where looking into if it made sense to create these from the config schemas, but the problem was the quality of the config schemas. We saw early on that the interface that describes value input and execution the best was usually in form files. They had the best descriptions, conditionals etc. and even custom validation sometimes that didn't happen when using JSON:API. Config schemas were missings keys, missing labels, almost never had descriptions and if they did they could be empty or just a copy of the label etc.

In the contrib module scanner, I had it go through with a lot of deterministic code and then have AI check the quality of labels and descriptions. More info here: https://drupal-ai.github.io/agent-module-documentation/drupal-schema-qua... (The agent ready index is agents evaluating, so it might not be 100%, but it gives an rough idea)

I've read through Matts great blogpost about complex usage of config schemas here, and I think it is quite flexible for a lot of the things conditional form fields or ajax would do, but I wonder if trying to work to full comparability to JSON Schemas conditionals would be good? This would also help with something like refinable tools that helps MCP/agents a lot on complex tools.

kim.pepper’s picture

I like the approach @larowlan pointed to in #7 https://symfony.com/blog/new-in-symfony-8-2-jsonschema-component i.e. generating Schemas from PHP types, using attributes for additional constraint details. Much nicer than writing json or yaml.

borisson_’s picture

I don't think I can provide feedback on the entire thing, the MR feels very reasonable, but the IS is long to understand in one go. I have a bit of feedback though.

The MR adds a new interface, JsonSchemaConstraintInterface, with one method, getJsonSchemaKeywords(). LengthConstraint, RangeConstraint and CountConstraint are its current implementations. Other constraints are left until we agree on the design/architecture.

The new interface is simple and small, so that's good. I'm not sure about the name, it's a yaml schema (but I guess json and yaml are the same), it's also not a constraint, nor is it used only for json schema generation (also for the forms)
I don't have a better proposal, but reading the interface name in isolation doesn't tell me much about it.

I also modified ConfigFormBase so it uses the #title from the config value's label, #description from its description, and #maxlength, #min and #max from the constraints' keywords.

This is great! I wonder if we should be putting the min, max and others on the validation constraints instead of in the form though. That way others (such as Options) can provide their own form structure? This seems to be putting a lot of extra responsability in ConfigForm?
It's only 140 lines added, so maybe it's not an issue?

I am wondering if other things we're adding will all need changes to the ConfigFormBase

For max_age, the form code was:

$form['caching']['page_cache_maximum_age'] = [
'#type' => 'select',
'#title' => $this->t('Browser and proxy cache maximum age'),
'#config_target' => 'system.performance:cache.page.max_age',
'#options' => $period,
'#description' => $this->t('This is used as the value for max-age in Cache-Control headers.'),
];

In the MR, max_age's config schema and form element read:

max_age:
type: integer
label: 'Browser and proxy cache maximum age'
description: 'This is used as the value for max-age in Cache-Control headers.'

$form['caching']['page_cache_maximum_age'] = [
'#type' => 'select',
'#config_target' => 'system.performance:cache.page.max_age',
'#options' => $period,
];

This is very cool.

2. A developer describes a setting once

Today a setting's label, description and limits are written in its config schema and again in its form. There are many things we can update, but I'd like to suggest the following first:

These should all be followups to this issue I think?

dries’s picture

I opened two issues for the form part of the prototype:

The latter depends on #3443432: Add validation constraints to system.site.

I also moved the most important related issues to the 'Related issues' section in preparation of simplifying the issue summary.