Skip to content

Form reference

Every key a form definition may carry, as the PHP engine actually reads it. This page covers flat, table-backed forms (Nebula\Form\Block\Form). Attribute-driven forms have their own page: EAV forms.

A form lives at view/adminhtml/form/<id>.json inside a module. It reaches a screen in one of two ways:

  • it claims a route with the route key, and the engine injects itself into that admin page; or
  • it is mounted from layout XML, like a grid:
<referenceContainer name="content">
    <block class="Nebula\Form\Block\Form" name="nebula.cms_block_edit">
        <arguments>
            <argument name="form_id" xsi:type="string">cms_block_edit</argument>
        </arguments>
    </block>
</referenceContainer>

Top level

Key Type Default What it does
id string — The definition identifier. A base file sets it; an overlay from another module omits it.
acl string none — denied Magento ACL resource guarding the form.
route string | array — Admin full-action-name(s) this form mounts on, lower-cased (cms_block_edit, adminhtml_system_store_editstore).
routeWhen object — Narrows a route to the requests this form owns. See routeWhen.
replaces string | array — Core block name(s) to remove from the page it mounts on.
replacesClass string | array — Core block class name(s) to suppress, for pages whose blocks have no stable name.
dataSource object — Which provider loads the record. See dataSource.
settings object {} Title, URLs, buttons, chrome. See settings.
fieldsets object (map) {} The fields themselves. See fieldsets.
layout array — An optional layout tree that arranges the fields declared in fieldsets. See The layout tree.
hiddenFields object {} Literal hidden inputs: name → value. They win over settings.hiddenInputs on a name collision.

Source: code/Nebula/Form/Block/Form.php, code/Nebula/Form/Model/Form/RouteFormMap.php, code/Nebula/Component/Model/Authorization/DefinitionAccessControl.php.

acl is default-deny

A form with no acl, or an empty one, is refused and a warning naming the definition is logged. There is no implicit fallback resource.

The definition normalizer strips _warning, _generated and _meta before anything reads the form.

route

{
  "id": "cms_block_edit",
  "route": "cms_block_edit",
  "replaces": "cms_block_form"
}

route is the admin full-action-name — <route>_<controller>_<action> — and may be a list when one form serves both the create and the edit action. The engine builds a reverse map of route → form once, injects a Block\Form into the matching page, and removes whatever replaces / replacesClass name.

  • replaces removes a core block by layout name (referenceBlock remove).
  • replacesClass suppresses a block by class, for controllers that build their layout in PHP and give the block no name worth matching.

routeWhen

A full action name identifies a page almost everywhere. Where it does not, routeWhen narrows it. Several forms may declare the same route as long as each guards it differently; the most specific match wins, and an unguarded form is the route's fallback. Every entry must hold.

Value Meaning
a scalar the key must equal it
null the key must be absent (mirrors Request::has(), so ?product= counts as present)
true the key must be present, with any value

Two sources are read: registry (values a controller put in Magento\Framework\Registry before forwarding) and param (values on the current request).

Core's store screens forward EditWebsite, EditGroup and EditStore all to editStore, and tell them apart by the registry:

{
  "id": "store_website_edit",
  "route": "adminhtml_system_store_editstore",
  "routeWhen": {
    "registry": {"store_type": "website"}
  }
}

Core's URL rewrite screen picks one of four edit blocks off request params, so the generic form claims the action only when none of the specific ones apply:

{
  "id": "url_rewrite_edit",
  "route": "adminhtml_url_rewrite_edit",
  "routeWhen": {
    "param": {"product": null, "category": null, "cms_page": null}
  },
  "replacesClass": "Magento\\UrlRewrite\\Block\\Edit"
}

Two definitions that guard one route the same way collide; the engine keeps the first and logs a warning naming both.

Source: code/Nebula/Form/Model/Form/RouteFormMap.php, code/Nebula/Form/Model/Form/RouteConditionEvaluator.php, code/Nebula/Form/Plugin/InjectFormLayout.php.

