Form examples¶
Twelve worked examples, each adapted from a definition that ships with Nebula. The Form reference lists every key on its own; this page shows the keys that belong together, in the shape a working file has. Copy one, change the ids and the routes, and you have a screen.
Every example below parses and validates against form.schema.json, or against
eav-form.schema.json where the definition declares type: "eav". Keys the engine reads
but the schema rejects do not appear in any of them; where one is worth knowing about it
is called out in a note.
About the ids
An example whose id is a shipped one is a trim of that shipped file. Dropping such
a file into your own module does not create a second form: the merge layer joins it
to the shipped definition by id, and you get an overlay. Rename the id and the file
together when you adapt one. The other examples use a vendor_module_ namespace and
are free to copy. A base definition's id must match its file name; bin/magento
nebula:lint checks that.
1. A flat form with several fieldsets¶
The starting shape. Three fieldsets, the first two open and the third collapsed, ordered
by position. It carries a select with its options written inline, a select and a
multiselect that name a registered option source, and a toggle whose stored values are not
1 and 0.
{
"id": "vendor_promo_edit",
"acl": "Vendor_Module::promo",
"route": ["vendor_module_promo_edit", "vendor_module_promo_new"],
"dataSource": {
"provider": "vendor_module_promo.form",
"config": {
"identifierParam": "promo_id"
}
},
"settings": {
"title": "Promotion",
"titleField": "title",
"identifierField": "promo_id",
"backUrl": "vendor_module/promo/index",
"saveUrl": "vendor_module/promo/save",
"deleteUrl": "vendor_module/promo/delete"
},
"fieldsets": {
"general": {
"label": "General",
"position": 10,
"open": true,
"fields": {
"title": {
"label": "Title",
"type": "text",
"required": true,
"placeholder": "Spring Sale",
"position": 10,
"validation": {"required": true, "maxLength": 255}
},
"url_key": {
"label": "URL Key",
"type": "text",
"required": true,
"position": 20,
"note": "Relative URL of the page, for example 'spring-sale'.",
"validation": {"required": true, "pattern": "/^[a-z0-9\\-]+$/"}
},
"is_active": {
"label": "Enabled",
"type": "toggle",
"default": "1",
"position": 30
},
"store_id": {
"label": "Store View",
"type": "multiselect",
"optionsSource": "nebula.store.view",
"required": true,
"position": 40
}
}
},
"targeting": {
"label": "Targeting",
"position": 20,
"open": false,
"fields": {
"display_mode": {
"label": "This page shows",
"type": "select",
"default": "both",
"position": 10,
"options": [
{"value": "content", "label": "Content only"},
{"value": "products", "label": "Products only"},
{"value": "both", "label": "Content and products"}
]
},
"page_layout": {
"label": "Layout",
"type": "select",
"optionsSource": "magento.cms.page.layout",
"default": "1column",
"position": 20
},
"merge_on_conflict": {
"label": "Merge existing promotions",
"type": "toggle",
"toggleLabel": "Merge on conflict",
"trueValue": "true",
"falseValue": "false",
"position": 30,
"note": "The save controller reads the string 'true', so the switch stores words rather than 1 and 0."
}
}
},
"content": {
"label": "Content",
"position": 30,
"open": true,
"fields": {
"content": {
"label": "Page Content",
"type": "wysiwyg",
"rows": 20,
"full_width": true,
"position": 10
}
}
}
}
}
Keys worth noticing
routetakes a list, so one definition serves both the create and the edit action. No layout XML is needed once a form claims a route.open: falsecollapses a titled fieldset on load. A fieldset with nolabelrenders as a plain card and cannot collapse at all.defaultis merged under whatever the data provider returns, so it only shows up on a record the provider knows nothing about.optionswins overoptionsSourcewhen both are present, and a provider's_optionsfor the field wins over both.trueValueandfalseValueare compared as strings. They are what lets a switch stand for a value that runs the other way to its label.full_widthmoves the label above the control. Use it for anything wide: a rich text editor, a long textarea, a snippet-rendered panel.
Source: Nebula_Theme/view/adminhtml/form/cms_block_edit.json,
Nebula_LandingPage/view/adminhtml/form/nebula_landing_page_edit.json,
Nebula_Theme/view/adminhtml/form/search_synonyms_edit.json.
2. Two columns¶
settings.layout: "two-column" turns on the row builder. Fieldsets sharing a row value
render side by side, and width gives each one its share. A fieldset with no row stacks
full width underneath. Nothing else changes: the fields, their types and their order are
declared exactly as in example 1.
{
"id": "vendor_article_edit",
"acl": "Vendor_Module::article",
"route": "vendor_module_article_edit",
"dataSource": {
"provider": "vendor_module_article.form",
"config": {
"identifierParam": "article_id"
}
},
"settings": {
"title": "Article",
"identifierField": "article_id",
"backUrl": "vendor_module/article/index",
"saveUrl": "vendor_module/article/save",
"deleteUrl": "vendor_module/article/delete",
"layout": "two-column"
},
"fieldsets": {
"general": {
"label": "General",
"position": 10,
"open": true,
"row": "top",
"width": "3/4",
"fields": {
"title": {"label": "Article Title", "type": "text", "required": true, "position": 10},
"identifier": {"label": "URL Key", "type": "text", "required": true, "position": 20},
"is_active": {"label": "Enabled", "type": "toggle", "position": 30},
"store_id": {
"label": "Store View",
"type": "multiselect",
"optionsSource": "nebula.store.view",
"required": true,
"position": 40
}
}
},
"design": {
"label": "Design",
"position": 10,
"row": "top",
"width": "1/4",
"fields": {
"page_layout": {
"label": "Layout",
"type": "select",
"optionsSource": "magento.cms.page.layout",
"position": 10
}
}
},
"seo": {
"label": "Search Engine Optimization",
"position": 20,
"row": "seo",
"width": "1/2",
"fields": {
"meta_title": {"label": "Meta Title", "type": "text", "position": 10},
"meta_keywords": {"label": "Meta Keywords", "type": "textarea", "position": 20},
"meta_description": {"label": "Meta Description", "type": "textarea", "rows": 3, "position": 30}
}
},
"seo_preview": {
"label": "Google Preview",
"position": 20,
"row": "seo",
"width": "1/2",
"renderer": "snippet.seo_preview",
"entity_type": "cms_page",
"entity_noun": "article",
"title_field": "title",
"url_field": "identifier",
"title_placeholder": "Article Title",
"url_placeholder": "article-url"
},
"content": {
"label": "Content",
"position": 30,
"open": true,
"fields": {
"content_heading": {"label": "Content Heading", "type": "text", "position": 10},
"content": {"label": "Content", "type": "wysiwyg", "full_width": true, "position": 20}
}
}
}
}
Keys worth noticing
rowis an arbitrary key. Any number of rows is possible, and the two fieldsets inrow: "top"are a 3/4 and a 1/4 rather than an even split.- Only
1/2,1/3,2/3,1/4and3/4map to a width class. Any other fraction renders full width. column: "left" | "right" | "third"is the older dialect for the same idea and is still read. It gives one row of equal columns and nothing more, so preferrowandwidth.settings.layoutalso acceptsone-columnandthree-column. Neither changes anything; onlytwo-columnturns the row builder on.- A fieldset with a
rendererand nofieldshands its whole body to a snippet. The extra keys besiderendererhere are read bysnippet.seo_previewitself.
Source: Nebula_Theme/view/adminhtml/form/cms_page_edit.json.
3. The layout tree¶
The second dialect. layout is a tree of tabs, row, column and section nodes that
arranges fields; fieldsets still declares them. A section names its fields as a list of
keys, and a section's children may mix a fields node with a snippet node so custom
markup sits among the inputs. Every node carries an id, which is what an overlay from
another module merges onto.
{
"id": "vendor_article_edit_tabs",
"acl": "Vendor_Module::article",
"dataSource": {
"provider": "vendor_module_article.form",
"config": {"identifierParam": "article_id"}
},
"settings": {
"title": "Article",
"identifierField": "article_id",
"backUrl": "vendor_module/article/index",
"saveUrl": "vendor_module/article/save",
"deleteUrl": "vendor_module/article/delete"
},
"fieldsets": {
"general": {
"label": "General",
"position": 10,
"fields": {
"title": {"label": "Article Title", "type": "text", "required": true, "position": 10},
"identifier": {"label": "URL Key", "type": "text", "required": true, "position": 20},
"is_active": {"label": "Enabled", "type": "toggle", "position": 30},
"store_id": {
"label": "Store View",
"type": "multiselect",
"optionsSource": "nebula.store.view",
"required": true,
"position": 40
},
"page_layout": {
"label": "Layout",
"type": "select",
"optionsSource": "magento.cms.page.layout",
"position": 50
}
}
},
"seo": {
"label": "Search Engine Optimization",
"position": 20,
"fields": {
"meta_title": {"label": "Meta Title", "type": "text", "position": 10},
"meta_description": {"label": "Meta Description", "type": "textarea", "rows": 3, "position": 20}
}
},
"content": {
"label": "Content",
"position": 30,
"fields": {
"content_heading": {"label": "Content Heading", "type": "text", "position": 10},
"content": {"label": "Content", "type": "wysiwyg", "full_width": true, "position": 20}
}
}
},
"layout": [
{
"id": "vendor_article_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"]
}
]
}
]
}
]
},
{
"id": "seo_tab",
"label": "SEO",
"children": [
{
"id": "seo_row",
"type": "row",
"children": [
{
"id": "seo_left",
"type": "column",
"width": "1/2",
"children": [
{
"id": "seo_section",
"type": "section",
"label": "Search Engine Optimization",
"children": [
{"type": "fields", "fields": ["meta_title", "meta_description"]},
{"type": "snippet", "renderer": "snippet.vendor_article_checklist"}
]
}
]
},
{
"id": "seo_right",
"type": "column",
"width": "1/2",
"children": [
{
"id": "seo_preview_section",
"type": "section",
"label": "Google Preview",
"renderer": "snippet.seo_preview",
"entity_type": "cms_page",
"entity_noun": "article",
"title_field": "title",
"url_field": "identifier",
"title_placeholder": "Article Title",
"url_placeholder": "article-url"
}
]
}
]
}
]
},
{
"id": "content_tab",
"label": "Content",
"children": [
{
"id": "content_section",
"type": "section",
"label": "Content",
"fields": ["content_heading", "content"]
}
]
}
]
}
]
}
Keys worth noticing
- A tab is a child of a
tabsnode with alabelandchildren. It needs notypeof its own; the renderer reads the children of atabsnode as tabs whatever they say. - A section's
fieldsis a list of keys, not field definitions. The configuration stays infieldsets, and a field named by no node simply does not render. - Fields inside a section are ordered by their own
position, not by the order of the list. conditionon a flat form's layout node is accepted by the schema and ignored by the renderer. It is evaluated on EAV forms only. UsedisabledWhenon the fields instead.- There is no
overrideskey on a flat form. It belongs to EAV definitions, where the fields come from attribute metadata rather than from the file.
open on a layout section does not lint
The flat form template reads open on a section and the schema has no such property,
so a definition that sets it fails nebula:lint. A titled section starts open either
way, which is what open: true would have asked for.
Source: Nebula_Theme/view/adminhtml/form/cms_page_edit_tabs.json.
4. Claiming a route that is not an identity¶
A full action name usually identifies a page. On two core screens it does not, and
routeWhen is how a form says which of the requests on that action are its own. Core
forwards the website, store and store view editors to a single editStore action and
tells them apart by a value left in the registry. 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. 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 fallback.
{
"id": "store_view_edit",
"acl": "Magento_Backend::store",
"route": "adminhtml_system_store_editstore",
"routeWhen": {
"registry": {"store_type": "store"}
},
"replaces": "adminhtml_admin_save_confirm",
"dataSource": {
"provider": "store_view.form",
"config": {"identifierParam": "store_id"}
},
"settings": {
"title": "Store View",
"identifierField": "store[store_id]",
"backUrl": "adminhtml/system_store/index",
"saveUrl": "adminhtml/system_store/save"
},
"hiddenFields": {
"store_type": "store",
"store_action": "edit"
},
"fieldsets": {
"general": {
"label": "General",
"position": 10,
"open": true,
"fields": {
"group_id": {
"label": "Store",
"type": "select",
"submitName": "store[group_id]",
"optionsSource": "nebula.store.group",
"required": true,
"position": 10
},
"name": {
"label": "Name",
"type": "text",
"submitName": "store[name]",
"required": true,
"position": 20
},
"code": {
"label": "Code",
"type": "text",
"submitName": "store[code]",
"required": true,
"position": 30
},
"is_active": {
"label": "Status",
"type": "toggle",
"submitName": "store[is_active]",
"position": 40
}
}
}
}
}
{
"id": "url_rewrite_edit",
"acl": "Magento_UrlRewrite::urlrewrite",
"route": "adminhtml_url_rewrite_edit",
"routeWhen": {
"param": {"product": null, "category": null, "cms_page": null}
},
"replacesClass": "Magento\\UrlRewrite\\Block\\Edit",
"dataSource": {
"provider": "url_rewrite.form",
"config": {"identifierParam": "id"}
},
"settings": {
"title": "URL Rewrite",
"identifierField": "url_rewrite_id",
"backUrl": "adminhtml/url_rewrite/index",
"saveUrl": "adminhtml/url_rewrite/save",
"deleteUrl": "adminhtml/url_rewrite/delete"
},
"fieldsets": {
"general": {
"label": "General",
"position": 10,
"open": true,
"fields": {
"store_id": {
"label": "Store",
"type": "select",
"required": true,
"optionsSource": "magento.store.system",
"optionsMethod": "getStoreValuesForForm",
"position": 10
},
"request_path": {"label": "Request Path", "type": "text", "required": true, "position": 20},
"target_path": {"label": "Target Path", "type": "text", "required": true, "position": 30},
"redirect_type": {
"label": "Redirect Type",
"type": "select",
"optionsSource": "magento.urlrewrite.option",
"position": 40
},
"description": {"label": "Description", "type": "textarea", "rows": 3, "position": 50}
}
}
}
}
Keys worth noticing
- A
routeWhenvalue is one of three things: a scalar the key must equal,nullfor a key that must be absent, ortruefor a key that must be present with any value. Every entry must hold. nullmirrorsRequest::has(), so a URL ending in?product=counts as present and the generic form stands aside.replacesremoves a core block by its layout name.replacesClasssuppresses one by class, for controllers that build their layout in PHP and leave the block with a generated name nothing can match.hiddenFieldsposts literal values. Core's store save controller branches onstore_typeandstore_action, and neither is a field an administrator edits.- A field key must match
[a-z][a-z0-9_]*. Bracket notation belongs insubmitName, which replaces the POST name outright.
Source: Nebula_Store/view/adminhtml/form/store_view_edit.json,
Nebula_Theme/view/adminhtml/form/url_rewrite_edit.json.
5. Extra toolbar buttons¶
The shell draws Back, Delete, Save and Save and Continue. settings.buttons adds the rest.
A button either navigates to a route, posts to one, or re-targets this form at another
route so the controller behind it receives the fields. Core's admin user screen needs all
three kinds: a POST that acts on an id alone, and a delete that verifies the current
password out of the form it was posted from.
{
"id": "admin_user_edit",
"acl": "Magento_User::user",
"route": "adminhtml_user_edit",
"replaces": ["adminhtml.user.edit.tabs", "adminhtml.user.edit", "adminhtml.user.roles.grid.js"],
"dataSource": {
"provider": "admin_user.form",
"config": {"identifierParam": "user_id"}
},
"settings": {
"title": "Admin User",
"identifierField": "user_id",
"backUrl": "adminhtml/user/index",
"saveUrl": "adminhtml/user/save",
"buttons": [
{
"label": "Force Sign-In",
"url": "adminhtml/user/invalidatetoken",
"method": "post",
"class": "ghost",
"showWhen": "existing",
"acl": "Magento_User::user",
"confirmTitle": "Revoke this user's tokens?",
"confirm": "They will be signed out of the admin and any API session, and will need to sign in again."
},
{
"label": "Delete User",
"submitsForm": true,
"action": "adminhtml/user/delete",
"class": "danger",
"showWhen": "existing",
"confirmTitle": "Delete this user?",
"confirm": "This cannot be undone. Your own current password must be filled in above for the deletion to be accepted."
}
]
},
"fieldsets": {
"user_info": {
"label": "User Information",
"position": 10,
"open": true,
"fields": {
"username": {"label": "User Name", "type": "text", "required": true, "position": 10},
"firstname": {"label": "First Name", "type": "text", "required": true, "position": 20},
"lastname": {"label": "Last Name", "type": "text", "required": true, "position": 30},
"email": {
"label": "Email",
"type": "email",
"required": true,
"position": 40,
"validation": {"required": true, "email": true}
},
"is_active": {"label": "This Account is", "type": "toggle", "default": "1", "position": 50}
}
},
"verification": {
"label": "Current User Identity Verification",
"position": 20,
"open": true,
"fields": {
"current_password": {
"label": "Your Password",
"type": "password",
"required": true,
"position": 10,
"note": "The delete and save controllers both read this out of the POST."
}
}
}
}
}
A submit that should not leave the page adds target: "modal". The form posts into an
iframe inside a dialog, which is how a preview works without losing the edit behind it:
{
"settings": {
"buttons": [
{
"label": "Preview Template",
"submitsForm": true,
"action": "newsletter/template/drop",
"target": "modal",
"class": "ghost",
"showWhen": "always"
}
]
}
}
Keys worth noticing
showWhendefaults toexisting. An action on a record has nothing to act on until the record is saved, so a button meant for a create page must sayalwaysornew.- A navigating button carries the record id as a route param under
idParam. AsubmitsFormbutton posts it as a field instead, never both. targetis only read alongsidesubmitsForm. On a plain link it does nothing.class: "danger"sets the tone and arms the destructive styling on the confirmation.aclon a button hides it from roles that could not use it. The controller behind the route is still what decides.emitsmakes a button client-side only. It posts nothing and navigates nowhere, it dispatches a DOM event onwindowthat a panel elsewhere on the page listens for. A button that emits cannot also submit.- For a screen that performs an action rather than editing a record, pair the buttons with
settings.saveAndContinue: false, as in example 7.
Source: Nebula_User/view/adminhtml/form/admin_user_edit.json,
Nebula_Theme/view/adminhtml/form/newsletter_template_edit.json.
6. Shaping the POST for a core save controller¶
Nebula forms mostly post to controllers written for the core UI components, and those
controllers expect a particular request shape. Four settings between them cover it:
fieldPrefix namespaces every field, identifierField names the input carrying the record
id, hiddenParams copies request params onto the save URL, and hiddenInputs adds inputs
whose values come from somewhere other than a field.
{
"id": "vendor_source_edit",
"acl": "Vendor_Module::source",
"route": ["vendor_module_source_new", "vendor_module_source_edit"],
"dataSource": {
"provider": "vendor_module_source.form",
"config": {"identifierParam": "source_code"}
},
"settings": {
"title": "Source",
"titleField": "name",
"identifierField": "general[id_field_name]",
"fieldPrefix": "general",
"backUrl": "vendor_module/source/index",
"saveUrl": "vendor_module/source/save",
"deleteUrl": "vendor_module/source/delete",
"deleteParam": "source_code",
"hiddenParams": ["store"],
"hiddenInputs": {
"template_id": "template_id",
"back": "literal:edit",
"channel_codes": "array:sales_channels"
}
},
"fieldsets": {
"general": {
"label": "General",
"position": 10,
"open": true,
"fields": {
"source_code": {
"label": "Code",
"type": "text",
"required": true,
"readonlyOnEdit": true,
"position": 10,
"note": "Letters, digits and underscores. It cannot be changed once the source is saved."
},
"name": {"label": "Name", "type": "text", "required": true, "position": 20},
"enabled": {
"label": "Is Enabled",
"type": "checkbox",
"toggleLabel": "Enabled",
"default": "1",
"position": 30
},
"is_pickup_location_active": {
"label": "Use as Pickup Location",
"type": "checkbox",
"toggleLabel": "Pickup location",
"submitName": "general[extension_attributes][is_pickup_location_active]",
"default": "0",
"position": 40
}
}
}
}
}
That definition posts general[source_code], general[name], general[enabled],
general[extension_attributes][is_pickup_location_active], general[id_field_name], the
form key, and the three hidden inputs.
Keys worth noticing
hiddenInputsreads three source forms.literal:editposts the raw stringedit.array:sales_channelscomma-splits a value intochannel_codes[]. Anything else is the request param of that name, falling back to the record field it names.hiddenParamsgoes on the save URL, not into the body. The identifier param is added to that URL automatically when a record is being edited, because a core save controller that sees no id treats the POST as a create.identifierFieldis the body-level id. Native save controllers callsetData(getPostValue()), so without it an edit silently becomes an insert.deleteParamcovers a delete controller that reads the id under a different name than the edit route uses.readonlyOnEditlocks a field and still posts it. Use it, notdisableOnEdit, whenever the save controller reads the value out of the body: a disabled control is not submitted.submitNamereplaces the POST name outright,fieldPrefixincluded, which is the only way to reach a nested key.
Source: Nebula_Theme/view/adminhtml/form/inventory_source_form.json,
Nebula_Theme/view/adminhtml/form/newsletter_queue_edit.json,
Nebula_Theme/view/adminhtml/form/inventory_stock_form.json.
7. A screen with no save, and a screen that is all action¶
Two forms that are not record editors. The first is a detail view of something the admin
cannot change: settings.readOnly drops Save, Save and Continue, Delete and the pager's
save variants, and each field says readonly for itself. The second performs an import.
It has no record and no id at all, which singleton tells the shell, and running it twice
is a real mistake rather than a cosmetic one, which is what saveAndContinue: false is
for.
{
"id": "design_theme_edit",
"acl": "Magento_Theme::theme",
"route": "adminhtml_system_design_theme_edit",
"replaces": "theme_edit_tabs",
"dataSource": {
"provider": "design_theme.form",
"config": {"identifierParam": "id"}
},
"settings": {
"title": "Theme",
"titleField": "theme_title",
"identifierField": "theme_id",
"readOnly": true,
"backUrl": "adminhtml/system_design_theme/index"
},
"fieldsets": {
"general": {
"label": "Theme Settings",
"position": 10,
"open": true,
"fields": {
"theme_title": {"label": "Theme Title", "type": "text", "readonly": true, "position": 10},
"parent_title": {"label": "Parent Theme", "type": "text", "readonly": true, "position": 20},
"theme_path": {"label": "Theme Path", "type": "text", "readonly": true, "position": 30},
"area": {"label": "Area", "type": "text", "readonly": true, "position": 40}
}
},
"preview": {
"label": "Theme Preview Image",
"position": 20,
"open": true,
"renderer": "snippet.theme_preview"
}
}
}
{
"id": "tax_rate_import_export",
"acl": "Magento_TaxImportExport::import_export",
"route": "tax_rate_importexport",
"replacesClass": [
"Magento\\TaxImportExport\\Block\\Adminhtml\\Rate\\ImportExport",
"Magento\\TaxImportExport\\Block\\Adminhtml\\Rate\\ImportExportHeader"
],
"settings": {
"title": "Import and Export Tax Rates",
"singleton": true,
"saveUrl": "tax/rate/importPost",
"saveLabel": "Import Tax Rates",
"saveAndContinue": false,
"backUrl": "tax/rate/index",
"buttons": [
{
"label": "Export Tax Rates",
"url": "tax/rate/exportPost",
"method": "post",
"class": "ghost",
"showWhen": "always"
}
]
},
"fieldsets": {
"import": {
"label": "Import Tax Rates",
"position": 10,
"open": true,
"fields": {
"import_rates_file": {
"label": "CSV File",
"type": "file",
"required": true,
"position": 10,
"note": "Export first to see the exact column shape."
}
}
}
}
}
Keys worth noticing
readOnlygoverns the chrome only. Without areadonlyon each field the controls are still editable, and there is simply no button to save them with.- A read-only form needs no
saveUrland nodeleteUrl. OmittingdeleteUrlis what removes the Delete button; a provider returning_can_delete: falseremoves it for one record. singletonsays the record always exists and has no id. Without it the shell reads the missing id as a create and titles the page "New ...".saveLabelrenames the primary submit for a page whose action has a name of its own.- The action button uses
showWhen: "always"because a singleton screen is never an existing record in the sense the default means. replacesClasstakes a list. One screen often draws two core blocks, a body and a header, and both have to go.
Source: Nebula_Store/view/adminhtml/form/design_theme_edit.json,
Nebula_System/view/adminhtml/form/tax_rate_import_export.json.
8. Every control, once¶
A settings screen that happens to use most of the field types: an image with an upload endpoint, a colour picker, a date and a datetime, a number with bounds, an email, a password, a sized textarea and a rich text editor.
{
"id": "vendor_theme_settings_edit",
"acl": "Vendor_Module::settings",
"route": "vendor_module_settings_edit",
"dataSource": {
"provider": "vendor_module_settings.form",
"config": {"identifierParam": "scope"}
},
"settings": {
"title": "Storefront Settings",
"identifierField": "scope",
"backUrl": "vendor_module/settings/index",
"saveUrl": "vendor_module/settings/save"
},
"fieldsets": {
"branding": {
"label": "Branding",
"position": 10,
"open": true,
"fields": {
"scope": {"type": "hidden", "position": 1},
"logo_src": {
"label": "Logo Image",
"type": "image",
"uploadUrl": "vendor_module/settings_fileUploader/save",
"position": 10,
"note": "Allowed file types: png, gif, jpg, jpeg, svg."
},
"accent_color": {
"label": "Accent Colour",
"type": "color",
"default": "#4f46e5",
"position": 20
},
"welcome_text": {
"label": "Welcome Text",
"type": "text",
"placeholder": "Default welcome message",
"position": 30
}
}
},
"campaign": {
"label": "Campaign Window",
"position": 20,
"fields": {
"active_from": {
"label": "Live From",
"type": "date",
"position": 10,
"note": "Leave empty to go live as soon as the banner is enabled."
},
"send_at": {
"label": "Announce At",
"type": "datetime",
"position": 20
},
"sort_order": {
"label": "Sort Order",
"type": "number",
"position": 30,
"validation": {"min": 0, "max": 999}
}
}
},
"contact": {
"label": "Contact",
"position": 30,
"fields": {
"contact_email": {
"label": "Contact Email",
"type": "email",
"required": true,
"position": 10,
"validation": {"required": true, "email": true}
},
"api_secret": {
"label": "API Secret",
"type": "password",
"position": 20,
"note": "Leave empty to keep the stored secret."
},
"head_includes": {
"label": "Scripts and Style Sheets",
"type": "textarea",
"rows": 8,
"full_width": true,
"position": 30
}
}
},
"content": {
"label": "Footer Content",
"position": 40,
"fields": {
"footer_content": {
"label": "Footer Content",
"type": "wysiwyg",
"rows": 20,
"full_width": true,
"position": 10
}
}
}
}
}
Keys worth noticing
validation.minandvalidation.maxon anumberbecome the input's ownminandmaxattributes as well as client and server rules. The template always adds thenumberrule on top.uploadUrlis the admin route afileor animagefield uploads to. Animagefield also renders a preview of whatever is stored.rowssizes atextareaand awysiwyg, defaulting to 5.- A
hiddenfield renders no row. It is the way to carry a scope or a type through the POST without showing it. dateanddatetimepost in the admin locale's numeric format, so the receiving controller should parse rather than assume.- An unknown
typesilently renders astext. A typo costs you a control, not a page.
step is read but does not lint
The number template reads a field-level step and defaults it to 1. The schema has
no such property and every schema object is strict, so a definition declaring step
fails bin/magento nebula:lint. Express the bounds with validation.min and
validation.max, which do lint, and leave the step alone until the schema carries it.
Source: Nebula_Store/view/adminhtml/form/design_config_form.json,
Nebula_Theme/view/adminhtml/form/newsletter_queue_edit.json,
Nebula_User/view/adminhtml/form/admin_account_edit.json,
Nebula_System/view/adminhtml/form/variable_edit.json. The color type has a
template and an Alpine component but no shipped form uses it yet.
9. Fields that depend on other fields¶
Three different problems that look alike. A field that must never change after the first
save is disableOnEdit when the controller does not need it, and readonlyOnEdit when it
does. A field that is meaningless while another field holds a particular value is
disabledWhen, which is re-evaluated live rather than only at render. A field whose option
list depends on the record is left to the data provider.
{
"id": "vendor_attribute_edit",
"acl": "Vendor_Module::attribute",
"route": ["vendor_module_attribute_new", "vendor_module_attribute_edit"],
"dataSource": {
"provider": "vendor_module_attribute.form",
"config": {"identifierParam": "attribute_id"}
},
"settings": {
"title": "Attribute",
"identifierField": "attribute_id",
"backUrl": "vendor_module/attribute/index",
"saveUrl": "vendor_module/attribute/save",
"deleteUrl": "vendor_module/attribute/delete"
},
"fieldsets": {
"properties": {
"label": "Properties",
"position": 10,
"open": true,
"fields": {
"attribute_code": {
"label": "Attribute Code",
"type": "text",
"required": true,
"disableOnEdit": true,
"position": 10,
"note": "Letters, digits and underscores. It cannot be changed once the attribute exists.",
"validation": {"required": true, "maxLength": 30, "pattern": "/^[a-z][a-z0-9_]*$/"}
},
"source_code": {
"label": "Source Code",
"type": "text",
"required": true,
"readonlyOnEdit": true,
"position": 20,
"note": "Locked after the first save, but still posted, because the save controller looks the record up by it."
},
"frontend_input": {
"label": "Input Type",
"type": "select",
"required": true,
"disableOnEdit": true,
"position": 30,
"options": [
{"value": "text", "label": "Text Field"},
{"value": "select", "label": "Dropdown"},
{"value": "price", "label": "Price"}
]
},
"price_type": {
"label": "Dynamic Price",
"type": "toggle",
"trueValue": "0",
"falseValue": "1",
"default": "0",
"position": 40,
"note": "Stores 0 for dynamic, so the switch reads on when the value is 0."
},
"price": {
"label": "Price",
"type": "number",
"position": 50,
"disabledWhen": {"field": "price_type", "equals": "0"},
"validation": {"min": 0},
"note": "Disabled while Dynamic Price is on, and a disabled control is not posted."
},
"attribute_set_id": {
"label": "Attribute Set",
"type": "select",
"optionsSource": "vendor_module.attribute_set",
"position": 60,
"note": "The provider narrows this list to the sets of the current entity through _options."
}
}
}
}
}
The last field's list is scoped by the provider, which returns it under the reserved
_options key alongside the record:
public function getData(array $config, array $params = []): array
{
$entityId = $params['entityId'] ?? null;
if ($entityId === null) {
return [];
}
$attribute = $this->repository->getById((int) $entityId);
return $attribute->getData() + [
'_options' => [
'attribute_set_id' => $this->setsForEntity($attribute->getEntityTypeId()),
],
'_notices' => [
['type' => 'warning', 'message' => 'This attribute is used by 12 products.'],
],
];
}
Keys worth noticing
disabledWhentakes exactlyfieldandequals, both required, and compares as strings. It works for every field type.- A disabled control is not submitted, which is usually the point. A price that must not post while dynamic pricing is on is the canonical case.
disableOnEditandreadonlyOnEditdiffer only in whether the value reaches the controller. Choose by asking what the save controller does with it._optionsrows use the same shape an option source yields:valueandlabel, plus an optionaldepthorgroupfor an indented ladder. Keep anoptionsSourceon the field as well, because on a create page the provider has no record to scope to._noticesand_can_deleteare the other two reserved provider keys. Every key the provider returns beginning with an underscore is an engine instruction rather than a field.- There is no
dependenciesordependskey. Conditional enabling on a flat form isdisabledWhenand nothing else.
Source: Nebula_Theme/view/adminhtml/form/product_attribute_edit.json,
Nebula_Theme/view/adminhtml/form/inventory_source_form.json,
Nebula_Theme/view/adminhtml/form/catalog_product_edit.json.
10. A record pager and the context around it¶
A record reached from a listing, from a product page and from a customer page all at once
wants two things: prev and next navigation along the listing's order, and enough context
to get back where it came from. settings.pager names a registered pager alias, and
settings.contextParams names the request params that ride along.
{
"id": "review_product_edit",
"acl": "Magento_Review::reviews_all",
"route": "review_product_edit",
"dataSource": {
"provider": "review.form",
"config": {"identifierParam": "id"}
},
"settings": {
"title": "Review",
"titleField": "title",
"identifierField": "id",
"backUrl": "review/product/index",
"saveUrl": "review/product/save",
"deleteUrl": "review/product/delete",
"pager": {
"provider": "review",
"editUrl": "review/product/edit",
"idParam": "id"
},
"contextParams": ["ret", "productId", "customerId"]
},
"fieldsets": {
"details": {
"label": "Review Details",
"position": 10,
"open": true,
"fields": {
"status_id": {
"label": "Status",
"type": "select",
"required": true,
"optionsSource": "magento.review.status",
"position": 10
},
"stores": {
"label": "Visibility",
"type": "multiselect",
"required": true,
"optionsSource": "nebula.store.view",
"position": 20
},
"nickname": {"label": "Nickname", "type": "text", "required": true, "position": 30},
"title": {"label": "Summary of Review", "type": "text", "required": true, "position": 40},
"detail": {"label": "Review", "type": "textarea", "rows": 6, "required": true, "position": 50}
}
},
"ratings": {
"label": "Detailed Rating",
"position": 20,
"open": true,
"renderer": "snippet.review_ratings"
}
}
}
Keys worth noticing
pager.provideris an alias registered onRecordPagerRegistry, the same binding pattern as a data provider. An unknown alias yields no pager rather than an error.idParamdefaults todataSource.config.identifierParam, so it can usually be left out.- The pager is skipped entirely on a new record, and again when the provider finds neither a previous nor a next id.
contextParamsare rendered as hidden inputs and appended to the prev and next links, which is what keeps "return to the product I came from" true after a save.settings.readOnlyremoves the pager's Save and Previous and Save and Next variants and leaves plain navigation.
sidebarRenderer is not part of this
settings.sidebarRenderer is read by the EAV shell templates only. On a flat form the
key lints and renders nothing. See example 11 for where it applies.
Source: Nebula_Theme/view/adminhtml/form/review_product_edit.json.
11. An EAV form¶
An EAV definition does not list its fields. It says which attribute groups and codes go
where, and the engine renders each attribute with the control its frontend_input asks
for. This one places six attributes and two snippets in a two-column row, adds a
conditional section, moves the rest into a tab strip, drops two groups, and patches five
attributes on the way through.
{
"id": "catalog_category_edit",
"type": "eav",
"entity": "catalog_category",
"acl": "Magento_Catalog::categories",
"dataSource": {
"provider": "category.form",
"config": {"identifierParam": "id"}
},
"settings": {
"title": "Category",
"identifierField": "entity_id",
"backUrl": "catalog/category/index",
"saveUrl": "catalog/category/save",
"deleteUrl": "catalog/category/delete",
"hiddenParams": ["store"],
"template": "Nebula_Theme::eav/form-with-sidebar.phtml",
"sidebarRenderer": "snippet.category_navigation_tree"
},
"layout": [
{
"id": "main_row",
"type": "row",
"children": [
{
"id": "left_col",
"type": "column",
"width": "1/2",
"children": [
{
"id": "general_section",
"type": "section",
"label": "General",
"fields": ["name", "is_active", "include_in_menu"]
},
{
"id": "content_section",
"type": "section",
"label": "Content",
"fields": ["description", "image", "landing_page"]
}
]
},
{
"id": "right_col",
"type": "column",
"width": "1/2",
"children": [
{
"id": "display_section",
"type": "section",
"label": "Display Settings",
"children": [
{"type": "fields", "fields": ["display_mode", "is_anchor"]},
{"type": "snippet", "renderer": "snippet.category_sortby_fields"}
]
},
{
"id": "anchor_section",
"type": "section",
"label": "Layered Navigation",
"condition": {"is_anchor": "1"},
"fields": ["available_sort_by", "default_sort_by"]
}
]
}
]
},
{
"id": "category_tabs",
"type": "tabs",
"children": [
{
"id": "products_tab",
"label": "Products in Category",
"renderer": "snippet.category_products"
},
{
"id": "design_tab",
"label": "Design",
"groups": ["custom-design"]
},
{
"id": "attributes_tab",
"label": "Attributes",
"remainingGroups": true
}
]
}
],
"excludeGroups": ["general-information", "display-settings"],
"overrides": {
"fields": {
"image": {
"type": "image",
"label": "Category Image",
"uploadUrl": "catalog/category_image/upload"
},
"description": {"type": "wysiwyg"},
"is_active": {"type": "toggle"},
"include_in_menu": {"type": "toggle"},
"is_anchor": {"type": "toggle"},
"custom_apply_to_products": {"$remove": true}
}
}
}
Keys worth noticing
type: "eav"selects both the runtime and the schema.bin/magento nebula:lintpickseav-form.schema.jsonfor anyform/*.jsonwhosetypeiseav.- An EAV form is mounted from layout XML and must never carry
route. Route claiming injects the flat-form block, which knows nothing about attributes. fieldson an EAV section is a list of attribute codes;groupsis a list of attribute group codes and renders every attribute in each.conditionis evaluated here, unlike on a flat form. It is a map of record field to expected value, compared as strings, and a list of values means any of them.excludeGroupsdrops groups from the tree entirely. What is neither excluded nor claimed by a node is the remaining set, andremainingGroups: trueon one tab is where it lands. Say nothing and it renders below the tree instead.overrides.fields.<code>reads a short list:$remove,type,renderer,label,default,uploadUrl,disabled,disableOnEdit,trueValue,falseValueanddisabledWhen. Anything else is ignored.settings.templateselects the shell, andsettings.sidebarRendererfills the sidebar that shell draws. Both are EAV-only.
Source: Nebula_Theme/view/adminhtml/form/catalog_category_edit.json. The
conditional section, the remaining-groups tab and hiddenParams are additions, taken from
Nebula_Theme/view/adminhtml/form/catalog_product_edit.json.
12. Two overlays¶
An overlay is a file with the same id as a shipped definition and no id key of its own.
The merge layer joins the two, so the overlay only names what it changes. Both of these
ship, and they are the only two form overlays in the free package.
The passkey module adds a panel to the admin user form, rendered end to end by a snippet:
{
"fieldsets": {
"nebula_passkey_security": {
"label": "Sign-in security",
"position": 25,
"open": true,
"renderer": "snippet.nebula_passkey_admin_status"
}
}
}
The same module reaches into a layout tree by node id. main_row and account_col exist
in the shipped customer form, so they are merged rather than duplicated, and the new
section is appended to that column:
{
"layout": [
{
"id": "main_row",
"children": [
{
"id": "account_col",
"children": [
{
"id": "nebula_passkey_security_section",
"type": "section",
"label": "Sign-in security",
"renderer": "snippet.nebula_passkey_customer_status"
}
]
}
]
}
]
}
Keys worth noticing
- An overlay must omit
id. The schema has two branches, and the one that allows a missingidrequires at least one offieldsets,layout,settings,dataSource,aclorhiddenFields. positionon a new fieldset places it among the shipped ones.25here puts the panel between the fieldsets at 20 and 30.- A fieldset with a
rendererand nofieldsrenders as a panel whose body is the snippet's template. - Give every layout node you add an
id. A node whose id matches a shipped one is merged into it; a new id is appended to that parent's children. - Sequence your module after the one you overlay, in
module.xml, or the merge order is undefined. "fields": {}deletes the fields. Never write an empty object to mean "no change".
An EAV overlay cannot carry overrides
Lint validates each file on its own and picks the EAV schema only for a file that
declares type: "eav". The EAV schema forbids type on an overlay, so an EAV overlay
is always checked against the flat form schema, which has no overrides and no
excludeGroups. The customer overlay above works because layout is a key both
schemas share. To change how an attribute renders from another module, contribute a
frontendInputMap entry in di.xml instead.
Source: Nebula_Passkey/view/adminhtml/form/admin_user_edit.json,
Nebula_Passkey/view/adminhtml/form/customer_edit.json.
A save handler¶
Everything above posts to a controller. The alternative is the generic JSON endpoint at
nebula/form/save, which validates the payload against the field rules, whitelists it
against the declared fields, and hands it to SavePipeline. The pipeline dispatches
nebula_form_save_before, persists, and dispatches nebula_form_save_after. Persistence
is either a save handler you bind, or a repository named on the data source.
namespace Vendor\Module\Model\Form;
use Nebula\Component\Api\V1\FormSaveHandlerInterface;
use Vendor\Module\Api\NoteRepositoryInterface;
use Vendor\Module\Model\NoteFactory;
class NoteSaveHandler implements FormSaveHandlerInterface
{
public function __construct(
private readonly NoteRepositoryInterface $repository,
private readonly NoteFactory $factory,
) {
}
/**
* @param array<string, mixed> $data submitted payload, already unwrapped from fieldPrefix
* @param array<string, mixed> $context entityId, storeId, handlerAlias
* @return array{entityId: int|string|null, messages?: list<string>, redirectUrl?: string|null}
*/
public function save(array $data, array $context = []): array
{
$entityId = $context['entityId'] ?? null;
$note = $entityId ? $this->repository->getById((int) $entityId) : $this->factory->create();
$note->addData($data);
$saved = $this->repository->save($note);
return [
'entityId' => $saved->getId(),
'messages' => [(string) __('The Note was saved.')],
];
}
}
<type name="Nebula\Component\Model\Registry\SaveHandlerRegistry">
<arguments>
<argument name="bindings" xsi:type="array">
<item name="vendor.note" xsi:type="string">Vendor\Module\Model\Form\NoteSaveHandler</item>
</argument>
</arguments>
</type>
The registry rejects an unknown alias rather than instantiating it, and refuses a class that does not implement the interface, so a typo in a definition is an error rather than an arbitrary construction.
This applies to the generic save route only
A handler runs when the form posts to nebula/form/save. Every form Nebula ships posts
to an entity-specific controller instead, so neither the handler nor the two save events
is reached on those screens. Server-side validation rules have the same boundary. If
you need the events for a shipped screen, observe the core *_save_after event for that
entity.
The alias itself is declared as "saveHandler": "vendor.note" at the top level of the
definition. The form schema has no such property and every schema object is strict, so
a definition carrying it fails bin/magento nebula:lint until the schema is updated.
The same is true of dataSource.repository and dataSource.model, which are the keys
the repository path reads. Treat the generic route as an extension point you opt into
with your eyes open, not as the default.
Validate before you ship¶
It validates every form/*.json against form.schema.json, switching to
eav-form.schema.json for a file that declares type: "eav", and it checks that a base
definition's id matches its file name. Every schema object is strict, so an unknown key
is an error rather than a warning. Then clear the definition cache:
In production mode run bin/magento nebula:compile instead, which also runs after
setup:di:compile.
Every JSON document on this page was validated against the schema it belongs to before the
page was written: the twelve flat definitions against form.schema.json, the category
definition against eav-form.schema.json, and the four fragments with no id against the
overlay branch of form.schema.json. No example declares a key the schema rejects.
What these examples leave out¶
stepon a number field, andrendererson a field or a fieldset. All three are read by the templates and absent from the schema, so a definition using them does not lint. The pluralrenderersis only needed to stack several snippets in one panel; a singlerendereris in the schema and does everything else.conditionon a flat form's layout node. Accepted by the schema, evaluated only on EAV forms. Every node on a flat form is drawn.$removeon a fieldset. The merge layer honours it, the schema's fieldset object does not list it.$removeon a field is in the schema and does lint.saveHandler,dataSource.repository,dataSource.modelandcontext. All read by the save pipeline and the branch resolver, none in the schema. See the warning above.defaultson an EAV form. Read by the block for a new record, not in the EAV schema. Useoverrides.fields.<code>.default, which is read on new records and does lint.
See also¶
- Form reference for every key on its own, with its default.
- EAV forms for the attribute tree, the
frontend_inputmap and remaining groups. - Saving and validation for the two save paths in full.
- Extending a shipped form for the overlay rules.
- Snippets for what a
rendererreceives.