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
routekey, 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¶
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.
replacesremoves a core block by layout name (referenceBlock remove).replacesClasssuppresses 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¶
| 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 towarning);_options: {"<field key>": [rows…]}— an option list scoped to this record, which beats the field's ownoptionsandoptionsSource.
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.
{
"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+widthis the expressive one: any number of rows, any widths.column: left|right|thirdis 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.
textarea — multi-line input; rows sizes it.
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.
color — a colour picker.
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.
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.
datetime — the same, with a time part.
password — a masked input.
hidden — a value carried through the POST with no row rendered.
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:
- Declare the row collection as an ordinary field key in
fieldsets(or name it in thelayouttree) and give it"renderer": "snippet.<id>". Declaring it matters beyond rendering: on the generic save routeAllowedFieldsCollectordrops every posted key the definition does not declare. - Hold the rows in one Alpine component.
x-dataon the snippet's root,x-forover 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. - Register that component as a single model, with
Alpine.store('nebulaModels').register('section:<key>', this)ininit()andunregisteron destroy. The contract isserialize()returning[{name: 'options[0][title]', value: …}, …]and an optionalvalidate()returning a boolean. On Save the form callsvalidateAll()and thenserializeAll(), which writes each pair into the native<form>as a hidden input, so a core controller doingsetData($request->getPostValue())receives the nested array unchanged. - Put the row-level checks in two places. The
validationrules are per-field scalar rules; none of them understands an array of rows, andrequiredon one only sees that the array is non-empty. Check the rows in the model'svalidate()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
- Field-level
required: trueis a client rule at best. The server validator readsvalidationand 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. - Server validation runs on the generic save route only. It executes when the
form posts to
nebula/form/save(or is checked throughnebula/form/validate). A form whosesettings.saveUrlpoints 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 fromcolumn/row+width; - layout node
position— the merge sorts on it, the renderer does not; - layout node
conditionon a flat form (EAV only — see above); settings.layout: "one-column"/"three-column"— onlytwo-columnchanges anything;settings.templateon a flat form — the EAV block reads it,Block\Formdoes 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 underNebulaComponent/schemas/contains anifproperty, so a definition using it does not lint. See Conditional branches; - field
step, fieldrenderers, fieldsetrenderers, layout sectionopen; - a
renderernaming a template (Vendor_Module::x.phtml) on a field, a fieldset or a layout node: the section renderer honours it, but the schema'srendereraccepts onlysnippet.<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¶
- EAV forms — attribute-driven forms (
type: "eav"). - Saving and validation — the two save paths in full.
- Extending a shipped form — overlays,
positionand$remove. - Snippets — what a
rendererreceives.