dataSource

{
  "dataSource": {
    "provider": "cms_block.form",
    "config": {"identifierParam": "block_id"}
  }
}
Key Type Default What it does
provider string — Registered form-data-provider alias. It is asked for the record with ['entityId' => <id>\|null].
config object {} Forwarded to the provider verbatim.
config.identifierParam string id Request param carrying the record id. Also used to build the save and delete URLs, and as the fallback name of the hidden id input.
repository string — The default save path when the definition declares no saveHandler: SavePipeline::saveViaRepository() loads an existing record with this class's getById() and persists with save().
model string — Same path, for a record with no id yet: the class instantiated and filled before the repository saves it.

Source: code/Nebula/Form/Block/Form.php, code/Nebula/Form/Model/Form/HiddenInputResolver.php.

A new record still asks the provider, with a null id, and the definition's field default values are merged underneath whatever comes back. A provider with nothing to say for a new record returns [].

The generic entity provider

Nebula\Form\Model\DataProvider\EntityProvider needs no PHP of its own; it reads these config keys:

Key Type Default What it does
repository string — Repository FQCN; the record is loaded with getById().
dataProviderClass string — An existing Magento UI data provider to borrow the data from; the repository path is the fallback when it throws.
providerName string dataProviderClass name argument handed to that provider.
primaryFieldName string entity_id primaryFieldName argument handed to it.
requestFieldName string id requestFieldName argument handed to it.

Source: code/Nebula/Form/Model/DataProvider/EntityProvider.php.

Reserved keys a provider may return

Data keys beginning with _ are engine-reserved rather than fields:

  • _can_delete: false — hides the Delete button for this record (chrome only; the controller behind the button still decides);
  • _notices: [{type, message}, …] — notices rendered above the form (warning, error, success; anything else is clamped to warning);
  • _options: {"<field key>": [rows…]} — an option list scoped to this record, which beats the field's own options and optionsSource.

settings

Key Type Default What it does
title string — Static part of the page heading.
titleField string candidates Record field whose value titles the page. Without it the shell tries title, name, identifier, code, subject, rule_name, page_title.
saveUrl string */*/save Route the form POSTs to. The identifier param and any hiddenParams are injected into it.
backUrl string */*/ Route the Back button navigates to.
backUrlParams object {} url param → record field, for records whose Back is a parent rather than a listing.
deleteUrl string — Route the Delete button posts to. Absent means no Delete button; the button also disappears when the provider returns _can_delete: false.
deleteParam string identifierParam Param name the delete controller reads the id under, when it differs from the edit route's.
identifierField string identifierParam Name of the hidden POST field carrying the record id (page_id, customer[entity_id], …). Native save controllers call setData(getPostValue()), so without this an edit silently becomes an insert.
identifierParam string — A partial legacy fallback, and not the one that loads the record. See the warning below.
fieldPrefix string — Wraps every field name as prefix[key].
hiddenParams array<string> [] Request params copied onto the save URL. Falls back to the record when the request has no value, mapping type → type_id and set → attribute_set_id.
hiddenInputs object {} Extra hidden inputs: name → source. literal:foo is the raw string; array:field comma-splits the value, but the shell casts every hidden value to a string, so the split never reaches the POST; anything else is read from the request param of that name, else from the record field it names.
layout string single Only two-column changes anything; see Two-column fieldsets.
pager object — Record pager (prev/next): {provider, editUrl, idParam}. provider is a registered pager alias; idParam defaults to dataSource.config.identifierParam.
contextParams array<string> [] Request params rendered as hidden inputs and carried on the pager links, so context survives a save (ret, customerId, …).
readOnly boolean false Drops Save, Save & Continue, Delete and the pager's save variants. Chrome only — fields still need their own readonly.
saveLabel string Save Label of the primary submit, for pages whose action has a name of its own ("Import Tax Rates").
saveAndContinue boolean true false removes Save & Continue, for a form that performs an action rather than editing a record.
singleton boolean false The record always exists and has no id (Account Setting). Stops the shell titling it "New …" and hiding Delete.
chrome boolean true false suppresses the form's own action bar, for a form mounted inside a panel that draws its own.
buttons array [] Extra toolbar buttons. See Buttons.
sidebarRenderer string — A snippet.<id> rendered as the shell's sidebar. Read by EAV shell templates.
template string — Alternative block template. Read by the EAV block only.

