Skip to content

feat: add an attribute-based form system on the view system - #6754

Merged
gharlan merged 21 commits into
6.xfrom
form-system
Oct 20, 2026
Merged

gharlan merged 21 commits into
6.xfrom
form-system

Conversation

@gharlan

@gharlan gharlan commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Adds a new form system built on the view system. It is meant to replace the old one (DbForm, ConfigForm and the forms built from the core/form/* fragments) without a migration path; the old system is removed before 6.0.

Concept

A form edits a data object. Its properties hold the values in their real types, attributes declare the field types:

final class SystemSettings
{
    #[Choice(
        new Translatable('backend_language'),
        choices: self::locales(...),
        required: true,
        key: 'lang',
    )]
    public string $language = 'en_gb';

    #[ArticlePicker(
        new Translatable('system_setting_start_article_id'),
        required: true,
        key: 'start_article_id',
    )]
    public ?Article $startArticle = null;

    #[Checkbox(new Translatable('system_setting_article_history'))]
    public bool $articleHistory = false; // stored as `article_history`
}
$settings = new SystemSettings();
$storage = new ConfigStorage(Core::CONFIG_NAMESPACE);
$storage->load($settings);

$form = new Form($settings, title: new Translatable('system_settings'));
if ($form->handleRequest()) {
    $storage->save($settings); // or any own save routine reading $settings->startArticle etc.
}

echo $form->render();
  • Submitted values are converted into the property types (scalars, backed enums, BcMath\Number, Article, Media, DateTimeImmutable, lists). Invalid values never reach the object; they are kept as raw values and redisplayed with the error.

  • The constraints of the field types (required, maxLength, valid e-mail addresses and URLs, min/max of numbers and dates, the offered choices, existing articles/media, allowed media types) are checked on submit; where HTML has an equivalent (required, maxlength, type="email"/type="url", min/max/step), it is set on the input element as well.

  • Storages use the property name in snake_case as key ($articleHistory → article_history), unless the field sets an explicit key.

  • Each field type declares which property types it supports and every field property needs a default value – both are checked when the class is read.

  • Choices and other dynamic arguments are passed as first-class callables (self::locales(...)), labels as Translatable (a lazily resolved translation key, usable in attributes).

  • Texts: labels, notes, legends, titles and errors accept string|Renderable – a string is plain text, a Translatable renders like I18n::msg() (basic tags of the message are kept), an Html object passes trusted markup. Choice labels, placeholders and the submit label are plain text (string|Translatable), as they end up in contexts without HTML. Translatable is deliberately not Stringable: render() for HTML, translate() for plain text.

  • Rendering: render() for the whole form, or capture() for custom markup – the closure writes the markup inside the form tag, using row()/widget()/label()/rest()/submitButton():

    echo $form->capture(static function (Form $form): void { ?>
        <div class="row">
            <div class="col-md-6"><?= $form->row('sender') ?></div>
            <div class="col-md-6"><?= $form->row('listId') ?></div>
        </div>
        <?= $form->rest() ?>
        <?= $form->submitButton() ?>
    <?php });

    open()/close() are available for form tags that can not be rendered in one place. #[Fieldset] groups fields in the default rendering.

  • Embedding: submit() binds given values without the standalone request handling (marker, csrf token), only restricts a form to a subset of its fields – needed for meta info, which will follow in a separate PR.

Field types and widgets

Text (with maxLength), Email, Url (only http/https by default), Number (int, float for approximate values, BcMath\Number for exact decimals if ext-bcmath is available; with min/max/step, also as numeric strings), Textarea (with maxLength), Checkbox, Choice (select, multi-select, radio buttons, checkboxes, with groups), DatePicker, DateTimePicker, TimePicker (with min/max, also relative like today), ArticlePicker and MediaPicker (single and multiple).

The field types only bind the values; the markup comes from widgets (Input, Textarea, SelectInput, ChoiceList, CheckboxInput, ArticleInput, ArticleListInput, MediaInput, MediaListInput) and FieldRow. They are standalone view components, so they can be used without a form as well (e.g. by yform), and their views can be overridden per class via ViewResolver. No fragments and no RexVar widgets are used. The picker widgets still follow the REX_LINKLIST_*/REX_MEDIA_*/REX_MEDIALIST_* element ids the linkmap and media pool scripts rely on.

Users

  • The system settings page uses it for both of its forms: the settings with ConfigStorage, the editor settings with their own cookie routine and custom markup.
  • The test addon gets a page showing all field types (System → Form), added to the visual tests.

Not included yet

  • Meta info on top of the form system (next PR).
  • Porting the remaining pages and removing the old form system.

A form edits a data object: its properties hold the values in their real
types, attributes on them declare the field types (`#[Text]`, `#[Choice]`,
`#[ArticlePicker]` …). `Form` binds such an object, reads the submitted
values and writes them into the object once all of them are valid, and
renders the whole form or single rows/widgets for custom markup. Forms can
also be embedded into other forms. `ConfigStorage` persists a data object
into a config namespace; other save routines just read the properties.

The widgets are standalone view components, so they can be used without
a form, too. None of the markup uses fragments or the RexVar widgets.

`Translatable` holds a translation key resolved lazily, so translated
labels can be passed to attributes.

The system settings page is the first user; the test addon gets a page
showing all field types for the visual tests.
@gharlan gharlan added this to the REDAXO 6.0 milestone Oct 20, 2026
@rex-bot rex-bot added the feature Additional functionality label Oct 20, 2026
gharlan and others added 20 commits October 20, 2026 08:43
Labels, notes, legends, titles and errors accept `string|Renderable`: a
string is plain text, a `Translatable` renders like `I18n::msg()` (keeping
basic tags of the message), an `Html` object passes trusted markup.
Choice labels, placeholders and the submit label stay plain text, as they
end up in contexts without HTML.

`Translatable` is no longer `Stringable`, so the context has to be chosen
explicitly: `render()` for HTML, `translate()` for plain text.
# Conflicts:
#	.tools/visual-tests/screenshots/markdown--dark.png
#	.tools/visual-tests/screenshots/markdown.png
`Number` edits `int` and `float` properties, with `min`/`max` checked on
submit and `step` for the input element. `Text` is limited to `string`
properties now and checks its `maxLength` on submit as well.
Besides `int` and `float` (for approximate values), a `Number` field edits
`BcMath\Number` properties for exact decimals, if ext-bcmath is available.
`min`, `max` and `step` accept numeric strings to stay exact as well.
The date, datetime and time pickers accept `min` and `max`, in any format
`DateTimeImmutable` understands, so relative limits like `today` work.
`Textarea` gets `maxLength`; line breaks count as one character like in
the browser. Both are set on the input elements and checked on submit.
Both are text fields with the matching input type and a check on submit.
`Url` accepts only absolute URLs with the given schemes, `http` and `https`
by default, so that e.g. `javascript:` URLs never make it into links.
`capture()` renders the form tag around the markup a closure emits, so the
opening and closing tags can not get out of sync. `open()`/`close()` stay
for form tags that can not be rendered in one place.

The form tag no longer gets an `action`, so the browser submits to the
current url including its parameters; this also makes forms usable
outside of the backend.
Without an explicit `key`, a field is stored under its property name in
snake_case (`$maxItems` → `max_items`), matching the config keys and
database columns. The system settings only keep explicit keys where the
property is named differently on purpose.

Form field attributes with more than the label are written with one
argument per line, as noted in AGENTS.md.
@gharlan
gharlan merged commit 6214329 into 6.x Oct 20, 2026
15 checks passed
@gharlan
gharlan deleted the form-system branch October 20, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Additional functionality

Development

Successfully merging this pull request may close these issues.

2 participants