Source: code/Nebula/Form/Block/Form.php, code/Nebula/Form/Model/Form/HiddenInputResolver.php, code/Nebula/Form/Model/Form/PagerResolver.php, code/Nebula/Form/view/adminhtml/templates/form.phtml, code/Nebula/Form/view/adminhtml/templates/form-shell.phtml.

settings.identifierParam does not load the record

Block\Form::getEntityId() reads the id from dataSource.config.identifierParam and nothing else, falling back to id. A flat form that names the param under settings only never finds the id, so the provider is asked with a null id and every edit renders as a create, titled "New …". The key is still read for three narrower jobs: the name of the hidden id input, the param on the save URL, and, ahead of dataSource.config, a custom button's idParam. Declare dataSource.config.identifierParam on every flat form. On an EAV form it is a genuine fallback; Block\EavForm reads both.

Buttons

{
  "settings": {
    "buttons": [
      {
        "label": "Delete Website",
        "url": "adminhtml/system_store/deleteWebsitePost",
        "method": "post",
        "idParam": "item_id",
        "class": "danger",
        "confirmTitle": "Delete this website?",
        "confirm": "Its stores and store views go with it. This cannot be undone."
      }
    ]
  }
}
Key Type Default What it does
label string — Required. A button with no label is dropped.
url string — Route for a link or a bare POST. Required unless submitsForm or emits is set.
submitsForm boolean false Retargets the edit form itself at action, for a controller that needs the posted fields.
action string — Route a submitsForm button posts the form to.
emits string — A client-side button: it posts nothing and navigates nowhere, it dispatches this DOM event on window. Constrained to ^[a-z][a-z0-9-]*$. A button that emits cannot also submit.
target window | modal window modal lands a submitsForm post in an iframe inside a dialog (a preview). Ignored without submitsForm.
method get | post get A POST button builds a synthetic form carrying the form key and the record id.
class primary | danger | ghost ghost Visual weight. danger also marks the confirmation as destructive.
confirm string — Confirmation body shown before the button acts.
confirmTitle string — Confirmation heading.
idParam string identifierParam Param the record id rides under. A submitsForm button posts the id as a field instead.
showWhen always | new | existing existing When the button appears.
acl string shown Optional resource; the button is hidden from roles without it.

Source: code/Nebula/Form/Model/Form/CustomButtonResolver.php.

fieldsets

fieldsets is a map of fieldset key → fieldset. Every form needs it: even a form that arranges itself with a layout tree takes its field configuration from here.

Key Type Default What it does
label string — Panel title. A fieldset with no label renders as a plain card instead of a collapsible panel.
position integer 0 Order of the fieldsets.
open boolean true Whether a titled panel starts expanded.
fields object (map) {} Field key → field definition. See Fields.
renderer string — snippet.<id> or Vendor_Module::x.phtml rendered as (or above) the panel body.
renderers array<string> — Several of the same. Read by the engine, rejected by the schema.
rule_type catalog/cart/sales/sales_actions catalog Which condition tree the rule_conditions snippet builds. Read nowhere else. See Rule condition fieldsets.
column left/right/third/full full Legacy two-column placement.
row string — Groups fieldsets sharing the same value into one row.
width string equal split Tailwind fraction for this fieldset inside its row: 1/2, 1/3, 2/3, 1/4, 3/4.

Source: code/Nebula/Form/Block/Form.php (getFieldsets()), code/Nebula/Form/Model/Form/FieldsetLayoutBuilder.php, code/Nebula/Form/view/adminhtml/templates/form.phtml.

The fieldset's map key is carried forward as name, so a renderer can identify it.

Rule condition fieldsets

A fieldset whose renderer is snippet.rule_conditions draws a price-rule condition tree, and rule_type picks which vocabulary that tree offers. The schema allows the key on a layout node too, for a form that arranges itself with a layout tree.

rule_type Root condition Binds to Posts as
catalog Magento\CatalogRule\Model\Rule\Condition\Combine conditions_serialized rule[conditions][…]
sales Magento\SalesRule\Model\Rule\Condition\Combine conditions_serialized rule[conditions][…]
sales_actions Magento\SalesRule\Model\Rule\Condition\Product\Combine actions_serialized rule[actions][…]
cart falls through to catalog conditions_serialized rule[conditions][…]

The separate binding is what lets one cart-rule form carry both trees. The shipped cart price rule does exactly that: one fieldset declares sales, the other sales_actions, and they serialise into different columns. cart is accepted by the schema but has no branch of its own, so treat it as a synonym for catalog rather than a cart vocabulary.

fieldsets, from the shipped cart price rule
{
  "conditions": {"label": "Conditions", "renderer": "snippet.rule_conditions",
                 "rule_type": "sales", "position": 17, "open": true, "fields": {}},
  "apply_to":   {"label": "Apply the rule only to cart items matching the following conditions.",
                 "renderer": "snippet.rule_conditions",
                 "rule_type": "sales_actions", "position": 30, "open": true, "fields": {}}
}

Source: code/Nebula/Component/ViewModel/RuleEditor.php, code/Nebula/Component/view/adminhtml/templates/snippet/rule_editor.phtml.

Two-column fieldsets

settings.layout: "two-column" turns on the row builder. Two dialects coexist:

{
  "settings": {"layout": "two-column"},
  "fieldsets": {
    "general": {"label": "General", "row": "top", "width": "3/4", "position": 10,
                "fields": {"title": {"label": "Title", "type": "text"}}},
    "status":  {"label": "Status",  "row": "top", "width": "1/4", "position": 20,
                "fields": {"is_active": {"label": "Enabled", "type": "toggle"}}},
    "seo":     {"label": "SEO", "position": 30, "fields": {}}
  }
}
  • row + width is the expressive one: any number of rows, any widths.
  • column: left|right|third is the legacy one: one row of two or three equal columns.
  • A fieldset with neither stacks full width, under the rows.

Any other value of settings.layout (including one-column and three-column, which the schema accepts) leaves the fieldsets as a single stack.

Fields

fields is a map of field key → field. The key is the record field, and — unless submitName says otherwise — the POST name too.

Key Type Default What it does
type string text Which control renders. See Field types.
label string field key Row label.
required boolean false Marks the row with *, and adds the client-side required rule on the types that carry one. It does not add a server-side rule, and on eight of the types it adds no client rule either. See Validation.
position integer 0 Order of the fields inside the fieldset or section.
note string — Help text under the control.
default string/number/bool/null — Value used on a new record (merged under whatever the provider returns).
disabled boolean false Renders the control disabled. A disabled control is not submitted.
disableOnEdit boolean false Disabled only when editing an existing record.
readonly boolean false Locks the control but still submits it.
readonlyOnEdit boolean false Read-only only when editing an existing record — the right choice when the save controller needs the value in the POST.
disabledWhen object — {"field": "<other field>", "equals": "<value>"}: disables this field while the other holds that value. Works for every field type.
full_width boolean false The control spans the row instead of sitting in the value column.
rows integer 5 Rows of a textarea or wysiwyg.
placeholder string — Placeholder text.
step string/number 1 Step of a number input. Read by the template, rejected by the schema.
validation object {} Rule name → parameter. See Validation.
options array — Inline options: [{"value": …, "label": …}, …].
optionsSource string — Registered option-source alias, used when options is absent.
optionsMethod string toOptionArray Method called on that source.
trueValue string 1 Value a toggle or checkbox stores when on — including a value that runs the other way to the label.
falseValue string 0 Value it stores when off.
toggleLabel string — Text rendered beside a checkbox. A toggle ignores it; its switch always reads Yes/No.
submitName string field key (prefixed) Overrides the POST name entirely, prefix included.
uploadUrl string theme/design_config_fileUploader/save AJAX upload endpoint for an image field. A file field ignores the key.
renderer string — snippet.<id> or Vendor_Module::x.phtml rendered instead of the control.
renderers array — Several of the same. Read by the engine, rejected by the schema.
$remove boolean — In an overlay: deletes this field from the base definition.

Source: code/Nebula/Form/view/adminhtml/templates/form.phtml, code/Nebula/Form/view/adminhtml/templates/field/_row.phtml, code/Nebula/Form/view/adminhtml/templates/field/*.phtml, code/Nebula/Form/Block/Form.php (getFieldOptions()).

Option precedence is: the provider's _options for this field key, then inline options, then optionsSource. Declaring a source as well as relying on the provider is worth doing — on a create page the provider has no record to scope to.

An option row is {"value": …, "label": …} plus four optional keys:

Key Read by What it does
depth select, multiselect Indent level. select prefixes two non-breaking spaces per level; on a multiselect any row carrying it switches the field into tree mode.
group select, multiselect true makes the row a non-selectable header, and also switches a multiselect into tree mode. A header row may carry a null value; a leaf may not, and a valueless leaf is dropped.
disabled select true renders a disabled <option>: visible, listed, not choosable.
all multiselect true marks the row as the "all of them" sentinel. Selecting it clears the specific rows, and selecting a specific row clears it.

The shipped nebula.store.view source uses three of them: its first row is {"value": "0", "label": "All Store Views", "depth": 0, "all": true}, and the website and store-group rows above each store view are group headers.

Field types

Sixteen types ship with the form layer, and a module may contribute more through custom field renderers. A type a module registers through RendererPool lints since 2026-09-13 — the schema accepts any lower-case identifier beside the built-in enum. An unknown type, one no renderer answers, silently renders as text.

text — single-line input.

{"identifier": {"label": "URL Key", "type": "text", "required": true, "position": 20}}

textarea — multi-line input; rows sizes it.

{"description": {"label": "Description", "type": "textarea", "rows": 8, "position": 30}}

select — one choice. Sources carrying depth / group render as an indented ladder with non-selectable headers.

{"page_layout": {"label": "Layout", "type": "select", "optionsSource": "magento.cms.page.layout", "position": 40}}

multiselect — several choices; posts as name[].

{"store_id": {"label": "Store View", "type": "multiselect", "optionsSource": "nebula.store.view", "required": true, "position": 50}}

checkbox — a single boolean box.

{"use_default": {"label": "Use Default", "type": "checkbox", "trueValue": "1", "falseValue": "0", "position": 60}}

toggle — a Yes/No switch; the usual control for a status flag.

{"is_active": {"label": "Enable Block", "type": "toggle", "position": 30}}

color — a colour picker.

{"accent": {"label": "Accent", "type": "color", "default": "#4f46e5", "position": 70}}

email — an email input; pair it with the email validation rule.

{"email": {"label": "Email", "type": "email", "required": true, "validation": {"required": true, "email": true}, "position": 10}}

number — a numeric input; step and validation.min / validation.max become the input's own attributes.

{"sort_order": {"label": "Sort Order", "type": "number", "step": "1", "validation": {"min": 0}, "position": 80}}

file — a native file input. It reads no field keys of its own, and it has no AJAX upload: the binary rides the normal POST to settings.saveUrl, and the receiving controller reads it from $_FILES under the field's POST name.

{"import_rates_file": {"label": "CSV File", "type": "file", "position": 10}}

A form holding a file or an image field is rendered with enctype="multipart/form-data".

date — a date picker that posts in the admin locale's numeric format.

{"from_date": {"label": "From", "type": "date", "position": 20}}

datetime — the same, with a time part.

{"starts_at": {"label": "Starts", "type": "datetime", "position": 30}}

password — a masked input.

{"current_password": {"label": "Current Password", "type": "password", "position": 90}}

hidden — a value carried through the POST with no row rendered.

{"store_type": {"type": "hidden", "default": "website"}}

image — an image upload with a preview. The file is uploaded to uploadUrl the moment it is chosen, and only the returned tmp path is submitted with the form, in the field[0][file] / [name] / [url] shape the design-config backend models read. Leaving uploadUrl out does not disable the upload; it points it at theme/design_config_fileUploader/save, which belongs to another module. Name your own endpoint.

{"logo": {"label": "Logo", "type": "image", "uploadUrl": "nebulaskin/image/upload", "position": 10}}

wysiwyg — the rich-text editor, through the configured WYSIWYG renderer.

{"content": {"label": "Content", "type": "wysiwyg", "rows": 20, "full_width": true, "position": 20}}

There is no repeatable-rows field

The field type enum in form.schema.json is closed at the sixteen types above plus pagebuilder, a leftover of the removed Nebula_PageBuilder that no module registers and that renders as a text input. Nothing in it repeats a row, so a dynamic_rows has no counterpart and a ported one becomes a custom control. The supported way to build it is a snippet that registers itself as one model:

  1. Declare the row collection as an ordinary field key in fieldsets (or name it in the layout tree) and give it "renderer": "snippet.<id>". Declaring it matters beyond rendering: on the generic save route AllowedFieldsCollector drops every posted key the definition does not declare.
  2. Hold the rows in one Alpine component. x-data on the snippet's root, x-for over the rows, add and remove methods on the component. The shipped precedent is the product custom-options snippet, Nebula_Theme::eav/snippet/custom_options.phtml, which is worth reading before writing your own.
  3. Register that component as a single model, with Alpine.store('nebulaModels').register('section:<key>', this) in init() and unregister on destroy. The contract is serialize() returning [{name: 'options[0][title]', value: …}, …] and an optional validate() returning a boolean. On Save the form calls validateAll() and then serializeAll(), which writes each pair into the native <form> as a hidden input, so a core controller doing setData($request->getPostValue()) receives the nested array unchanged.
  4. Put the row-level checks in two places. The validation rules are per-field scalar rules; none of them understands an array of rows, and required on one only sees that the array is non-empty. Check the rows in the model's validate() for the message under the field, and again in the receiving controller for the guarantee.

See Snippets for what the renderer receives and how a snippet id is registered.

Validation

There are two validators, and they read different keys.

Client-side rules come from data-validate attributes the field templates emit and run in the browser before the form submits: required, number, min, max, minLength, maxLength, email, pattern. Which of them actually runs depends on the control:

Types validation rules required: true
text, password, email, number, textarea, select, date, datetime, wysiwyg run runs
multiselect, color run ignored
toggle, checkbox, file, image, hidden ignored ignored

The first group writes data-validate and the shared validator owns the rules. The second writes no attribute and hands its validation object to the Alpine component instead, which runs the declared rules but knows nothing of a bare required flag, so declare "validation": {"required": true} on those. The third group runs neither, for two different reasons. A toggle does hand its validation object to the Alpine component, but the component finds its rules by reading the first input, select or textarea inside the control, and a toggle renders a button and a label instead, so the rules are never read. The rest never pass validation to the component at all. In this whole group required only draws the asterisk, and on a hidden field not even that, since it renders no row at all. The shipped tax-rate import form declares "required": true on a file field: the asterisk appears and nothing enforces it. Enforce those in the receiving controller.

Server-side rules come from validation only, and are applied by Nebula\Form\Model\Validator: required, minLength, maxLength, pattern, min, max, email, and custom (an FQCN implementing FieldValidatorInterface).

Rule Client Server Parameter
required yes yes true
minLength / maxLength yes yes integer
min / max yes yes number
email yes yes true
pattern yes yes regex string
number yes no true
custom no yes validator FQCN

Two things to know before you rely on a rule

  1. Field-level required: true is a client rule at best. The server validator reads validation and nothing else, and eight of the seventeen control types ignore the flag on the client too, so a required field that must be enforced anywhere declares "validation": {"required": true} as well.
  2. Server validation runs on the generic save route only. It executes when the form posts to nebula/form/save (or is checked through nebula/form/validate). A form whose settings.saveUrl points at a core Magento controller — cms/block/save, customer/index/save — never reaches it, and that controller's own validation is what applies.
{
  "identifier": {
    "label": "URL Key",
    "type": "text",
    "required": true,
    "validation": {"required": true, "maxLength": 100, "pattern": "/^[a-z0-9\\-]+$/"},
    "position": 20
  }
}

Source: code/Nebula/Form/Model/Validator.php, code/Nebula/Form/Controller/Adminhtml/Form/Save.php, code/Nebula/Form/Controller/Adminhtml/Form/Validate.php.

The layout tree

A form may replace the fieldset stack with a layout tree. The tree arranges fields; it does not configure them — every field key it names must still be declared under fieldsets.*.fields.

Node type Keys read What it does
row children A horizontal band. Each child renders inside its own column div.
column width, children A column inside a row. width is 3/4, 1/4, 2/3, 1/3 or 1/2; anything else is full width.
tabs id, children A tab strip. Each child is a tab with label, id and children.
section label, open, fields, renderer, children A panel. fields is a list of field keys; children may hold {"type": "fields", "fields": [...]} and {"type": "snippet", "renderer": "snippet.<id>"} nodes. A titled section is a collapsible <details>; an untitled one is a plain card.

Any other type renders nothing. Fields inside a section are ordered by their own position.

Source: code/Nebula/Form/view/adminhtml/templates/form.phtml.

condition is not evaluated here

condition on a layout node is honoured on EAV forms only. On a flat form the key is accepted by the schema, carried through the merge, and ignored at render — every node is drawn. Use disabledWhen on the fields, or split the screen.

open on a layout section is read by the template but rejected by the schema; a titled section defaults to open either way.

{
  "layout": [
    {
      "id": "cms_page_edit_tabs",
      "type": "tabs",
      "children": [
        {
          "id": "general_tab",
          "label": "General",
          "children": [
            {
              "id": "general_row",
              "type": "row",
              "children": [
                {
                  "id": "general_left",
                  "type": "column",
                  "width": "3/4",
                  "children": [
                    {"id": "general_section", "type": "section", "label": "General",
                     "fields": ["title", "identifier", "is_active", "store_id"]}
                  ]
                },
                {
                  "id": "general_right",
                  "type": "column",
                  "width": "1/4",
                  "children": [
                    {"id": "design_section", "type": "section", "label": "Design",
                     "fields": ["page_layout"]}
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

A full form

Adapted from the shipped CMS block editor: it claims the core route, removes the core block, loads through a registered provider, and posts to Magento's own save controller.

{
  "id": "cms_block_edit",
  "acl": "Magento_Cms::save",
  "route": "cms_block_edit",
  "replaces": "cms_block_form",
  "dataSource": {
    "provider": "cms_block.form",
    "config": {
      "identifierParam": "block_id"
    }
  },
  "settings": {
    "title": "CMS Block",
    "titleField": "title",
    "identifierField": "block_id",
    "backUrl": "cms/block/index",
    "saveUrl": "cms/block/save",
    "deleteUrl": "cms/block/delete",
    "saveAndContinue": true,
    "buttons": [
      {
        "label": "View Usages",
        "url": "cms/block/usages",
        "class": "ghost",
        "showWhen": "existing"
      }
    ]
  },
  "fieldsets": {
    "general": {
      "label": "General",
      "position": 10,
      "open": true,
      "fields": {
        "title": {
          "label": "Block Title",
          "type": "text",
          "required": true,
          "validation": {"required": true, "maxLength": 255},
          "position": 10
        },
        "identifier": {
          "label": "Identifier",
          "type": "text",
          "required": true,
          "note": "Used by the widget and the {{block}} directive.",
          "validation": {"required": true, "pattern": "/^[a-z0-9_\\-]+$/"},
          "readonlyOnEdit": true,
          "position": 20
        },
        "is_active": {
          "label": "Enable Block",
          "type": "toggle",
          "default": "1",
          "position": 30
        },
        "store_id": {
          "type": "multiselect",
          "label": "Store View",
          "optionsSource": "nebula.store.view",
          "required": true,
          "position": 40
        }
      }
    },
    "content": {
      "label": "Content",
      "position": 20,
      "fields": {
        "content": {
          "label": "Content",
          "type": "wysiwyg",
          "rows": 20,
          "full_width": true,
          "position": 10
        }
      }
    }
  }
}

Accepted by the schema but not read

bin/magento nebula:lint passes these and the engine ignores them:

  • field colspan — no template reads it;
  • fieldset columns — width comes from column / row + width;
  • layout node position — the merge sorts on it, the renderer does not;
  • layout node condition on a flat form (EAV only — see above);
  • settings.layout: "one-column" / "three-column" — only two-column changes anything;
  • settings.template on a flat form — the EAV block reads it, Block\Form does not.

There is no dependencies or depends key. Conditional enabling is disabledWhen.

Read by the engine, rejected by the schema

These work at runtime but fail nebula:lint, because every schema object is strict (additionalProperties: false). Treat them as unsupported until the schema learns them — as it did on 2026-09-13 for saveHandler, context, dataSource.repository and dataSource.model, for $remove on fieldsets, layout nodes and grid columns, and for a module-registered field type, none of which is on this list any more:

  • a layout node of the shape {"if": <condition>, "then": <node>, "else": <node>} — collapsed to one branch by the conditional-branch resolver after refs and slots expand. No schema under NebulaComponent/schemas/ contains an if property, so a definition using it does not lint. See Conditional branches;
  • field step, field renderers, fieldset renderers, layout section open;
  • a renderer naming a template (Vendor_Module::x.phtml) on a field, a fieldset or a layout node: the section renderer honours it, but the schema's renderer accepts only snippet.<id>;
  • _warning / _generated / _meta — stripped at runtime, but the form schema has no ^_ pattern, so they fail lint.

Conditional branches

The condition inside an if node is a JSON object with one discriminator key, or a bare boolean. There are no expression strings and no callables.

Shape Evaluates
{"field": "status", "op": "eq", "value": "enabled"} A value from the evaluation context.
{"role": "admin"} The current admin user's role.
{"config": "web/secure/use_in_adminhtml", "op": "eq", "value": "1"} Any store-config path, at scope (default store) and scopeCode (default the context's store id).
{"all": […]} / {"any": […]} / {"not": …} Combinations of the above.
true / false Itself, which is useful while authoring.

Operators are eq, neq, gt, gte, lt, lte, in, nin, contains, empty and notEmpty. An unknown discriminator or operator throws MalformedConditionException rather than quietly evaluating to false, so a typo is loud.

Because the config shape reads any core_config_data path, a definition can branch on store configuration, not only on the record. The paths themselves are the ones described under Configuration.

Source: code/Nebula/Component/Model/Condition/ConditionEvaluator.php, code/Nebula/Component/Plugin/Definition/ConditionalBranchResolver.php.

See also